/**
 * Smoke render tests for Conversations.tsx — covers new lines added in #1123
 * (welcome-lock removal: unconditional sidebar, label filter, effectiveShowSidebar,
 * quota usage pills, etc.).
 *
 * These tests intentionally do not test complex user interactions; they verify
 * that the key JSX branches render without crashing, driving coverage of the
 * previously-blocked lines that are now always rendered.
 */
import { combineReducers, configureStore } from '@reduxjs/toolkit';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { Provider } from 'react-redux';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { SidebarSlotOutlet, SidebarSlotProvider } from '../../components/layout/shell/SidebarSlot';
import { threadApi } from '../../services/api/threadApi';
import { chatCancel, chatClearQueue, chatSend } from '../../services/chatService';
import { CoreRpcError } from '../../services/coreRpcClient';
import agentProfileReducer from '../../store/agentProfileSlice';
import chatRuntimeReducer, {
  appendProcessingProse,
  beginInferenceTurn,
  bumpInferenceHeartbeatForThread,
  clearFollowupsForThread,
  enqueueFollowup,
  setInferenceStatusForThread,
  setPendingPlanReviewForThread,
  setTaskBoardForThread,
  setToolTimelineForThread,
} from '../../store/chatRuntimeSlice';
import layoutReducer from '../../store/layoutSlice';
import socketReducer from '../../store/socketSlice';
import themeReducer from '../../store/themeSlice';
import threadReducer, { setSelectedThread } from '../../store/threadSlice';
import type { Thread, ThreadMessage } from '../../types/thread';

// ── Hoisted mock state ─────────────────────────────────────────────────────

const { mockGetThreads, mockGetThreadMessages, mockUseUsageState } = vi.hoisted(() => ({
  mockGetThreads: vi.fn().mockResolvedValue({ threads: [], count: 0 }),
  mockGetThreadMessages: vi.fn().mockResolvedValue({ messages: [], count: 0 }),
  mockUseUsageState: vi.fn(() => ({
    teamUsage: null as null | {
      cycleBudgetUsd: number;
      remainingUsd: number;
      cycleSpentUsd: number;
      cycleEndsAt: string | null;
    },
    currentPlan: null,
    currentTier: 'FREE' as 'FREE' | 'BASIC' | 'PRO',
    isFreeTier: true,
    usagePct: 0,
    isNearLimit: false,
    isAtLimit: false,
    isBudgetExhausted: false,
    shouldShowBudgetCompletedMessage: false,
    isLoading: false,
    refresh: vi.fn(),
  })),
}));
const mockUseOpenRouterFreeModels = vi.hoisted(() => vi.fn());

// ── Module mocks ───────────────────────────────────────────────────────────

vi.mock('../../services/chatService', () => ({
  chatCancel: vi.fn(),
  chatClearQueue: vi.fn().mockResolvedValue(0),
  chatSend: vi.fn().mockResolvedValue(undefined),
  subscribeChatEvents: vi.fn(() => () => {}),
  useRustChat: vi.fn(() => true),
}));

vi.mock('../../services/api/threadApi', () => ({
  threadApi: {
    createNewThread: vi.fn().mockResolvedValue({ id: 'new-thread', labels: [] }),
    getThreads: mockGetThreads,
    getThreadMessages: mockGetThreadMessages,
    getTurnState: vi.fn().mockResolvedValue(null),
    getTaskBoard: vi
      .fn()
      .mockResolvedValue({ threadId: 't-1', cards: [], updatedAt: '2026-05-04T10:00:00Z' }),
    putTaskBoard: vi
      .fn()
      .mockResolvedValue({ threadId: 't-1', cards: [], updatedAt: '2026-05-04T10:00:00Z' }),
    decidePlan: vi
      .fn()
      .mockResolvedValue({ threadId: 't-1', cards: [], updatedAt: '2026-05-04T10:00:00Z' }),
    appendMessage: vi.fn().mockResolvedValue({}),
    deleteThread: vi.fn().mockResolvedValue({ deleted: true }),
    generateTitleIfNeeded: vi.fn().mockResolvedValue({}),
    updateMessage: vi.fn().mockResolvedValue({}),
    purge: vi.fn().mockResolvedValue({}),
    updateLabels: vi.fn().mockResolvedValue({}),
    updateTitle: vi.fn().mockResolvedValue({}),
    persistReaction: vi.fn().mockResolvedValue({}),
  },
}));

vi.mock('../../services/api/agentProfilesApi', () => ({
  agentProfilesApi: {
    list: vi
      .fn()
      .mockResolvedValue({
        activeProfileId: 'default',
        profiles: [
          {
            id: 'default',
            name: 'Default',
            description: 'Default',
            agentId: 'orchestrator',
            builtIn: true,
          },
        ],
      }),
    select: vi
      .fn()
      .mockResolvedValue({
        activeProfileId: 'default',
        profiles: [
          {
            id: 'default',
            name: 'Default',
            description: 'Default',
            agentId: 'orchestrator',
            builtIn: true,
          },
        ],
      }),
    upsert: vi.fn().mockResolvedValue({ activeProfileId: 'default', profiles: [] }),
    delete: vi.fn().mockResolvedValue({ activeProfileId: 'default', profiles: [] }),
  },
}));

vi.mock('../../services/api/openrouterFreeModels', () => ({
  applyOpenRouterFreeModels: () => mockUseOpenRouterFreeModels(),
}));

vi.mock('../../hooks/useUsageState', () => ({ useUsageState: mockUseUsageState }));

// The new-window hero pulls useUser/useCoreState; stub it so the page renders
// without a CoreStateProvider (these tests assert the sidebar/composer, not the
// empty-state hero).
vi.mock('../../components/chat/ChatNewWindowHero', () => ({ default: () => null }));

vi.mock('../../store/socketSelectors', () => ({
  selectSocketStatus: (state: { socket?: { byUser?: Record<string, { status: string }> } }) =>
    state.socket?.byUser?.__pending__?.status ?? 'disconnected',
}));

// useStickToBottom returns refs; mock it so layout-effects don't fire in jsdom.
vi.mock('../../hooks/useStickToBottom', () => ({
  useStickToBottom: vi.fn(() => ({ containerRef: { current: null }, endRef: { current: null } })),
}));

// useAutocompleteSkillStatus may make API calls; stub it.
vi.mock('../../features/autocomplete/useAutocompleteSkillStatus', () => ({
  useAutocompleteSkillStatus: vi.fn(() => ({ status: 'idle', skills: [] })),
}));

// openUrl uses Tauri; stub it.
vi.mock('../../utils/openUrl', () => ({ openUrl: vi.fn() }));

// coreRpcClient: the PlanReviewCard resolves a parked plan via callCoreRpc.
// Preserve the real exports (e.g. CoreRpcError) and only stub the call.
const mockCallCoreRpc = vi.fn().mockResolvedValue({});
vi.mock('../../services/coreRpcClient', async orig => {
  const actual = await orig<typeof import('../../services/coreRpcClient')>();
  return { ...actual, callCoreRpc: (...args: unknown[]) => mockCallCoreRpc(...args) };
});

// coreState/store: getCoreStateSnapshot used by selectSocketStatus.
vi.mock('../../lib/coreState/store', () => ({
  getCoreStateSnapshot: vi.fn(() => ({
    isBootstrapping: false,
    isReady: true,
    snapshot: {
      auth: { isAuthenticated: false, userId: null, user: null, profileId: null },
      sessionToken: null,
      currentUser: null,
      onboardingCompleted: true,
      chatOnboardingCompleted: true,
      analyticsEnabled: false,
      localState: {},
      runtime: {},
    },
  })),
  isWelcomeLocked: vi.fn(() => false),
  setCoreStateSnapshot: vi.fn(),
}));

// ── Helpers ────────────────────────────────────────────────────────────────

function buildStore(preload: Record<string, unknown> = {}) {
  return configureStore({
    reducer: combineReducers({
      thread: threadReducer,
      layout: layoutReducer,
      socket: socketReducer,
      chatRuntime: chatRuntimeReducer,
      agentProfiles: agentProfileReducer,
      theme: themeReducer,
    }),
    preloadedState: preload as never,
  });
}

function makeThread(overrides: Partial<Thread> = {}): Thread {
  return {
    id: 't-1',
    title: 'Test thread',
    chatId: null,
    isActive: false,
    messageCount: 0,
    lastMessageAt: '2026-01-01T00:00:00.000Z',
    createdAt: '2026-01-01T00:00:00.000Z',
    labels: ['general'],
    ...overrides,
  };
}

async function renderConversations(preload: Record<string, unknown> = {}) {
  const store = buildStore(preload);
  const { default: Conversations } = await import('../Conversations');

  render(
    <Provider store={store}>
      <MemoryRouter initialEntries={['/conversations']}>
        {/* The thread sidebar is projected into the root sidebar slot, so the
            page needs a provider + outlet for that portal to mount in tests. */}
        <SidebarSlotProvider>
          <SidebarSlotOutlet />
          <Conversations />
        </SidebarSlotProvider>
      </MemoryRouter>
    </Provider>
  );

  return store;
}

async function renderConversationsRoute(route: string, preload: Record<string, unknown> = {}) {
  const store = buildStore(preload);
  const { default: Conversations } = await import('../Conversations');

  render(
    <Provider store={store}>
      <MemoryRouter initialEntries={[route]}>
        <SidebarSlotProvider>
          <SidebarSlotOutlet />
          <Routes>
            <Route
              path="/chat/:threadId?"
              element={
                <>
                  <LocationProbe />
                  <Conversations />
                </>
              }
            />
          </Routes>
        </SidebarSlotProvider>
      </MemoryRouter>
    </Provider>
  );

  return store;
}

async function renderEmbeddedConversationsRoute(
  route: string,
  preload: Record<string, unknown> = {}
) {
  const store = buildStore(preload);
  const { default: Conversations } = await import('../Conversations');

  render(
    <Provider store={store}>
      <MemoryRouter initialEntries={[route]}>
        <SidebarSlotProvider>
          <SidebarSlotOutlet />
          <Routes>
            <Route
              path="/human"
              element={
                <>
                  <LocationProbe />
                  <Conversations variant="sidebar" composer="mic-cloud" projectThreadList />
                </>
              }
            />
          </Routes>
        </SidebarSlotProvider>
      </MemoryRouter>
    </Provider>
  );

  return store;
}

function LocationProbe() {
  const location = useLocation();
  return <span data-testid="route-path">{location.pathname}</span>;
}

/** The thread sidebar is always projected now (no toggle); just flush effects. */
async function openSidebar() {
  await act(async () => {});
}

// Default empty state
const emptyThreadState = {
  threads: [],
  selectedThreadId: null,
  activeThreadIds: {},
  welcomeThreadId: null,
  messagesByThreadId: {},
  messages: [],
  isLoadingThreads: false,
  isLoadingMessages: false,
  messagesError: null,
};

function selectedThreadState(thread: Thread) {
  return {
    ...emptyThreadState,
    threads: [thread],
    selectedThreadId: thread.id,
    messagesByThreadId: { [thread.id]: [] },
    messages: [],
  };
}

function socketState(status: 'connected' | 'disconnected') {
  return {
    byUser: { __pending__: { status, socketId: status === 'connected' ? 'socket-1' : null } },
  };
}

