import {
  cleanup,
  fireEvent,
  render,
  screen,
  waitFor,
  within,
} from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";

vi.mock("@/components/apps/InlineAppletEmbed", () => ({
  InlineAppletEmbed: ({ appId }: { appId: string }) => (
    <div data-testid="inline-applet-embed-stub" data-app-id={appId} />
  ),
}));

import { TaskThreadView } from "./TaskThreadView";

afterEach(cleanup);

function getThinkingDisclosure(): HTMLElement {
  const el = screen.getByLabelText("Thinking and tool activity");
  expect(el.getAttribute("data-state")).not.toBeNull();
  return el;
}

function openThinkingDisclosure(index = 0): HTMLElement {
  const buttons = screen.getAllByRole("button", { name: /thinking/i });
  fireEvent.click(buttons[index]);
  const disclosures = screen.getAllByLabelText("Thinking and tool activity");
  expect(disclosures[index].getAttribute("data-state")).toBe("open");
  return disclosures[index];
}

describe("TaskThreadView", () => {
  it("renders transcript messages, generated artifact cards, and command composer", () => {
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "CRM pipeline risk",
          lifecycleStatus: "COMPLETED",
          costSummary: 0.42,
          messages: [
            {
              id: "message-1",
              role: "USER",
              content: "Build a CRM pipeline dashboard",
            },
            {
              id: "message-2",
              role: "ASSISTANT",
              content: "I created a dashboard app.",
              metadata: {
                summary: "Built a dashboard from CRM opportunity data.",
              },
              toolCalls: [{ name: "data_visualization" }],
              durableArtifact: {
                id: "artifact_123",
                title: "CRM pipeline risk app",
                type: "DATA_VIEW",
                summary: "Stale opportunity analysis",
                metadata: { kind: "research_dashboard" },
              },
            },
          ],
        }}
      />,
    );

    // Thread title now lives in AppTopBar via PageHeaderContext, not inside TaskThreadView.
    expect(screen.getByText("Build a CRM pipeline dashboard")).toBeTruthy();
    expect(screen.getByText("I created a dashboard app.")).toBeTruthy();
    expect(screen.getByRole("log", { name: "Thread transcript" })).toBeTruthy();
    expect(document.querySelector('[data-message-role="user"]')).toBeTruthy();
    expect(
      document.querySelector('[data-message-role="assistant"]'),
    ).toBeTruthy();
    expect(screen.getByText("Using data_visualization")).toBeTruthy();
    expect(screen.getByText("CRM pipeline risk app")).toBeTruthy();
    expect(screen.queryByTestId("inline-applet-embed-stub")).toBeNull();
    expect(screen.getByLabelText("Follow up")).toBeTruthy();
    // No turn → no turn-level Thinking; tool calls present → no fallback Thinking;
    // per-message Thinking row was removed because it was a duplicate of the
    // authoritative turn-level row.
    expect(screen.queryByText("Thinking")).toBeNull();
    expect(screen.queryByText("Computer planned the response.")).toBeNull();
  });

  it("opens a transcript artifact through the artifact panel callback", () => {
    const onSelectArtifact = vi.fn();

    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "CRM pipeline risk",
          lifecycleStatus: "COMPLETED",
          messages: [
            {
              id: "message-1",
              role: "USER",
              content: "Build a CRM pipeline dashboard",
            },
            {
              id: "message-2",
              role: "ASSISTANT",
              content: "I created a dashboard app.",
              durableArtifact: {
                id: "artifact_123",
                title: "CRM pipeline risk app",
                type: "DATA_VIEW",
                summary: "Stale opportunity analysis",
                metadata: { kind: "research_dashboard" },
              },
            },
          ],
        }}
        artifactPanelState={{
          artifacts: [
            {
              id: "artifact_123",
              title: "CRM pipeline risk app",
              type: "DATA_VIEW",
              summary: "Stale opportunity analysis",
              metadata: { kind: "research_dashboard" },
            },
          ],
          selectedArtifactId: "artifact_123",
          isOpen: false,
          onOpenChange: vi.fn(),
          onSelectArtifact,
        }}
      />,
    );

    fireEvent.click(
      screen.getByRole("button", {
        name: /open artifact crm pipeline risk app/i,
      }),
    );

    expect(onSelectArtifact).toHaveBeenCalledWith("artifact_123");
    expect(screen.queryByTestId("inline-applet-embed-stub")).toBeNull();
  });

  it("submits the follow-up composer when Enter is pressed", async () => {
    const onSendFollowUp = vi.fn();
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "CRM pipeline risk",
          lifecycleStatus: "COMPLETED",
          messages: [
            {
              id: "message-1",
              role: "USER",
              content: "Build a CRM pipeline dashboard",
            },
            {
              id: "message-2",
              role: "ASSISTANT",
              content: "I created a dashboard app.",
            },
          ],
        }}
        onSendFollowUp={onSendFollowUp}
      />,
    );

    const input = screen.getByLabelText("Follow up");
    fireEvent.change(input, { target: { value: "Please continue" } });
    fireEvent.keyDown(input, { key: "Enter" });

    await waitFor(() =>
      expect(onSendFollowUp).toHaveBeenCalledWith("Please continue", [], []),
    );
  });

  it("renders the selected artifact in the side panel when artifact panel state is open", () => {
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "CRM pipeline risk",
          lifecycleStatus: "COMPLETED",
          messages: [
            {
              id: "message-1",
              role: "USER",
              content: "Build a CRM pipeline dashboard",
            },
            {
              id: "message-2",
              role: "ASSISTANT",
              content: "I created a dashboard app.",
              durableArtifact: {
                id: "artifact_123",
                title: "CRM pipeline risk app",
                type: "DATA_VIEW",
                summary: "Stale opportunity analysis",
                metadata: { kind: "research_dashboard" },
              },
            },
          ],
        }}
        artifactPanelState={{
          artifacts: [
            {
              id: "artifact_123",
              title: "CRM pipeline risk app",
              type: "DATA_VIEW",
              summary: "Stale opportunity analysis",
              metadata: { kind: "research_dashboard" },
            },
          ],
          selectedArtifactId: "artifact_123",
          isOpen: true,
          onOpenChange: vi.fn(),
          onSelectArtifact: vi.fn(),
        }}
      />,
    );

    const panel = screen.getByTestId("artifact-side-panel");
    expect(within(panel).queryByText("CRM pipeline risk app")).toBeNull();
    expect(within(panel).getByTestId("inline-applet-embed-stub")).toBeTruthy();
    expect(
      within(panel).queryByRole("button", { name: /maximize artifact panel/i }),
    ).toBeNull();
    expect(
      within(panel).getByRole("separator", { name: /resize artifact panel/i }),
    ).toBeTruthy();
  });

  it("reserves thread width for the info panel with details and downloadable attachments", () => {
    const onDownloadAttachment = vi.fn();

    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "CRM pipeline risk",
          lifecycleStatus: "COMPLETED",
          messages: [
            {
              id: "message-1",
              role: "USER",
              content: "Analyze this file",
            },
          ],
        }}
        infoPanelState={{
          isOpen: true,
          onOpenChange: vi.fn(),
          startedAt: "2026-05-18T20:50:00.000Z",
          startedBy: "Eric Odom",
          agents: ["Executive"],
          attachments: [
            {
              id: "attachment-1",
              name: "general-ledger.xlsx",
              sizeBytes: 2_048,
              mimeType:
                "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
              createdAt: "2026-05-18T20:51:00.000Z",
            },
          ],
          onDownloadAttachment,
        }}
      />,
    );

    expect(
      screen.getByTestId("thread-conversation-content").className,
    ).toContain("md:pr-[324px]");
    expect(
      screen.getByTestId("thread-conversation-column").className,
    ).toContain("max-w-[750px]");
    expect(
      screen.getByTestId("thread-conversation-column").className,
    ).toContain("px-3");
    expect(screen.getByTestId("follow-up-composer-dock").className).toContain(
      "md:pr-[324px]",
    );
    const panel = screen.getByTestId("thread-info-panel");
    expect(panel.className).toContain("w-[300px]");
    expect(panel.className).toContain("absolute");
    expect(panel.className).toContain("right-6");
    expect(within(panel).getByText("Date started")).toBeTruthy();
    expect(within(panel).getByText("Eric Odom")).toBeTruthy();
    expect(within(panel).getByText("Executive")).toBeTruthy();
    fireEvent.click(
      within(panel).getByRole("button", { name: /general-ledger/i }),
    );
    expect(onDownloadAttachment).toHaveBeenCalledWith("attachment-1");
  });

  it("does not turn the Conversation outer into a second scroll container (regression: double scrollbar with artifact side panel)", () => {
    // Regression: TaskThreadView passed `overflow-y-auto` to <Conversation>,
    // which tailwind-merge resolved by overriding the library's default
    // `overflow-y-hidden` on the outer StickToBottom div. Combined with the
    // library's inner scroll container (which sets overflow:auto via a
    // layout effect plus scrollbarGutter "stable both-edges"), the
    // transcript ended up with TWO stacked scroll containers, rendering
    // two adjacent scrollbars at the right edge of the conversation column.
    // The artifact side panel doesn't cause the bug -- it just narrows the
    // column so the double scrollbars become obvious in the gutter between
    // the panels. The outer [role="log"] element must not declare any
    // overflow override that competes with the library's inner scroller.
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "Double scrollbar regression",
          lifecycleStatus: "RUNNING",
          messages: [
            { id: "m1", role: "USER", content: "Long enough to overflow" },
          ],
        }}
      />,
    );

    const log = screen.getByRole("log", { name: "Thread transcript" });
    expect(log.className).not.toMatch(/overflow-y-auto/);
    expect(log.className).not.toMatch(/overflow-y-scroll/);
    expect(log.className).not.toMatch(/\boverflow-auto\b/);
  });

  it("renders exactly one Thinking row when an assistant message has no tool calls and a turn is running", () => {
    // Regression: before C-01 the per-message fallback ThinkingRow ('Reasoning
    // complete.') fired here on top of the turn-level ThinkingRow, producing
    // two 'Thinking' rows in the case the user originally reported in
    // screenshots #28 / #29 (assistant message used no tools).
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "No-tool assistant",
          lifecycleStatus: "RUNNING",
          messages: [
            {
              id: "message-1",
              role: "USER",
              content: "Read example.com",
            },
            {
              id: "message-2",
              role: "ASSISTANT",
              content: "The page title is **Example Domain**.",
            },
          ],
          turns: [
            {
              id: "turn-1",
              status: "running",
              invocationSource: "chat_message",
            },
          ],
        }}
      />,
    );

    expect(screen.getAllByText("Thinking")).toHaveLength(1);
    expect(screen.queryByText("Reasoning complete.")).toBeNull();
  });

  it("renders exactly one Thinking row when both an assistant message and a running turn exist", () => {
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "One thinking only",
          lifecycleStatus: "RUNNING",
          messages: [
            {
              id: "message-1",
              role: "USER",
              content: "Investigate",
            },
            {
              id: "message-2",
              role: "ASSISTANT",
              content: "On it.",
              toolCalls: [{ name: "crm_search" }],
            },
          ],
          turns: [
            {
              id: "turn-1",
              status: "running",
              invocationSource: "chat_message",
            },
          ],
        }}
      />,
    );

    expect(screen.getAllByText("Thinking")).toHaveLength(1);
    // Anchor the surviving row to the authoritative turn-level container so a
    // future regression that moves the row back into per-message rendering
    // would fail this test rather than silently keep the count at 1.
    expect(screen.getByLabelText("Thinking and tool activity")).toBeTruthy();
    expect(screen.queryByText("Computer planned the response.")).toBeNull();
  });

  it("renders a thinking row when the thread has no messages", () => {
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "Blank thread",
          lifecycleStatus: "IDLE",
          messages: [],
        }}
      />,
    );

    expect(screen.getByText("Thinking")).toBeTruthy();
  });

  it("renders streaming assistant chunks below persisted messages", () => {
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "Streaming thread",
          lifecycleStatus: "RUNNING",
          messages: [
            {
              id: "message-1",
              role: "USER",
              content: "Think out loud",
            },
          ],
        }}
        streamingChunks={[
          { seq: 1, text: "Working" },
          { seq: 2, text: " on it" },
        ]}
      />,
    );

    expect(screen.getByText("Working on it")).toBeTruthy();
    expect(screen.getByLabelText("ThinkWork is typing")).toBeTruthy();
  });

  it("projects persisted runbook queue parts into the prompt queue after reload", () => {
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "Runbook thread",
          messages: [
            {
              id: "message-1",
              role: "USER",
              content: "Run the research dashboard",
            },
            {
              id: "message-2",
              role: "ASSISTANT",
              content: "Starting Research Dashboard.",
              metadata: { runbookMessageKey: "runbook-queue:run-1" },
              parts: [
                {
                  type: "data-runbook-queue",
                  id: "runbook-queue:run-1",
                  data: {
                    runbookRunId: "run-1",
                    displayName: "Research Dashboard",
                    status: "QUEUED",
                    phases: [
                      {
                        id: "discover",
                        title: "Discover",
                        tasks: [
                          {
                            id: "task-1",
                            title: "Gather source material",
                            status: "PENDING",
                          },
                        ],
                      },
                    ],
                  },
                },
              ],
            },
          ],
        }}
      />,
    );

    const promptQueue = screen.getByLabelText("Active task queue");
    expect(
      within(promptQueue).getAllByText("Research Dashboard").length,
    ).toBeGreaterThan(0);
    expect(within(promptQueue).getByText("1 task · 1 pending")).toBeTruthy();
    expect(
      within(promptQueue).queryByText("Gather source material"),
    ).toBeNull();
    expect(screen.queryByText("Starting Research Dashboard.")).toBeNull();
  });

  it("projects generic task queue parts without hiding assistant prose", () => {
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "Research thread",
          messages: [
            {
              id: "message-1",
              role: "USER",
              content: "Research the market",
            },
            {
              id: "message-2",
              role: "ASSISTANT",
              content: "I drafted the research plan.",
              parts: [
                {
                  type: "data-task-queue",
                  id: "task-queue:research-1",
                  data: {
                    queueId: "research-1",
                    title: "Research plan",
                    status: "RUNNING",
                    source: { type: "deep_research", id: "research-1" },
                    items: [
                      {
                        id: "task-1",
                        title: "Collect source evidence",
                        status: "RUNNING",
                      },
                    ],
                  },
                },
              ],
            },
          ],
        }}
      />,
    );

    expect(screen.getByText("I drafted the research plan.")).toBeTruthy();
    const promptQueue = screen.getByLabelText("Active task queue");
    expect(within(promptQueue).getByText("Research plan")).toBeTruthy();
    expect(within(promptQueue).getByText("1 task · 1 running")).toBeTruthy();
  });

  it("renders the active runbook queue above the prompt input", () => {
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "Runbook thread",
          messages: [
            {
              id: "message-1",
              role: "USER",
              content: "Run the research dashboard",
            },
            {
              id: "message-2",
              role: "ASSISTANT",
              parts: [
                {
                  type: "data-runbook-queue",
                  id: "runbook-queue:run-1",
                  data: {
                    runbookRunId: "run-1",
                    displayName: "Research Dashboard",
                    status: "RUNNING",
                    phases: [
                      {
                        id: "discover",
                        title: "Discover",
                        tasks: [
                          {
                            id: "task-1",
                            title: "Gather source material",
                            status: "COMPLETED",
                          },
                          {
                            id: "task-2",
                            title: "Summarize evidence",
                            status: "RUNNING",
                          },
                        ],
                      },
                    ],
                  },
                },
              ],
            },
          ],
        }}
      />,
    );

    const promptQueue = screen.getByLabelText("Active task queue");
    expect(
      within(promptQueue).getAllByText("Research Dashboard").length,
    ).toBeGreaterThan(0);
    expect(
      within(promptQueue).getByText("2 tasks · 1 completed · 1 running"),
    ).toBeTruthy();
    expect(within(promptQueue).queryByText("Summarize evidence")).toBeNull();
    fireEvent.click(
      within(promptQueue).getByRole("button", { name: "Expand task queue" }),
    );
    expect(within(promptQueue).getByText("Summarize evidence")).toBeTruthy();
    expect(screen.getByLabelText("Follow up")).toBeTruthy();
  });

  it("reserves transcript scroll space for the docked composer and task queue", async () => {
    const rectSpy = vi
      .spyOn(HTMLElement.prototype, "getBoundingClientRect")
      .mockReturnValue({
        x: 0,
        y: 0,
        width: 750,
        height: 260,
        top: 0,
        right: 750,
        bottom: 260,
        left: 0,
        toJSON: () => ({}),
      } as DOMRect);

    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "Runbook thread",
          messages: [
            {
              id: "message-1",
              role: "USER",
              content: "Run the CRM dashboard",
            },
          ],
        }}
        runbookQueues={[
          {
            runbookRunId: "run-1",
            displayName: "CRM Dashboard",
            status: "COMPLETED",
            phases: [
              {
                id: "produce",
                title: "Produce",
                tasks: [
                  {
                    id: "task-1",
                    title: "Build dashboard",
                    status: "COMPLETED",
                  },
                ],
              },
            ],
          },
        ]}
      />,
    );

    await waitFor(() => {
      const transcriptContent = screen
        .getByText("Run the CRM dashboard")
        .closest('[style*="padding-bottom"]') as HTMLElement | null;
      expect(transcriptContent?.style.paddingBottom).toBe("292px");
    });

    rectSpy.mockRestore();
  });

  it("collapses and expands the prompt-area runbook queue", () => {
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "Runbook thread",
          messages: [
            {
              id: "message-1",
              role: "USER",
              content: "Run the CRM dashboard",
            },
            {
              id: "message-2",
              role: "ASSISTANT",
              parts: [
                {
                  type: "data-runbook-queue",
                  id: "runbook-queue:run-1",
                  data: {
                    runbookRunId: "run-1",
                    displayName: "CRM Dashboard",
                    status: "RUNNING",
                    phases: [
                      {
                        id: "produce",
                        title: "Produce",
                        tasks: [
                          {
                            id: "task-1",
                            title: "Build dashboard",
                            status: "RUNNING",
                          },
                        ],
                      },
                    ],
                  },
                },
              ],
            },
          ],
        }}
      />,
    );

    const promptQueue = screen.getByLabelText("Active task queue");
    const expand = within(promptQueue).getByRole("button", {
      name: "Expand task queue",
    });
    expect(expand.getAttribute("aria-expanded")).toBe("false");
    expect(within(promptQueue).getByText("Review tasks")).toBeTruthy();
    expect(within(promptQueue).queryByText("Build dashboard")).toBeNull();

    fireEvent.click(expand);

    const collapse = within(promptQueue).getByRole("button", {
      name: "Collapse task queue",
    });
    expect(collapse.getAttribute("aria-expanded")).toBe("true");
    expect(within(promptQueue).getByText("Hide tasks")).toBeTruthy();
    expect(within(promptQueue).getByText("Build dashboard")).toBeTruthy();
    expect(
      within(promptQueue).getAllByText("CRM Dashboard").length,
    ).toBeGreaterThan(0);

    fireEvent.pointerDown(document.body);

    expect(
      within(promptQueue)
        .getByRole("button", { name: "Expand task queue" })
        .getAttribute("aria-expanded"),
    ).toBe("false");
    expect(within(promptQueue).queryByText("Build dashboard")).toBeNull();

    fireEvent.click(
      within(promptQueue).getByRole("button", { name: "Expand task queue" }),
    );
    expect(within(promptQueue).getByText("Build dashboard")).toBeTruthy();

    const collapseAfterOutsideClick = within(promptQueue).getByRole("button", {
      name: "Collapse task queue",
    });
    fireEvent.click(collapseAfterOutsideClick);

    expect(
      within(promptQueue)
        .getByRole("button", { name: "Expand task queue" })
        .getAttribute("aria-expanded"),
    ).toBe("false");
    expect(within(promptQueue).queryByText("Build dashboard")).toBeNull();
  });

  it("renders the prompt queue from durable runbook run data when message parts are missing", () => {
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "Runbook thread",
          messages: [
            {
              id: "message-1",
              role: "USER",
              content: "Run the research dashboard",
            },
          ],
        }}
        runbookQueues={[
          {
            runbookRunId: "run-1",
            displayName: "Research Dashboard",
            status: "RUNNING",
            phases: [
              {
                id: "discover",
                title: "Discover",
                tasks: [
                  {
                    id: "task-1",
                    title: "Gather source material",
                    status: "COMPLETED",
                  },
                  {
                    id: "task-2",
                    title: "Build dashboard",
                    status: "PENDING",
                  },
                ],
              },
            ],
          },
        ]}
      />,
    );

    const promptQueue = screen.getByLabelText("Active task queue");
    expect(within(promptQueue).getByText("Research Dashboard")).toBeTruthy();
    expect(
      within(promptQueue).getByText("2 tasks · 1 completed · 1 pending"),
    ).toBeTruthy();
  });

  it("keeps a processing shimmer visible while an active runbook queue is working", () => {
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "Runbook thread",
          messages: [
            {
              id: "message-1",
              role: "USER",
              content: "Run the CRM dashboard",
            },
            {
              id: "message-2",
              role: "ASSISTANT",
              content: "User approved the CRM Dashboard runbook workflow.",
            },
            {
              id: "message-3",
              role: "ASSISTANT",
              content: "**Completed:** Discover CRM context",
            },
          ],
        }}
        runbookQueues={[
          {
            runbookRunId: "run-1",
            displayName: "CRM Dashboard",
            status: "RUNNING",
            phases: [
              {
                id: "discover",
                title: "Discover",
                tasks: [
                  {
                    id: "task-1",
                    title: "Discover CRM context",
                    status: "COMPLETED",
                  },
                  {
                    id: "task-2",
                    title: "Analyze pipeline",
                    status: "RUNNING",
                  },
                ],
              },
            ],
          },
        ]}
      />,
    );

    expect(screen.getByLabelText("Processing request")).toBeTruthy();
  });

  it("keeps historical completed queues out of the prompt and transcript", () => {
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "Completed runbook thread",
          messages: [
            {
              id: "message-1",
              role: "USER",
              content: "Run the CRM dashboard",
            },
            {
              id: "message-2",
              role: "ASSISTANT",
              metadata: { runbookMessageKey: "runbook-queue:run-1" },
              parts: [
                {
                  type: "data-runbook-queue",
                  id: "runbook-queue:run-1",
                  data: {
                    runbookRunId: "run-1",
                    displayName: "CRM Dashboard",
                    status: "COMPLETED",
                    phases: [
                      {
                        id: "produce",
                        title: "Produce",
                        tasks: [
                          {
                            id: "task-1",
                            title: "Build dashboard",
                            status: "COMPLETED",
                          },
                        ],
                      },
                    ],
                  },
                },
              ],
            },
          ],
        }}
      />,
    );

    expect(screen.queryByLabelText("Active task queue")).toBeNull();
    expect(screen.queryByLabelText("CRM Dashboard queue")).toBeNull();
    expect(screen.queryByText("Build dashboard")).toBeNull();
  });

  it("uses completed durable runbook state instead of a stale persisted pending queue", () => {
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "Completed runbook thread",
          messages: [
            {
              id: "message-1",
              role: "USER",
              content: "Run the CRM dashboard",
            },
            {
              id: "message-2",
              role: "ASSISTANT",
              metadata: { runbookMessageKey: "runbook-queue:run-1" },
              parts: [
                {
                  type: "data-runbook-queue",
                  id: "runbook-queue:run-1",
                  data: {
                    runbookRunId: "run-1",
                    displayName: "CRM Dashboard",
                    status: "QUEUED",
                    phases: [
                      {
                        id: "produce",
                        title: "Produce",
                        tasks: [
                          {
                            id: "task-1",
                            title: "Build dashboard",
                            status: "PENDING",
                          },
                        ],
                      },
                    ],
                  },
                },
              ],
            },
          ],
        }}
        runbookQueues={[
          {
            runbookRunId: "run-1",
            displayName: "CRM Dashboard",
            status: "COMPLETED",
            phases: [
              {
                id: "produce",
                title: "Produce",
                tasks: [
                  {
                    id: "task-1",
                    title: "Build dashboard",
                    status: "COMPLETED",
                  },
                ],
              },
            ],
          },
        ]}
      />,
    );

    const promptQueue = screen.getByLabelText("Active task queue");
    expect(within(promptQueue).getByText("1 task · 1 completed")).toBeTruthy();
    expect(
      within(promptQueue).getAllByText(/completed/i).length,
    ).toBeGreaterThan(0);
    expect(within(promptQueue).queryByText("1 task · 1 pending")).toBeNull();
  });

  it("uses completed durable runbook state instead of a stale streamed pending queue", () => {
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "Completed runbook thread",
          messages: [
            {
              id: "message-1",
              role: "USER",
              content: "Run the CRM dashboard",
            },
          ],
        }}
        runbookQueues={[
          {
            runbookRunId: "run-1",
            displayName: "CRM Dashboard",
            status: "COMPLETED",
            phases: [
              {
                id: "produce",
                title: "Produce",
                tasks: [
                  {
                    id: "task-1",
                    title: "Build dashboard",
                    status: "COMPLETED",
                  },
                ],
              },
            ],
          },
        ]}
        streamState={{
          status: "streaming",
          legacyText: "",
          parts: [
            {
              type: "data-runbook-queue",
              id: "runbook-queue:run-1",
              data: {
                runbookRunId: "run-1",
                displayName: "CRM Dashboard",
                status: "QUEUED",
                phases: [
                  {
                    id: "produce",
                    title: "Produce",
                    tasks: [
                      {
                        id: "task-1",
                        title: "Build dashboard",
                        status: "PENDING",
                      },
                    ],
                  },
                ],
              },
            },
          ],
        }}
      />,
    );

    const promptQueue = screen.getByLabelText("Active task queue");
    expect(within(promptQueue).getByText("1 task · 1 completed")).toBeTruthy();
    expect(within(promptQueue).queryByText("1 task · 1 pending")).toBeNull();
  });

  it("updates the prompt-area queue from fresher streaming runbook data", () => {
    const thread = {
      id: "thread-1",
      title: "Runbook thread",
      messages: [
        {
          id: "message-1",
          role: "USER",
          content: "Run the map artifact runbook",
        },
        {
          id: "message-2",
          role: "ASSISTANT",
          parts: [
            {
              type: "data-runbook-queue" as const,
              id: "runbook-queue:run-1",
              data: {
                runbookRunId: "run-1",
                displayName: "Map Artifact",
                status: "QUEUED",
                phases: [
                  {
                    id: "produce",
                    title: "Produce",
                    tasks: [
                      {
                        id: "task-1",
                        title: "Render map",
                        status: "PENDING",
                      },
                    ],
                  },
                ],
              },
            },
          ],
        },
      ],
    };

    const { rerender } = render(
      <TaskThreadView
        thread={thread}
        streamState={{
          status: "streaming",
          legacyText: "",
          parts: [
            {
              type: "data-runbook-queue",
              id: "runbook-queue:run-1",
              data: {
                runbookRunId: "run-1",
                displayName: "Map Artifact",
                status: "RUNNING",
                phases: [
                  {
                    id: "produce",
                    title: "Produce",
                    tasks: [
                      {
                        id: "task-1",
                        title: "Render map",
                        status: "RUNNING",
                      },
                    ],
                  },
                ],
              },
            },
          ],
        }}
      />,
    );

    let promptQueue = screen.getByLabelText("Active task queue");
    expect(within(promptQueue).getAllByText(/running/i).length).toBeGreaterThan(
      0,
    );

    rerender(
      <TaskThreadView
        thread={thread}
        streamState={{
          status: "streaming",
          legacyText: "",
          parts: [
            {
              type: "data-runbook-queue",
              id: "runbook-queue:run-1",
              data: {
                runbookRunId: "run-1",
                displayName: "Map Artifact",
                status: "COMPLETED",
                phases: [
                  {
                    id: "produce",
                    title: "Produce",
                    tasks: [
                      {
                        id: "task-1",
                        title: "Render map",
                        status: "COMPLETED",
                      },
                    ],
                  },
                ],
              },
            },
          ],
        }}
      />,
    );

    promptQueue = screen.getByLabelText("Active task queue");
    expect(within(promptQueue).getByText("1 task · 1 completed")).toBeTruthy();
    expect(
      within(promptQueue).getAllByText(/completed/i).length,
    ).toBeGreaterThan(0);
  });

  it("renders a completed turn response when the assistant message has not refetched yet", () => {
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "Completed turn",
          lifecycleStatus: "COMPLETED",
          messages: [
            {
              id: "message-1",
              role: "USER",
              content: "What is my name?",
            },
          ],
          turns: [
            {
              id: "turn-1",
              status: "succeeded",
              invocationSource: "chat_message",
              finishedAt: "2026-05-08T20:00:00Z",
              resultJson: {
                response: "Your name is Eric.",
              },
            },
          ],
        }}
        streamingChunks={[
          { seq: 1, text: "Your" },
          { seq: 2, text: " name" },
        ]}
      />,
    );

    expect(screen.getByText("Your name is Eric.")).toBeTruthy();
    expect(screen.queryByLabelText("ThinkWork is typing")).toBeNull();
  });

  it("renders a completed Computer task response when the assistant message has not refetched yet", () => {
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "Completed task",
          lifecycleStatus: "COMPLETED",
          messages: [
            {
              id: "message-1",
              role: "USER",
              content: "What is my name?",
            },
          ],
          turns: [
            {
              id: "task-1",
              status: "COMPLETED",
              invocationSource: "chat_message",
              finishedAt: "2026-05-08T20:00:00Z",
              resultJson: {
                response: "Your name is Eric.",
                responseMessageId: "message-2",
              },
            },
          ],
        }}
      />,
    );

    expect(screen.getByText("Your name is Eric.")).toBeTruthy();
  });

  it("renders thread turn thinking and tool details", () => {
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "Tool trace thread",
          lifecycleStatus: "RUNNING",
          messages: [
            {
              id: "message-1",
              role: "USER",
              content: "Find account risk",
            },
          ],
          turns: [
            {
              id: "turn-1",
              status: "running",
              invocationSource: "chat_message",
              usageJson: {
                tools_called: ["crm_search"],
                input_tokens: 1200,
                output_tokens: 300,
              },
            },
          ],
        }}
      />,
    );

    expect(screen.getByLabelText("Thinking and tool activity")).toBeTruthy();
    openThinkingDisclosure();
    expect(screen.getByText("Finding sources")).toBeTruthy();
    expect(screen.getByText(/Manual chat/)).toBeTruthy();
    expect(screen.getByText(/1.2K in \/ 300 out/)).toBeTruthy();
  });

  it("renders durable Computer event detail rows for a thread turn", () => {
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "Browser trace thread",
          lifecycleStatus: "RUNNING",
          messages: [
            {
              id: "message-1",
              role: "USER",
              content: "Use the browser",
            },
          ],
          turns: [
            {
              id: "turn-1",
              status: "running",
              invocationSource: "chat_message",
              events: [
                {
                  id: "event-1",
                  eventType: "browser_automation_started",
                  level: "INFO",
                  payload: {
                    url: "https://example.com",
                    task: "Read the page title",
                    taskId: "task-1",
                  },
                  createdAt: "2026-05-09T08:01:00Z",
                },
                {
                  id: "event-2",
                  eventType: "browser_automation_completed",
                  level: "INFO",
                  payload: { responseLen: 12 },
                  createdAt: "2026-05-09T08:01:05Z",
                },
              ],
            },
          ],
        }}
      />,
    );

    openThinkingDisclosure();
    expect(screen.getByText("Opening browser")).toBeTruthy();
    expect(screen.getByText("Browser completed")).toBeTruthy();
    expect(screen.getByText(/https:\/\/example.com/)).toBeTruthy();
    expect(
      screen.getByText(/"instruction": "Read the page title"/),
    ).toBeTruthy();
    expect(screen.getByText(/"runId": "task-1"/)).toBeTruthy();
    expect(screen.queryByText(/"task":/)).toBeNull();
    expect(screen.queryByText(/"taskId":/)).toBeNull();
  });

  it("renders turn events in chronological order regardless of input order", () => {
    // The computerEvents resolver returns events DESC; this fixture mirrors
    // that wire shape. Rendering must invert it so the user sees the timeline
    // oldest-at-top → newest-at-bottom.
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "Order check",
          lifecycleStatus: "RUNNING",
          messages: [
            {
              id: "message-1",
              role: "USER",
              content: "Run it",
            },
          ],
          turns: [
            {
              id: "turn-1",
              status: "running",
              invocationSource: "chat_message",
              events: [
                {
                  id: "event-late",
                  eventType: "browser_automation_completed",
                  payload: {},
                  createdAt: "2026-05-09T11:58:07Z",
                },
                {
                  id: "event-mid",
                  eventType: "browser_automation_started",
                  payload: { url: "https://example.com" },
                  createdAt: "2026-05-09T11:58:00.530Z",
                },
                {
                  id: "event-early",
                  eventType: "thread_turn_enqueued",
                  payload: {},
                  createdAt: "2026-05-09T11:58:00.500Z",
                },
              ],
            },
          ],
        }}
      />,
    );

    openThinkingDisclosure();
    const rendered = [
      screen.getByText("thread turn enqueued"),
      screen.getByText("Opening browser"),
      screen.getByText("Browser completed"),
    ];
    // Each row's title is unique; compare DOM order via compareDocumentPosition
    expect(
      rendered[0].compareDocumentPosition(rendered[1]) &
        Node.DOCUMENT_POSITION_FOLLOWING,
    ).toBeTruthy();
    expect(
      rendered[1].compareDocumentPosition(rendered[2]) &
        Node.DOCUMENT_POSITION_FOLLOWING,
    ).toBeTruthy();
  });

  it("stably orders events with identical createdAt by event id", () => {
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "Tiebreak check",
          lifecycleStatus: "RUNNING",
          messages: [
            {
              id: "message-1",
              role: "USER",
              content: "Tiebreak",
            },
          ],
          turns: [
            {
              id: "turn-1",
              status: "running",
              invocationSource: "chat_message",
              events: [
                // Resolver-DESC order with identical timestamps; expected
                // render order is by id ascending: event-a, event-b.
                {
                  id: "event-b",
                  eventType: "browser_automation_completed",
                  payload: {},
                  createdAt: "2026-05-09T11:58:00.530Z",
                },
                {
                  id: "event-a",
                  eventType: "browser_automation_started",
                  payload: { url: "https://example.com" },
                  createdAt: "2026-05-09T11:58:00.530Z",
                },
              ],
            },
          ],
        }}
      />,
    );

    openThinkingDisclosure();
    const opening = screen.getByText("Opening browser");
    const completed = screen.getByText("Browser completed");
    expect(
      opening.compareDocumentPosition(completed) &
        Node.DOCUMENT_POSITION_FOLLOWING,
    ).toBeTruthy();
  });

  it("renders the mobile-style processing shimmer while waiting for the first chunk", () => {
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "Waiting thread",
          lifecycleStatus: "RUNNING",
          messages: [
            {
              id: "message-1",
              role: "USER",
              content: "Answer me",
            },
          ],
          turns: [
            {
              id: "task-1",
              status: "RUNNING",
              invocationSource: "chat_message",
            },
          ],
        }}
      />,
    );

    expect(screen.getByLabelText("Processing request")).toBeTruthy();
    expect(screen.queryByLabelText("ThinkWork is typing")).toBeNull();
  });

  it("prefers streaming chunks over the processing shimmer", () => {
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "Streaming thread",
          lifecycleStatus: "RUNNING",
          messages: [
            {
              id: "message-1",
              role: "USER",
              content: "Answer me",
            },
          ],
          turns: [
            {
              id: "task-1",
              status: "RUNNING",
              invocationSource: "chat_message",
            },
          ],
        }}
        streamingChunks={[{ seq: 1, text: "Streaming now" }]}
      />,
    );

    expect(screen.getByText("Streaming now")).toBeTruthy();
    expect(screen.queryByLabelText("Processing request")).toBeNull();
  });

  it("renders Markdown bold and pipe tables in assistant content", () => {
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "Markdown render",
          lifecycleStatus: "COMPLETED",
          messages: [
            {
              id: "message-1",
              role: "USER",
              content: "List leads",
            },
            {
              id: "message-2",
              role: "ASSISTANT",
              content:
                "Here are your **leads**:\n\n| # | Title | Stage |\n|---|---|---|\n| 1 | Royal Concrete | Working |\n| 2 | Diesel Fleet Care | Working |",
            },
          ],
        }}
      />,
    );

    // Bold text is rendered (Streamdown wraps it; the visible text is what matters).
    expect(screen.getByText("leads")).toBeTruthy();
    // Table rendered as a real <table>
    expect(document.querySelector("table")).not.toBeNull();
    // Cells render the row content
    expect(screen.getByText("Royal Concrete")).toBeTruthy();
    expect(screen.getByText("Diesel Fleet Care")).toBeTruthy();
  });

  it("renders Markdown links as anchor elements with safe URLs", () => {
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "Link render",
          lifecycleStatus: "COMPLETED",
          messages: [
            {
              id: "message-1",
              role: "USER",
              content: "Visit",
            },
            {
              id: "message-2",
              role: "ASSISTANT",
              content: "Visit [example](https://example.com).",
            },
          ],
        }}
      />,
    );

    // The link text renders. Streamdown renders links through a link-safety
    // component (button-shaped at v2) rather than a bare <a>, so we assert
    // visible text rather than the element type — the latter is a private
    // implementation detail of Streamdown.
    expect(screen.getByText("example")).toBeTruthy();
  });

  it("renders streaming chunks through the Markdown parser", () => {
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "Streaming markdown",
          lifecycleStatus: "RUNNING",
          messages: [
            {
              id: "message-1",
              role: "USER",
              content: "Stream me",
            },
          ],
        }}
        streamingChunks={[
          { seq: 1, text: "**Working**" },
          { seq: 2, text: " on it" },
        ]}
      />,
    );

    expect(screen.getByText("Working")).toBeTruthy();
    expect(screen.getByLabelText("ThinkWork is typing")).toBeTruthy();
  });

  it("renders streaming partial Markdown without crashing", () => {
    // Mid-table token sequence — the row terminator hasn't arrived yet.
    expect(() =>
      render(
        <TaskThreadView
          thread={{
            id: "thread-1",
            title: "Partial markdown",
            lifecycleStatus: "RUNNING",
            messages: [
              {
                id: "message-1",
                role: "USER",
                content: "Stream a partial table",
              },
            ],
          }}
          streamingChunks={[
            { seq: 1, text: "| col1 | col2 |\n|---|---|\n| a | " },
          ]}
        />,
      ),
    ).not.toThrow();
    expect(screen.getByLabelText("ThinkWork is typing")).toBeTruthy();
  });

  it("renders empty content placeholder when assistant message body is blank", () => {
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "Blank assistant",
          lifecycleStatus: "COMPLETED",
          messages: [
            {
              id: "message-1",
              role: "USER",
              content: "Hello",
            },
            {
              id: "message-2",
              role: "ASSISTANT",
              content: "   ",
            },
          ],
        }}
      />,
    );

    expect(screen.getByText("(No message content)")).toBeTruthy();
  });

  it("renders assistant Markdown wrapper with Codex-transcript prose density", () => {
    // Regression guard: matches the tightened token set targeted by the
    // "make Computer match the Codex CLI transcript density" iteration —
    // text-sm + leading-5 + my-1.5 paragraph/list margins, prose-sm modifier
    // shrinks the inline element rhythm. Reverting any one token sends the
    // page back toward the looser pre-merge rhythm.
    const { container } = render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "Density check",
          lifecycleStatus: "COMPLETED",
          messages: [
            { id: "m1", role: "USER", content: "List options" },
            {
              id: "m2",
              role: "ASSISTANT",
              content: "## Options\n\n- Alpha\n- Beta\n- Gamma",
            },
          ],
        }}
      />,
    );
    const proseWrapper = container.querySelector("div.prose");
    expect(proseWrapper).not.toBeNull();
    const cls = proseWrapper!.className;
    for (const token of [
      "prose-sm",
      "text-sm",
      "leading-5",
      "prose-p:leading-5",
      "prose-li:leading-5",
      "prose-p:my-1.5",
      "prose-ul:my-1.5",
      "prose-ol:my-1.5",
      "prose-li:my-0",
      "prose-headings:mt-3",
      "prose-headings:mb-1.5",
    ]) {
      expect(cls).toContain(token);
    }
    // Loose tokens from earlier iterations must not survive.
    expect(cls).not.toContain("leading-8");
    expect(cls).not.toContain("text-[1.05rem]");
    expect(cls).not.toContain("prose-p:my-0");
    expect(cls).not.toContain("prose-p:my-2");
  });

  it("renders the transcript segment grid with tightened gap-3 spacing", () => {
    // U1 regression guard: gap-8 (and the interim gap-5) waste vertical
    // space between transcript segments — Thinking should sit close to the
    // assistant answer it precedes, like one continuous thought.
    const { container } = render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "Gap check",
          lifecycleStatus: "RUNNING",
          messages: [
            { id: "m1", role: "USER", content: "Hi" },
            { id: "m2", role: "ASSISTANT", content: "Hello." },
          ],
        }}
      />,
    );
    const grid = container.querySelector("div.gap-3");
    expect(grid).not.toBeNull();
    expect(container.querySelector("div.gap-8")).toBeNull();
    expect(container.querySelector("div.gap-5")).toBeNull();
  });

  it("renders Thinking row collapsed when a turn completes cleanly", () => {
    // U2 collapse-on-finish: defaultOpen is false for terminal-clean states so
    // child action rows nest inside the closed disclosure by default.
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "Completed quietly",
          lifecycleStatus: "COMPLETED",
          messages: [{ id: "m1", role: "USER", content: "Pull leads" }],
          turns: [
            {
              id: "turn-1",
              status: "succeeded",
              invocationSource: "chat_message",
              finishedAt: "2026-05-09T08:01:05Z",
              events: [
                {
                  id: "e1",
                  eventType: "browser_automation_started",
                  payload: { url: "https://example.com" },
                  createdAt: "2026-05-09T08:01:00Z",
                },
              ],
            },
          ],
        }}
      />,
    );
    const disclosure = getThinkingDisclosure();
    expect(disclosure.getAttribute("data-state")).toBe("closed");
  });

  it("nests the Run failed row inside the Thinking disclosure when a turn errors", () => {
    // The Run failed row lives inside Thinking now. Thinking is collapsed
    // by default to avoid content shift, so the error is one click away.
    // without forcing the user to expand a closed disclosure.
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "Failed turn",
          lifecycleStatus: "COMPLETED",
          messages: [{ id: "m1", role: "USER", content: "Reach the page" }],
          turns: [
            {
              id: "turn-1",
              status: "failed",
              invocationSource: "chat_message",
              finishedAt: "2026-05-09T08:01:05Z",
              error: "Browser session timed out",
            },
          ],
        }}
      />,
    );
    const disclosure = getThinkingDisclosure();
    expect(disclosure.getAttribute("data-state")).toBe("closed");
    expect(screen.queryByText("Run failed")).toBeNull();
    expect(screen.queryByText("Browser session timed out")).toBeNull();

    fireEvent.click(screen.getByRole("button", { name: /thinking/i }));
    expect(getThinkingDisclosure().getAttribute("data-state")).toBe("open");
    expect(screen.queryByText("Run failed")).toBeTruthy();
    expect(screen.queryByText("Browser session timed out")).toBeTruthy();
  });

  it("defaults Thinking closed for every turn status to prevent content shift", () => {
    // The user explicitly does not want streaming action rows pushing the
    // page taller as a turn runs. Closing Thinking by default keeps the
    // viewport stable; ProcessingShimmer is the only in-flight signal that
    // grows the height (and only by one line). User can click to expand.
    for (const status of [
      "running",
      "pending",
      "queued",
      "claimed",
      "completed",
      "succeeded",
      "failed",
    ] as const) {
      const { unmount } = render(
        <TaskThreadView
          thread={{
            id: `thread-${status}`,
            title: `${status} turn`,
            lifecycleStatus: "RUNNING",
            messages: [{ id: "m1", role: "USER", content: "Start" }],
            turns: [
              {
                id: "turn-1",
                status,
                invocationSource: "chat_message",
              },
            ],
          }}
        />,
      );
      const disclosure = getThinkingDisclosure();
      expect(disclosure.getAttribute("data-state")).toBe("closed");
      unmount();
    }
  });

  it("preserves user-toggled Thinking state across passive re-renders within the same status", () => {
    // No more key-based remount on status flip — the user's manual expand
    // sticks even when the parent re-renders for unrelated reasons (chunk
    // arrival, polling, etc).
    const baseThread = {
      id: "thread-1",
      title: "Toggle persistence",
      lifecycleStatus: "RUNNING",
      messages: [{ id: "m1", role: "USER", content: "Run" }],
      turns: [
        {
          id: "turn-1",
          status: "running",
          invocationSource: "chat_message",
        },
      ],
    };

    const { rerender } = render(<TaskThreadView thread={baseThread} />);
    const disclosure = getThinkingDisclosure();
    expect(disclosure.getAttribute("data-state")).toBe("closed");

    fireEvent.click(screen.getByRole("button", { name: /thinking/i }));

    rerender(<TaskThreadView thread={{ ...baseThread }} />);
    const reRendered = getThinkingDisclosure();
    expect(reRendered.getAttribute("data-state")).toBe("open");
  });

  it("does not synthesize a fallback response when a new turn is in flight after a previous completed turn", () => {
    // Regression: withTurnResponseFallback used to append the latest *completed*
    // turn's response after the latest user message, even when the latest user
    // message was a brand-new question whose own turn was still running. The
    // result was the previous answer rendered as a phantom duplicate below the
    // new question's running Thinking row.
    const previousResponse =
      "Two great options at the same location — Springdale General.";
    render(
      <TaskThreadView
        thread={{
          id: "thread-flight",
          title: "Mid-flight follow-up",
          lifecycleStatus: "RUNNING",
          messages: [
            {
              id: "u1",
              role: "USER",
              content: "Find the farmer's market",
              createdAt: "2026-05-09T10:00:00Z",
            },
            {
              id: "a1",
              role: "ASSISTANT",
              content: previousResponse,
              createdAt: "2026-05-09T10:00:30Z",
            },
            {
              id: "u2",
              role: "USER",
              content: "What is its address?",
              createdAt: "2026-05-09T10:01:00Z",
            },
          ],
          turns: [
            // Newest first (resolver emits DESC). The new turn for u2 is still
            // running; the previous turn for u1 is completed but its response
            // is already represented by message a1.
            {
              id: "turn-2",
              status: "running",
              invocationSource: "chat_message",
              startedAt: "2026-05-09T10:01:01Z",
            },
            {
              id: "turn-1",
              status: "succeeded",
              invocationSource: "chat_message",
              startedAt: "2026-05-09T10:00:00Z",
              finishedAt: "2026-05-09T10:00:30Z",
              resultJson: { response: previousResponse },
            },
          ],
        }}
      />,
    );

    // The previous response should appear exactly once (as the persisted
    // assistant message a1), not twice (no synthesized duplicate below u2).
    expect(
      screen.getAllByText(previousResponse, { exact: false }),
    ).toHaveLength(1);
  });

  it("renders live tool_invocation_started events with toolActionTitle formatting", () => {
    // U4 regression guard: the Strands runtime emits tool_invocation_started
    // events as tools begin (instead of waiting for end-of-turn). The UI
    // must format them with the same toolActionTitle helper used for
    // post-turn usage.tool_invocations so the live row's title matches what
    // the row will look like once the turn finishes.
    render(
      <TaskThreadView
        thread={{
          id: "thread-live",
          title: "Live tools",
          lifecycleStatus: "RUNNING",
          messages: [{ id: "u1", role: "USER", content: "Find sources" }],
          turns: [
            {
              id: "turn-1",
              status: "running",
              invocationSource: "chat_message",
              events: [
                {
                  id: "e1",
                  eventType: "tool_invocation_started",
                  payload: {
                    tool_name: "web_search",
                    tool_use_id: "tool-1",
                    input_preview: "best brunch east austin",
                  },
                  createdAt: "2026-05-09T11:30:00Z",
                },
                {
                  id: "e2",
                  eventType: "tool_invocation_started",
                  payload: { tool_name: "recall", tool_use_id: "tool-2" },
                  createdAt: "2026-05-09T11:30:01Z",
                },
              ],
            },
          ],
        }}
      />,
    );
    // toolActionTitle maps "web_search" → "Finding sources" and "recall" →
    // "Checking memory" — verifying the live event uses that formatter.
    openThinkingDisclosure();
    expect(screen.getByText("Finding sources")).toBeTruthy();
    expect(screen.getByText("Checking memory")).toBeTruthy();
  });

  it("dedupes live tool_invocation_started events against post-turn usage.tool_invocations", () => {
    // U4 regression guard: when a turn finishes, the same tool appears in
    // both `usage.tool_invocations` (post-turn reconstruction) and the
    // streaming events list. Without dedup, the row renders twice.
    render(
      <TaskThreadView
        thread={{
          id: "thread-dedup",
          title: "Dedup",
          lifecycleStatus: "COMPLETED",
          messages: [{ id: "u1", role: "USER", content: "Find sources" }],
          turns: [
            {
              id: "turn-1",
              status: "succeeded",
              invocationSource: "chat_message",
              usageJson: {
                tool_invocations: [
                  {
                    tool_name: "web_search",
                    input_preview: "best brunch east austin",
                    output_preview: "...",
                    status: "success",
                  },
                ],
              },
              events: [
                {
                  id: "e1",
                  eventType: "tool_invocation_started",
                  payload: {
                    tool_name: "web_search",
                    tool_use_id: "tool-1",
                  },
                  createdAt: "2026-05-09T11:30:00Z",
                },
              ],
            },
          ],
        }}
      />,
    );
    // Exactly one "Finding sources" row, not two.
    openThinkingDisclosure();
    expect(screen.getAllByText("Finding sources")).toHaveLength(1);
  });

  it("renders one Thinking disclosure per turn, anchored to its user message in chronological order", () => {
    // U3 regression guard: prior behavior attached only the latest turn's
    // activity to the latest user message, leaving earlier turns invisible.
    // Admin shows a Thinking row per user/computer pair; Computer must match.
    render(
      <TaskThreadView
        thread={{
          id: "thread-multi",
          title: "Multi-turn",
          lifecycleStatus: "COMPLETED",
          messages: [
            { id: "u1", role: "USER", content: "First question" },
            {
              id: "a1",
              role: "ASSISTANT",
              content: "First answer",
            },
            { id: "u2", role: "USER", content: "Second question" },
            {
              id: "a2",
              role: "ASSISTANT",
              content: "Second answer",
            },
          ],
          // Resolver emits turns DESC; the component must sort ASC before
          // pairing with user messages.
          turns: [
            {
              id: "turn-2",
              status: "succeeded",
              invocationSource: "chat_message",
              startedAt: "2026-05-09T10:05:00Z",
              finishedAt: "2026-05-09T10:05:30Z",
            },
            {
              id: "turn-1",
              status: "succeeded",
              invocationSource: "chat_message",
              startedAt: "2026-05-09T10:00:00Z",
              finishedAt: "2026-05-09T10:00:30Z",
            },
          ],
        }}
      />,
    );

    // Exactly one Thinking summary per turn, both with the labelled-region affordance.
    const thinkingDetailsList = screen.getAllByLabelText(
      "Thinking and tool activity",
    );
    expect(thinkingDetailsList).toHaveLength(2);

    // Chronological order: the first user's Thinking row must appear before
    // the second user's Thinking row in the DOM.
    const [first, second] = thinkingDetailsList;
    expect(
      first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING,
    ).toBeTruthy();
  });

  it("keeps the Thinking summary aria-label intact on the Reasoning disclosure", () => {
    // U2 / DL-003: dropping the <article> wrapper must not lose the labelled
    // region affordance. Screen readers continue to find the same name.
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "Aria check",
          lifecycleStatus: "RUNNING",
          messages: [{ id: "m1", role: "USER", content: "Run" }],
          turns: [
            {
              id: "turn-1",
              status: "running",
              invocationSource: "chat_message",
            },
          ],
        }}
      />,
    );
    const labelled = screen.getByLabelText("Thinking and tool activity");
    expect(labelled.getAttribute("data-state")).toBe("closed");
  });

  it("sends follow-up messages from the composer", async () => {
    // Plan-012 U13: PromptInput form submit is async (Promise.all
    // chain through file conversion before dispatch). useComposerState
    // tracks the text so we type via the textarea and click submit;
    // waitFor handles the microtask boundary.
    const onSendFollowUp = vi.fn();
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "Follow-up thread",
          lifecycleStatus: "IDLE",
          messages: [
            {
              id: "message-1",
              role: "USER",
              content: "Start",
            },
          ],
        }}
        onSendFollowUp={onSendFollowUp}
      />,
    );

    fireEvent.change(screen.getByLabelText("Follow up"), {
      target: { value: "Add detail" },
    });
    fireEvent.click(screen.getByRole("button", { name: /^send$/i }));

    await waitFor(() => {
      // U1 of finance pilot — FollowUpComposer forwards a files array
      // (empty when no attachments) alongside the text. The route
      // uploads files before sendMessage and embeds attachmentId refs
      // in metadata.attachments.
      expect(onSendFollowUp).toHaveBeenCalledWith("Add detail", [], []);
    });
  });

  it("shows a contains-filtered mention picker and submits the selected mention", async () => {
    const onSendFollowUp = vi.fn();
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "Mention thread",
          lifecycleStatus: "IDLE",
          messages: [
            {
              id: "message-1",
              role: "USER",
              content: "Start",
            },
          ],
        }}
        mentionTargets={[
          {
            id: "user:u1",
            targetType: "USER",
            targetId: "u1",
            displayName: "Scott Odom",
            role: "eric@thinkwork.ai",
          },
          {
            id: "agent:a1",
            targetType: "AGENT",
            targetId: "a1",
            displayName: "Marco",
            role: "agent",
          },
        ]}
        onSendFollowUp={onSendFollowUp}
      />,
    );

    const input = screen.getByLabelText("Follow up") as HTMLTextAreaElement;
    fireEvent.change(input, { target: { value: "@cot" } });

    expect(screen.getByText("Scott Odom")).toBeTruthy();
    expect(screen.queryByText("Marco")).toBeNull();

    fireEvent.click(screen.getByRole("option", { name: /Scott Odom/ }));
    expect(input.value).toBe("@Scott Odom ");

    fireEvent.click(screen.getByRole("button", { name: /^send$/i }));

    await waitFor(() => {
      expect(onSendFollowUp).toHaveBeenCalledWith(
        "@Scott Odom",
        [],
        [
          {
            targetType: "USER",
            targetId: "u1",
            displayName: "Scott Odom",
            rawText: "@Scott Odom",
          },
        ],
      );
    });
  });

  it("supports keyboard selection in the mention picker", () => {
    render(
      <TaskThreadView
        thread={{
          id: "thread-1",
          title: "Mention thread",
          lifecycleStatus: "IDLE",
          messages: [
            {
              id: "message-1",
              role: "USER",
              content: "Start",
            },
          ],
        }}
        mentionTargets={[
          {
            id: "user:u1",
            targetType: "USER",
            targetId: "u1",
            displayName: "Scott Odom",
            role: "member",
          },
          {
            id: "agent:a1",
            targetType: "AGENT",
            targetId: "a1",
            displayName: "Marco",
            role: "agent",
          },
        ]}
        onSendFollowUp={vi.fn()}
      />,
    );

    const input = screen.getByLabelText("Follow up") as HTMLTextAreaElement;
    fireEvent.change(input, { target: { value: "@o" } });

    let options = screen.getAllByRole("option");
    expect(options[0].getAttribute("aria-selected")).toBe("true");

    fireEvent.keyDown(input, { key: "ArrowDown" });
    options = screen.getAllByRole("option");
    expect(options[1].getAttribute("aria-selected")).toBe("true");

    fireEvent.keyDown(input, { key: "Enter" });
    expect(input.value).toBe("@Marco ");
  });

  describe("collapsible user message body", () => {
    // jsdom returns 0 for scrollHeight by default. Mock the prototype
    // getter so the layout effect inside CollapsibleUserMessageBody can
    // decide whether a message overflows the 10-line clamp (280px).
    function mockScrollHeight(value: number) {
      Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
        configurable: true,
        get() {
          return value;
        },
      });
    }

    afterEach(() => {
      delete (HTMLElement.prototype as { scrollHeight?: unknown }).scrollHeight;
    });

    function renderUserMessage(content: string) {
      return render(
        <TaskThreadView
          thread={{
            id: "thread-collapse",
            messages: [
              {
                id: "user-1",
                role: "USER",
                content,
              },
            ],
          }}
        />,
      );
    }

    it("renders short user messages without a Show more affordance", () => {
      mockScrollHeight(120);
      renderUserMessage("Tiny prompt");

      const wrapper = screen.getByTestId("collapsible-user-body");
      expect(wrapper.getAttribute("data-collapsed")).toBe("false");
      expect(wrapper.style.maxHeight).toBe("");
      expect(screen.queryByRole("button", { name: /show more/i })).toBeNull();
    });

    it("clips long user messages and surfaces a Show more affordance", () => {
      mockScrollHeight(800);
      renderUserMessage("Long prompt that exceeds the clamp threshold");

      const wrapper = screen.getByTestId("collapsible-user-body");
      expect(wrapper.getAttribute("data-collapsed")).toBe("true");
      expect(wrapper.style.maxHeight).toBe("280px");
      expect(wrapper.className).toContain("overflow-hidden");
      expect(screen.getByRole("button", { name: /show more/i })).toBeTruthy();
    });

    it("expands to full content when Show more is clicked", () => {
      mockScrollHeight(800);
      renderUserMessage("Long prompt that overflows");

      const button = screen.getByRole("button", { name: /show more/i });
      fireEvent.click(button);

      const wrapper = screen.getByTestId("collapsible-user-body");
      expect(wrapper.getAttribute("data-collapsed")).toBe("false");
      expect(wrapper.style.maxHeight).toBe("");
      // The clamp affordance flips to "Show less" once expanded so the
      // user can re-collapse without scrolling past the full prompt.
      expect(screen.queryByRole("button", { name: /show more/i })).toBeNull();
      expect(screen.getByRole("button", { name: /show less/i })).toBeTruthy();
    });

    it("re-collapses back to clipped state when Show less is clicked", () => {
      mockScrollHeight(800);
      renderUserMessage("Long prompt that overflows");

      fireEvent.click(screen.getByRole("button", { name: /show more/i }));
      fireEvent.click(screen.getByRole("button", { name: /show less/i }));

      const wrapper = screen.getByTestId("collapsible-user-body");
      expect(wrapper.getAttribute("data-collapsed")).toBe("true");
      expect(wrapper.style.maxHeight).toBe("280px");
      expect(screen.getByRole("button", { name: /show more/i })).toBeTruthy();
      expect(screen.queryByRole("button", { name: /show less/i })).toBeNull();
    });

    it("re-evaluates overflow when the body content grows past the threshold", () => {
      mockScrollHeight(120);
      const { rerender } = renderUserMessage("Tiny prompt");

      expect(screen.queryByRole("button", { name: /show more/i })).toBeNull();

      mockScrollHeight(900);
      rerender(
        <TaskThreadView
          thread={{
            id: "thread-collapse",
            messages: [
              {
                id: "user-1",
                role: "USER",
                content: "Now-long prompt that exceeds the clamp",
              },
            ],
          }}
        />,
      );

      const wrapper = screen.getByTestId("collapsible-user-body");
      expect(wrapper.getAttribute("data-collapsed")).toBe("true");
      expect(wrapper.style.maxHeight).toBe("280px");
      expect(screen.getByRole("button", { name: /show more/i })).toBeTruthy();
    });

    it("leaves long assistant messages unchanged", () => {
      // Even with a giant scrollHeight, the assistant render path does
      // not route through CollapsibleUserMessageBody, so no clamp wrapper
      // is added to the assistant message. (The user message, also
      // mocked at 900px, legitimately surfaces its own "Show more" — the
      // invariant being checked here is wrapper count and assistant
      // content presence.)
      const longAssistantBody =
        "A very long assistant response that would overflow if " +
        "the clamp logic applied to it, but must not.";
      mockScrollHeight(900);
      render(
        <TaskThreadView
          thread={{
            id: "thread-collapse-assistant",
            messages: [
              {
                id: "user-1",
                role: "USER",
                content: "short",
              },
              {
                id: "assistant-1",
                role: "ASSISTANT",
                content: longAssistantBody,
              },
            ],
          }}
        />,
      );

      // Exactly one CollapsibleUserMessageBody wrapper exists — the
      // user message. The assistant message never receives one.
      expect(screen.getAllByTestId("collapsible-user-body")).toHaveLength(1);

      // The assistant message renders inside the assistant DOM region
      // with its content visible (no clamp).
      const assistantNode = document.querySelector(
        '[data-message-role="assistant"]',
      );
      expect(assistantNode).toBeTruthy();
      expect(assistantNode?.textContent ?? "").toContain(longAssistantBody);
      expect(
        assistantNode?.querySelector('[data-testid="collapsible-user-body"]'),
      ).toBeNull();
    });

    it("preserves the empty-body fallback for messages with no content", () => {
      mockScrollHeight(0);
      renderUserMessage("");

      expect(screen.getByText("(No message content)")).toBeTruthy();
      expect(screen.queryByTestId("collapsible-user-body")).toBeNull();
      expect(screen.queryByRole("button", { name: /show more/i })).toBeNull();
    });
  });
});
