// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========

import { TimelineModeRenderer } from '@/components/ChatBox/TimelineModes';
import {
  composeTimelineRuns,
  reconcileTimelineRun,
} from '@/lib/projector/chat/presentation';
import type { ChatProjectionNode } from '@/lib/projector/chat/types';
import { getSessionPreviewSlice, usePageTabStore } from '@/store/pageTabStore';
import { SessionMode } from '@/types/constants';
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';

const motionPreference = vi.hoisted(() => ({ reduced: false }));

vi.mock('framer-motion', async () => {
  const actual =
    await vi.importActual<typeof import('framer-motion')>('framer-motion');
  return {
    ...actual,
    useReducedMotion: () => motionPreference.reduced,
  };
});

const base = {
  projectId: 'project-1',
  runId: 'run-1',
  cloudCursor: 1,
  legacyStep: null,
} as const;

function nodes(status: 'running' | 'completed'): ChatProjectionNode[] {
  const result: ChatProjectionNode[] = [
    {
      ...base,
      kind: 'message',
      id: 'user-1',
      eventId: 'user-1',
      eventType: 'user.message',
      runSequence: 1,
      createdAt: '2026-08-19T00:00:00Z',
      role: 'user',
      purpose: 'query',
      status: 'complete',
      content: 'Review the event timeline',
      attachments: [
        {
          fileName: 'timeline-notes.md',
          filePath: 'uploads/timeline-notes.md',
          source: 'upload',
        },
      ],
    },
    {
      ...base,
      kind: 'message',
      id: 'narration-1',
      eventId: 'narration-1',
      eventType: 'message.completed',
      runSequence: 2,
      createdAt: '2026-08-19T00:00:01Z',
      role: 'assistant',
      purpose: 'narration',
      status: 'complete',
      content: 'I will inspect the implementation.',
      agentName: 'Developer Agent',
    },
    {
      ...base,
      kind: 'activity',
      id: 'tool-start',
      eventId: 'tool-start',
      eventType: 'tool.started',
      runSequence: 3,
      createdAt: '2026-08-19T00:00:02Z',
      activityType: 'tool',
      phase: 'started',
      status: 'running',
      title: 'read_file',
      input: 'src/components/ChatBox/index.tsx',
      toolCallId: 'call-1',
      toolkitName: 'File Toolkit',
      methodName: 'read_file',
    },
  ];

  if (status === 'completed') {
    result.push(
      {
        ...base,
        kind: 'activity',
        id: 'tool-end',
        eventId: 'tool-end',
        eventType: 'tool.completed',
        runSequence: 4,
        createdAt: '2026-08-19T00:00:03Z',
        activityType: 'tool',
        phase: 'completed',
        status: 'completed',
        title: 'read_file',
        output: 'Loaded 220 lines',
        toolCallId: 'call-1',
        toolkitName: 'File Toolkit',
        methodName: 'read_file',
      },
      {
        ...base,
        kind: 'artifact',
        id: 'artifact-1',
        eventId: 'artifact-1',
        eventType: 'artifact.updated',
        runSequence: 5,
        createdAt: '2026-08-19T00:00:04Z',
        operation: 'updated',
        path: 'src/timeline.tsx',
        relativePath: 'src/timeline.tsx',
      },
      {
        ...base,
        kind: 'run_status',
        id: 'run-completed',
        eventId: 'run-completed',
        eventType: 'run.completed',
        runSequence: 6,
        createdAt: '2026-08-19T00:00:05Z',
        status: 'completed',
      }
    );
  }

  return result;
}

function interactionNodes(
  status: 'requested' | 'responded'
): ChatProjectionNode[] {
  return [
    {
      ...base,
      kind: 'interaction',
      id: 'interaction-request',
      eventId: 'interaction-request',
      eventType:
        status === 'requested'
          ? 'interaction.requested'
          : 'interaction.resolved',
      runSequence: 1,
      createdAt: '2026-08-19T00:00:00Z',
      interactionId: 'format-choice',
      interactionType: 'choice',
      status,
      prompt: 'Which output format should I use?',
      response: status === 'responded' ? 'Markdown file' : undefined,
      options: [{ id: 'markdown', label: 'Markdown file' }],
    },
  ];
}

function runStatusNodes(
  status: 'completed' | 'running' | 'failed' | 'cancelling' | 'cancelled'
): ChatProjectionNode[] {
  return [
    {
      ...base,
      kind: 'run_status',
      id: `run-${status}`,
      eventId: `run-${status}`,
      eventType: `run.${status}`,
      runSequence: 1,
      createdAt: '2026-08-19T00:00:00Z',
      status,
    },
  ];
}

function normalToolActivity({
  id,
  runSequence,
  status = 'running',
  toolkitName = 'File Toolkit',
  methodName = 'read_file',
  title = `${toolkitName} · ${methodName}`,
  toolCallId = id,
  agentName,
}: {
  id: string;
  runSequence: number;
  status?: 'running' | 'completed';
  toolkitName?: string;
  methodName?: string;
  title?: string;
  toolCallId?: string;
  agentName?: string;
}): ChatProjectionNode {
  return {
    ...base,
    kind: 'activity',
    id,
    eventId: id,
    eventType: status === 'running' ? 'tool.started' : 'tool.completed',
    runSequence,
    createdAt: `2026-08-19T00:00:${String(runSequence).padStart(2, '0')}Z`,
    activityType: 'tool',
    phase: status === 'running' ? 'started' : 'completed',
    status,
    title,
    output: status === 'completed' ? `${title} complete` : undefined,
    toolCallId,
    toolkitName,
    methodName,
    agentName,
  };
}

function runningRunStatus(runSequence: number): ChatProjectionNode {
  return {
    ...base,
    kind: 'run_status',
    id: `running-run-${runSequence}`,
    eventId: `running-run-${runSequence}`,
    eventType: 'run.running',
    runSequence,
    createdAt: `2026-08-19T00:00:${String(runSequence).padStart(2, '0')}Z`,
    status: 'running',
  };
}

/**
 * Narrative folds its calls by default. Tests that assert on individual call
 * rows open every segment first, which is also the real drill-down path.
 */
