// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';

const {
  mockConnection,
  mockUseSessions,
  mockActive,
  mockArchived,
  renameSessionSpy,
  mockExportSession,
  mockWorkspaceActions,
} = vi.hoisted(() => {
  const makeStore = () => ({
    sessions: [] as MockSession[],
    loading: false,
    error: null as unknown,
    reload: vi.fn(),
    deleteSession: vi.fn().mockResolvedValue(true),
    archiveSession: vi.fn().mockResolvedValue(true),
    unarchiveSession: vi.fn().mockResolvedValue(true),
  });
  const mockActive = makeStore();
  const mockArchived = makeStore();
  const mockExportSession = vi.fn();
  const mockUseSessions = vi.fn(
    (options?: { archiveState?: 'active' | 'archived' }) =>
      options?.archiveState === 'archived'
        ? mockArchived
        : { ...mockActive, exportSession: mockExportSession },
  );
  return {
    mockConnection: {
      status: 'connected',
      sessionId: null as string | null,
      workspaceCwd: '/tmp/project',
      capabilities: { qwenCodeVersion: '1.2.3', features: [] as string[] } as
        | { qwenCodeVersion?: string; features?: string[] }
        | undefined,
    },
    mockUseSessions,
    mockActive,
    mockArchived,
    renameSessionSpy: vi.fn(),
    mockExportSession,
    mockWorkspaceActions: {
      listSessionGroups: vi.fn().mockResolvedValue({
        groups: [],
        colorOptions: ['red', 'orange', 'yellow', 'green', 'blue', 'purple'],
      }),
      createSessionGroup: vi.fn(),
      updateSessionGroup: vi.fn(),
      deleteSessionGroup: vi.fn(),
      updateSessionOrganization: vi.fn(),
    },
  };
});

type MockSession = {
  sessionId: string;
  workspaceCwd: string;
  displayName?: string;
  createdAt?: string;
  updatedAt?: string;
  clientCount?: number;
  hasActivePrompt?: boolean;
  isArchived?: boolean;
  isPinned?: boolean;
  groupId?: string | null;
  color?: 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple' | null;
};

vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
  useConnection: () => mockConnection,
  useActions: () => ({ renameSession: renameSessionSpy }),
  useWorkspaceActions: () => mockWorkspaceActions,
  useSessions: (options?: { archiveState?: 'active' | 'archived' }) =>
    mockUseSessions(options),
}));

function makeSession(
  sessionId: string,
  over: Partial<MockSession> = {},
): MockSession {
  return {
    sessionId,
    workspaceCwd: '/tmp/project',
    displayName: `Session ${sessionId}`,
    createdAt: '2026-01-01T00:00:00.000Z',
    updatedAt: '2026-01-01T00:00:00.000Z',
    clientCount: 0,
    hasActivePrompt: false,
    ...over,
  };
}

const { I18nProvider } = await import('../../i18n');
const { WebShellSidebar } = await import('./WebShellSidebar');

(
  globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;

const mounted: Array<{ root: Root; container: HTMLElement }> = [];

const noop = () => {};
const SIDEBAR_WIDTH_STORAGE_KEY = 'qwen-code-web-shell-sidebar-width';

function setStoredSidebarWidth(width: number): void {
  window.localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(width));
}

function pointerEvent(type: string, clientX: number): MouseEvent {
  return new MouseEvent(type, { bubbles: true, clientX });
}

function renderSidebar(
  collapsed: boolean,
  overrides: Partial<{
    onOpenSettings: () => void;
    onOpenDaemonStatus: () => void;
    onOpenScheduledTasks: () => void;
    onOpenSessions: () => void;
    canOpenSessionsOverview: boolean;
    onOpenSplitView: () => void;
    canOpenSplitView: boolean;
    onCollapsedChange: (collapsed: boolean) => void;
    onNewSession: () => Promise<boolean> | boolean;
    onLoadSession: (sessionId: string) => Promise<void> | void;
    onError: (error: unknown, message: string) => void;
    sessionListReloadToken: number;
  }> = {},
): { container: HTMLElement; rerender: (props: typeof overrides) => void } {
  const container = document.createElement('div');
  document.body.appendChild(container);
  const root = createRoot(container);
  const doRender = (props: typeof overrides) => {
    act(() => {
      root.render(
        <I18nProvider language="en">
          <WebShellSidebar
            collapsed={collapsed}
            onCollapsedChange={noop}
            onOpenSettings={noop}
            onOpenDaemonStatus={noop}
            onOpenScheduledTasks={noop}
            onOpenSessions={noop}
            onOpenSplitView={noop}
            onNewSession={() => false}
            onLoadSession={noop}
            onError={noop}
            {...props}
          />
        </I18nProvider>,
      );
    });
  };
  doRender(overrides);
  mounted.push({ root, container });
  return { container, rerender: doRender };
}