async function renderSelectedConversation(
  options: { isAtLimit?: boolean; socketStatus?: 'connected' | 'disconnected' } = {}
) {
  const thread = makeThread({ id: 'send-thread', title: 'Send Thread' });
  mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
  mockGetThreadMessages.mockResolvedValue({ messages: [], count: 0 });
  mockUseUsageState.mockReturnValue({
    teamUsage: null,
    currentPlan: null,
    currentTier: 'FREE' as const,
    isFreeTier: true,
    usagePct: options.isAtLimit ? 1 : 0,
    isNearLimit: Boolean(options.isAtLimit),
    isAtLimit: Boolean(options.isAtLimit),
    isBudgetExhausted: false,
    shouldShowBudgetCompletedMessage: false,
    isLoading: false,
    refresh: vi.fn(),
  });

  let renderedStore: ReturnType<typeof buildStore> | undefined;
  await act(async () => {
    renderedStore = await renderConversations({
      thread: selectedThreadState(thread),
      socket: socketState(options.socketStatus ?? 'connected'),
    });
  });

  const textarea = await screen.findByPlaceholderText('How can I help you today?');
  return { store: renderedStore, textarea, thread };
}

async function submitComposerText(textarea: HTMLElement, text: string) {
  await act(async () => {
    fireEvent.change(textarea, { target: { value: text } });
  });
  await waitFor(() => {
    expect(textarea).toHaveValue(text);
    expect(screen.getByRole('button', { name: 'Send message' })).not.toBeDisabled();
  });
  await act(async () => {
    fireEvent.click(screen.getByRole('button', { name: 'Send message' }));
  });
}

// ── Tests ──────────────────────────────────────────────────────────────────

describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    window.localStorage.clear();
    // Reset the mock to defaults for each test
    mockGetThreads.mockResolvedValue({ threads: [], count: 0 });
    mockGetThreadMessages.mockResolvedValue({ messages: [], count: 0 });
    mockUseUsageState.mockReturnValue({
      teamUsage: null,
      currentPlan: null,
      currentTier: 'FREE' as const,
      isFreeTier: true,
      usagePct: 0.0,
      isNearLimit: false,
      isAtLimit: false,
      isBudgetExhausted: false,
      shouldShowBudgetCompletedMessage: false,
      isLoading: false,
      refresh: vi.fn(),
    });
  });

  // Covers the page-mode sidebar (TwoPanelLayout, id `chat`) once opened. The
  // General/Subconscious/Tasks filter chips were removed; the thread search is
  // the stable top-of-sidebar control.
  it('renders the sidebar thread search in page mode', async () => {
    await act(async () => {
      await renderConversations({ thread: emptyThreadState });
    });

    await openSidebar();

    expect(screen.getByTestId('chat-thread-search-input')).toBeInTheDocument();
    expect(screen.queryByRole('tab', { name: 'General' })).not.toBeInTheDocument();
  });

  // Covers the empty branch — with the filter chips gone the list always shows
  // the generic empty message when no (General-bucket) threads exist.
  it('shows the empty message when there are no threads', async () => {
    await act(async () => {
      await renderConversations({ thread: emptyThreadState });
    });

    // Sidebar is hidden by default — open it first.
    await openSidebar();
    expect(screen.getByText('No threads yet')).toBeInTheDocument();
  });

  // Covers lines 1002-1004, 1007, 1011-1012, 1014: thread list items rendered unconditionally
  it('renders thread list items when threads are pre-loaded', async () => {
    const threads = [
      makeThread({ id: 't-1', title: 'Thread Alpha' }),
      makeThread({ id: 't-2', title: 'Thread Beta' }),
    ];

    // Return the threads from the API so the useEffect loadThreads picks them up
    mockGetThreads.mockResolvedValue({ threads, count: 2 });

    await act(async () => {
      await renderConversations({ thread: emptyThreadState });
    });

    // Sidebar is hidden by default — open it first.
    await openSidebar();

    // Wait for loadThreads to complete and the thread list to render.
    // Use getAllByText because the title may appear in both the sidebar list
    // and the conversation header (both are rendered).
    await waitFor(() => {
      expect(screen.getAllByText('Thread Alpha').length).toBeGreaterThan(0);
    });
    expect(screen.getAllByText('Thread Beta').length).toBeGreaterThan(0);
  });

  it('falls back to /chat when the routed thread id is missing', async () => {
    mockGetThreads.mockResolvedValue({
      threads: [makeThread({ id: 't-1', title: 'Thread Alpha' })],
      count: 1,
    });

    await act(async () => {
      await renderConversationsRoute('/chat/missing-thread', { thread: emptyThreadState });
    });

    await waitFor(() => {
      expect(screen.getByTestId('route-path')).toHaveTextContent('/chat');
    });
    expect(threadApi.createNewThread).not.toHaveBeenCalled();
  });

  it('updates the route when selecting sidebar threads by click or keyboard', async () => {
    const threads = [
      makeThread({ id: 't-1', title: 'Thread Alpha' }),
      makeThread({ id: 't-2', title: 'Thread Beta' }),
    ];
    mockGetThreads.mockResolvedValue({ threads, count: 2 });

    await act(async () => {
      await renderConversationsRoute('/chat', { thread: emptyThreadState });
    });
    await openSidebar();

    const alphaRow = await screen.findByRole('button', { name: /Thread Alpha/ });
    await act(async () => {
      fireEvent.click(alphaRow);
    });
    await waitFor(() => {
      expect(screen.getByTestId('route-path')).toHaveTextContent('/chat/t-1');
    });

    const betaRow = await screen.findByRole('button', { name: /Thread Beta/ });
    await act(async () => {
      fireEvent.keyDown(betaRow, { key: 'Enter' });
    });
    await waitFor(() => {
      expect(screen.getByTestId('route-path')).toHaveTextContent('/chat/t-2');
    });
  });

  it('does not push chat routes when embedded chat creates a thread', async () => {
    mockGetThreads.mockResolvedValue({ threads: [], count: 0 });

    await act(async () => {
      await renderEmbeddedConversationsRoute('/human', { thread: emptyThreadState });
    });

    await waitFor(() => {
      expect(threadApi.createNewThread).toHaveBeenCalled();
    });
    expect(screen.getByTestId('route-path')).toHaveTextContent('/human');
  });

  it('does not push chat routes when embedded chat selects a thread', async () => {
    const threads = [makeThread({ id: 't-1', title: 'Thread Alpha' })];
    mockGetThreads.mockResolvedValue({ threads, count: 1 });

    await act(async () => {
      await renderEmbeddedConversationsRoute('/human', { thread: emptyThreadState });
    });
    await openSidebar();

    const alphaRow = await screen.findByRole('button', { name: /Thread Alpha/ });
    await act(async () => {
      fireEvent.click(alphaRow);
    });

    expect(screen.getByTestId('route-path')).toHaveTextContent('/human');
  });

  // Covers line 1083: messagesError branch renders error state
  it('renders the error icon section when loadThreadMessages rejects', async () => {
    // Make loadThreadMessages always fail so messagesError is set in the store
    mockGetThreadMessages.mockRejectedValue(new Error('Network error'));

    // Return one thread so the component selects it and loads messages
    const thread = makeThread({ id: 't-2', title: 'Error Thread' });
    mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });

    await act(async () => {
      await renderConversations({ thread: emptyThreadState });
    });

    // After the failed load, messagesError is set in state — the error branch renders.
    // This covers line 1083 (the error container div).
    await waitFor(() => {
      // The error branch renders "Failed to load messages" static text
      expect(screen.getByText('Failed to load messages')).toBeInTheDocument();
    });
  });

  it('renders assistant messages as unframed text when the appearance preference is enabled', async () => {
    const thread = makeThread({ id: 'view-mode-thread', title: 'View Mode Thread' });
    const messages: ThreadMessage[] = [
      {
        id: 'm-user',
        sender: 'user',
        type: 'text',
        content: 'Can you summarize this?',
        extraMetadata: {},
        createdAt: '2026-01-01T00:00:00.000Z',
      },
      {
        id: 'm-agent',
        sender: 'agent',
        type: 'text',
        content: 'Long agent output\n\nwith enough structure to prefer a text view.',
        extraMetadata: {},
        createdAt: '2026-01-01T00:01:00.000Z',
      },
    ];
    mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
    mockGetThreadMessages.mockResolvedValue({ messages, count: messages.length });

    await act(async () => {
      await renderConversations({
        thread: {
          ...selectedThreadState(thread),
          messagesByThreadId: { [thread.id]: messages },
          messages,
        },
        socket: socketState('connected'),
        theme: {
          mode: 'system',
          tabBarLabels: 'hover',
          fontSize: 'medium',
          agentMessageViewMode: 'text',
        },
      });
    });

    expect(screen.getByTestId('agent-message-text')).toHaveTextContent(
      'Long agent output with enough structure to prefer a text view.'
    );
    expect(screen.getByText('Can you summarize this?')).toBeInTheDocument();
  });

  it('keeps bubble mode interactions for assistant citations, copy, and reactions', async () => {
    const writeText = vi.fn().mockResolvedValue(undefined);
    Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } });
    const thread = makeThread({ id: 'bubble-mode-thread', title: 'Bubble Mode Thread' });
    const agentContent =
      'First assistant paragraph with enough text to render.\n\nSecond assistant paragraph stays in bubbles.';
    const messages: ThreadMessage[] = [
      {
        id: 'm-agent-bubble',
        sender: 'agent',
        type: 'text',
        content: agentContent,
        extraMetadata: {
          citations: [
            {
              id: 'cite-1',
              key: 'memory-key',
              namespace: 'personal',
              snippet: 'Remembered preference',
              timestamp: '2026-01-01T00:00:00.000Z',
              score: 0.91,
            },
          ],
          myReactions: ['👍'],
        },
        createdAt: '2026-01-01T00:01:00.000Z',
      },
    ];
    vi.mocked(threadApi.updateMessage).mockImplementation(
      async (_threadId, _messageId, extraMetadata) =>
        ({ ...messages[0], extraMetadata }) as ThreadMessage
    );
    mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
    mockGetThreadMessages.mockResolvedValue({ messages, count: messages.length });

    await act(async () => {
      await renderConversations({
        thread: {
          ...selectedThreadState(thread),
          messagesByThreadId: { [thread.id]: messages },
          messages,
        },
        socket: socketState('connected'),
        theme: {
          mode: 'system',
          tabBarLabels: 'hover',
          fontSize: 'medium',
          agentMessageViewMode: 'bubbles',
        },
      });
    });

    expect(screen.queryByTestId('agent-message-text')).not.toBeInTheDocument();
    expect(
      screen.getByText('First assistant paragraph with enough text to render.')
    ).toBeInTheDocument();
    expect(screen.getByText('personal 91%')).toBeInTheDocument();
    expect(screen.getByTitle(/Remembered preference/)).toBeInTheDocument();

    await act(async () => {
      fireEvent.click(screen.getByTitle('Copy response'));
    });
    expect(writeText).toHaveBeenCalledWith(agentContent);

    await act(async () => {
      fireEvent.click(screen.getByTitle('Remove 👍'));
    });
    await waitFor(() => {
      expect(threadApi.updateMessage).toHaveBeenCalledWith(
        thread.id,
        'm-agent-bubble',
        expect.objectContaining({ myReactions: [] })
      );
    });

    await act(async () => {
      fireEvent.click(screen.getByTitle('Add reaction'));
    });
    await act(async () => {
      fireEvent.click(screen.getByTitle('🎯'));
    });
    await waitFor(() => {
      expect(threadApi.updateMessage).toHaveBeenCalledWith(
        thread.id,
        'm-agent-bubble',
        expect.objectContaining({ myReactions: expect.arrayContaining(['🎯']) })
      );
    });
  });

  // CycleUsagePill moved into ChatComposer toolbar (#3611) — quota-pill
  // loading test removed; the "Loading…" text no longer renders here.

  // Covers budget banner: budget-exhausted banner + OpenRouter CTA
  it('renders budget-limit banner when teamUsage is present', async () => {
    // cycleBudgetUsd: 0 → renders "Your included budget is complete" branch
    const teamUsage = { cycleBudgetUsd: 0, remainingUsd: 0, cycleSpentUsd: 0, cycleEndsAt: null };

    mockUseUsageState.mockReturnValue({
      teamUsage,
      currentPlan: null,
      currentTier: 'PRO' as const,
      isFreeTier: false,
      usagePct: 1.0,
      isNearLimit: true,
      isAtLimit: true,
      isBudgetExhausted: true,
      shouldShowBudgetCompletedMessage: true,
      isLoading: false,
      refresh: vi.fn(),
    });

    await act(async () => {
      await renderConversations({ thread: emptyThreadState });
    });

    expect(screen.getByText(/Your included budget is complete/i)).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /Use OpenRouter free models/i })).toBeInTheDocument();
  });

  // Covers line 247: if (cancelled) return — the non-cancelled path through loadThreads callback
  it('selects first thread after loadThreads resolves (non-cancelled path)', async () => {
    const threads = [makeThread({ id: 't-1', title: 'First Thread' })];
    mockGetThreads.mockResolvedValue({ threads, count: 1 });

    let resolvedStore: ReturnType<typeof buildStore> | undefined;
    await act(async () => {
      resolvedStore = await renderConversations({ thread: emptyThreadState });
    });

    // After loadThreads resolves and cancelled=false, the first thread is selected.
    // This exercises line 247 (the if (cancelled) return check runs and is false).
    await waitFor(() => {
      const state = resolvedStore?.getState() as { thread: { selectedThreadId: string | null } };
      expect(state.thread.selectedThreadId).toBe('t-1');
    });
  });

  // Sidebar "New thread" button was removed in the composer flattening refactor.
  // The "+ New" header button (tested below) is the remaining create-thread entry point.

  it('clicking "+ New" header button calls handleCreateNewThread', async () => {
    // Need a selected thread so the header renders
    const threads = [makeThread({ id: 't-1', title: 'Header Thread' })];
    mockGetThreads.mockResolvedValue({ threads, count: 1 });

    await act(async () => {
      await renderConversations({ thread: emptyThreadState });
    });

    // Wait for thread to be selected so the header with "+ New" button renders
    await waitFor(() => {
      expect(screen.getByTitle('New thread (/new)')).toBeInTheDocument();
    });

    const headerNewBtn = screen.getByTitle('New thread (/new)');
    await act(async () => {
      fireEvent.click(headerNewBtn);
    });

    // createNewThread was called — verifies line 1061 callback executed
    expect(threadApi.createNewThread).toHaveBeenCalled();
  });

  // Covers lines 981, 982: e.stopPropagation() and setDeleteModal(...) inside delete onClick
  it('clicking delete button on a thread opens the delete modal', async () => {
    const threads = [makeThread({ id: 't-del', title: 'Deletable Thread' })];
    mockGetThreads.mockResolvedValue({ threads, count: 1 });

    await act(async () => {
      await renderConversations({ thread: emptyThreadState });
    });

    // Sidebar is hidden by default — open it first.
    await openSidebar();

    // Wait for the thread to appear in the sidebar
    await waitFor(() => {
      expect(screen.getAllByText('Deletable Thread').length).toBeGreaterThan(0);
    });

    // The delete button has title="Delete thread"
    const deleteBtn = screen.getByTitle('Delete thread');
    await act(async () => {
      fireEvent.click(deleteBtn);
    });

    // The modal should now be open — "Are you sure you want to delete" text
    // This verifies lines 981, 982, 985 inside the delete onClick callback executed
    expect(screen.getByText(/Are you sure you want to delete/i)).toBeInTheDocument();
  });

  it('replaces the route when deleting the currently-routed thread', async () => {
    const thread = makeThread({ id: 't-del', title: 'Deletable Thread' });
    mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });

    await act(async () => {
      await renderConversationsRoute('/chat/t-del', { thread: selectedThreadState(thread) });
    });
    await openSidebar();

    const deleteBtn = await screen.findByTitle('Delete thread');
    await act(async () => {
      fireEvent.click(deleteBtn);
    });
    await act(async () => {
      fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
    });

    await waitFor(() => expect(threadApi.deleteThread).toHaveBeenCalledWith('t-del'));
    expect(screen.getByTestId('route-path')).toHaveTextContent('/chat');
  });

  // Covers lines 1399, 1409-1410: isNearLimit UpsellBanner render + onCtaClick
  it('renders near-limit UpsellBanner and clicking Upgrade calls openUrl', async () => {
    const { openUrl } = await import('../../utils/openUrl');

    mockUseUsageState.mockReturnValue({
      teamUsage: null,
      currentPlan: null,
      currentTier: 'FREE' as const,
      isFreeTier: true,
      usagePct: 0.85,
      isNearLimit: true,
      isAtLimit: false,
      isBudgetExhausted: false,
      shouldShowBudgetCompletedMessage: false,
      isLoading: false,
      refresh: vi.fn(),
    });

    await act(async () => {
      await renderConversations({ thread: emptyThreadState });
    });

    // UpsellBanner renders with "Approaching usage limit" (line 1399 branch)
    expect(screen.getByText('Approaching usage limit')).toBeInTheDocument();

    // Click the "Upgrade" button — covers line 1409-1410 (onCtaClick callback)
    const upgradeBtn = screen.getByText('Upgrade');
    await act(async () => {
      fireEvent.click(upgradeBtn);
    });

    expect(openUrl).toHaveBeenCalled();
  });

  // Covers line 1413: onDismiss callback inside UpsellBanner
  it('dismissing the near-limit UpsellBanner writes to localStorage (onDismiss executes)', async () => {
    mockUseUsageState.mockReturnValue({
      teamUsage: null,
      currentPlan: null,
      currentTier: 'FREE' as const,
      isFreeTier: true,
      usagePct: 0.9,
      isNearLimit: true,
      isAtLimit: false,
      isBudgetExhausted: false,
      shouldShowBudgetCompletedMessage: false,
      isLoading: false,
      refresh: vi.fn(),
    });

    await act(async () => {
      await renderConversations({ thread: emptyThreadState });
    });

    // UpsellBanner renders
    expect(screen.getByText('Approaching usage limit')).toBeInTheDocument();

    // Click dismiss button (aria-label="Dismiss") — covers line 1413 (onDismiss callback)
    const dismissBtn = screen.getByRole('button', { name: 'Dismiss' });
    await act(async () => {
      fireEvent.click(dismissBtn);
    });

    // dismissBanner writes to localStorage with the banner key — confirms line 1413 executed
    expect(localStorage.getItem('openhuman:upsell:conversations-warning')).not.toBeNull();
  });

  // Covers line 1443: onClick inside "Top Up" button in budget-exceeded banner
  it('clicking "Top Up" in the budget banner calls openUrl', async () => {
    const { openUrl } = await import('../../utils/openUrl');

    const teamUsage = { cycleBudgetUsd: 10, remainingUsd: 0, cycleSpentUsd: 10, cycleEndsAt: null };

    mockUseUsageState.mockReturnValue({
      teamUsage,
      currentPlan: null,
      currentTier: 'PRO' as const,
      isFreeTier: false,
      usagePct: 1.0,
      isNearLimit: true,
      isAtLimit: true,
      isBudgetExhausted: true,
      shouldShowBudgetCompletedMessage: true,
      isLoading: false,
      refresh: vi.fn(),
    });

    await act(async () => {
      await renderConversations({ thread: emptyThreadState });
    });

    // Budget banner renders — cycleBudgetUsd: 10 > 0 → cycle-budget exhausted copy
    expect(screen.getByText(/used your included cycle budget/i)).toBeInTheDocument();

    // Click "Top Up" button — covers line 1442-1443 (onClick callback)
    const topUpBtn = screen.getByText('Top Up');
    await act(async () => {
      fireEvent.click(topUpBtn);
    });

    expect(openUrl).toHaveBeenCalled();
  });

  it('clicking OpenRouter free models in the budget banner routes chat workloads', async () => {
    const teamUsage = { cycleBudgetUsd: 10, remainingUsd: 0, cycleSpentUsd: 10, cycleEndsAt: null };
    mockUseOpenRouterFreeModels.mockResolvedValueOnce(undefined);

    mockUseUsageState.mockReturnValue({
      teamUsage,
      currentPlan: null,
      currentTier: 'PRO' as const,
      isFreeTier: false,
      usagePct: 1.0,
      isNearLimit: true,
      isAtLimit: true,
      isBudgetExhausted: true,
      shouldShowBudgetCompletedMessage: true,
      isLoading: false,
      refresh: vi.fn(),
    });

    await act(async () => {
      await renderConversations({ thread: emptyThreadState });
    });

    await act(async () => {
      fireEvent.click(screen.getByRole('button', { name: /Use OpenRouter free models/i }));
    });

    await waitFor(() => {
      expect(mockUseOpenRouterFreeModels).toHaveBeenCalledTimes(1);
    });
  });

  it('handles /new from the composer without a selected thread or sending chat text', async () => {
    mockGetThreads.mockReturnValue(new Promise(() => {}));

    await act(async () => {
      await renderConversations({ thread: emptyThreadState, socket: socketState('connected') });
    });
    const textarea = await screen.findByPlaceholderText('How can I help you today?');
    vi.mocked(threadApi.createNewThread).mockClear();
    vi.mocked(chatSend).mockClear();

    await submitComposerText(textarea, '/new');

    await waitFor(() => {
      expect(threadApi.createNewThread).toHaveBeenCalled();
    });
    expect(chatSend).not.toHaveBeenCalled();
    expect(textarea).toHaveValue('');
  });

  it('blocks the send when the account is over budget (no rate-limit modal anymore)', async () => {
    const { textarea } = await renderSelectedConversation({ isAtLimit: true });

    await submitComposerText(textarea, 'hello at limit');

    // Backend PR #790 removed the rate-limit modal; over-budget now surfaces
    // only the inline send-error (which clears as soon as the user keeps
    // typing). The contract we still care about: chatSend is suppressed.
    expect(chatSend).not.toHaveBeenCalled();
  });

  it('persists a local user message and sends through chat service for valid input', async () => {
    const { textarea, thread } = await renderSelectedConversation();

    await submitComposerText(textarea, ' hello cloud ');

    await waitFor(() => {
      expect(threadApi.appendMessage).toHaveBeenCalledWith(
        thread.id,
        expect.objectContaining({ content: 'hello cloud', sender: 'user', type: 'text' })
      );
    });
    expect(chatSend).toHaveBeenCalledWith({
      threadId: thread.id,
      message: 'hello cloud',
      model: 'hint:chat',
      profileId: 'default',
      locale: 'en',
    });
  });

  it('auto-sends a dictation transcript (autoSend) straight to chat without the composer', async () => {
    const { thread } = await renderSelectedConversation();

    // Hotkey dictation dispatches this event with autoSend:true (see
    // useDictationHotkey). Conversations must route it directly to chatSend,
    // bypassing the text composer.
    await act(async () => {
      window.dispatchEvent(
        new CustomEvent('dictation://insert-text', {
          detail: { text: '  play highway to hell  ', autoSend: true },
        })
      );
    });

    await waitFor(() => {
      expect(chatSend).toHaveBeenCalledWith({
        threadId: thread.id,
        message: 'play highway to hell',
        model: 'hint:chat',
        profileId: 'default',
        locale: 'en',
      });
    });
  });

  it('ignores a blank autoSend dictation event (no send)', async () => {
    await renderSelectedConversation();
    vi.mocked(chatSend).mockClear();

    await act(async () => {
      window.dispatchEvent(
        new CustomEvent('dictation://insert-text', { detail: { text: '   ', autoSend: true } })
      );
    });

    expect(chatSend).not.toHaveBeenCalled();
  });

  it('blocks duplicate sends while the first send is still pending', async () => {
    let resolveSend: (() => void) | undefined;
    vi.mocked(chatSend).mockImplementationOnce(
      () =>
        new Promise<string | undefined>(resolve => {
          resolveSend = () => resolve(undefined);
        })
    );
    const { textarea, thread } = await renderSelectedConversation();

    await act(async () => {
      fireEvent.change(textarea, { target: { value: 'slow backend' } });
    });
    await waitFor(() => {
      expect(textarea).toHaveValue('slow backend');
      expect(screen.getByRole('button', { name: 'Send message' })).not.toBeDisabled();
    });

    const sendButton = screen.getByRole('button', { name: 'Send message' });
    await act(async () => {
      fireEvent.click(sendButton);
      fireEvent.click(sendButton);
      fireEvent.click(sendButton);
    });

    await waitFor(() => {
      expect(chatSend).toHaveBeenCalledTimes(1);
    });
    expect(threadApi.appendMessage).toHaveBeenCalledTimes(1);
    expect(chatSend).toHaveBeenCalledWith({
      threadId: thread.id,
      message: 'slow backend',
      model: 'hint:chat',
      profileId: 'default',
      locale: 'en',
    });
    // The send cleared the composer; with an empty composer mid-send the Send
    // button morphs into the Stop button, so there is no Send affordance left
    // to fire a duplicate send.
    expect(screen.getByTestId('stop-generation-button')).toBeInTheDocument();
    expect(screen.queryByRole('button', { name: 'Send message' })).not.toBeInTheDocument();
    resolveSend?.();
  });

  it('cancels the in-flight generation when the in-composer Stop button is clicked', async () => {
    let resolveSend: (() => void) | undefined;
    vi.mocked(chatSend).mockImplementationOnce(
      () =>
        new Promise<string | undefined>(resolve => {
          resolveSend = () => resolve(undefined);
        })
    );
    const { textarea, thread } = await renderSelectedConversation();

    await act(async () => {
      fireEvent.change(textarea, { target: { value: 'cancel me' } });
    });
    const sendButton = screen.getByRole('button', { name: 'Send message' });
    await act(async () => {
      fireEvent.click(sendButton);
    });

    // Empty composer + in-flight turn -> the Send button became the Stop button.
    const stopButton = await screen.findByTestId('stop-generation-button');
    await act(async () => {
      fireEvent.click(stopButton);
    });

    expect(chatCancel).toHaveBeenCalledWith(thread.id);
    resolveSend?.();
  });

  it('keeps a footer Cancel control in the mic-cloud composer while generating', async () => {
    const thread = makeThread({ id: 'mic-cancel-thread', title: 'Mic' });
    mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
    mockGetThreadMessages.mockResolvedValue({ messages: [], count: 0 });
    const { default: Conversations } = await import('../Conversations');
    const store = buildStore({
      thread: selectedThreadState(thread),
      socket: socketState('connected'),
    });
    await act(async () => {
      render(
        <Provider store={store}>
          <MemoryRouter initialEntries={['/conversations']}>
            <SidebarSlotProvider>
              <SidebarSlotOutlet />
              <Conversations composer="mic-cloud" />
            </SidebarSlotProvider>
          </MemoryRouter>
        </Provider>
      );
    });

    // Drive an in-flight turn so `isSending` is true. The mic-cloud composer has
    // no in-box Stop button, so the footer Cancel control is the cancel path.
    await act(async () => {
      store.dispatch(beginInferenceTurn({ threadId: thread.id }));
    });

    const cancelButtons = await screen.findAllByRole('button', { name: 'Cancel' });
    const footerCancel = cancelButtons.find(
      b => b.getAttribute('data-analytics-id') === 'chat-cancel-generation'
    );
    expect(footerCancel).toBeTruthy();
    await act(async () => {
      fireEvent.click(footerCancel as HTMLElement);
    });
    expect(chatCancel).toHaveBeenCalledWith(thread.id);
  });

  it('releases the pending-send lock when appendMessage rejects with a generic error', async () => {
    vi.mocked(threadApi.appendMessage).mockRejectedValueOnce(new Error('disk full'));
    const { textarea } = await renderSelectedConversation();

    await act(async () => {
      fireEvent.change(textarea, { target: { value: 'will fail locally' } });
    });
    const sendButton = screen.getByRole('button', { name: 'Send message' });
    await act(async () => {
      fireEvent.click(sendButton);
    });

    // chatSend never runs because the local append failed first.
    await waitFor(() => {
      expect(threadApi.appendMessage).toHaveBeenCalledTimes(1);
    });
    expect(chatSend).not.toHaveBeenCalled();

    // Pending guard released: the user can re-enter text and the send button
    // enables again.
    await act(async () => {
      fireEvent.change(textarea, { target: { value: 'retry' } });
    });
    await waitFor(() => {
      expect(screen.getByRole('button', { name: 'Send message' })).not.toBeDisabled();
    });
  });

  it('releases the pending-send lock when appendMessage hits a stale-thread error', async () => {
    vi.mocked(threadApi.appendMessage).mockRejectedValueOnce(
      new CoreRpcError('thread missing', 'thread_not_found')
    );
    const { textarea } = await renderSelectedConversation();

    await act(async () => {
      fireEvent.change(textarea, { target: { value: 'stale thread send' } });
    });
    const sendButton = screen.getByRole('button', { name: 'Send message' });
    await act(async () => {
      fireEvent.click(sendButton);
    });

    await waitFor(() => {
      expect(threadApi.appendMessage).toHaveBeenCalledTimes(1);
    });
    expect(chatSend).not.toHaveBeenCalled();

    // Stale-thread branch silently clears the guard; typing must re-enable Send.
    await act(async () => {
      fireEvent.change(textarea, { target: { value: 'retry' } });
    });
    await waitFor(() => {
      expect(screen.getByRole('button', { name: 'Send message' })).not.toBeDisabled();
    });
  });

  it('clears the pending guard when the 120s silence timer fires', async () => {
    vi.useFakeTimers({ shouldAdvanceTime: true });
    try {
      const { textarea } = await renderSelectedConversation();

      await act(async () => {
        fireEvent.change(textarea, { target: { value: 'hang the backend' } });
      });
      const sendButton = screen.getByRole('button', { name: 'Send message' });
      await act(async () => {
        fireEvent.click(sendButton);
      });
      await waitFor(() => {
        expect(chatSend).toHaveBeenCalledTimes(1);
      });

      // Fast-forward past the 120s silence window with no inference signals.
      await act(async () => {
        await vi.advanceTimersByTimeAsync(120_000);
      });

      // After the safety timeout, typing should re-enable Send — proves the
      // pending guard was reset inside the timeout callback.
      await act(async () => {
        fireEvent.change(textarea, { target: { value: 'retry after timeout' } });
      });
      await waitFor(() => {
        expect(screen.getByRole('button', { name: 'Send message' })).not.toBeDisabled();
      });
    } finally {
      vi.useRealTimers();
    }
  });

  it('rearms the silence timer on sub-agent tool-timeline updates', async () => {
    // Regression: when a delegated sub-agent (`Research`, `Tools Agent`,
    // …) is running, the parent thread's `inferenceStatusByThread` and
    // `streamingAssistantByThread` references can stay put while
    // `toolTimelineByThread` and `taskBoardByThread` tick. The rearm
    // effect must watch all four — otherwise a long sub-agent loop
    // trips the 120s safety timer even though the user can see tools
    // firing in the timeline.
    vi.useFakeTimers({ shouldAdvanceTime: true });
    try {
      const { textarea, store, thread } = await renderSelectedConversation();

      await act(async () => {
        fireEvent.change(textarea, { target: { value: 'kick off a sub-agent loop' } });
      });
      await act(async () => {
        fireEvent.click(screen.getByRole('button', { name: 'Send message' }));
      });
      await waitFor(() => {
        expect(chatSend).toHaveBeenCalledTimes(1);
      });

      // Two-thirds of the way through the safety window, the parent
      // status is already in `subagent` phase and a delegated tool
      // posts a timeline update. After the fix this re-arms the timer.
      await act(async () => {
        await vi.advanceTimersByTimeAsync(80_000);
      });
      await act(async () => {
        store!.dispatch(
          setInferenceStatusForThread({
            threadId: thread.id,
            status: { phase: 'subagent', iteration: 1, maxIterations: 8 },
          })
        );
        store!.dispatch(
          setToolTimelineForThread({
            threadId: thread.id,
            entries: [{ id: 'tl-1', name: 'web_fetch', round: 1, status: 'running' }],
          })
        );
      });

      // Advance another 80s (total elapsed 160s, well past the 120s
      // window). The tool-timeline dispatch should have re-armed the
      // timer at the 80s mark, so the silence timer is now at 80s of
      // its fresh 120s budget and has NOT fired — the thread therefore
      // stays marked active. (The safety timeout would have dispatched
      // `clearThreadInferenceActive`, dropping it from `activeThreadIds`.)
      // We assert the active flag directly rather than the Send button:
      // a streaming thread now keeps the composer open for follow-up
      // queueing, so Send is intentionally enabled here.
      await act(async () => {
        await vi.advanceTimersByTimeAsync(80_000);
      });
      expect(store!.getState().thread.activeThreadIds[thread.id]).toBe(true);
    } finally {
      vi.useRealTimers();
    }
  });

  it('rearms the silence timer on inference heartbeat beats during a silent reasoning phase (#4270)', async () => {
    // Repro for #4270: a long prefill on a large context, or a reasoning-tier
    // model that buffers `reasoning_content` server-side, streams NO status /
    // text / tool / board signal for minutes. The core now emits a periodic
    // `inference_heartbeat`; the rearm effect must treat it as liveness so the
    // 120s silence timer never false-fires while the turn is genuinely working.
    vi.useFakeTimers({ shouldAdvanceTime: true });
    try {
      const { textarea, store, thread } = await renderSelectedConversation();

      await act(async () => {
        fireEvent.change(textarea, {
          target: { value: 'summarize a big codebase in reasoning mode' },
        });
      });
      await act(async () => {
        fireEvent.click(screen.getByRole('button', { name: 'Send message' }));
      });
      await waitFor(() => {
        expect(chatSend).toHaveBeenCalledTimes(1);
      });

      // 200s elapse in 20s steps — only a heartbeat each step, nothing else.
      // Without the #4270 fix the 120s timer would fire around the 6th step.
      for (let i = 0; i < 10; i++) {
        await act(async () => {
          await vi.advanceTimersByTimeAsync(20_000);
        });
        await act(async () => {
          store!.dispatch(bumpInferenceHeartbeatForThread({ threadId: thread.id }));
        });
      }

      // The beats kept rearming the timer → the turn is still marked active
      // (a fired safety timeout would have dispatched `clearThreadInferenceActive`).
      expect(store!.getState().thread.activeThreadIds[thread.id]).toBe(true);
    } finally {
      vi.useRealTimers();
    }
  });

  it('still fails fast when heartbeats stop — genuine disconnect surfaces (#4270 regression safety)', async () => {
    // Regression safety: the heartbeat is the liveness signal, so a real
    // connectivity drop (core/socket dead → no more beats) MUST still trip the
    // 120s silence timer rather than hanging forever.
    vi.useFakeTimers({ shouldAdvanceTime: true });
    try {
      const { textarea, store, thread } = await renderSelectedConversation();

      await act(async () => {
        fireEvent.change(textarea, { target: { value: 'task whose connection dies' } });
      });
      await act(async () => {
        fireEvent.click(screen.getByRole('button', { name: 'Send message' }));
      });
      await waitFor(() => {
        expect(chatSend).toHaveBeenCalledTimes(1);
      });

      // A couple of early beats, then silence (the socket died).
      await act(async () => {
        await vi.advanceTimersByTimeAsync(20_000);
      });
      await act(async () => {
        store!.dispatch(bumpInferenceHeartbeatForThread({ threadId: thread.id }));
      });

      // No more beats for a full 120s window → the silence timer fires and
      // drops the thread from the active set.
      await act(async () => {
        await vi.advanceTimersByTimeAsync(120_000);
      });
      expect(store!.getState().thread.activeThreadIds[thread.id]).toBeFalsy();
    } finally {
      vi.useRealTimers();
    }
  });

  it('does NOT rearm the silence timer on an unrelated thread’s updates', async () => {
    // Regression for the per-thread dependency scoping: the rearm effect must
    // react only to the SENDING thread's slices. A different thread churning
    // (background triage, another conversation) must not keep the foreground
    // turn's 120s timer alive — otherwise a truly hung send never fails fast.
    vi.useFakeTimers({ shouldAdvanceTime: true });
    try {
      const { textarea, store } = await renderSelectedConversation();

      await act(async () => {
        fireEvent.change(textarea, { target: { value: 'send on the foreground thread' } });
      });
      await act(async () => {
        fireEvent.click(screen.getByRole('button', { name: 'Send message' }));
      });
      await waitFor(() => {
        expect(chatSend).toHaveBeenCalledTimes(1);
      });

      // Churn an UNRELATED thread the whole time the foreground send is open.
      // None of these dispatches target the sending thread ('send-thread'),
      // so they must not rearm its timer.
      await act(async () => {
        await vi.advanceTimersByTimeAsync(80_000);
      });
      await act(async () => {
        store!.dispatch(
          setInferenceStatusForThread({
            threadId: 'some-other-thread',
            status: { phase: 'subagent', iteration: 3, maxIterations: 8 },
          })
        );
        store!.dispatch(
          setToolTimelineForThread({
            threadId: 'some-other-thread',
            entries: [{ id: 'other-1', name: 'web_fetch', round: 1, status: 'running' }],
          })
        );
      });

      // Cross the original 120s deadline (80s + 50s = 130s). Because the
      // unrelated-thread churn did NOT rearm, the safety timer fires: the
      // pending guard is released and Send re-enables once the user types.
      await act(async () => {
        await vi.advanceTimersByTimeAsync(50_000);
      });
      await act(async () => {
        fireEvent.change(textarea, { target: { value: 'retry after timeout' } });
      });
      await waitFor(() => {
        expect(screen.getByRole('button', { name: 'Send message' })).not.toBeDisabled();
      });
    } finally {
      vi.useRealTimers();
    }
  });

  it('releases the pending-send lock when chatSend rejects', async () => {
    vi.mocked(chatSend).mockRejectedValueOnce(new Error('emit failed'));
    const { textarea } = await renderSelectedConversation();

    await act(async () => {
      fireEvent.change(textarea, { target: { value: 'doomed send' } });
    });
    await waitFor(() => {
      expect(textarea).toHaveValue('doomed send');
    });

    const sendButton = screen.getByRole('button', { name: 'Send message' });
    await act(async () => {
      fireEvent.click(sendButton);
    });

    await waitFor(() => {
      expect(chatSend).toHaveBeenCalledTimes(1);
    });

    // After the failed send, typing again should leave the composer enabled so
    // the user can retry — proves the pending guard was released.
    await act(async () => {
      fireEvent.change(textarea, { target: { value: 'retry send' } });
    });
    await waitFor(() => {
      expect(screen.getByRole('button', { name: 'Send message' })).not.toBeDisabled();
    });
  });

  it('sends with Enter when the composer is not composing text', async () => {
    const { textarea, thread } = await renderSelectedConversation();

    await act(async () => {
      fireEvent.change(textarea, { target: { value: 'enter send' } });
    });
    await waitFor(() => {
      expect(textarea).toHaveValue('enter send');
      expect(screen.getByRole('button', { name: 'Send message' })).not.toBeDisabled();
    });

    await act(async () => {
      fireEvent.keyDown(textarea, { key: 'Enter' });
    });

    await waitFor(() => {
      expect(chatSend).toHaveBeenCalledWith({
        threadId: thread.id,
        message: 'enter send',
        model: 'hint:chat',
        profileId: 'default',
        locale: 'en',
      });
    });
  });

  it('does not send while an IME composition key event is confirming text', async () => {
    const { textarea } = await renderSelectedConversation();

    await act(async () => {
      fireEvent.change(textarea, { target: { value: '你好' } });
    });
    await waitFor(() => {
      expect(textarea).toHaveValue('你好');
      expect(screen.getByRole('button', { name: 'Send message' })).not.toBeDisabled();
    });

    await act(async () => {
      const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true });
      Object.defineProperty(event, 'isComposing', { value: true });
      textarea.dispatchEvent(event);
    });

    expect(chatSend).not.toHaveBeenCalled();
    expect(textarea).toHaveValue('你好');
  });

  it('does not send for legacy IME keyCode 229 events', async () => {
    const { textarea } = await renderSelectedConversation();

    await act(async () => {
      fireEvent.change(textarea, { target: { value: 'かな' } });
    });
    await waitFor(() => {
      expect(textarea).toHaveValue('かな');
      expect(screen.getByRole('button', { name: 'Send message' })).not.toBeDisabled();
    });

    await act(async () => {
      fireEvent.keyDown(textarea, { key: 'Enter', keyCode: 229 });
    });

    expect(chatSend).not.toHaveBeenCalled();
    expect(textarea).toHaveValue('かな');
  });

  it('does not send while composition is active even if keydown lacks IME flags', async () => {
    const { textarea, thread } = await renderSelectedConversation();

    await act(async () => {
      fireEvent.change(textarea, { target: { value: '안녕' } });
    });
    await waitFor(() => {
      expect(textarea).toHaveValue('안녕');
      expect(screen.getByRole('button', { name: 'Send message' })).not.toBeDisabled();
    });

    await act(async () => {
      fireEvent.compositionStart(textarea);
      fireEvent.keyDown(textarea, { key: 'Enter' });
    });

    expect(chatSend).not.toHaveBeenCalled();
    expect(textarea).toHaveValue('안녕');

    await act(async () => {
      fireEvent.compositionEnd(textarea);
      fireEvent.keyDown(textarea, { key: 'Enter' });
    });

    await waitFor(() => {
      expect(chatSend).toHaveBeenCalledWith({
        threadId: thread.id,
        message: '안녕',
        model: 'hint:chat',
        profileId: 'default',
        locale: 'en',
      });
    });
  });

  // The General/Subconscious/Tasks filter chips were removed — the thread list
  // is now fixed to the General bucket with no in-sidebar bucket switcher.
  // Subconscious reflections and task/worker threads have dedicated surfaces.
  it('does not render the removed bucket filter tabs', async () => {
    await act(async () => {
      await renderConversations({ thread: emptyThreadState });
    });

    // Sidebar is hidden by default — open it first.
    await openSidebar();

    expect(screen.queryByRole('tab', { name: 'General' })).not.toBeInTheDocument();
    expect(screen.queryByRole('tab', { name: 'Subconscious' })).not.toBeInTheDocument();
    expect(screen.queryByRole('tab', { name: 'Tasks' })).not.toBeInTheDocument();
  });
});