function openNarrativeSegments(container: HTMLElement) {
  for (const trigger of container.querySelectorAll(
    '[data-narrative-segment-trigger]'
  )) {
    fireEvent.click(trigger as HTMLElement);
  }
}

describe('ChatBox timeline modes', () => {
  afterEach(() => {
    vi.useRealTimers();
    motionPreference.reduced = false;
    usePageTabStore.setState({
      sessionPreviewProjectId: null,
      sessionPreviewByProject: {},
    });
  });

  it('renders Detailed as labelled rows with vertical Input then Output', () => {
    const runs = composeTimelineRuns(nodes('completed'));
    const { container } = render(
      <TimelineModeRenderer detailLevel="trajectory" runs={runs} />
    );

    expect(
      container.querySelector('[data-timeline-mode="trajectory"]')
    ).toBeTruthy();
    expect(screen.getByText('USER')).toBeInTheDocument();
    expect(screen.getByText('ASSISTANT')).toBeInTheDocument();
    expect(screen.getByText('TOOL')).toBeInTheDocument();

    const rows = container.querySelectorAll('[data-detailed-trace-row]');
    expect(rows.length).toBeGreaterThan(0);
    expect(container.querySelectorAll('[data-trace-chevron]')).toHaveLength(
      rows.length
    );
    rows.forEach((row) => {
      const rowButton = row.querySelector(':scope > button');
      expect(row).toHaveAttribute('data-expanded', 'false');
      expect(row).toHaveClass(
        'bg-ds-bg-neutral-subtle-default',
        'hover:bg-ds-bg-neutral-default-default'
      );
      expect(row.querySelectorAll(':scope > button')).toHaveLength(1);
      expect(rowButton).toHaveClass('flex', 'flex-row', 'items-center');
      expect(rowButton).not.toHaveClass('grid');
      expect(row.querySelector('[data-trace-summary]')).toHaveClass(
        'flex-1',
        'truncate',
        '!text-label-xs'
      );
      expect(row.querySelector('[data-trace-tag-column]')).toHaveClass(
        'w-28',
        'shrink-0',
        'justify-end'
      );
      expect(row.querySelector('[data-trace-tag]')).toHaveClass(
        'max-w-full',
        'text-right',
        '!text-label-xs'
      );
      expect(row.querySelector('[data-trace-ending]')).toHaveClass(
        'ml-auto',
        'shrink-0'
      );
    });
    expect(
      container.querySelector('[data-timeline-mode="trajectory"] ol')
    ).toHaveClass('bg-transparent');
    const contextTag = container.querySelector(
      '[data-trace-category="context"] [data-trace-tag]'
    );
    const userTag = container.querySelector(
      '[data-trace-category="user"] [data-trace-tag]'
    );
    const assistantTag = container.querySelector(
      '[data-trace-category="assistant"] [data-trace-tag]'
    );
    const toolTag = container.querySelector(
      '[data-trace-category="tool"] [data-trace-tag]'
    );
    const fileTag = container.querySelector(
      '[data-trace-category="file"] [data-trace-tag]'
    );
    expect(userTag).toHaveClass(
      'bg-ds-category-blue-background-default',
      'text-ds-category-blue-text-strong'
    );
    expect(contextTag).toHaveClass(
      'bg-ds-category-slate-background-default',
      'text-ds-category-slate-text-strong'
    );
    expect(assistantTag).toHaveClass(
      'bg-ds-category-indigo-background-default',
      'text-ds-category-indigo-text-strong'
    );
    expect(toolTag).toHaveClass(
      'bg-ds-category-teal-background-default',
      'text-ds-category-teal-text-strong'
    );
    expect(fileTag).toHaveClass(
      'bg-ds-category-green-background-default',
      'text-ds-category-green-text-strong'
    );
    expect(contextTag?.className).not.toBe(toolTag?.className);

    const toolRow = container.querySelector(
      '[data-trace-category="tool"]'
    ) as HTMLElement;
    fireEvent.click(within(toolRow).getByRole('button'));
    const input = within(toolRow).getByText('Input');
    const output = within(toolRow).getByText('Output');
    expect(
      input.compareDocumentPosition(output) & Node.DOCUMENT_POSITION_FOLLOWING
    ).toBeTruthy();
    expect(
      within(toolRow).getByText('src/components/ChatBox/index.tsx')
    ).toBeInTheDocument();
    expect(within(toolRow).getByText('Loaded 220 lines')).toBeInTheDocument();
    expect(within(toolRow).getByText('completed').parentElement).toHaveClass(
      '!text-label-xs'
    );
  });

  it('opens a trajectory Artifact in the owning Run review', () => {
    usePageTabStore.setState({
      sessionPreviewProjectId: 'project-1',
      sessionPreviewByProject: {},
    });
    const { container } = render(
      <TimelineModeRenderer
        detailLevel="trajectory"
        runs={composeTimelineRuns(nodes('completed'))}
      />
    );
    const fileRow = container.querySelector(
      '[data-trace-category="file"]'
    ) as HTMLElement;

    fireEvent.click(fileRow.querySelector(':scope > button') as HTMLElement);
    fireEvent.click(within(fileRow).getByTitle('Review this change'));

    const preview = getSessionPreviewSlice(usePageTabStore.getState());
    expect(preview.tabs[0]).toMatchObject({
      type: 'review',
      reviewTarget: {
        scope: 'run',
        runId: 'run-1',
        focusPath: 'src/timeline.tsx',
      },
    });
  });

  it('auto-expands a pending question and folds it after the answer arrives', () => {
    const { container, rerender } = render(
      <TimelineModeRenderer
        detailLevel="trajectory"
        runs={composeTimelineRuns(interactionNodes('requested'))}
      />
    );

    const row = container.querySelector(
      '[data-trace-category="input-required"]'
    ) as HTMLElement;
    expect(row).toHaveAttribute('data-expanded', 'true');
    expect(row.querySelector('[data-trace-tag]')).toHaveClass(
      'bg-ds-category-amber-background-default',
      'text-ds-category-amber-text-strong'
    );
    expect(within(row).getByText('Question')).toHaveClass('!text-label-xs');
    expect(
      within(row).getAllByText('Which output format should I use?')[1]
    ).toHaveClass('!text-label-xs');

    rerender(
      <TimelineModeRenderer
        detailLevel="trajectory"
        runs={composeTimelineRuns(interactionNodes('responded'))}
      />
    );

    const resolvedRow = container.querySelector(
      '[data-trace-category="input-required"]'
    ) as HTMLElement;
    expect(resolvedRow).toHaveAttribute('data-expanded', 'false');
    expect(within(resolvedRow).queryByText('Question')).toBeNull();
    expect(within(resolvedRow).queryByText('Answer')).toBeNull();

    fireEvent.click(within(resolvedRow).getByRole('button'));
    expect(within(resolvedRow).getByText('Question')).toHaveClass(
      '!text-label-xs'
    );
    expect(within(resolvedRow).getByText('Answer')).toHaveClass(
      '!text-label-xs'
    );
    expect(within(resolvedRow).getByText('Markdown file')).toHaveClass(
      '!text-label-xs'
    );
  });

  it('uses lightly tinted backgrounds with strong text for agent tags', () => {
    const agentNode: ChatProjectionNode = {
      ...base,
      kind: 'activity',
      id: 'agent-status',
      eventId: 'agent-status',
      eventType: 'agent.completed',
      runSequence: 1,
      createdAt: '2026-08-19T00:00:00Z',
      activityType: 'agent',
      phase: 'completed',
      status: 'completed',
      title: 'question_confirm_agent',
    };
    const { container } = render(
      <TimelineModeRenderer
        detailLevel="trajectory"
        runs={composeTimelineRuns([agentNode])}
      />
    );
    const agentTag = container.querySelector(
      '[data-trace-category="agent"] [data-trace-tag]'
    );

    expect(agentTag).toHaveClass(
      'bg-ds-category-purple-background-default',
      'text-ds-category-purple-text-strong'
    );
  });

  it('uses the same design-system status color for detailed labels and icons', () => {
    const cases = [
      [
        'completed',
        'text-ds-text-status-completed-default-default',
        '!text-ds-text-status-completed-default-default',
      ],
      [
        'running',
        'text-ds-text-status-running-default-default',
        '!text-ds-text-status-running-default-default',
      ],
      [
        'failed',
        'text-ds-text-status-error-default-default',
        '!text-ds-text-status-error-default-default',
      ],
      [
        'cancelling',
        'text-ds-text-status-cancelled-default-default',
        '!text-ds-text-status-cancelled-default-default',
      ],
      [
        'cancelled',
        'text-ds-text-status-cancelled-default-default',
        '!text-ds-text-status-cancelled-default-default',
      ],
    ] as const;

    cases.forEach(([status, labelTone, iconTone]) => {
      const { container, unmount } = render(
        <TimelineModeRenderer
          detailLevel="trajectory"
          runs={composeTimelineRuns(runStatusNodes(status))}
        />
      );
      const statusLabel = container.querySelector(
        `[data-detailed-status="${status}"]`
      ) as HTMLElement;

      expect(statusLabel).toHaveClass(labelTone);
      expect(statusLabel.querySelector('svg')).toHaveClass(iconTone);
      expect(statusLabel.querySelector('span')).toHaveClass(labelTone);
      unmount();
    });

    const { container } = render(
      <TimelineModeRenderer
        detailLevel="trajectory"
        runs={composeTimelineRuns(interactionNodes('requested'))}
      />
    );
    const inputStatus = container.querySelector(
      '[data-detailed-status="requested"]'
    ) as HTMLElement;

    expect(inputStatus).toHaveClass(
      'text-ds-text-status-pending-default-default'
    );
    expect(inputStatus.querySelector('svg')).toHaveClass(
      '!text-ds-text-status-pending-default-default'
    );
    expect(inputStatus.querySelector('span')).toHaveClass(
      'text-ds-text-status-pending-default-default'
    );
  });

  it('renders event-native Normal with the legacy work-log disclosure', () => {
    const runs = composeTimelineRuns(nodes('running'));
    const { container } = render(
      <TimelineModeRenderer detailLevel="narrative" runs={runs} />
    );

    expect(
      container.querySelector('[data-timeline-mode="narrative"]')
    ).toBeTruthy();
    expect(screen.getByText('Review the event timeline')).toBeInTheDocument();
    expect(screen.getByText('timeline-notes.md')).toHaveClass('!text-label-xs');
    expect(screen.getByText(/Working on tasks for/)).toBeInTheDocument();
    expect(
      container.querySelector('[data-narrative-timeline]')
    ).toBeInTheDocument();

    // Narration is primary text; the calls behind it stay folded until asked
    // for, which is the whole point of the narrative mode.
    expect(
      container.querySelector('[data-narrative-segment-narration]')
    ).toHaveClass('text-ds-text-neutral-default-default');
    expect(container.querySelector('[data-timeline-call-id]')).toBeNull();
    expect(screen.queryByText('Developer Agent')).toBeNull();
    expect(container.querySelector('[data-narrative-agent-group]')).toBeNull();

    openNarrativeSegments(container);

    const tool = container.querySelector(
      '[data-timeline-call-id="call-1"]'
    ) as HTMLElement;
    const trigger = tool.querySelector(
      '[data-timeline-call-trigger]'
    ) as HTMLElement;
    expect(tool).toHaveAttribute('data-timeline-call-status', 'running');
    expect(tool).toHaveAttribute('data-timeline-call-executor', 'toolkit');
    expect(trigger.querySelectorAll('svg')).toHaveLength(1);
    expect(within(tool).getByText('Request')).toBeInTheDocument();
    expect(
      within(tool).getByText('src/components/ChatBox/index.tsx')
    ).toBeInTheDocument();
    expect(within(tool).getByText('Response')).toBeInTheDocument();
    expect(
      within(tool).getByText('Waiting for a response.')
    ).toBeInTheDocument();
  });

  it('keeps Preparing visible until the narrative work band can render', () => {
    const { container, rerender } = render(
      <TimelineModeRenderer
        detailLevel="narrative"
        runs={composeTimelineRuns([runningRunStatus(1)])}
      />
    );

    expect(screen.getByText('Preparing to start tasks')).toBeInTheDocument();
    expect(container.querySelector('[data-narrative-run-work-log]')).toBeNull();

    rerender(
      <TimelineModeRenderer
        detailLevel="narrative"
        runs={composeTimelineRuns([
          normalToolActivity({ id: 'first-work', runSequence: 2 }),
          runningRunStatus(3),
        ])}
      />
    );

    expect(screen.queryByText('Preparing to start tasks')).toBeNull();
    expect(
      container.querySelector('[data-narrative-run-work-log]')
    ).toBeInTheDocument();
  });

  it('highlights only the latest running tool and hands off when it completes', () => {
    const firstTool = normalToolActivity({
      id: 'concurrent-read-start',
      runSequence: 1,
      methodName: 'read_file',
      title: 'Read the repository index',
      toolCallId: 'concurrent-read',
    });
    const secondTool = normalToolActivity({
      id: 'concurrent-write-start',
      runSequence: 2,
      methodName: 'write_file',
      title: 'Write the audit notes',
      toolCallId: 'concurrent-write',
    });
    const activeNodes = [firstTool, secondTool, runningRunStatus(9)];
    const { container, rerender } = render(
      <TimelineModeRenderer
        detailLevel="narrative"
        runs={composeTimelineRuns(activeNodes)}
        sessionMode={SessionMode.SINGLE_AGENT}
      />
    );

    openNarrativeSegments(container);

    const first = container.querySelector(
      '[data-timeline-call-id="concurrent-read"]'
    ) as HTMLElement;
    const second = container.querySelector(
      '[data-timeline-call-id="concurrent-write"]'
    ) as HTMLElement;

    expect(first).toHaveAttribute('data-timeline-call-status', 'running');
    expect(second).toHaveAttribute('data-timeline-call-status', 'running');
    expect(first).not.toHaveAttribute('data-timeline-call-highlighted');
    expect(second).toHaveAttribute('data-timeline-call-highlighted', 'true');
    expect(container.querySelectorAll('.shiny-text')).toHaveLength(1);
    expect(
      within(second).getByText('Waiting for a response.')
    ).toBeInTheDocument();

    fireEvent.click(
      first.querySelector('[data-timeline-call-trigger]') as HTMLElement
    );
    expect(
      within(first).getByText('Waiting for a response.')
    ).toBeInTheDocument();

    rerender(
      <TimelineModeRenderer
        detailLevel="narrative"
        runs={composeTimelineRuns([
          firstTool,
          secondTool,
          normalToolActivity({
            id: 'concurrent-write-complete',
            runSequence: 3,
            status: 'completed',
            methodName: 'write_file',
            title: 'Write the audit notes',
            toolCallId: 'concurrent-write',
          }),
          runningRunStatus(9),
        ])}
        sessionMode={SessionMode.SINGLE_AGENT}
      />
    );

    const updatedFirst = container.querySelector(
      '[data-timeline-call-id="concurrent-read"]'
    ) as HTMLElement;
    const updatedSecond = container.querySelector(
      '[data-timeline-call-id="concurrent-write"]'
    ) as HTMLElement;
    expect(updatedFirst).toHaveAttribute(
      'data-timeline-call-highlighted',
      'true'
    );
    expect(updatedSecond).not.toHaveAttribute('data-timeline-call-highlighted');
    expect(updatedSecond).toHaveAttribute(
      'data-timeline-call-status',
      'completed'
    );
    expect(container.querySelectorAll('.shiny-text')).toHaveLength(1);
  });

  it('renders running reasoning activity in the default text color without shimmer', () => {
    const reasoningNode: ChatProjectionNode = {
      ...base,
      kind: 'activity',
      id: 'readme-reasoning',
      eventId: 'readme-reasoning',
      eventType: 'work_log.progress',
      runSequence: 1,
      createdAt: '2026-08-19T00:00:00Z',
      activityType: 'work_log',
      phase: 'progress',
      status: 'running',
      title: 'Searching for README files in the project',
    };
    const { container } = render(
      <TimelineModeRenderer
        detailLevel="narrative"
        runs={composeTimelineRuns([reasoningNode, runningRunStatus(9)])}
        sessionMode={SessionMode.SINGLE_AGENT}
      />
    );

    const reasoning = screen.getByText(
      'Searching for README files in the project'
    );
    expect(reasoning).toHaveClass('text-ds-text-neutral-default-default');
    expect(reasoning).not.toHaveClass(
      'text-ds-text-neutral-subtle-default',
      'shiny-text'
    );
    expect(container.querySelector('.shiny-text')).toBeNull();
  });

  it('moves the only repeated-tool shimmer from the closed group to the latest child', () => {
    const repeatedTools = [
      normalToolActivity({
        id: 'repeat-one',
        runSequence: 1,
        title: 'Search the first README',
        toolCallId: 'repeat-one',
      }),
      normalToolActivity({
        id: 'repeat-two',
        runSequence: 2,
        title: 'Search the second README',
        toolCallId: 'repeat-two',
      }),
      runningRunStatus(9),
    ];
    const { container } = render(
      <TimelineModeRenderer
        detailLevel="narrative"
        runs={composeTimelineRuns(repeatedTools)}
        sessionMode={SessionMode.SINGLE_AGENT}
      />
    );

    // Two identical consecutive calls form one segment. While it is folded the
    // shimmer sits on the segment label; opening it hands the shimmer to the
    // call that actually owns it, so exactly one indicator shows either way.
    const segmentTrigger = container.querySelector(
      '[data-narrative-segment-trigger]'
    ) as HTMLElement;
    expect(segmentTrigger.querySelector('.shiny-text')).toBeInTheDocument();
    expect(container.querySelectorAll('.shiny-text')).toHaveLength(1);

    fireEvent.click(segmentTrigger);

    const latest = container.querySelector(
      '[data-timeline-call-id="repeat-two"]'
    ) as HTMLElement;
    expect(segmentTrigger.querySelector('.shiny-text')).toBeNull();
    expect(latest).toHaveAttribute('data-timeline-call-highlighted', 'true');
    expect(latest.querySelector('.shiny-text')).toBeInTheDocument();
    expect(container.querySelectorAll('.shiny-text')).toHaveLength(1);
  });

  it('animates the work-log lifecycle when the first live event arrives', () => {
    const { container, rerender } = render(
      <TimelineModeRenderer
        detailLevel="narrative"
        runs={composeTimelineRuns([runningRunStatus(9)])}
        sessionMode={SessionMode.SINGLE_AGENT}
      />
    );
    expect(container.querySelector('[data-narrative-run-motion]')).toBeNull();

    const liveRuns = composeTimelineRuns([
      normalToolActivity({ id: 'first-live-event', runSequence: 1 }),
      runningRunStatus(9),
    ]);
    rerender(
      <TimelineModeRenderer
        detailLevel="narrative"
        runs={liveRuns}
        sessionMode={SessionMode.SINGLE_AGENT}
      />
    );

    const workLogMotion = container.querySelector(
      '[data-narrative-run-motion="standard"]'
    ) as HTMLElement;
    expect(workLogMotion).toHaveAttribute('data-narrative-run-work-log');
    expect(workLogMotion).toHaveAttribute(
      'data-narrative-run-motion-id',
      liveRuns[0]!.id
    );
    expect(workLogMotion).toHaveStyle({
      opacity: '0',
      transform: 'translateY(6px)',
    });
    expect(
      workLogMotion.querySelector('[data-narrative-segment-id]')
    ).toBeInTheDocument();
  });

  it('marks event entries as reduced when reduced motion is requested', () => {
    motionPreference.reduced = true;
    const { container } = render(
      <TimelineModeRenderer
        detailLevel="narrative"
        runs={composeTimelineRuns([
          normalToolActivity({ id: 'reduced-motion', runSequence: 1 }),
          runningRunStatus(9),
        ])}
        sessionMode={SessionMode.SINGLE_AGENT}
      />
    );

    expect(
      container.querySelector('[data-narrative-run-motion="reduced"]')
    ).toBeInTheDocument();
    expect(
      container.querySelector('[data-narrative-run-motion="standard"]')
    ).toBeNull();
  });

  it('renders tool calls as status-free accordions and colors failures as errors', () => {
    const toolNodes: ChatProjectionNode[] = [
      {
        ...base,
        kind: 'activity',
        id: 'success-tool',
        eventId: 'success-tool',
        eventType: 'tool.completed',
        runSequence: 1,
        createdAt: '2026-08-19T00:00:00Z',
        activityType: 'tool',
        phase: 'completed',
        status: 'completed',
        title: 'Success Toolkit · Read',
        output: 'Safe response value',
        toolCallId: 'success-tool',
        toolkitName: 'Success Toolkit',
        methodName: 'Read',
      },
      {
        ...base,
        kind: 'activity',
        id: 'failed-tool',
        eventId: 'failed-tool',
        eventType: 'tool.failed',
        runSequence: 2,
        createdAt: '2026-08-19T00:00:01Z',
        activityType: 'tool',
        phase: 'completed',
        status: 'failed',
        title: 'Failure Toolkit · Write',
        toolCallId: 'failed-tool',
        toolkitName: 'Failure Toolkit',
        methodName: 'Write',
      },
      {
        ...base,
        kind: 'activity',
        id: 'timed-out-tool',
        eventId: 'timed-out-tool',
        eventType: 'tool.timed_out',
        runSequence: 3,
        createdAt: '2026-08-19T00:00:02Z',
        activityType: 'tool',
        phase: 'completed',
        status: 'timed_out',
        title: 'Timeout Toolkit · Fetch',
        toolCallId: 'timed-out-tool',
        toolkitName: 'Timeout Toolkit',
        methodName: 'Fetch',
      },
      {
        ...base,
        kind: 'activity',
        id: 'unknown-tool',
        eventId: 'unknown-tool',
        eventType: 'tool.outcome_unknown',
        runSequence: 4,
        createdAt: '2026-08-19T00:00:03Z',
        activityType: 'tool',
        phase: 'completed',
        status: 'outcome_unknown',
        title: 'Unknown Toolkit · Execute',
        toolCallId: 'unknown-tool',
        toolkitName: 'Unknown Toolkit',
        methodName: 'Execute',
      },
      {
        ...base,
        kind: 'run_status',
        id: 'tool-status-run',
        eventId: 'tool-status-run',
        eventType: 'run.running',
        runSequence: 5,
        createdAt: '2026-08-19T00:00:04Z',
        status: 'running',
      },
    ];

    const { container } = render(
      <TimelineModeRenderer
        detailLevel="narrative"
        runs={composeTimelineRuns(toolNodes)}
        sessionMode={SessionMode.SINGLE_AGENT}
      />
    );

    openNarrativeSegments(container);

    const success = container.querySelector(
      '[data-timeline-call-id="success-tool"]'
    ) as HTMLElement;
    const successTrigger = success.querySelector(
      '[data-timeline-call-trigger]'
    ) as HTMLElement;
    expect(successTrigger).not.toHaveClass(
      'text-ds-text-status-error-default-default'
    );
    expect(successTrigger.querySelectorAll('svg')).toHaveLength(1);
    fireEvent.click(successTrigger);
    expect(within(success).getByText('Request')).toBeInTheDocument();
    expect(
      within(success).getByText('No request was recorded for this event.')
    ).toBeInTheDocument();
    expect(within(success).getByText('Response')).toBeInTheDocument();
    expect(
      within(success).getByText('Safe response value')
    ).toBeInTheDocument();

    for (const status of ['failed', 'timed_out', 'outcome_unknown']) {
      const tool = container.querySelector(
        '[data-timeline-call-status="' + status + '"]'
      ) as HTMLElement;
      const trigger = tool.querySelector(
        '[data-timeline-call-trigger]'
      ) as HTMLElement;
      expect(trigger).toHaveClass('text-ds-text-status-error-default-default');
      expect(trigger.querySelectorAll('svg')).toHaveLength(1);
    }
  });

  it('renders single-agent Normal as one flat chronological event list', () => {
    const chronologicalNodes: ChatProjectionNode[] = [
      {
        ...base,
        kind: 'plan',
        id: 'plan-1',
        eventId: 'plan-1',
        eventType: 'plan.created',
        runSequence: 1,
        createdAt: '2026-08-19T00:00:00Z',
        status: 'active',
        title: 'Plan',
        tasks: [],
      },
      {
        ...base,
        kind: 'message',
        id: 'reasoning-before',
        eventId: 'reasoning-before',
        eventType: 'message.completed',
        runSequence: 2,
        createdAt: '2026-08-19T00:00:01Z',
        role: 'assistant',
        purpose: 'narration',
        status: 'complete',
        content: 'I will search two indexes first.',
        agentName: 'single_agent',
      },
      ...['first', 'second'].map((suffix, index): ChatProjectionNode => ({
        ...base,
        kind: 'activity',
        id: `search-${suffix}`,
        eventId: `search-${suffix}`,
        eventType: 'tool.completed',
        runSequence: index + 3,
        createdAt: `2026-08-19T00:00:0${index + 2}Z`,
        activityType: 'tool',
        phase: 'completed',
        status: 'completed',
        title: 'WebFetchToolkit · Search',
        toolCallId: `search-${suffix}`,
        toolkitName: 'WebFetchToolkit',
        methodName: 'Search',
      })),
      {
        ...base,
        kind: 'message',
        id: 'reasoning-between',
        eventId: 'reasoning-between',
        eventType: 'message.completed',
        runSequence: 5,
        createdAt: '2026-08-19T00:00:04Z',
        role: 'assistant',
        purpose: 'narration',
        status: 'complete',
        content: 'The first indexes disagree, so I need your input.',
      },
      {
        ...base,
        kind: 'interaction',
        id: 'permission-request',
        eventId: 'permission-request',
        eventType: 'approval.requested',
        runSequence: 6,
        createdAt: '2026-08-19T00:00:05Z',
        interactionId: 'approval:search-third',
        interactionType: 'approval',
        status: 'requested',
        prompt: 'Allow another search?',
      },
      {
        ...base,
        kind: 'activity',
        id: 'search-third',
        eventId: 'search-third',
        eventType: 'tool.completed',
        runSequence: 7,
        createdAt: '2026-08-19T00:00:06Z',
        activityType: 'tool',
        phase: 'completed',
        status: 'completed',
        title: 'WebFetchToolkit · Search',
        toolCallId: 'search-third',
        toolkitName: 'WebFetchToolkit',
        methodName: 'Search',
      },
      {
        ...base,
        kind: 'activity',
        id: 'system-event',
        eventId: 'system-event',
        eventType: 'agent.completed',
        runSequence: 8,
        createdAt: '2026-08-19T00:00:07Z',
        activityType: 'agent',
        phase: 'completed',
        status: 'completed',
        title: 'Agent finished',
      },
    ];

    const { container } = render(
      <TimelineModeRenderer
        detailLevel="narrative"
        runs={composeTimelineRuns(chronologicalNodes)}
        sessionMode={SessionMode.SINGLE_AGENT}
      />
    );

    expect(
      container.querySelector('[data-narrative-timeline]')
    ).toBeInTheDocument();

    // Reasoning, then the interrupt it triggered, then the work that followed.
    // Narrative keeps source order; it only changes how each item is drawn.
    const reasoning = screen.getByText(
      'The first indexes disagree, so I need your input.'
    );
    const permission = container.querySelector(
      '[data-timeline-call-executor="human"]'
    ) as HTMLElement;
    const laterSegments = [
      ...container.querySelectorAll('[data-narrative-segment-id]'),
    ];
    const thirdTool = laterSegments.at(-1) as HTMLElement;

    expect(permission).toBeInTheDocument();
    expect(
      reasoning.compareDocumentPosition(permission) &
        Node.DOCUMENT_POSITION_FOLLOWING
    ).toBeTruthy();
    expect(
      permission.compareDocumentPosition(thirdTool) &
        Node.DOCUMENT_POSITION_FOLLOWING
    ).toBeTruthy();
    // Agent lifecycle frames supply identity metadata, never timeline rows.
    expect(screen.queryByText('Agent finished')).toBeNull();
  });

  it('nests each workforce agent in its own accordion', () => {
    const workforceNodes: ChatProjectionNode[] = [
      {
        ...base,
        kind: 'message',
        id: 'alpha-narration',
        eventId: 'alpha-narration',
        eventType: 'message.completed',
        runSequence: 1,
        createdAt: '2026-08-19T00:00:01Z',
        role: 'assistant',
        purpose: 'narration',
        status: 'complete',
        content: 'Alpha will search first.',
        agentId: 'alpha',
        agentName: 'Alpha Agent',
      },
      {
        ...base,
        kind: 'activity',
        id: 'alpha-search',
        eventId: 'alpha-search',
        eventType: 'tool.completed',
        runSequence: 2,
        createdAt: '2026-08-19T00:00:02Z',
        activityType: 'tool',
        phase: 'completed',
        status: 'completed',
        title: 'WebFetchToolkit · Search',
        toolCallId: 'alpha-search',
        toolkitName: 'WebFetchToolkit',
        methodName: 'Search',
        agentId: 'alpha',
        agentName: 'Alpha Agent',
      },
      {
        ...base,
        kind: 'message',
        id: 'beta-narration',
        eventId: 'beta-narration',
        eventType: 'message.completed',
        runSequence: 3,
        createdAt: '2026-08-19T00:00:03Z',
        role: 'assistant',
        purpose: 'narration',
        status: 'complete',
        content: 'Beta will write the notes.',
        agentId: 'beta',
        agentName: 'Beta Agent',
      },
      {
        ...base,
        kind: 'run_status',
        id: 'run-running',
        eventId: 'run-running',
        eventType: 'run.running',
        runSequence: 4,
        createdAt: '2026-08-19T00:00:04Z',
        status: 'running',
      },
    ];

    const { container, rerender } = render(
      <TimelineModeRenderer
        detailLevel="narrative"
        runs={composeTimelineRuns(workforceNodes)}
        sessionMode={SessionMode.WORKFORCE}
      />
    );

    const groups = [
      ...container.querySelectorAll('[data-narrative-agent-group]'),
    ].map((element) => element.getAttribute('data-narrative-agent-group'));
    expect(groups).toEqual(['Alpha Agent', 'Beta Agent']);
    expect(
      screen.queryByText('Alpha will search first.')
    ).not.toBeInTheDocument();
    expect(screen.getByText('Beta will write the notes.')).toBeInTheDocument();

    expect(screen.getByRole('button', { name: 'Alpha Agent' })).toHaveAttribute(
      'aria-expanded',
      'false'
    );
    expect(screen.getByRole('button', { name: 'Beta Agent' })).toHaveAttribute(
      'aria-expanded',
      'true'
    );
    rerender(
      <TimelineModeRenderer
        detailLevel="narrative"
        paused
        runs={composeTimelineRuns(workforceNodes)}
        sessionMode={SessionMode.WORKFORCE}
      />
    );
    expect(screen.getByRole('button', { name: 'Alpha Agent' })).toHaveAttribute(
      'aria-expanded',
      'false'
    );
    expect(screen.getByRole('button', { name: 'Beta Agent' })).toHaveAttribute(
      'aria-expanded',
      'true'
    );
    rerender(
      <TimelineModeRenderer
        detailLevel="narrative"
        runs={composeTimelineRuns(workforceNodes)}
        sessionMode={SessionMode.WORKFORCE}
      />
    );
    expect(screen.getByRole('button', { name: 'Alpha Agent' })).toHaveAttribute(
      'aria-expanded',
      'false'
    );

    fireEvent.click(screen.getByRole('button', { name: 'Alpha Agent' }));
    expect(screen.getByText('Alpha will search first.')).toBeInTheDocument();
  });

  it.each(['completed', 'failed', 'cancelled', 'interrupted'] as const)(
    'shows terminal %s Run artifacts without a final assistant response',
    (status) => {
      const artifact = nodes('completed').find(
        (node) => node.kind === 'artifact'
      )!;
      const terminalStatus: ChatProjectionNode = {
        ...base,
        kind: 'run_status',
        id: `run-${status}`,
        eventId: `run-${status}`,
        eventType: `run.${status}`,
        runSequence: 6,
        createdAt: '2026-08-19T00:00:05Z',
        status,
      };

      render(
        <TimelineModeRenderer
          detailLevel="narrative"
          runs={composeTimelineRuns([artifact, terminalStatus])}
        />
      );

      expect(screen.getByText('Files changed')).toBeInTheDocument();
    }
  );

  it('keeps approvals at their chronological position after a repeated event group', () => {
    const approvalNodes: ChatProjectionNode[] = [
      {
        ...base,
        kind: 'activity',
        id: 'mcp-call-1-start',
        eventId: 'mcp-call-1-start',
        eventType: 'tool.started',
        runSequence: 1,
        createdAt: '2026-08-19T00:00:00Z',
        activityType: 'tool',
        phase: 'started',
        status: 'running',
        title: 'MCPToolkit · Execute_action one',
        toolCallId: 'mcp-call-1',
        toolkitName: 'MCPToolkit',
        methodName: 'Execute_action',
      },
      {
        ...base,
        kind: 'activity',
        id: 'mcp-call-2-start',
        eventId: 'mcp-call-2-start',
        eventType: 'tool.started',
        runSequence: 2,
        createdAt: '2026-08-19T00:00:01Z',
        activityType: 'tool',
        phase: 'started',
        status: 'running',
        title: 'MCPToolkit · Execute_action two',
        toolCallId: 'mcp-call-2',
        toolkitName: 'MCPToolkit',
        methodName: 'Execute_action',
      },
      {
        ...base,
        kind: 'interaction',
        id: 'approval-call-1',
        eventId: 'approval-call-1',
        eventType: 'approval.decided',
        runSequence: 3,
        createdAt: '2026-08-19T00:00:02Z',
        interactionId: 'approval:mcp-call-1',
        interactionType: 'approval',
        status: 'responded',
        prompt: 'Allow the first MCP action?',
        response: 'approved',
      },
      {
        ...base,
        kind: 'interaction',
        id: 'approval-call-2',
        eventId: 'approval-call-2',
        eventType: 'approval.decided',
        runSequence: 4,
        createdAt: '2026-08-19T00:00:03Z',
        interactionId: 'approval:mcp-call-2',
        interactionType: 'approval',
        status: 'responded',
        prompt: 'Allow the second MCP action?',
        response: 'rejected',
      },
      {
        ...base,
        kind: 'activity',
        id: 'mcp-call-1-end',
        eventId: 'mcp-call-1-end',
        eventType: 'tool.completed',
        runSequence: 5,
        createdAt: '2026-08-19T00:00:04Z',
        activityType: 'tool',
        phase: 'completed',
        status: 'completed',
        title: 'MCPToolkit · Execute_action one',
        toolCallId: 'mcp-call-1',
        toolkitName: 'MCPToolkit',
        methodName: 'Execute_action',
      },
      {
        ...base,
        kind: 'activity',
        id: 'mcp-call-2-end',
        eventId: 'mcp-call-2-end',
        eventType: 'tool.completed',
        runSequence: 6,
        createdAt: '2026-08-19T00:00:05Z',
        activityType: 'tool',
        phase: 'completed',
        status: 'completed',
        title: 'MCPToolkit · Execute_action two',
        toolCallId: 'mcp-call-2',
        toolkitName: 'MCPToolkit',
        methodName: 'Execute_action',
      },
      {
        ...base,
        kind: 'run_status',
        id: 'approval-run-running',
        eventId: 'approval-run-running',
        eventType: 'run.running',
        runSequence: 7,
        createdAt: '2026-08-19T00:00:06Z',
        status: 'running',
      },
    ];

    const { container } = render(
      <TimelineModeRenderer
        detailLevel="narrative"
        runs={composeTimelineRuns(approvalNodes)}
        sessionMode={SessionMode.SINGLE_AGENT}
      />
    );

    const toolSegment = container.querySelector(
      '[data-narrative-segment-id]'
    ) as HTMLElement;
    const receipts = container.querySelectorAll(
      '[data-timeline-call-executor="human"]'
    );

    // Both decisions are recorded like tool calls, with the human as the
    // executor, and neither is folded into the toolkit segment before them.
    expect(receipts).toHaveLength(2);
    expect(
      within(receipts[0] as HTMLElement).getByText('You · Allowed')
    ).toBeInTheDocument();
    expect(
      within(receipts[1] as HTMLElement).getByText('You · Rejected')
    ).toBeInTheDocument();
    expect(
      toolSegment.compareDocumentPosition(receipts[0]!) &
        Node.DOCUMENT_POSITION_FOLLOWING
    ).toBeTruthy();
    expect(
      receipts[0]!.compareDocumentPosition(receipts[1]!) &
        Node.DOCUMENT_POSITION_FOLLOWING
    ).toBeTruthy();

    openNarrativeSegments(container);
    expect(
      container.querySelector('[data-timeline-call-id="mcp-call-1"]')
    ).toBeInTheDocument();
    expect(
      container.querySelector('[data-timeline-call-id="mcp-call-2"]')
    ).toBeInTheDocument();
  });

  it('keeps a human question after its triggering tool in the same agent block', () => {
    const humanToolNodes: ChatProjectionNode[] = [
      {
        ...base,
        kind: 'activity',
        id: 'human-tool-start',
        eventId: 'human-tool-start',
        eventType: 'legacy.activate_toolkit',
        legacyStep: 'activate_toolkit',
        runSequence: 1,
        createdAt: '2026-08-19T00:00:00Z',
        activityType: 'tool',
        phase: 'started',
        status: 'running',
        title: 'Human Toolkit · Ask human via gui',
        input: 'Which mesh should be the reference?',
        agentName: 'single_agent',
        taskId: 'task-1',
        toolkitName: 'Human Toolkit',
        methodName: 'Ask human via gui',
      },
      {
        ...base,
        kind: 'interaction',
        id: 'mesh-choice',
        eventId: 'mesh-choice',
        eventType: 'interaction.resolved',
        runSequence: 2,
        createdAt: '2026-08-19T00:00:01Z',
        interactionId: 'mesh-choice',
        interactionType: 'question',
        status: 'responded',
        prompt: 'Which mesh should be the reference?',
        response: 'Use the right hemisphere',
      },
      {
        ...base,
        kind: 'activity',
        id: 'human-tool-end',
        eventId: 'human-tool-end',
        eventType: 'legacy.deactivate_toolkit',
        legacyStep: 'deactivate_toolkit',
        runSequence: 3,
        createdAt: '2026-08-19T00:00:02Z',
        activityType: 'tool',
        phase: 'completed',
        status: 'completed',
        title: 'Human Toolkit · Ask human via gui',
        output: 'Use the right hemisphere',
        agentName: 'single_agent',
        taskId: 'task-1',
        toolkitName: 'Human Toolkit',
        methodName: 'Ask human via gui',
      },
      {
        ...base,
        kind: 'run_status',
        id: 'human-run-running',
        eventId: 'human-run-running',
        eventType: 'run.running',
        runSequence: 4,
        createdAt: '2026-08-19T00:00:03Z',
        status: 'running',
      },
    ];

    const { container } = render(
      <TimelineModeRenderer
        detailLevel="narrative"
        runs={composeTimelineRuns(humanToolNodes)}
        sessionMode={SessionMode.SINGLE_AGENT}
      />
    );
    const timeline = container.querySelector(
      '[data-narrative-timeline]'
    ) as HTMLElement;
    const tool = timeline.querySelector(
      '[data-narrative-segment-id]'
    ) as HTMLElement;
    const receipt = timeline.querySelector(
      '[data-timeline-call-executor="human"]'
    ) as HTMLElement;

    expect(tool).toBeInTheDocument();
    expect(receipt).toBeInTheDocument();
    expect(within(receipt).getByText('You · Answered')).toBeInTheDocument();

    // The answer is the response half of a human-executed call, so it reads
    // through the same Request/Response disclosure a tool call uses.
    fireEvent.click(
      receipt.querySelector('[data-timeline-call-trigger]') as HTMLElement
    );
    expect(within(receipt).getByText('Question')).toBeInTheDocument();
    expect(within(receipt).getByText('Answer')).toBeInTheDocument();
    expect(
      within(receipt).getByText('Use the right hemisphere')
    ).toBeInTheDocument();
    expect(
      tool.compareDocumentPosition(receipt) & Node.DOCUMENT_POSITION_FOLLOWING
    ).toBeTruthy();
  });

  it('advances elapsed time from the authoritative attempt anchor', () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date('2026-08-19T00:00:10Z'));
    const composed = composeTimelineRuns(nodes('running'))[0]!;
    const run = reconcileTimelineRun(composed, {
      runId: 'run-1',
      status: 'running',
      lastSequence: 3,
      runVersion: 1,
      updatedAt: '2026-08-19T00:00:05Z',
      totalAttemptElapsedMs: 3_000,
    });

    render(<TimelineModeRenderer detailLevel="narrative" runs={[run]} />);
    expect(screen.getByText('8s')).toBeInTheDocument();

    act(() => vi.advanceTimersByTime(2_000));
    expect(screen.getByText('10s')).toBeInTheDocument();
  });
  it('holds elapsed time and the shimmer while the user has taken control', () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date('2026-08-19T00:00:10Z'));
    const composed = composeTimelineRuns(nodes('running'))[0]!;
    const run = reconcileTimelineRun(composed, {
      runId: 'run-1',
      status: 'running',
      lastSequence: 3,
      runVersion: 1,
      updatedAt: '2026-08-19T00:00:05Z',
      totalAttemptElapsedMs: 3_000,
    });

    const { container, rerender } = render(
      <TimelineModeRenderer detailLevel="narrative" runs={[run]} />
    );
    expect(screen.getByText('8s')).toBeInTheDocument();
    expect(container.querySelectorAll('.shiny-text')).toHaveLength(1);

    // Pause: the timer freezes and the running shimmer stops.
    rerender(
      <TimelineModeRenderer detailLevel="narrative" paused runs={[run]} />
    );
    act(() => vi.advanceTimersByTime(5_000));
    expect(screen.getByText('8s')).toBeInTheDocument();
    expect(container.querySelectorAll('.shiny-text')).toHaveLength(0);
    expect(screen.getByText(/Paused after/)).toBeInTheDocument();
    // A pause is not an ending, so the work log stays open.
    expect(
      container.querySelector('[data-narrative-timeline]')
    ).toBeInTheDocument();

    // Resume: counting continues from where it stopped, not from wall clock.
    rerender(<TimelineModeRenderer detailLevel="narrative" runs={[run]} />);
    act(() => vi.advanceTimersByTime(2_000));
    expect(screen.getByText('10s')).toBeInTheDocument();
    expect(container.querySelectorAll('.shiny-text')).toHaveLength(1);
    expect(screen.getByText(/Working on tasks for/)).toBeInTheDocument();
  });
});