beforeEach(() => {
  mockUseSessions.mockClear();
  window.localStorage.clear();
  mockConnection.sessionId = null;
  mockConnection.capabilities = { qwenCodeVersion: '1.2.3', features: [] };
  for (const store of [mockActive, mockArchived]) {
    store.sessions = [];
    store.loading = false;
    store.error = null;
    store.reload.mockReset();
    store.deleteSession.mockReset();
    store.archiveSession.mockReset();
    store.unarchiveSession.mockReset();
    store.deleteSession.mockResolvedValue(true);
    store.archiveSession.mockResolvedValue(true);
    store.unarchiveSession.mockResolvedValue(true);
  }
  renameSessionSpy.mockClear();
  mockExportSession.mockReset();
  mockExportSession.mockResolvedValue({
    content: '<html>export</html>',
    filename: 'session.html',
    mimeType: 'text/html',
    format: 'html',
  });
  mockWorkspaceActions.listSessionGroups.mockReset();
  mockWorkspaceActions.listSessionGroups.mockResolvedValue({
    groups: [],
    colorOptions: ['red', 'orange', 'yellow', 'green', 'blue', 'purple'],
  });
  mockWorkspaceActions.createSessionGroup.mockReset();
  mockWorkspaceActions.updateSessionGroup.mockReset();
  mockWorkspaceActions.deleteSessionGroup.mockReset();
  mockWorkspaceActions.updateSessionOrganization.mockReset();
});

afterEach(() => {
  for (const { root, container } of mounted.splice(0)) {
    act(() => root.unmount());
    container.remove();
  }
  vi.restoreAllMocks();
  vi.unstubAllGlobals();
});

describe('WebShellSidebar — version footer', () => {
  it('shows the settings label and qwen-code version at full footer width', () => {
    setStoredSidebarWidth(360);
    const { container } = renderSidebar(false, {
      canOpenSessionsOverview: true,
      canOpenSplitView: true,
    });
    const settingsButton = container.querySelector<HTMLButtonElement>(
      '[aria-label="Settings"]',
    );
    const badge = container.querySelector('[title="Qwen Code v1.2.3"]');
    expect(settingsButton).not.toBeNull();
    expect(settingsButton?.textContent).toContain('Settings');
    expect(badge).not.toBeNull();
    expect(badge?.textContent).toBe('v1.2.3');
  });

  it('shows the qwen-code version in the footer when expanded', () => {
    const { container } = renderSidebar(false);
    const badge = container.querySelector('[title="Qwen Code v1.2.3"]');
    expect(badge).not.toBeNull();
    expect(badge?.textContent).toBe('v1.2.3');
  });

  it('hides the settings label first while keeping the settings button accessible', () => {
    setStoredSidebarWidth(260);
    const { container } = renderSidebar(false, {
      canOpenSessionsOverview: true,
      canOpenSplitView: true,
    });
    const settingsButton = container.querySelector<HTMLButtonElement>(
      '[aria-label="Settings"]',
    );
    const badge = container.querySelector('[title="Qwen Code v1.2.3"]');
    expect(settingsButton).not.toBeNull();
    expect(settingsButton?.title).toBe('Settings');
    expect(settingsButton?.textContent).not.toContain('Settings');
    expect(settingsButton?.querySelector('svg')).not.toBeNull();
    expect(badge).not.toBeNull();
  });

  it('hides the version at tight footer width', () => {
    setStoredSidebarWidth(220);
    const { container } = renderSidebar(false, {
      canOpenSessionsOverview: true,
      canOpenSplitView: true,
    });
    const settingsButton = container.querySelector<HTMLButtonElement>(
      '[aria-label="Settings"]',
    );
    expect(settingsButton).not.toBeNull();
    expect(settingsButton?.textContent).not.toContain('Settings');
    expect(container.querySelector('[title="Qwen Code v1.2.3"]')).toBeNull();
    expect(container.textContent ?? '').not.toContain('v1.2.3');
  });

  it('renders a non-semver fallback (e.g. "unknown") without a bogus "v" prefix', () => {
    mockConnection.capabilities = { qwenCodeVersion: 'unknown' };
    const { container } = renderSidebar(false);
    const badge = container.querySelector('[title="Qwen Code unknown"]');
    expect(badge).not.toBeNull();
    expect(badge?.textContent).toBe('unknown');
    expect(container.textContent ?? '').not.toContain('vunknown');
  });

  it('hides the version when the sidebar is collapsed', () => {
    const { container } = renderSidebar(true);
    expect(container.querySelector('[title="Qwen Code v1.2.3"]')).toBeNull();
    expect(container.textContent ?? '').not.toContain('v1.2.3');
  });

  it('renders no version badge when the daemon reports none', () => {
    mockConnection.capabilities = undefined;
    const { container } = renderSidebar(false);
    expect(container.textContent ?? '').not.toMatch(/v\d/);
  });
});

describe('WebShellSidebar — brand logo', () => {
  it('renders the Qwen brand mark beside the new-chat button when expanded', () => {
    const { container } = renderSidebar(false);
    // Filled brand mark (shared with the favicon), not a stroked nav icon.
    const mark = container.querySelector('svg path[fill="#6D44E8"]');
    expect(mark).not.toBeNull();
    // The mark and the new-chat button share the same top row.
    const topRow = mark!.closest('div');
    expect(topRow?.querySelector('[aria-label="New chat"]')).not.toBeNull();
  });

  it('hides the brand mark when collapsed (only the new-chat button remains)', () => {
    const { container } = renderSidebar(true);
    expect(container.querySelector('svg path[fill="#6D44E8"]')).toBeNull();
    expect(container.querySelector('[aria-label="New chat"]')).not.toBeNull();
  });
});

describe('WebShellSidebar — daemon status entry', () => {
  it('invokes onOpenDaemonStatus when the footer button is clicked', () => {
    const onOpenDaemonStatus = vi.fn();
    const { container } = renderSidebar(false, { onOpenDaemonStatus });
    const button = container.querySelector<HTMLButtonElement>(
      '[aria-label="Daemon Status"]',
    );
    expect(button).not.toBeNull();
    act(() => {
      button!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
    });
    expect(onOpenDaemonStatus).toHaveBeenCalledTimes(1);
  });

  it('still exposes the daemon status button when collapsed', () => {
    const onOpenDaemonStatus = vi.fn();
    const { container } = renderSidebar(true, { onOpenDaemonStatus });
    const button = container.querySelector<HTMLButtonElement>(
      '[aria-label="Daemon Status"]',
    );
    expect(button).not.toBeNull();
    act(() => {
      button!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
    });
    expect(onOpenDaemonStatus).toHaveBeenCalledTimes(1);
  });
});