// #1624 — When a worker thread is the active selection, the header surfaces
// a "back to <parent title>" button that navigates the user back to the
// parent conversation. Covers the `selectedThreadParent` derivation and the
// click handler that dispatches setSelectedThread + loadThreadMessages.
describe('Conversations — worker thread back-to-parent navigation (#1624)', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    mockGetThreads.mockResolvedValue({ threads: [], count: 0 });
    mockGetThreadMessages.mockResolvedValue({ messages: [], count: 0 });
  });

  it('renders a back-to-parent button when the active thread has a parent', async () => {
    const parent = makeThread({ id: 't-parent', title: 'Parent Conversation' });
    const child = makeThread({ id: 't-child', title: 'Worker Task', parentThreadId: 't-parent' });
    mockGetThreads.mockResolvedValue({ threads: [parent, child], count: 2 });

    let store: ReturnType<typeof buildStore> | undefined;
    await act(async () => {
      store = await renderConversations({
        thread: {
          ...emptyThreadState,
          threads: [parent, child],
          selectedThreadId: child.id,
          messagesByThreadId: { [child.id]: [] },
        },
      });
    });

    // The mount effect now restores the persisted worker session directly;
    // re-select it explicitly so the assertion is independent of mount timing.
    await act(async () => {
      store!.dispatch(setSelectedThread('t-child'));
    });

    const backBtn = await screen.findByTestId('worker-thread-back-to-parent');
    expect(backBtn.textContent).toContain('Parent Conversation');
  });

  it('falls back to a generic title when the parent thread is missing from the list', async () => {
    const parent = makeThread({ id: 't-parent', title: 'Other Parent' });
    const child = makeThread({
      id: 't-child',
      title: 'Worker Task',
      parentThreadId: 't-missing-parent',
    });
    // The parent referenced by `parentThreadId` is intentionally absent from
    // the thread list so the `selectedThreadParent` resolver hits its fallback
    // branch. A separate parent is kept around so mount-time resume has a
    // visible thread to land on.
    mockGetThreads.mockResolvedValue({ threads: [parent, child], count: 2 });

    let store: ReturnType<typeof buildStore> | undefined;
    await act(async () => {
      store = await renderConversations({
        thread: {
          ...emptyThreadState,
          threads: [parent, child],
          selectedThreadId: child.id,
          messagesByThreadId: { [child.id]: [] },
        },
      });
    });
    await act(async () => {
      store!.dispatch(setSelectedThread('t-child'));
    });

    const backBtn = await screen.findByTestId('worker-thread-back-to-parent');
    expect(backBtn.textContent).toContain('parent thread');
  });

  it('dispatches selection + load when the back-to-parent button is clicked', async () => {
    const parent = makeThread({ id: 't-parent', title: 'Parent Conversation' });
    const child = makeThread({ id: 't-child', title: 'Worker Task', parentThreadId: 't-parent' });
    mockGetThreads.mockResolvedValue({ threads: [parent, child], count: 2 });

    let store: ReturnType<typeof buildStore> | undefined;
    await act(async () => {
      store = await renderConversations({
        thread: {
          ...emptyThreadState,
          threads: [parent, child],
          selectedThreadId: child.id,
          messagesByThreadId: { [child.id]: [] },
        },
      });
    });
    await act(async () => {
      store!.dispatch(setSelectedThread('t-child'));
    });

    const backBtn = await screen.findByTestId('worker-thread-back-to-parent');
    await act(async () => {
      fireEvent.click(backBtn);
    });

    // After click, the redux store should reflect the parent thread as the
    // newly selected conversation.
    await waitFor(() => {
      const state = store!.getState() as { thread: { selectedThreadId: string | null } };
      expect(state.thread.selectedThreadId).toBe('t-parent');
    });
    // The loadThreadMessages thunk goes through the threadApi.getThreadMessages
    // helper — verify it was kicked off for the parent thread.
    expect(mockGetThreadMessages).toHaveBeenCalledWith('t-parent');
  });

  // Covers line 1871: t('chat.budgetComplete') — cycleBudgetUsd=0 exhausted branch
  it('renders budgetComplete copy when cycleBudgetUsd=0 and budget is exhausted', async () => {
    const teamUsage = { cycleBudgetUsd: 0, remainingUsd: 0, cycleSpentUsd: 0, cycleEndsAt: null };

    mockUseUsageState.mockReturnValue({
      teamUsage,
      currentPlan: null,
      currentTier: 'PRO' as const,
      isFreeTier: false,
      usagePct: 1.0,
      isNearLimit: true,
      isAtLimit: true,
      isBudgetExhausted: true,
      shouldShowBudgetCompletedMessage: true,
      isLoading: false,
      refresh: vi.fn(),
    });

    await act(async () => {
      await renderConversations({ thread: emptyThreadState });
    });

    // cycleBudgetUsd=0 → false branch of cycleBudgetUsd > 0 ternary → budgetComplete key
    expect(screen.getByText(/Your included budget is complete/i)).toBeInTheDocument();
  });

  // CycleUsagePill (cycle-pill tooltip, loading pulse) moved into ChatComposer
  // toolbar (#3611) — those tests removed; the pill no longer renders here.
});