describe('WebShellSidebar — session overview entry', () => {
  it('offers the entry point only on large screens', () => {
    const small = renderSidebar(false, { canOpenSessionsOverview: false });
    expect(
      small.container.querySelector('[aria-label="Session Overview"]'),
    ).toBeNull();

    const large = renderSidebar(false, { canOpenSessionsOverview: true });
    expect(
      large.container.querySelector('[aria-label="Session Overview"]'),
    ).not.toBeNull();
  });

  it('invokes onOpenSessions when the footer button is clicked', () => {
    const onOpenSessions = vi.fn();
    const { container } = renderSidebar(false, {
      canOpenSessionsOverview: true,
      onOpenSessions,
    });
    const button = container.querySelector<HTMLButtonElement>(
      '[aria-label="Session Overview"]',
    );
    expect(button).not.toBeNull();
    act(() => {
      button!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
    });
    expect(onOpenSessions).toHaveBeenCalledTimes(1);
  });
});

describe('WebShellSidebar — split view entry', () => {
  it('offers the split view entry only on large screens', () => {
    const small = renderSidebar(false, { canOpenSplitView: false });
    expect(
      small.container.querySelector('[aria-label="Split View"]'),
    ).toBeNull();

    const large = renderSidebar(false, { canOpenSplitView: true });
    expect(
      large.container.querySelector('[aria-label="Split View"]'),
    ).not.toBeNull();
  });

  it('invokes onOpenSplitView when the footer button is clicked', () => {
    const onOpenSplitView = vi.fn();
    const { container } = renderSidebar(false, {
      canOpenSplitView: true,
      onOpenSplitView,
    });
    const button = container.querySelector<HTMLButtonElement>(
      '[aria-label="Split View"]',
    );
    expect(button).not.toBeNull();
    act(() => {
      button!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
    });
    expect(onOpenSplitView).toHaveBeenCalledTimes(1);
  });
});

describe('WebShellSidebar — resize behavior', () => {
  it('persists normal drag widths without collapsing', () => {
    setStoredSidebarWidth(260);
    const onCollapsedChange = vi.fn();
    const { container } = renderSidebar(false, { onCollapsedChange });
    const handle = container.querySelector<HTMLElement>('[role="separator"]');
    expect(handle).not.toBeNull();

    act(() => {
      handle!.dispatchEvent(pointerEvent('pointerdown', 260));
      window.dispatchEvent(pointerEvent('pointermove', 230));
      window.dispatchEvent(pointerEvent('pointerup', 230));
    });

    expect(onCollapsedChange).not.toHaveBeenCalled();
    expect(window.localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY)).toBe('230');
  });

  it('collapses when dragged past the compact threshold and restores the expanded width', () => {
    setStoredSidebarWidth(260);
    const onCollapsedChange = vi.fn();
    const { container } = renderSidebar(false, { onCollapsedChange });
    const handle = container.querySelector<HTMLElement>('[role="separator"]');
    expect(handle).not.toBeNull();

    act(() => {
      handle!.dispatchEvent(pointerEvent('pointerdown', 260));
      window.dispatchEvent(pointerEvent('pointermove', 130));
    });

    expect(onCollapsedChange).toHaveBeenCalledWith(true);
    expect(onCollapsedChange).toHaveBeenCalledTimes(1);
    expect(window.localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY)).toBe('260');
  });
});

function click(el: Element | null): void {
  expect(el).not.toBeNull();
  act(() => {
    el!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
  });
}

// Clicks that kick off an async action settle trailing state updates in
// `.finally()`; flush those microtasks inside act().
async function clickAsync(el: Element | null): Promise<void> {
  expect(el).not.toBeNull();
  await act(async () => {
    el!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
  });
}