// #3717 (Bug 2) — A single logical assistant turn can be persisted as multiple
// agent ThreadMessages. The "Agentic task insights" panel used to be anchored
// immediately before the LAST agent message, which dropped it BETWEEN the
// earlier agent content and the final message — splitting one response into two
// disconnected chunks. The panel (and the "View full agent process" button) now
// render exactly once, anchored after the latest turn's USER message so they
// sit ABOVE the whole answer (processing before result) — never split between
// agent bubbles, regardless of how many agent messages the turn produced.
describe('Conversations — agent task insights panel anchoring (#3717 Bug 2)', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    mockGetThreads.mockResolvedValue({ threads: [], count: 0 });
    mockGetThreadMessages.mockResolvedValue({ messages: [], count: 0 });
    mockUseUsageState.mockReturnValue({
      teamUsage: null,
      currentPlan: null,
      currentTier: 'FREE' as const,
      isFreeTier: true,
      usagePct: 0,
      isNearLimit: false,
      isAtLimit: false,
      isBudgetExhausted: false,
      shouldShowBudgetCompletedMessage: false,
      isLoading: false,
      refresh: vi.fn(),
    });
  });

  it('renders the insights panel exactly once, above the answer of a multi-message turn (not split between bubbles)', async () => {
    const thread = makeThread({ id: 'multi-agent-thread', title: 'Multi-message turn' });
    // One logical assistant turn persisted as TWO agent ThreadMessages.
    const messages: ThreadMessage[] = [
      {
        id: 'm-user',
        sender: 'user',
        type: 'text',
        content: 'Plan and then summarize.',
        extraMetadata: {},
        createdAt: '2026-01-01T00:00:00.000Z',
      },
      {
        id: 'm-agent-1',
        sender: 'agent',
        type: 'text',
        content: 'First part of the answer.',
        extraMetadata: {},
        createdAt: '2026-01-01T00:01:00.000Z',
      },
      {
        id: 'm-agent-2',
        sender: 'agent',
        type: 'text',
        content: 'Second part of the answer.',
        extraMetadata: {},
        createdAt: '2026-01-01T00:02:00.000Z',
      },
    ];
    mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
    mockGetThreadMessages.mockResolvedValue({ messages, count: messages.length });

    let store: ReturnType<typeof buildStore> | undefined;
    await act(async () => {
      store = await renderConversations({
        thread: {
          ...selectedThreadState(thread),
          messagesByThreadId: { [thread.id]: messages },
          messages,
        },
        socket: socketState('connected'),
      });
    });

    // Seed the tool timeline after mount settles (mount-time turn-state
    // hydration would otherwise clobber a preloaded timeline). Include a
    // running subagent row so the panel exposes its "view full processing"
    // affordance (drives the onViewSubagent callback below).
    await screen.findByText('Second part of the answer.');
    await act(async () => {
      store!.dispatch(
        setToolTimelineForThread({
          threadId: thread.id,
          entries: [
            { id: 'tl-1', name: 'web_fetch', round: 1, status: 'success' },
            {
              id: 'sa-1',
              name: 'subagent:researcher',
              round: 1,
              status: 'running',
              subagent: { taskId: 'task-1', agentId: 'researcher', toolCalls: [] },
            },
          ],
        })
      );
    });

    // Panel renders once — not once per agent message.
    const panels = await screen.findAllByTestId('agent-task-insights');
    expect(panels).toHaveLength(1);
    const panel = panels[0];

    // The "View full agent process" button is hoisted alongside it — also once.
    expect(screen.getAllByTestId('view-process-source')).toHaveLength(1);

    // DOM order: processing happens before the result, so the panel sits ABOVE
    // the answer — after the latest turn's user message and before BOTH agent
    // bubbles (never split between them, preserving the #3717 invariant).
    const userText = screen.getByText('Plan and then summarize.');
    const firstAgentText = screen.getByText('First part of the answer.');
    const lastAgentText = screen.getByText('Second part of the answer.');
    // The panel precedes the first agent bubble (and therefore both).
    expect(firstAgentText.compareDocumentPosition(panel) & Node.DOCUMENT_POSITION_PRECEDING).toBe(
      Node.DOCUMENT_POSITION_PRECEDING
    );
    expect(lastAgentText.compareDocumentPosition(panel) & Node.DOCUMENT_POSITION_PRECEDING).toBe(
      Node.DOCUMENT_POSITION_PRECEDING
    );
    // …and follows the user message.
    expect(userText.compareDocumentPosition(panel) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(
      Node.DOCUMENT_POSITION_FOLLOWING
    );

    // Inline rows are compact — each shows a "View details →" link instead of
    // an inline expand. Clicking one opens the full-run Agent Process Source
    // side panel (every row opens the same panel).
    const viewDetails = screen.getAllByTestId('view-details');
    expect(viewDetails.length).toBeGreaterThan(0);
    await act(async () => {
      fireEvent.click(viewDetails[0]);
    });
    expect(await screen.findByTestId('agent-process-source-panel')).toBeInTheDocument();
  });

  it('hides the verbose timeline when "hide agent thinking" is on, but still opens the source panel', async () => {
    const thread = makeThread({ id: 'hide-insights-thread', title: 'Hide insights' });
    const messages: ThreadMessage[] = [
      {
        id: 'm-user',
        sender: 'user',
        type: 'text',
        content: 'How many posts?',
        extraMetadata: {},
        createdAt: '2026-01-01T00:00:00.000Z',
      },
      {
        id: 'm-agent',
        sender: 'agent',
        type: 'text',
        content: 'Zero posts went up.',
        extraMetadata: {},
        createdAt: '2026-01-01T00:01:00.000Z',
      },
    ];
    mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
    mockGetThreadMessages.mockResolvedValue({ messages, count: messages.length });

    let store: ReturnType<typeof buildStore> | undefined;
    await act(async () => {
      store = await renderConversations({
        thread: {
          ...selectedThreadState(thread),
          messagesByThreadId: { [thread.id]: messages },
          messages,
        },
        socket: socketState('connected'),
        theme: {
          mode: 'system',
          tabBarLabels: 'hover',
          fontSize: 'medium',
          hideAgentInsights: true,
        },
      });
    });

    await screen.findByText('Zero posts went up.');
    await act(async () => {
      store!.dispatch(
        setToolTimelineForThread({
          threadId: thread.id,
          entries: [{ id: 'tl-1', name: 'web_fetch', round: 1, status: 'success' }],
        })
      );
    });

    // Settled turn + preference ON: the verbose inline timeline is suppressed…
    expect(screen.queryByTestId('agent-task-insights')).toBeNull();
    // …but the "View full agent process Source" affordance still works and the
    // full run is one click away in the side panel (which renders the timeline).
    await act(async () => {
      fireEvent.click(screen.getByTestId('view-process-source'));
    });
    expect(await screen.findByTestId('agent-task-insights')).toBeInTheDocument();
  });

  it('shows a blinking "Processing" link instead of the timeline while in flight', async () => {
    const thread = makeThread({ id: 'processing-thread', title: 'Processing' });
    const messages: ThreadMessage[] = [
      {
        id: 'm-user',
        sender: 'user',
        type: 'text',
        content: 'Go.',
        extraMetadata: {},
        createdAt: '2026-01-01T00:00:00.000Z',
      },
    ];
    mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
    mockGetThreadMessages.mockResolvedValue({ messages, count: messages.length });

    let store: ReturnType<typeof buildStore> | undefined;
    await act(async () => {
      store = await renderConversations({
        thread: {
          ...selectedThreadState(thread),
          messagesByThreadId: { [thread.id]: messages },
          messages,
        },
        socket: socketState('connected'),
        theme: {
          mode: 'system',
          tabBarLabels: 'hover',
          fontSize: 'medium',
          hideAgentInsights: true,
        },
      });
    });

    await screen.findByText('Go.');
    // Drive the thread into an in-flight turn so `isSending` is true, then seed
    // a running timeline that the preference should keep hidden behind the link.
    await act(async () => {
      store!.dispatch(beginInferenceTurn({ threadId: thread.id }));
      store!.dispatch(
        setToolTimelineForThread({
          threadId: thread.id,
          entries: [{ id: 'tl-1', name: 'web_fetch', round: 1, status: 'running' }],
        })
      );
    });

    const link = await screen.findByTestId('agent-processing-link');
    expect(link).toBeInTheDocument();
    expect(screen.queryByTestId('agent-task-insights')).toBeNull();

    // Clicking the compact link opens the full run in the source panel.
    await act(async () => {
      fireEvent.click(link);
    });
    expect(await screen.findByTestId('agent-task-insights')).toBeInTheDocument();
  });

  it('surfaces a process-source opener for a tool-less (transcript-only) turn', async () => {
    // The agent only streamed reasoning/narration — no tool calls — so the
    // inline step timeline is empty, but the persisted thoughts must stay
    // reachable through a standalone opener into the full-run panel.
    const thread = makeThread({ id: 'transcript-only-thread', title: 'Thinking only' });
    const messages: ThreadMessage[] = [
      {
        id: 'm-user',
        sender: 'user',
        type: 'text',
        content: 'Just think out loud.',
        extraMetadata: {},
        createdAt: '2026-01-01T00:00:00.000Z',
      },
      {
        id: 'm-agent',
        sender: 'agent',
        type: 'text',
        content: 'Here is my reasoning result.',
        extraMetadata: {},
        createdAt: '2026-01-01T00:01:00.000Z',
      },
    ];
    mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
    mockGetThreadMessages.mockResolvedValue({ messages, count: messages.length });

    let store: ReturnType<typeof buildStore> | undefined;
    await act(async () => {
      store = await renderConversations({
        thread: {
          ...selectedThreadState(thread),
          messagesByThreadId: { [thread.id]: messages },
          messages,
        },
        socket: socketState('connected'),
      });
    });

    await screen.findByText('Here is my reasoning result.');
    // Seed a narration-only transcript (no tool timeline at all).
    await act(async () => {
      store!.dispatch(
        appendProcessingProse({
          threadId: thread.id,
          kind: 'narration',
          round: 1,
          delta: 'Let me reason about this carefully.',
        })
      );
    });

    // The verbose step timeline never renders (there are no tool steps)…
    expect(screen.queryByTestId('agent-task-insights')).toBeNull();
    // …but the standalone opener appears and opens the full-run panel, which
    // shows the persisted thoughts.
    const opener = screen.getByTestId('view-process-source');
    await act(async () => {
      fireEvent.click(opener);
    });
    expect(await screen.findByTestId('agent-process-source-panel')).toBeInTheDocument();
  });

  it('keeps a settled source opener when hidden and no agent message exists (cancelled first turn)', async () => {
    // A cancelled first turn records timeline steps but never persists an agent
    // message, so the hoisted "View full agent process Source" button does not
    // render. With the timeline hidden, the fallback opener must keep those
    // steps reachable.
    const thread = makeThread({ id: 'cancelled-first-turn', title: 'Cancelled' });
    const messages: ThreadMessage[] = [
      {
        id: 'm-user',
        sender: 'user',
        type: 'text',
        content: 'Start then stop.',
        extraMetadata: {},
        createdAt: '2026-01-01T00:00:00.000Z',
      },
    ];
    mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
    mockGetThreadMessages.mockResolvedValue({ messages, count: messages.length });

    let store: ReturnType<typeof buildStore> | undefined;
    await act(async () => {
      store = await renderConversations({
        thread: {
          ...selectedThreadState(thread),
          messagesByThreadId: { [thread.id]: messages },
          messages,
        },
        socket: socketState('connected'),
        theme: {
          mode: 'system',
          tabBarLabels: 'hover',
          fontSize: 'medium',
          hideAgentInsights: true,
        },
      });
    });

    await screen.findByText('Start then stop.');
    // Settled (no in-flight turn) timeline with steps but no agent message.
    await act(async () => {
      store!.dispatch(
        setToolTimelineForThread({
          threadId: thread.id,
          entries: [{ id: 'tl-1', name: 'web_fetch', round: 1, status: 'cancelled' }],
        })
      );
    });

    // No verbose timeline, and the hoisted opener is absent (no agent message)…
    expect(screen.queryByTestId('agent-task-insights')).toBeNull();
    expect(screen.queryByTestId('view-process-source')).toBeNull();
    // …so the fallback opener carries access to the recorded steps.
    const fallback = await screen.findByTestId('agent-process-source-fallback');
    await act(async () => {
      fireEvent.click(fallback);
    });
    expect(await screen.findByTestId('agent-task-insights')).toBeInTheDocument();
  });
});