describe('WebShellSidebar — session organization', () => {
  it('uses organized sessions only when the daemon advertises the capability', () => {
    renderSidebar(false);
    expect(mockUseSessions).toHaveBeenCalledWith({
      autoLoad: true,
      pageSize: 1000,
      archiveState: 'active',
    });

    for (const { root, container } of mounted.splice(0)) {
      act(() => root.unmount());
      container.remove();
    }

    mockConnection.capabilities = {
      qwenCodeVersion: '1.2.3',
      features: ['session_organization'],
    };
    mockWorkspaceActions.listSessionGroups.mockReturnValue(
      new Promise(() => undefined),
    );
    const { container } = renderSidebar(false);
    expect(mockUseSessions).toHaveBeenCalledWith({
      autoLoad: true,
      pageSize: 1000,
      archiveState: 'active',
      view: 'organized',
      group: 'all',
    });
    expect(container.querySelector('[aria-label="Session group"]')).toBeNull();
  });

  it('creates session groups from an in-app dialog form', async () => {
    mockConnection.capabilities = {
      qwenCodeVersion: '1.2.3',
      features: ['session_organization'],
    };
    mockWorkspaceActions.createSessionGroup.mockResolvedValue({
      id: 'group-1',
      name: 'Backend',
      color: 'green',
      order: 0,
      createdAt: '2026-07-04T00:00:00.000Z',
      updatedAt: '2026-07-04T00:00:00.000Z',
    });
    mockActive.sessions = [
      makeSession('550e8400-e29b-41d4-a716-446655440000', {
        displayName: 'Review plan',
        createdAt: '2026-07-04T00:00:00.000Z',
        updatedAt: '2026-07-04T00:00:00.000Z',
      }),
    ];
    const promptSpy = vi.spyOn(window, 'prompt');

    renderSidebar(false);
    await act(async () => {
      await Promise.resolve();
    });
    const organizeButton = document.body.querySelector<HTMLButtonElement>(
      '[aria-label="Group"]',
    );
    expect(organizeButton).not.toBeNull();
    act(() => {
      organizeButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
    });
    const createButton = Array.from(
      document.body.querySelectorAll<HTMLButtonElement>('button'),
    ).find((button) => button.textContent?.includes('Create group'));
    expect(createButton).not.toBeNull();
    act(() => {
      createButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
    });

    const nameInput = document.body.querySelector<HTMLInputElement>(
      'input[maxlength="64"]',
    );
    expect(nameInput).not.toBeNull();
    const setInputValue = Object.getOwnPropertyDescriptor(
      HTMLInputElement.prototype,
      'value',
    )?.set;
    act(() => {
      setInputValue?.call(nameInput, 'Backend');
      nameInput!.dispatchEvent(new Event('input', { bubbles: true }));
    });

    const colorSelect = Array.from(
      document.body.querySelectorAll<HTMLSelectElement>('select'),
    ).find((select) => select.value === 'red');
    expect(colorSelect).toBeDefined();
    const setSelectValue = Object.getOwnPropertyDescriptor(
      HTMLSelectElement.prototype,
      'value',
    )?.set;
    act(() => {
      setSelectValue?.call(colorSelect, 'green');
      colorSelect!.dispatchEvent(new Event('change', { bubbles: true }));
    });

    const saveButton = Array.from(
      document.body.querySelectorAll<HTMLButtonElement>('button'),
    ).find((button) => button.textContent === 'save');
    expect(saveButton).not.toBeNull();
    await act(async () => {
      saveButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
    });

    expect(promptSpy).not.toHaveBeenCalled();
    expect(mockWorkspaceActions.createSessionGroup).toHaveBeenCalledWith({
      name: 'Backend',
      color: 'green',
    });
    // Creating a group for a session assigns it and clears any color tag —
    // color and named group are mutually exclusive in the UI.
    expect(mockWorkspaceActions.updateSessionOrganization).toHaveBeenCalledWith(
      '550e8400-e29b-41d4-a716-446655440000',
      { groupId: 'group-1', color: null },
    );
    promptSpy.mockRestore();
  });

  it('uses a themed group menu and assigns the selected group', async () => {
    mockConnection.capabilities = {
      qwenCodeVersion: '1.2.3',
      features: ['session_organization'],
    };
    mockWorkspaceActions.listSessionGroups.mockResolvedValue({
      groups: [
        {
          id: 'group-1',
          name: 'Backend',
          color: 'green',
          order: 0,
          createdAt: '2026-07-04T00:00:00.000Z',
          updatedAt: '2026-07-04T00:00:00.000Z',
        },
      ],
      colorOptions: ['red', 'orange', 'yellow', 'green', 'blue', 'purple'],
    });
    mockWorkspaceActions.updateSessionOrganization.mockResolvedValue({
      sessionId: '550e8400-e29b-41d4-a716-446655440000',
      groupId: 'group-1',
      isPinned: false,
      updatedAt: '2026-07-04T00:00:00.000Z',
    });
    mockActive.sessions = [
      makeSession('550e8400-e29b-41d4-a716-446655440000', {
        displayName: 'Review plan',
        createdAt: '2026-07-04T00:00:00.000Z',
        updatedAt: '2026-07-04T00:00:00.000Z',
      }),
    ];

    renderSidebar(false);
    await act(async () => {
      await Promise.resolve();
    });
    const organizeButton = document.body.querySelector<HTMLButtonElement>(
      '[aria-label="Group"]',
    );
    expect(organizeButton).not.toBeNull();
    act(() => {
      organizeButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
    });

    expect(document.body.querySelector('[role="dialog"]')).toBeNull();
    const menu = document.body.querySelector<HTMLElement>(
      '[role="menu"][aria-label="Group"]',
    );
    expect(menu).not.toBeNull();
    expect(menu!.querySelector('select')).toBeNull();
    const selectedOption = menu!.querySelector<HTMLElement>(
      '[role="menuitemradio"][aria-checked="true"]',
    );
    expect(selectedOption?.textContent).toContain('Ungrouped');
    await act(async () => {
      await new Promise((resolve) => window.requestAnimationFrame(resolve));
    });
    expect(document.activeElement).toBe(selectedOption);
    act(() => {
      menu!.dispatchEvent(
        new KeyboardEvent('keydown', {
          key: 'ArrowDown',
          bubbles: true,
        }),
      );
    });
    // One ArrowDown from "Ungrouped" now lands on the first color quick-pick.
    expect(document.activeElement?.textContent).toContain('Red');
    const groupOption = Array.from(
      menu!.querySelectorAll<HTMLButtonElement>('button'),
    ).find((button) => button.textContent?.includes('Backend'));
    expect(groupOption).not.toBeNull();
    await act(async () => {
      groupOption!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
    });

    // Assigning a named group clears any color tag (single-choice).
    expect(mockWorkspaceActions.updateSessionOrganization).toHaveBeenCalledWith(
      '550e8400-e29b-41d4-a716-446655440000',
      { groupId: 'group-1', color: null },
    );
  });

  it('offers the six color quick-picks and assigns the chosen color', async () => {
    mockConnection.capabilities = {
      qwenCodeVersion: '1.2.3',
      features: ['session_organization'],
    };
    mockWorkspaceActions.updateSessionOrganization.mockResolvedValue({
      sessionId: '550e8400-e29b-41d4-a716-446655440000',
      groupId: null,
      color: 'red',
      isPinned: false,
      updatedAt: '2026-07-04T00:00:00.000Z',
    });
    mockActive.sessions = [
      makeSession('550e8400-e29b-41d4-a716-446655440000', {
        displayName: 'Review plan',
      }),
    ];

    renderSidebar(false);
    await act(async () => {
      await Promise.resolve();
    });
    const organizeButton = document.body.querySelector<HTMLButtonElement>(
      '[aria-label="Group"]',
    );
    expect(organizeButton).not.toBeNull();
    act(() => {
      organizeButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
    });

    const menu = document.body.querySelector<HTMLElement>(
      '[role="menu"][aria-label="Group"]',
    );
    expect(menu).not.toBeNull();
    const radioLabels = Array.from(
      menu!.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]'),
    ).map((button) => button.textContent ?? '');
    // Ungrouped + all six colors are offered as single-choice radios.
    for (const name of [
      'Ungrouped',
      'Red',
      'Orange',
      'Yellow',
      'Green',
      'Blue',
      'Purple',
    ]) {
      expect(radioLabels.some((label) => label.includes(name))).toBe(true);
    }

    const redOption = Array.from(
      menu!.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]'),
    ).find((button) => button.textContent?.includes('Red'));
    expect(redOption).not.toBeNull();
    await act(async () => {
      redOption!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
    });

    // Picking a color clears any named-group assignment (single-choice).
    expect(mockWorkspaceActions.updateSessionOrganization).toHaveBeenCalledWith(
      '550e8400-e29b-41d4-a716-446655440000',
      { color: 'red', groupId: null },
    );
  });

  it('groups sessions into color sections ahead of the recent bucket', async () => {
    mockConnection.capabilities = {
      qwenCodeVersion: '1.2.3',
      features: ['session_organization'],
    };
    mockActive.sessions = [
      makeSession('session-red', { displayName: 'Red work', color: 'red' }),
      makeSession('session-plain', { displayName: 'Loose end', color: null }),
    ];

    const { container } = renderSidebar(false);
    await act(async () => {
      await Promise.resolve();
    });

    // A "Red" color section exists and holds the tagged session.
    const redSection = container.querySelector<HTMLElement>(
      'section[aria-label="Red"]',
    );
    expect(redSection).not.toBeNull();
    expect(redSection!.textContent).toContain('Red work');
    // The untagged session falls through to the Recent bucket.
    expect(container.textContent).toContain('Recent');
    expect(container.textContent).toContain('Loose end');
  });

  it('renders organized sessions as collapsible group sections', async () => {
    mockConnection.capabilities = {
      qwenCodeVersion: '1.2.3',
      features: ['session_organization'],
    };
    mockWorkspaceActions.listSessionGroups.mockResolvedValue({
      groups: [
        {
          id: 'group-1',
          name: 'Backend',
          color: 'green',
          order: 0,
          createdAt: '2026-07-04T00:00:00.000Z',
          updatedAt: '2026-07-04T00:00:00.000Z',
        },
      ],
      colorOptions: ['red', 'orange', 'yellow', 'green', 'blue', 'purple'],
    });
    mockActive.sessions = [
      makeSession('session-a', {
        displayName: 'API review',
        groupId: 'group-1',
      }),
      makeSession('session-b', {
        displayName: 'Release notes',
        groupId: null,
      }),
    ];

    const { container } = renderSidebar(false);
    await act(async () => {
      await Promise.resolve();
    });

    const backendHeader = Array.from(
      container.querySelectorAll<HTMLButtonElement>('button'),
    ).find(
      (button) =>
        button.textContent?.includes('Backend') &&
        button.textContent.includes('1'),
    );
    expect(backendHeader).not.toBeNull();
    expect(container.textContent).toContain('Recent');
    expect(container.textContent).toContain('API review');
    expect(container.textContent).toContain('Release notes');

    act(() => {
      backendHeader!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
    });

    expect(container.textContent).not.toContain('API review');
    expect(container.textContent).toContain('Release notes');
  });

  it('reloads sessions after deleting a group', async () => {
    mockConnection.capabilities = {
      qwenCodeVersion: '1.2.3',
      features: ['session_organization'],
    };
    mockWorkspaceActions.listSessionGroups.mockResolvedValue({
      groups: [
        {
          id: 'group-1',
          name: 'Backend',
          color: 'green',
          order: 0,
          createdAt: '2026-07-04T00:00:00.000Z',
          updatedAt: '2026-07-04T00:00:00.000Z',
        },
      ],
      colorOptions: ['red', 'orange', 'yellow', 'green', 'blue', 'purple'],
    });
    mockWorkspaceActions.deleteSessionGroup.mockResolvedValue(true);
    mockActive.sessions = [
      makeSession('session-a', {
        displayName: 'API review',
        groupId: 'group-1',
      }),
    ];

    const { container } = renderSidebar(false);
    await act(async () => {
      await Promise.resolve();
    });
    mockWorkspaceActions.listSessionGroups.mockClear();

    const deleteGroupButton = container.querySelector<HTMLButtonElement>(
      '[aria-label="Delete group"]',
    );
    expect(deleteGroupButton).not.toBeNull();
    act(() => {
      deleteGroupButton!.dispatchEvent(
        new MouseEvent('click', { bubbles: true }),
      );
    });
    const confirmDeleteButton = Array.from(
      document.body.querySelectorAll<HTMLButtonElement>('button'),
    ).find((button) => button.textContent?.trim() === 'Delete group');
    expect(confirmDeleteButton).toBeDefined();
    await act(async () => {
      confirmDeleteButton!.dispatchEvent(
        new MouseEvent('click', { bubbles: true }),
      );
      await Promise.resolve();
    });

    expect(mockWorkspaceActions.deleteSessionGroup).toHaveBeenCalledWith(
      'group-1',
    );
    expect(mockActive.reload).toHaveBeenCalledTimes(1);
    expect(mockWorkspaceActions.listSessionGroups).toHaveBeenCalledTimes(1);
  });

  it('toggles pin state from the session action button', async () => {
    mockConnection.capabilities = {
      qwenCodeVersion: '1.2.3',
      features: ['session_organization'],
    };
    mockWorkspaceActions.updateSessionOrganization.mockResolvedValue({
      sessionId: '550e8400-e29b-41d4-a716-446655440000',
      groupId: null,
      isPinned: true,
      pinnedAt: '2026-07-04T00:00:00.000Z',
      updatedAt: '2026-07-04T00:00:00.000Z',
    });
    mockActive.sessions = [
      makeSession('550e8400-e29b-41d4-a716-446655440000', {
        displayName: 'Review plan',
        createdAt: '2026-07-04T00:00:00.000Z',
        updatedAt: '2026-07-04T00:00:00.000Z',
      }),
    ];

    const { container } = renderSidebar(false);
    await act(async () => {
      await Promise.resolve();
    });
    const pinButton =
      container.querySelector<HTMLButtonElement>('[aria-label="Pin"]');
    expect(pinButton).not.toBeNull();
    await act(async () => {
      pinButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
    });

    expect(mockWorkspaceActions.updateSessionOrganization).toHaveBeenCalledWith(
      '550e8400-e29b-41d4-a716-446655440000',
      { isPinned: true },
    );
    expect(mockActive.reload).toHaveBeenCalledTimes(1);
  });

  it('does not drop organization actions for another session while one is busy', async () => {
    mockConnection.capabilities = {
      qwenCodeVersion: '1.2.3',
      features: ['session_organization'],
    };
    let resolveFirst: ((value: unknown) => void) | undefined;
    mockWorkspaceActions.updateSessionOrganization.mockImplementation(
      (sessionId: string) => {
        if (sessionId === 'session-a') {
          return new Promise((resolve) => {
            resolveFirst = resolve;
          });
        }
        return Promise.resolve({
          sessionId,
          groupId: null,
          isPinned: true,
          pinnedAt: '2026-07-04T00:00:00.000Z',
          updatedAt: '2026-07-04T00:00:00.000Z',
        });
      },
    );
    mockActive.sessions = [makeSession('session-a'), makeSession('session-b')];

    const { container } = renderSidebar(false);
    await act(async () => {
      await Promise.resolve();
    });
    const pinButtons = Array.from(
      container.querySelectorAll<HTMLButtonElement>('[aria-label="Pin"]'),
    );
    expect(pinButtons).toHaveLength(2);

    await act(async () => {
      pinButtons[0]!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
    });
    expect(
      mockWorkspaceActions.updateSessionOrganization,
    ).toHaveBeenCalledTimes(1);

    await act(async () => {
      pinButtons[1]!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
      await Promise.resolve();
    });
    expect(
      mockWorkspaceActions.updateSessionOrganization,
    ).toHaveBeenCalledTimes(2);
    expect(
      mockWorkspaceActions.updateSessionOrganization,
    ).toHaveBeenLastCalledWith('session-b', { isPinned: true });

    await act(async () => {
      resolveFirst?.({
        sessionId: 'session-a',
        groupId: null,
        isPinned: true,
        pinnedAt: '2026-07-04T00:00:00.000Z',
        updatedAt: '2026-07-04T00:00:00.000Z',
      });
      await Promise.resolve();
    });
  });

  it('keeps new session available while a session organization update is busy', async () => {
    mockConnection.capabilities = {
      qwenCodeVersion: '1.2.3',
      features: ['session_organization'],
    };
    let resolveUpdate: ((value: unknown) => void) | undefined;
    mockWorkspaceActions.updateSessionOrganization.mockReturnValueOnce(
      new Promise((resolve) => {
        resolveUpdate = resolve;
      }),
    );
    mockActive.sessions = [makeSession('session-a')];
    const onNewSession = vi.fn();

    const { container } = renderSidebar(false, { onNewSession });
    await act(async () => {
      await Promise.resolve();
    });
    const pinButton =
      container.querySelector<HTMLButtonElement>('[aria-label="Pin"]');
    await act(async () => {
      pinButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
    });

    const newSessionButton = container.querySelector<HTMLButtonElement>(
      '[aria-label="New chat"]',
    );
    expect(newSessionButton).not.toBeNull();
    expect(newSessionButton!.disabled).toBe(false);
    act(() => {
      newSessionButton!.click();
    });
    expect(onNewSession).toHaveBeenCalledTimes(1);

    await act(async () => {
      resolveUpdate?.({
        sessionId: 'session-a',
        groupId: null,
        isPinned: true,
        pinnedAt: '2026-07-04T00:00:00.000Z',
        updatedAt: '2026-07-04T00:00:00.000Z',
      });
      await Promise.resolve();
    });
    expect(newSessionButton!.disabled).toBe(false);
  });

  it('does not report organization failure when post-mutation reload fails', async () => {
    mockConnection.capabilities = {
      qwenCodeVersion: '1.2.3',
      features: ['session_organization'],
    };
    mockWorkspaceActions.updateSessionOrganization.mockResolvedValueOnce({
      sessionId: 'session-a',
      groupId: null,
      isPinned: true,
      pinnedAt: '2026-07-04T00:00:00.000Z',
      updatedAt: '2026-07-04T00:00:00.000Z',
    });
    mockActive.reload.mockRejectedValueOnce(new Error('reload failed'));
    mockActive.sessions = [makeSession('session-a')];
    const onError = vi.fn();

    const { container } = renderSidebar(false, { onError });
    await act(async () => {
      await Promise.resolve();
    });
    const pinButton =
      container.querySelector<HTMLButtonElement>('[aria-label="Pin"]');
    await act(async () => {
      pinButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
      await Promise.resolve();
    });

    expect(mockWorkspaceActions.updateSessionOrganization).toHaveBeenCalledWith(
      'session-a',
      { isPinned: true },
    );
    expect(mockActive.reload).toHaveBeenCalledTimes(1);
    expect(onError).not.toHaveBeenCalled();
  });
});

describe('WebShellSidebar — session export', () => {
  it('hides export action when daemon does not advertise session_export', () => {
    mockActive.sessions = [makeSession('session-1')];
    const { container } = renderSidebar(false);

    expect(
      container.querySelector('[aria-label="Export conversation record"]'),
    ).toBeNull();
  });

  it('downloads an HTML export when export action is clicked', async () => {
    mockConnection.capabilities = {
      qwenCodeVersion: '1.2.3',
      features: ['session_export'],
    };
    mockActive.sessions = [makeSession('session-1')];
    const createObjectURL = vi.fn(() => 'blob:session-export');
    const revokeObjectURL = vi.fn();
    vi.stubGlobal('URL', {
      ...URL,
      createObjectURL,
      revokeObjectURL,
    });
    const clickSpy = vi
      .spyOn(HTMLAnchorElement.prototype, 'click')
      .mockImplementation(() => {});
    const { container } = renderSidebar(false);
    const button = container.querySelector<HTMLButtonElement>(
      '[aria-label="Export conversation record"]',
    );

    expect(button).not.toBeNull();
    await act(async () => {
      button!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
      await new Promise((r) => setTimeout(r, 0));
    });

    expect(mockExportSession).toHaveBeenCalledWith('session-1', 'html');
    expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob));
    expect(clickSpy).toHaveBeenCalledTimes(1);
    expect(revokeObjectURL).toHaveBeenCalledWith('blob:session-export');
  });

  it('does not block switching sessions while an export is running', async () => {
    mockConnection.capabilities = {
      qwenCodeVersion: '1.2.3',
      features: ['session_export'],
    };
    mockActive.sessions = [makeSession('session-1'), makeSession('session-2')];
    let resolveExport:
      | ((value: Awaited<ReturnType<typeof mockExportSession>>) => void)
      | undefined;
    mockExportSession.mockReturnValueOnce(
      new Promise((resolve) => {
        resolveExport = resolve;
      }),
    );
    const createObjectURL = vi.fn(() => 'blob:session-export');
    vi.stubGlobal('URL', {
      ...URL,
      createObjectURL,
      revokeObjectURL: vi.fn(),
    });
    vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
    const onLoadSession = vi.fn();
    const { container } = renderSidebar(false, { onLoadSession });
    const exportButton = container.querySelector<HTMLButtonElement>(
      '[aria-label="Export conversation record"]',
    );

    await act(async () => {
      exportButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
    });
    const secondSessionRow = Array.from(
      container.querySelectorAll<HTMLElement>('[role="button"]'),
    ).find((el) => el.textContent?.includes('Session session-2'));
    click(secondSessionRow ?? null);

    expect(onLoadSession).toHaveBeenCalledWith('session-2');

    await act(async () => {
      resolveExport?.({
        content: '<html>export</html>',
        filename: 'session.html',
        mimeType: 'text/html',
        format: 'html',
      });
      await Promise.resolve();
    });
    expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob));
  });

  it('reports export failures through onError', async () => {
    mockConnection.capabilities = {
      qwenCodeVersion: '1.2.3',
      features: ['session_export'],
    };
    mockActive.sessions = [makeSession('session-1')];
    const error = new Error('download failed');
    mockExportSession.mockRejectedValueOnce(error);
    const onError = vi.fn();
    const { container } = renderSidebar(false, { onError });
    const button = container.querySelector<HTMLButtonElement>(
      '[aria-label="Export conversation record"]',
    );

    expect(button).not.toBeNull();
    await act(async () => {
      button!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
      await new Promise((r) => setTimeout(r, 0));
    });

    expect(onError).toHaveBeenCalledWith(error, 'Failed to export session');
  });
});