describe('Conversations — open-session resume (View work)', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    mockGetThreadMessages.mockResolvedValue({ messages: [], count: 0 });
  });

  it('honours /chat/:threadId to open a task session on mount', async () => {
    // A task-labelled session thread, reachable only via an explicit
    // open-intent because it's hidden behind the default General tab.
    const taskThread = makeThread({
      id: 'task-open-1',
      title: 'Autonomous run',
      labels: ['tasks'],
    });
    mockGetThreads.mockResolvedValue({ threads: [taskThread], count: 1 });

    const store = buildStore({ thread: emptyThreadState });
    const { default: Conversations } = await import('../Conversations');

    await act(async () => {
      render(
        <Provider store={store}>
          <MemoryRouter initialEntries={['/chat/task-open-1']}>
            <Routes>
              <Route path="/chat/:threadId" element={<Conversations />} />
            </Routes>
          </MemoryRouter>
        </Provider>
      );
    });

    // The open-intent selects the task session (bypassing the General-tab
    // filter) and loads its messages.
    await waitFor(() => expect(store.getState().thread.selectedThreadId).toBe('task-open-1'));
    await waitFor(() => expect(mockGetThreadMessages).toHaveBeenCalled());
  });

  it("View work on a selected task board opens that card's session thread", async () => {
    const thread = makeThread({ id: 'board-thread', title: 'Board thread' });
    mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });

    const store = buildStore({ thread: selectedThreadState(thread) });

    const { default: Conversations } = await import('../Conversations');
    await act(async () => {
      render(
        <Provider store={store}>
          <MemoryRouter initialEntries={['/conversations']}>
            <Conversations />
          </MemoryRouter>
        </Provider>
      );
    });
    // Let the mount-resume effect settle, then seed the selected thread's task
    // board with a card that has a live session (seeding before mount gets
    // clobbered by turn-state hydration).
    await screen.findByPlaceholderText('How can I help you today?');
    const selectedId = store.getState().thread.selectedThreadId ?? 'board-thread';
    await act(async () => {
      store.dispatch(
        setTaskBoardForThread({
          threadId: selectedId,
          board: {
            threadId: selectedId,
            updatedAt: '',
            cards: [
              {
                id: 'tc1',
                title: 'Worked card',
                status: 'in_progress',
                order: 0,
                updatedAt: '',
                sessionThreadId: 'sess-99',
              },
            ],
          },
        })
      );
    });

    const viewBtn = await screen.findByTitle('View work');
    await act(async () => {
      fireEvent.click(viewBtn);
    });

    // onViewSession navigates the chat view to the card's session thread.
    await waitFor(() => expect(store.getState().thread.selectedThreadId).toBe('sess-99'));
  });

  it('does not push chat routes when embedded chat opens task session work', async () => {
    const thread = makeThread({ id: 'board-thread', title: 'Board thread' });
    mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });

    const store = await renderEmbeddedConversationsRoute('/human', {
      thread: selectedThreadState(thread),
    });

    const selectedId = store.getState().thread.selectedThreadId ?? 'board-thread';
    await act(async () => {
      store.dispatch(
        setTaskBoardForThread({
          threadId: selectedId,
          board: {
            threadId: selectedId,
            updatedAt: '',
            cards: [
              {
                id: 'tc1',
                title: 'Worked card',
                status: 'in_progress',
                order: 0,
                updatedAt: '',
                sessionThreadId: 'sess-99',
              },
            ],
          },
        })
      );
    });

    const viewBtn = await screen.findByTitle('View work');
    await act(async () => {
      fireEvent.click(viewBtn);
    });

    await waitFor(() => expect(store.getState().thread.selectedThreadId).toBe('sess-99'));
    expect(screen.getByTestId('route-path')).toHaveTextContent('/human');
  });

  it('approves a parked plan from the plan-review card', async () => {
    mockCallCoreRpc.mockClear().mockResolvedValue({});
    const thread = makeThread({ id: 'approve-thread', title: 'Approve thread' });
    mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });

    const store = await renderConversations({ thread: selectedThreadState(thread) });
    const selectedId = store.getState().thread.selectedThreadId ?? 'approve-thread';
    await act(async () => {
      store.dispatch(
        setPendingPlanReviewForThread({
          threadId: selectedId,
          review: { requestId: 'pr-1', summary: 'Needs sign-off', steps: ['do the thing'] },
        })
      );
    });

    // A parked plan surfaces the PlanReviewCard above the composer; "Approve &
    // run" resolves the parked turn via the plan_review_decide RPC.
    const approveBtn = await screen.findByText('Approve & run');
    await act(async () => {
      fireEvent.click(approveBtn);
    });

    await waitFor(() =>
      expect(mockCallCoreRpc).toHaveBeenCalledWith({
        method: 'openhuman.plan_review_decide',
        params: { request_id: 'pr-1', decision: 'approve', feedback: undefined },
      })
    );
  });
});

// Returning to the Chat tab must restore the thread the user last had open
// (persisted on the `thread` slice, kept in-memory across in-app navigation),
// even when it's hidden behind the default General tab — a task / worker /
// subconscious / meeting session. The previous General-only resume default
// dropped such a session and, when it was the only thread, spawned a fresh
// chat — losing the active conversation.
describe('Conversations — active-thread restore across in-app navigation', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    mockGetThreadMessages.mockResolvedValue({ messages: [], count: 0 });
  });

  it('restores a non-General active session on remount instead of spawning a new chat', async () => {
    const taskThread = makeThread({
      id: 'task-active-1',
      title: 'Active task session',
      labels: ['tasks'],
    });
    // Only the (hidden) task session exists — pre-fix this falls through to
    // handleCreateNewThread and replaces the active session with a new chat.
    mockGetThreads.mockResolvedValue({ threads: [taskThread], count: 1 });

    let store: ReturnType<typeof buildStore> | undefined;
    await act(async () => {
      store = await renderConversations({
        thread: {
          ...emptyThreadState,
          threads: [taskThread],
          selectedThreadId: 'task-active-1',
          messagesByThreadId: { 'task-active-1': [] },
        },
      });
    });

    await waitFor(() => {
      expect(store!.getState().thread.selectedThreadId).toBe('task-active-1');
    });
    expect(threadApi.createNewThread).not.toHaveBeenCalled();
    expect(mockGetThreadMessages).toHaveBeenCalledWith('task-active-1');
  });

  it('keeps the General-only sidebar while restoring a non-General session', async () => {
    const taskThread = makeThread({
      id: 'task-active-2',
      title: 'Restored task',
      labels: ['tasks'],
    });
    mockGetThreads.mockResolvedValue({ threads: [taskThread], count: 1 });

    await act(async () => {
      await renderConversations({
        thread: {
          ...emptyThreadState,
          threads: [taskThread],
          selectedThreadId: 'task-active-2',
          messagesByThreadId: { 'task-active-2': [] },
        },
      });
    });

    await waitFor(() => {
      expect(threadApi.createNewThread).not.toHaveBeenCalled();
    });
    // Main removed the visible General/Subconscious/Tasks chips; restoring a
    // task session should not reintroduce that tab UI.
    await openSidebar();
    expect(screen.queryByRole('tab', { name: 'Tasks' })).not.toBeInTheDocument();
  });

  it('reuses an empty General thread when there is no active selection', async () => {
    // Fresh session (no persisted selection) keeps main's new-window behaviour:
    // reuse an existing empty General thread rather than spawning duplicates.
    const threads = [makeThread({ id: 'g-1', title: 'Recent general' })];
    mockGetThreads.mockResolvedValue({ threads, count: 1 });

    let store: ReturnType<typeof buildStore> | undefined;
    await act(async () => {
      store = await renderConversations({ thread: emptyThreadState });
    });

    await waitFor(() => {
      expect(store!.getState().thread.selectedThreadId).toBe('g-1');
    });
    expect(threadApi.createNewThread).not.toHaveBeenCalled();
  });

  it('opens a new chat for a genuinely fresh session with no threads', async () => {
    mockGetThreads.mockResolvedValue({ threads: [], count: 0 });

    await act(async () => {
      await renderConversations({ thread: emptyThreadState });
    });

    await waitFor(() => {
      expect(threadApi.createNewThread).toHaveBeenCalled();
    });
  });
});