describe('WebShellSidebar — archive actions', () => {
  it('archives an active session from the quick action button', async () => {
    mockActive.sessions = [makeSession('aaaaaaaa')];
    const { container } = renderSidebar(false);
    const archiveBtn = container.querySelector<HTMLButtonElement>(
      '[aria-label="Archive"]',
    );
    expect(archiveBtn).not.toBeNull();
    expect(archiveBtn!.disabled).toBe(false);
    await clickAsync(archiveBtn);
    expect(mockActive.archiveSession).toHaveBeenCalledWith('aaaaaaaa');
    expect(mockArchived.unarchiveSession).not.toHaveBeenCalled();
  });

  it('disables archiving the current session', () => {
    mockActive.sessions = [makeSession('current1')];
    mockConnection.sessionId = 'current1';
    const { container } = renderSidebar(false);
    const archiveBtn = container.querySelector<HTMLButtonElement>(
      '[aria-label="Archive"]',
    );
    expect(archiveBtn).not.toBeNull();
    expect(archiveBtn!.disabled).toBe(true);
    click(archiveBtn);
    expect(mockActive.archiveSession).not.toHaveBeenCalled();
  });

  it('opens the overflow menu with rename, archive, and delete', () => {
    mockActive.sessions = [makeSession('aaaaaaaa')];
    const { container } = renderSidebar(false);
    click(container.querySelector('[aria-label="More actions"]'));
    const menu = container.querySelector('[role="menu"]');
    expect(menu).not.toBeNull();
    const labels = Array.from(menu!.querySelectorAll('[role="menuitem"]')).map(
      (el) => el.textContent,
    );
    expect(labels).toEqual(['Rename', 'Archive', 'Delete']);
  });

  it('archives a non-current session from the overflow menu', async () => {
    mockActive.sessions = [makeSession('aaaaaaaa')];
    const { container } = renderSidebar(false);
    click(container.querySelector('[aria-label="More actions"]'));
    const archiveItem = Array.from(
      container.querySelectorAll<HTMLButtonElement>('[role="menuitem"]'),
    ).find((el) => el.textContent === 'Archive');
    await clickAsync(archiveItem ?? null);
    expect(mockActive.archiveSession).toHaveBeenCalledWith('aaaaaaaa');
  });

  it('reveals archived sessions on demand and restores them', async () => {
    mockArchived.sessions = [makeSession('bbbbbbbb', { isArchived: true })];
    const { container } = renderSidebar(false);
    // Collapsed by default: the archived rows (and their Restore button) are
    // not rendered until the section is expanded.
    expect(container.querySelector('[aria-label="Restore"]')).toBeNull();
    const header = Array.from(container.querySelectorAll('button')).find((b) =>
      b.textContent?.includes('Archived'),
    );
    click(header ?? null);
    const restoreBtn = container.querySelector<HTMLButtonElement>(
      '[aria-label="Restore"]',
    );
    expect(restoreBtn).not.toBeNull();
    await clickAsync(restoreBtn);
    expect(mockArchived.unarchiveSession).toHaveBeenCalledWith('bbbbbbbb');
  });
});