describe('Conversations — queued follow-ups while a turn streams', () => {
  // Reset shared mock call history + defaults per test so `toHaveBeenCalledWith`
  // assertions reflect only the current case (not bleed from an earlier one).
  beforeEach(() => {
    vi.clearAllMocks();
    mockGetThreads.mockResolvedValue({ threads: [], count: 0 });
    mockGetThreadMessages.mockResolvedValue({ messages: [], count: 0 });
    vi.mocked(chatSend).mockResolvedValue(undefined);
    vi.mocked(chatClearQueue).mockResolvedValue(0);
  });

  // A selected thread that is actively streaming (`activeThreadIds`) keeps the
  // composer open for follow-up queueing — the placeholder flips to the
  // follow-up hint and a plain-Enter / Send submission queues a follow-up.
  async function renderStreamingConversation() {
    const thread = makeThread({ id: 'fup-thread', title: 'FUP Thread' });
    mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
    mockGetThreadMessages.mockResolvedValue({ messages: [], count: 0 });
    let store: ReturnType<typeof buildStore> | undefined;
    await act(async () => {
      store = await renderConversations({
        thread: { ...selectedThreadState(thread), activeThreadIds: { [thread.id]: true } },
        socket: socketState('connected'),
      });
    });
    const textarea = await screen.findByPlaceholderText(/Queue a follow-up/i);
    return { store, textarea, thread };
  }

  it('queues a plain-Enter submission as a follow-up and lists it in the strip', async () => {
    const { textarea } = await renderStreamingConversation();

    await act(async () => {
      fireEvent.change(textarea, { target: { value: 'and the pricing?' } });
    });
    await act(async () => {
      fireEvent.keyDown(textarea, { key: 'Enter' });
    });

    await waitFor(() => {
      expect(chatSend).toHaveBeenCalledWith(expect.objectContaining({ queueMode: 'followup' }));
    });
    expect(await screen.findByText('and the pricing?')).toBeInTheDocument();
  });

  it('queues via the Send button while a turn streams', async () => {
    const { textarea } = await renderStreamingConversation();

    await act(async () => {
      fireEvent.change(textarea, { target: { value: 'one more thing' } });
    });
    await waitFor(() => {
      expect(screen.getByRole('button', { name: 'Send message' })).not.toBeDisabled();
    });
    await act(async () => {
      fireEvent.click(screen.getByRole('button', { name: 'Send message' }));
    });

    await waitFor(() => {
      expect(chatSend).toHaveBeenCalledWith(expect.objectContaining({ queueMode: 'followup' }));
    });
    expect(await screen.findByText('one more thing')).toBeInTheDocument();
  });

  it('clears the queued follow-ups and the backend queue on Clear', async () => {
    const { textarea } = await renderStreamingConversation();

    await act(async () => {
      fireEvent.change(textarea, { target: { value: 'dismiss me' } });
    });
    await act(async () => {
      fireEvent.keyDown(textarea, { key: 'Enter' });
    });

    const strip = await screen.findByTestId('queued-followups');
    expect(within(strip).getByText('dismiss me')).toBeInTheDocument();

    await act(async () => {
      fireEvent.click(within(strip).getByText('Clear'));
    });

    await waitFor(() => expect(chatClearQueue).toHaveBeenCalledWith('fup-thread'));
    await waitFor(() => expect(screen.queryByTestId('queued-followups')).not.toBeInTheDocument());
  });

  it('keeps the queued pills when the backend clear fails', async () => {
    vi.mocked(chatClearQueue).mockResolvedValueOnce(null);
    const { textarea } = await renderStreamingConversation();

    await act(async () => {
      fireEvent.change(textarea, { target: { value: 'still queued' } });
    });
    await act(async () => {
      fireEvent.keyDown(textarea, { key: 'Enter' });
    });

    const strip = await screen.findByTestId('queued-followups');
    await act(async () => {
      fireEvent.click(within(strip).getByText('Clear'));
    });

    await waitFor(() => expect(chatClearQueue).toHaveBeenCalledWith('fup-thread'));
    // Clear failed (null) → the backend will still dispatch them, so the pills
    // stay put instead of falsely showing the queue emptied.
    expect(screen.getByTestId('queued-followups')).toBeInTheDocument();
    expect(
      within(screen.getByTestId('queued-followups')).getByText('still queued')
    ).toBeInTheDocument();
  });

  it('keeps the draft intact when the follow-up send fails', async () => {
    vi.mocked(chatSend).mockRejectedValueOnce(new Error('send boom'));
    const { textarea } = await renderStreamingConversation();

    await act(async () => {
      fireEvent.change(textarea, { target: { value: 'keep me on failure' } });
    });
    await act(async () => {
      fireEvent.keyDown(textarea, { key: 'Enter' });
    });

    // Send rejected → no pill queued and the composer keeps the user's text so
    // they can retry instead of silently losing it.
    await waitFor(() => expect(chatSend).toHaveBeenCalled());
    expect(screen.queryByTestId('queued-followups')).not.toBeInTheDocument();
    expect(textarea).toHaveValue('keep me on failure');
  });
});

describe('Conversations — message list reserves room for the floating composer footer (#4268)', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    mockGetThreads.mockResolvedValue({ threads: [], count: 0 });
    mockGetThreadMessages.mockResolvedValue({ messages: [], count: 0 });
  });

  function followupMessage(id: string, content: string): ThreadMessage {
    return {
      id,
      sender: 'user',
      type: 'text',
      content,
      extraMetadata: {},
      createdAt: '2026-01-01T00:00:00.000Z',
    };
  }

  // Page-variant conversation with a visible message and an active (streaming)
  // turn, so the message list, the follow-up composer, and the queued-followups
  // panel all render together — the exact state where the overlap bug appeared.
  async function renderActiveConversationWithMessages() {
    const thread = makeThread({ id: 'pad-thread', title: 'Pad Thread' });
    const messages: ThreadMessage[] = [
      {
        id: 'm-agent',
        sender: 'agent',
        type: 'text',
        content: 'Streaming agent reply tail that must stay visible.',
        extraMetadata: {},
        createdAt: '2026-01-01T00:01:00.000Z',
      },
    ];
    mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
    mockGetThreadMessages.mockResolvedValue({ messages, count: messages.length });
    let store: ReturnType<typeof buildStore> | undefined;
    await act(async () => {
      store = await renderConversations({
        thread: {
          ...selectedThreadState(thread),
          messagesByThreadId: { [thread.id]: messages },
          messages,
          activeThreadIds: { [thread.id]: true },
        },
        socket: socketState('connected'),
      });
    });
    return { store: store as ReturnType<typeof buildStore>, thread };
  }

  function paddingBottomPx(): number {
    return parseInt(screen.getByTestId('chat-message-list').style.paddingBottom || '0', 10);
  }

  it('reserves bottom padding via an inline style, not the static pb-32 (0 follow-ups)', async () => {
    await renderActiveConversationWithMessages();
    const list = screen.getByTestId('chat-message-list');
    // The old static `pb-32` is gone; spacing is reserved by a dynamic inline
    // style so it can grow with the footer.
    expect(list.className).not.toMatch(/\bpb-32\b/);
    expect(list.style.paddingBottom).not.toBe('');
    expect(paddingBottomPx()).toBeGreaterThan(0);
    expect(screen.queryByTestId('queued-followups')).not.toBeInTheDocument();
  });

  it('keeps content padded when a single follow-up is queued', async () => {
    const { store, thread } = await renderActiveConversationWithMessages();
    await act(async () => {
      store.dispatch(
        enqueueFollowup({
          threadId: thread.id,
          message: followupMessage('f1', 'and the pricing?'),
          label: 'and the pricing?',
        })
      );
    });
    expect(await screen.findByTestId('queued-followups')).toBeInTheDocument();
    expect(paddingBottomPx()).toBeGreaterThan(0);
  });

  it('keeps content padded with multiple queued follow-ups', async () => {
    const { store, thread } = await renderActiveConversationWithMessages();
    await act(async () => {
      store.dispatch(
        enqueueFollowup({
          threadId: thread.id,
          message: followupMessage('f1', 'one'),
          label: 'one',
        })
      );
      store.dispatch(
        enqueueFollowup({
          threadId: thread.id,
          message: followupMessage('f2', 'two'),
          label: 'two',
        })
      );
      store.dispatch(
        enqueueFollowup({
          threadId: thread.id,
          message: followupMessage('f3', 'three'),
          label: 'three',
        })
      );
    });
    const strip = await screen.findByTestId('queued-followups');
    expect(within(strip).getByText('one')).toBeInTheDocument();
    expect(within(strip).getByText('three')).toBeInTheDocument();
    expect(paddingBottomPx()).toBeGreaterThan(0);
  });

  it('restores normal layout after the queued follow-ups are dismissed', async () => {
    const { store, thread } = await renderActiveConversationWithMessages();
    await act(async () => {
      store.dispatch(
        enqueueFollowup({
          threadId: thread.id,
          message: followupMessage('f1', 'dismiss me'),
          label: 'dismiss me',
        })
      );
    });
    expect(await screen.findByTestId('queued-followups')).toBeInTheDocument();
    await act(async () => {
      store.dispatch(clearFollowupsForThread({ threadId: thread.id }));
    });
    await waitFor(() => expect(screen.queryByTestId('queued-followups')).not.toBeInTheDocument());
    // Baseline padding stays reserved so the layout is still correct.
    expect(paddingBottomPx()).toBeGreaterThan(0);
  });

  it('grows the reserved padding to the footer height reported by the ResizeObserver', async () => {
    // jsdom performs no layout, so the polyfilled ResizeObserver never fires.
    // Drive it manually: capture the observed element + callback, report a
    // concrete footer height, and assert the list reserves that height + the
    // 16px gap.
    const original = globalThis.ResizeObserver;
    const handle: { cb: ResizeObserverCallback | null } = { cb: null };
    class MockResizeObserver {
      constructor(cb: ResizeObserverCallback) {
        handle.cb = cb;
      }
      observe() {}
      unobserve() {}
      disconnect() {}
    }
    globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
    try {
      await renderActiveConversationWithMessages();
      const footer = document.querySelector('[data-walkthrough="home-cta"]') as HTMLElement | null;
      expect(footer).not.toBeNull();
      (footer as HTMLElement).getBoundingClientRect = () => ({ height: 240 }) as DOMRect;
      await act(async () => {
        handle.cb?.([], {} as ResizeObserver);
      });
      // 240px measured footer + 16px gap.
      expect(paddingBottomPx()).toBe(256);
    } finally {
      globalThis.ResizeObserver = original;
    }
  });
});