describe('WebShellSidebar — sessionListReloadToken effect', () => {
  it('calls reload when token changes', async () => {
    mockActive.reload.mockResolvedValue(undefined);
    const { rerender } = renderSidebar(false, {
      sessionListReloadToken: 0,
    });
    expect(mockActive.reload).not.toHaveBeenCalled();

    rerender({ sessionListReloadToken: 1 });
    await act(async () => {
      await Promise.resolve();
    });
    expect(mockActive.reload).toHaveBeenCalledTimes(1);
  });

  it('does not call reload when token is undefined', async () => {
    mockActive.reload.mockResolvedValue(undefined);
    const { rerender } = renderSidebar(false);

    rerender({});
    await act(async () => {
      await Promise.resolve();
    });
    expect(mockActive.reload).not.toHaveBeenCalled();
  });

  it('does not call reload when token is unchanged', async () => {
    mockActive.reload.mockResolvedValue(undefined);
    const { rerender } = renderSidebar(false, {
      sessionListReloadToken: 1,
    });
    mockActive.reload.mockClear();

    rerender({ sessionListReloadToken: 1 });
    await act(async () => {
      await Promise.resolve();
    });
    expect(mockActive.reload).not.toHaveBeenCalled();
  });

  it('skips reload when document is hidden', async () => {
    mockActive.reload.mockResolvedValue(undefined);
    Object.defineProperty(document, 'hidden', {
      configurable: true,
      get: () => true,
    });
    const { rerender } = renderSidebar(false, {
      sessionListReloadToken: 0,
    });

    rerender({ sessionListReloadToken: 1 });
    await act(async () => {
      await Promise.resolve();
    });
    expect(mockActive.reload).not.toHaveBeenCalled();

    Object.defineProperty(document, 'hidden', {
      configurable: true,
      get: () => false,
    });
  });

  it('skips reload when a poll is already in flight', async () => {
    let resolveFirstPoll: (() => void) | undefined;
    mockActive.reload.mockReturnValueOnce(
      new Promise<void>((resolve) => {
        resolveFirstPoll = resolve;
      }),
    );
    const { rerender } = renderSidebar(false, {
      sessionListReloadToken: 0,
    });

    rerender({ sessionListReloadToken: 1 });
    await act(async () => {
      await Promise.resolve();
    });
    expect(mockActive.reload).toHaveBeenCalledTimes(1);

    mockActive.reload.mockResolvedValue(undefined);
    rerender({ sessionListReloadToken: 2 });
    await act(async () => {
      await Promise.resolve();
    });
    expect(mockActive.reload).toHaveBeenCalledTimes(1);

    await act(async () => {
      resolveFirstPoll?.();
      await Promise.resolve();
    });

    rerender({ sessionListReloadToken: 3 });
    await act(async () => {
      await Promise.resolve();
    });
    expect(mockActive.reload).toHaveBeenCalledTimes(2);
  });
});
