import {
  AppstoreOutlined,
  BellFilled,
  BellOutlined,
  CaretDownOutlined,
  CaretRightOutlined,
  CheckOutlined,
  CloseCircleOutlined,
  CloseOutlined,
  ClockCircleOutlined,
  ControlOutlined,
  CopyOutlined,
  DownloadOutlined,
  EditOutlined,
  EyeOutlined,
  ExclamationCircleOutlined,
  FolderOpenOutlined,
  FolderOutlined,
  FullscreenExitOutlined,
  FullscreenOutlined,
  MessageOutlined,
  MinusOutlined,
  SettingOutlined,
  PaperClipOutlined,
  PlusOutlined,
  ReloadOutlined,
  SearchOutlined,
  ShareAltOutlined,
  DeleteOutlined,
  WarningOutlined,
} from '@ant-design/icons';
import { Alert, Button, Card, Checkbox, Drawer, Dropdown, Empty, Input, Modal, Radio, Select, Space, Tabs, Tag, Typography, message } from 'antd';
import type { InputRef } from 'antd';
import type { TextAreaRef } from 'antd/es/input/TextArea';
import {
  useCallback,
  useDeferredValue,
  useEffect,
  useMemo,
  useRef,
  useState,
  type PointerEvent as ReactPointerEvent,
  type SetStateAction,
} from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAppStore } from '../../store';
import { FullscreenPreviewModal } from '../../components/previewer';
import { useAppConfig } from './appConfig';
import { fetchErrorLogs } from './errorLogApi';
import {
  CODEX_MODEL_OPTIONS,
  DEFAULT_CODEX_MODEL,
  normalizeCodexModel,
  resolveCodexModelLabel,
} from './codexModelOptions';
import { chatConnectionGateway, chatGateway } from './chatV2';
import {
  CHAT_CONVERSATION_CLEARED_EVENT,
  readChatConversationClearedEvent,
} from './chatV2/data/chatClientEvents';
import {
  useConversationComposerController,
  type SendMessageResult,
} from './chatV2/hooks/useConversationComposerController';
import { useConversationRoomActionsController } from './chatV2/hooks/useConversationRoomActionsController';
import { useConversationListController } from './chatV2/hooks/useConversationListController';
import { useConversationRoomController } from './chatV2/hooks/useConversationRoomController';
import { useRuntimeController } from './chatV2/hooks/useRuntimeController';
import { useConversationViewController } from './chatV2/hooks/useConversationViewController';
import { useConversationViewportController } from './chatV2/hooks/useConversationViewportController';
import { ChatPreviewBody } from './mainChatPanel/ChatPreviewBody';
import { triggerResourceDownload } from './mainChatPanel/downloadUtils';
import { extractPreviewItems, isHtmlPreviewItem } from './mainChatPanel/previewItems';
import { setSharedActiveConversationSnapshot } from './mainChatPanel/sharedActiveConversation';
import { setSkipContextConfirmForSessionToday } from './mainChatPanel/contextConfirmPreference';
import {
  canUseChatType,
  resolveCurrentChatPermissionRoles,
  useChatTypeRegistry,
  type ChatTypeRecord,
} from './chatTypeAccess';
import {
  resolveChatRoomContextSettings,
  resolveChatTypeDefaultContextIds,
  upsertChatRoomContextSettings,
  useChatContextSettingsRegistry,
  type ChatDefaultContextRecord,
  type ChatRoomCodexParticipant,
} from './chatContextSettingsAccess';
import { renderModalWithEnterConfirm } from './modalKeyboard';
import {
  createNotificationMessage,
  sendClientNotification,
  shouldFallbackToLocalNotification,
  showLocalClientNotification,
} from './notificationApi';
import { useTokenAccess } from './tokenAccess';
import { useTokenSettingRegistry } from './tokenSettingAccess';
import { applyViewportCssVars, scheduleViewportRecoverySync } from './viewportCssVars';
import {
  ChatConversationView,
  ChatRuntimeDashboard,
  completeChatConversationRequestManualBadge,
  copyPreviewContent,
  copyText,
  createActivityLogPlaceholder,
  createChatShareLink,
  createChatMessage,
  createLocalMessage,
  ErrorLogViewer,
  getStoredChatSessionLastTypeId,
  isMissingRequestMessage,
  isPreparingChatReplyText,
  removeChatRuntimeJob,
  sharePreviewLink,
  setStoredChatSessionLastTypeId,
  sortChatConversationSummaries,
  submitChatPromptSelection,
  upsertChatMessage,
  useErrorLogs,
} from './mainChatPanel';
import { extractChatMessageParts } from './mainChatPanel/messageParts';
import { stripHiddenPreviewTags } from './mainChatPanel/previewMarkers';
import { resolveMergedConversationTitle } from './mainChatPanel/conversationTitle';
import type {
  ChatComposerAttachment,
  ChatConversationDetailResponse,
  ChatConversationRequest,
  ChatConversationSummary,
  ChatJobEvent,
  ChatMessage,
  ChatPanelView,
  ChatPromptContextRef,
  ChatRuntimeSnapshot,
  ChatViewContext,
  MainChatPanelProps,
} from './mainChatPanel/types';
import { consumeCodexLiveDraft } from './codexLiveDraftBridge';
import { useChatActionContextSnapshot } from './chatActionContextStore';
import { getOrCreateClientId } from './clientIdentity';
import { getSavedNotificationDeviceId } from './notificationIdentity';
import { requestChatWindowAction } from './chatWindowActions';
import {
  buildIsolatedChatRoomContextSupplement,
  buildIsolatedChatRoomRequestBadgeLabel,
  buildIsolatedChatRoomTitle,
  createIsolatedChatRoomSessionId,
  doesIsolatedChatRoomScopeMatch,
  isIsolatedChatRoomSessionId,
  normalizeIsolatedChatRoomScope,
  resolveChatPathForSession,
  shouldShowConversationForMode,
  type IsolatedChatRoomScope,
  type MainChatPanelMode,
} from './isolatedChatRooms';
import {
  readActiveIsolatedChatRoomScope,
  readIsolatedChatRoomSessionScope,
  writeActiveIsolatedChatRoomScope,
  writeIsolatedChatRoomSessionScope,
} from './isolatedChatRoomScopeStore';
import { isPreviewRuntime } from './previewRuntime';
import { buildChatPath } from './routes';
import {
  resolveCurrentMenuRequestLabel,
  resolveConversationRequestBadgeLabelForUserText,
} from './mainChatPanel/requestBadgeLabel';
import { rememberConversationUnreadCleared } from './mainChatPanel/conversationUnread';
import { normalizeConversationDraftText, updateConversationDraftText } from './mainChatPanel/conversationDraftState';
import './MainChatPanel.css';
import './MainChatPanel.hotfix.css';
import './mainChatPanel/styles/MainChatPanel.rooms-shared.css';

const { Text } = Typography;
const CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH = 2000;
const CHAT_LIST_TEXT_SUMMARY_MAX_LENGTH = 240;
const CHAT_GENERAL_SECTION_STORAGE_KEY = 'codex-live-general-section-registry';
const CHAT_GENERAL_SECTION_ORDER_STORAGE_KEY = 'codex-live-general-section-order';
const CHAT_IMMEDIATE_SEND_PINNED_STORAGE_KEY = 'codex-live-immediate-send-pinned-by-session';
const CONVERSATION_TRACE_LOG_PREFIX = '[codex-live/request-trace]';
type ChatTypeOption = {
  value: string;
  label: string;
  description: string;
  disabled?: boolean;
};

type CreateConversationTarget = {
  id: string;
  name: string;
  description: string;
};

type PendingShareTarget = {
  kind: 'request-bundle' | 'inquiry-message' | 'prompt';
  requestId: string;
  sourceMessageId?: number;
  promptIndex?: number;
  promptSignature?: string;
  title: string;
};

type EditableCodexParticipant = ChatRoomCodexParticipant;

function createEditableCodexParticipant(
  index: number,
  model = DEFAULT_CODEX_MODEL,
  chatTypeId: string | null = null,
  defaultContextIds: string[] = [],
  role: EditableCodexParticipant['role'] = index === 1 ? 'moderator' : 'conversation',
): EditableCodexParticipant {
  return {
    id: `codex-participant-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
    name: `Codex ${index}`,
    model: normalizeCodexModel(model),
    prompt: '',
    chatTypeId,
    defaultContextIds: Array.from(new Set(defaultContextIds.map((item) => item.trim()).filter(Boolean))),
    role,
  };
}

function normalizeEditableCodexParticipants(
  participants: EditableCodexParticipant[] | null | undefined,
  fallbackModel = DEFAULT_CODEX_MODEL,
) {
  const normalized = Array.from(
    new Map(
      (participants ?? [])
        .map((participant, index) => {
          const id = participant.id?.trim() || `codex-participant-${index + 1}`;
          const name = participant.name?.trim() || '';
          const model = normalizeCodexModel(participant.model);
          const prompt = participant.prompt?.trim() || '';
          const chatTypeId = participant.chatTypeId?.trim() || null;
          const defaultContextIds = Array.from(
            new Set((participant.defaultContextIds ?? []).map((item) => item.trim()).filter(Boolean)),
          );
          const role =
            participant.role === 'moderator'
              ? 'moderator'
              : participant.role === 'conversation'
                ? 'conversation'
                : participant.role === 'reviewer'
                  ? 'reviewer'
                : 'default';

          if (!name) {
            return null;
          }

          return [
            id,
            {
              id,
              name,
              model,
              prompt,
              chatTypeId,
              defaultContextIds,
              role,
            } satisfies EditableCodexParticipant,
          ] as const;
        })
        .filter((entry): entry is readonly [string, EditableCodexParticipant] => Boolean(entry)),
    ).values(),
  );

  return normalized.length > 0 ? normalized : [createEditableCodexParticipant(1, fallbackModel)];
}

function buildLockedPrimaryCodexParticipant(args: {
  participant?: EditableCodexParticipant | null;
  fallbackModel?: string | null;
  fallbackChatTypeId?: string | null;
}) {
  const participant = args.participant ?? null;
  const fallbackModel = normalizeCodexModel(args.fallbackModel ?? DEFAULT_CODEX_MODEL);

  return {
    id: participant?.id?.trim() || 'codex-participant-1',
    name: participant?.name?.trim() || 'Codex 1',
    model: fallbackModel,
    prompt: participant?.prompt?.trim() || '',
    chatTypeId: args.fallbackChatTypeId?.trim() || null,
    defaultContextIds: [],
    role: 'moderator',
  } satisfies EditableCodexParticipant;
}

function normalizeEditableCodexParticipantsForDrawer(
  participants: EditableCodexParticipant[] | null | undefined,
  args: {
    fallbackModel?: string | null;
    fallbackChatTypeId?: string | null;
  },
) {
  const normalized = normalizeEditableCodexParticipants(participants, args.fallbackModel ?? DEFAULT_CODEX_MODEL);
  const lockedPrimaryParticipant = buildLockedPrimaryCodexParticipant({
    participant: normalized[0] ?? null,
    fallbackModel: args.fallbackModel,
    fallbackChatTypeId: args.fallbackChatTypeId,
  });

  return [lockedPrimaryParticipant, ...normalized.slice(1)];
}

function resolveParticipantChatTypeLabel(
  participantChatTypeId: string | null | undefined,
  chatTypes: ChatTypeRecord[],
  roomChatTypeName: string,
) {
  const normalizedParticipantChatTypeId = participantChatTypeId?.trim() || '';

  if (!normalizedParticipantChatTypeId) {
    return `${roomChatTypeName} 상속`;
  }

  return (
    chatTypes.find((item) => item.id === normalizedParticipantChatTypeId)?.name ??
    '개별 유형'
  );
}

function resolveParticipantDefaultContextLabel(
  participantDefaultContextIds: string[] | null | undefined,
  defaultContexts: ChatDefaultContextRecord[],
) {
  const titles = Array.from(
    new Set(
      (participantDefaultContextIds ?? [])
        .map((contextId) => defaultContexts.find((item) => item.id === contextId && item.enabled)?.title ?? '')
        .filter(Boolean),
    ),
  );

  return titles.length > 0 ? titles.join(', ') : '방 기본 공통 문맥 상속';
}

function resolveParticipantRoleLabel(role: EditableCodexParticipant['role']) {
  if (role === 'moderator') {
    return '중재·최종정리';
  }

  if (role === 'conversation') {
    return '프리토킹';
  }

  if (role === 'reviewer') {
    return '최종검토';
  }

  return '기본';
}

type PendingChatRequest = {
  sessionId: string;
  requestId: string;
  text: string;
  mode: 'queue' | 'direct';
  origin?: 'composer' | 'prompt';
  parentRequestId?: string | null;
  promptContextRef?: ChatPromptContextRef | null;
  omitPromptHistory?: boolean;
  codexModel: string;
  chatTypeId: string;
  chatTypeLabel: string;
  chatTypeDescription: string;
  defaultContextIds?: string[];
  chatTypeBaseDescription?: string;
  defaultContexts?: Array<{
    id: string;
    title: string;
    content: string;
  }>;
  customContextTitle?: string | null;
  customContextContent?: string | null;
  retryCount: number;
  failed: boolean;
};

type PendingContextConfirm = {
  sessionId: string;
  mode: 'queue' | 'direct';
  text: string;
  parentRequestId?: string | null;
  promptContextRef?: ChatPromptContextRef | null;
  codexModel: string;
  chatTypeId: string;
  chatTypeLabel: string;
  chatTypeDescription: string;
  defaultContextIds?: string[];
  chatTypeBaseDescription?: string;
  defaultContexts?: Array<{
    id: string;
    title: string;
    content: string;
  }>;
  customContextTitle?: string | null;
  customContextContent?: string | null;
  includedContextCount: number;
  omittedContextCount: number;
};

type ImportedCodexDraftRequest = {
  text: string;
  autoSend: boolean;
  sendMode: 'queue' | 'direct';
};

type CreateConversationOptions = {
  chatTypeOverride?: CreateConversationTarget | null;
  codexModelOverride?: string | null;
  persist?: boolean;
};

const CHAT_MAX_RETRY_ATTEMPTS = 5;
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
const CHAT_RESTART_REQUIRED_PATTERNS = [
  /재기동(?:이|은)? 필요/,
  /재시작(?:이|은)? 필요/,
  /앱\s*재기동\s*필요/,
  /서버\s*재기동\s*필요/,
  /\brestart(?:ing)? (?:is )?required\b/i,
  /\bneeds? (?:an? )?restart\b/i,
  /\brestart (?:the|this) (?:app|server|service)\b/i,
] as const;
const CHAT_RESTART_EXCLUSION_PATTERNS = [
  /재기동(?:은|이)?\s*(?:하지|안 해|안해)/,
  /재시작(?:은|이)?\s*(?:하지|안 해|안해)/,
  /재기동\s*(?:불필요|없음)/,
  /재시작\s*(?:불필요|없음)/,
  /\bno restart\b/i,
  /\bwithout restart\b/i,
] as const;
function areStringListsEqual(left: string[], right: string[]) {
  if (left.length !== right.length) {
    return false;
  }

  return left.every((value, index) => value === right[index]);
}

function isStandaloneDisplayMode() {
  if (typeof window === 'undefined') {
    return false;
  }

  return (
    window.matchMedia?.('(display-mode: standalone)').matches === true ||
    (window.navigator as Navigator & { standalone?: boolean }).standalone === true
  );
}

function isIpadLikeChatViewport() {
  if (typeof window === 'undefined') {
    return false;
  }

  const width = window.innerWidth;
  const height = window.innerHeight;
  const shortestSide = Math.min(width, height);
  const longestSide = Math.max(width, height);
  const hasTouchPoints = navigator.maxTouchPoints > 0;
  const isAppleTabletUserAgent =
    /iPad/i.test(navigator.userAgent) ||
    (navigator.platform === 'MacIntel' && hasTouchPoints);

  if (!hasTouchPoints) {
    return false;
  }

  return isAppleTabletUserAgent && shortestSide >= 820 && longestSide <= 1366;
}

function isRestartRequiredResponseText(text: string) {
  const normalized = String(text ?? '')
    .replace(/\s+/g, ' ')
    .trim();

  if (!normalized) {
    return false;
  }

  if (CHAT_RESTART_EXCLUSION_PATTERNS.some((pattern) => pattern.test(normalized))) {
    return false;
  }

  return CHAT_RESTART_REQUIRED_PATTERNS.some((pattern) => pattern.test(normalized));
}

function hasVisibleCompletedResponseForRequest(
  detail: ChatConversationDetailResponse,
  requestId: string,
) {
  const normalizedRequestId = requestId.trim();

  if (!normalizedRequestId) {
    return false;
  }

  const request = detail.requests.find((item) => item.requestId === normalizedRequestId) ?? null;

  if (!request || request.status !== 'completed') {
    return false;
  }

  const responseByMessageId =
    request.responseMessageId != null
      ? detail.messages.find((message) => message.id === request.responseMessageId && message.author === 'codex') ?? null
      : null;

  if (responseByMessageId && !isPreparingChatReplyText(responseByMessageId.text)) {
    return true;
  }

  return detail.messages.some(
    (message) =>
      message.author === 'codex' &&
      message.clientRequestId === normalizedRequestId &&
      !isPreparingChatReplyText(message.text),
  );
}

function resolveConversationDefaultTitle(
  _chatType: CreateConversationTarget | null,
  pageTitle?: string | null,
  _topMenu?: string | null,
  options?: {
    mode?: MainChatPanelMode;
    scope?: IsolatedChatRoomScope | null;
  },
) {
  if (options?.mode === 'rooms') {
    return buildIsolatedChatRoomTitle(options.scope) || pageTitle?.trim() || '격리 채팅방';
  }

  return '새 대화';
}

function buildChatSessionLink(sessionId: string) {
  const normalizedSessionId = sessionId.trim();

  if (!normalizedSessionId || typeof window === 'undefined') {
    return '';
  }

  const url = new URL(resolveChatPathForSession(normalizedSessionId), window.location.origin);
  url.searchParams.set('topMenu', 'chat');
  url.searchParams.set('sessionId', normalizedSessionId);
  return `${url.pathname}${url.search}${url.hash}`;
}

function buildChatSessionTargetUrl(sessionId: string) {
  const normalizedSessionId = sessionId.trim();

  if (!normalizedSessionId || typeof window === 'undefined') {
    return '';
  }

  const url = new URL(resolveChatPathForSession(normalizedSessionId), window.location.origin);
  url.searchParams.set('topMenu', 'chat');
  url.searchParams.set('sessionId', normalizedSessionId);
  return url.toString();
}

function logConversationTrace(event: string, payload: Record<string, unknown>) {
  if (typeof window === 'undefined' || !isPreviewRuntime()) {
    return;
  }

  console.info(CONVERSATION_TRACE_LOG_PREFIX, event, payload);
}

function createChatQuestionAnswerNotificationBody(args: {
  questionText?: string | null;
  answerText?: string | null;
  fallback: string;
}) {
  const questionPreview = createConversationPreviewText(args.questionText ?? '');
  const answerPreview = createConversationPreviewText(args.answerText ?? '');

  if (questionPreview && answerPreview) {
    return `질문: ${questionPreview}\n답변: ${answerPreview}`;
  }

  if (answerPreview) {
    return `답변: ${answerPreview}`;
  }

  if (questionPreview) {
    return `질문: ${questionPreview}`;
  }

  return args.fallback;
}

async function showLocalChatNotification(args: {
  title: string;
  body: string;
  threadId: string;
  data: Record<string, string>;
}) {
  if (shouldSuppressChatNotificationWhenAppOpen()) {
    return;
  }

  await showLocalClientNotification({
    title: args.title,
    body: args.body,
    threadId: args.threadId,
    data: args.data,
  }).catch(() => false);
}

function getCachedSessionMessages(cache: Map<string, ChatMessage[]>, sessionId: string) {
  const normalizedSessionId = sessionId.trim();

  if (!normalizedSessionId) {
    return [] as ChatMessage[];
  }

  const cached = cache.get(normalizedSessionId);

  if (cached) {
    return cached;
  }

  return [] as ChatMessage[];
}

function createConversationSessionId(mode: MainChatPanelMode = 'live') {
  if (mode === 'rooms') {
    return createIsolatedChatRoomSessionId();
  }

  if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
    return crypto.randomUUID();
  }

  return `chat-session-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}

function buildScopedErrorAssistText(items: Awaited<ReturnType<typeof fetchErrorLogs>>) {
  const relevantItems = items
    .filter((item) => item.source === 'server' || item.source === 'client')
    .slice(0, 6);

  if (relevantItems.length === 0) {
    return '';
  }

  return [
    '[최근 에러 요약]',
    ...relevantItems.map(
      (item) =>
        `- ${item.source === 'server' ? 'server' : 'client'} · ${item.errorType} · ${item.errorMessage}${item.requestPath ? ` · ${item.requestPath}` : ''}`,
    ),
  ].join('\n');
}

function isActiveChatSessionInForeground(args: {
  sessionId: string;
  activeSessionId: string;
  activeView: ChatPanelView;
  isConversationPaneClosed: boolean;
  isMobileViewport: boolean;
  isMobileConversationView: boolean;
}) {
  if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
    return false;
  }

  if (args.sessionId !== args.activeSessionId) {
    return false;
  }

  if (args.activeView !== 'chat' || args.isConversationPaneClosed) {
    return false;
  }

  if (args.isMobileViewport && !args.isMobileConversationView) {
    return false;
  }

  return true;
}

function shouldSuppressChatNotificationWhenAppOpen() {
  if (typeof window === 'undefined' || typeof document === 'undefined') {
    return false;
  }

  if (document.visibilityState === 'hidden') {
    return false;
  }

  if (typeof document.hasFocus === 'function') {
    return document.hasFocus();
  }

  return true;
}

function dismissMobileActiveTextInput() {
  if (typeof document === 'undefined') {
    return false;
  }

  const activeElement = document.activeElement;

  if (!(activeElement instanceof HTMLElement)) {
    return false;
  }

  const tagName = activeElement.tagName.toLowerCase();
  const isTextEntryTarget =
    activeElement.isContentEditable ||
    tagName === 'textarea' ||
    (tagName === 'input' && activeElement.getAttribute('type') !== 'button') ||
    activeElement.getAttribute('role') === 'textbox';

  if (!isTextEntryTarget || typeof activeElement.blur !== 'function') {
    return false;
  }

  activeElement.blur();
  return true;
}

function createConversationPreviewText(text: string) {
  const normalizedText = String(text ?? '').trim();
  const normalized =
    normalizedText.startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`)
      ? normalizedText
          .slice(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`.length)
          .split('\n\n')
          .map((line) => line.trim())
          .filter(Boolean)
          .at(-1) ?? ''
      : normalizedText.replace(/\s+/g, ' ').trim();
  return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized;
}

function buildOptimisticConversationSummary(args: {
  sessionId: string;
  title: string;
  requestBadgeLabel?: string | null;
  codexModel?: string | null;
  chatType: CreateConversationTarget | null;
  generalSectionName?: string | null;
  contextDescription?: string | null;
  draftText?: string | null;
  roomScope?: Record<string, unknown> | null;
  createdAt: string;
  isDraftOnly?: boolean;
}): ChatConversationSummary {
  const normalizedRoomScope = normalizeIsolatedChatRoomScope(args.roomScope as Partial<IsolatedChatRoomScope> | null);

  return {
    sessionId: args.sessionId,
    clientId: null,
    isDraftOnly: args.isDraftOnly === true,
    draftText: normalizeConversationDraftText(args.draftText),
    title: args.title,
    requestBadgeLabel: args.requestBadgeLabel?.trim() || null,
    codexModel: normalizeCodexModel(args.codexModel),
    chatTypeId: args.chatType?.id ?? null,
    lastChatTypeId: args.chatType?.id ?? null,
    generalSectionName: normalizeGeneralSectionName(args.generalSectionName) ?? null,
    contextLabel: args.chatType?.name ?? null,
    contextDescription: normalizeConversationContextDescription(args.contextDescription),
    roomScope: normalizedRoomScope,
    notifyOffline: true,
    hasUnreadResponse: false,
    hasPendingAttention: false,
    currentRequestId: null,
    currentJobStatus: null,
    currentJobMessage: null,
    currentQueueSize: 0,
    currentStatusUpdatedAt: null,
    isPendingWork: false,
    pendingWorkReason: null,
    lastRequestPreview: '',
    lastMessagePreview: '',
    lastResponsePreview: '',
    createdAt: args.createdAt,
    updatedAt: args.createdAt,
    lastMessageAt: null,
  };
}

function normalizeConversationContextDescription(value: string | null | undefined) {
  const normalized = value?.trim() ?? '';

  if (!normalized) {
    return null;
  }

  return normalized.slice(0, CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH);
}

function composeConversationContextDescription(
  baseDescription: string | null | undefined,
  defaultContexts: ChatDefaultContextRecord[],
  customContextTitle?: string | null,
  customContextContent?: string | null,
) {
  const sections = [baseDescription?.trim() ?? ''].filter(Boolean);

  for (const context of defaultContexts) {
    const normalizedContent = context.content.trim();

    if (!normalizedContent) {
      continue;
    }

    sections.push(`## 기본 유형 · ${context.title}\n${normalizedContent}`);
  }

  const normalizedCustomTitle = customContextTitle?.trim() ?? '';
  const normalizedCustomContent = customContextContent?.trim() ?? '';

  if (normalizedCustomTitle || normalizedCustomContent) {
    sections.push(
      [`## 채팅방 전용 Context${normalizedCustomTitle ? ` · ${normalizedCustomTitle}` : ''}`, normalizedCustomContent]
        .filter(Boolean)
        .join('\n'),
    );
  }

  return normalizeConversationContextDescription(sections.join('\n\n'));
}

type PreviewTextMatch = {
  node: Text;
  start: number;
  end: number;
};

type PreviewMatchAnchor = {
  match: PreviewTextMatch;
  target: HTMLElement;
  offsetTop: number;
};

function collectPreviewTextMatches(root: HTMLElement, keyword: string) {
  const normalizedKeyword = keyword.trim().toLocaleLowerCase();

  if (!normalizedKeyword || typeof document === 'undefined') {
    return [] as PreviewTextMatch[];
  }

  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
  const matches: PreviewTextMatch[] = [];
  let currentNode = walker.nextNode();

  while (currentNode) {
    if (currentNode.nodeType === Node.TEXT_NODE) {
      const textNode = currentNode as Text;
      const textContent = textNode.textContent ?? '';
      const normalizedText = textContent.toLocaleLowerCase();

      if (normalizedText) {
        let fromIndex = 0;

        while (fromIndex < normalizedText.length) {
          const matchIndex = normalizedText.indexOf(normalizedKeyword, fromIndex);

          if (matchIndex < 0) {
            break;
          }

          matches.push({
            node: textNode,
            start: matchIndex,
            end: matchIndex + normalizedKeyword.length,
          });
          fromIndex = matchIndex + Math.max(1, normalizedKeyword.length);
        }
      }
    }

    currentNode = walker.nextNode();
  }

  return matches;
}

function focusPreviewTextMatch(match: PreviewTextMatch) {
  if (typeof document === 'undefined' || typeof window === 'undefined') {
    return false;
  }

  const selection = window.getSelection();

  if (!selection) {
    return false;
  }

  const range = document.createRange();
  range.setStart(match.node, match.start);
  range.setEnd(match.node, match.end);
  selection.removeAllRanges();
  selection.addRange(range);

  return true;
}

function resolvePreviewMatchTargetElement(match: PreviewTextMatch) {
  return (
    match.node.parentElement?.closest<HTMLElement>(
      '.previewer-ui__line, .markdown-preview__block, .codex-diff-previewer__diff-line, p, li, h1, h2, h3, pre, code, div',
    ) ?? match.node.parentElement
  );
}

function resolvePreviewScrollContainer(root: HTMLElement) {
  const preferredSelectors = [
    '.app-chat-panel__preview-rich--markdown',
    '.app-chat-panel__preview-rich',
    '.codex-diff-previewer__diff-body',
    '.previewer-ui__editor-body',
  ];

  if (root.scrollHeight > root.clientHeight + 4) {
    return root;
  }

  for (const selector of preferredSelectors) {
    const candidate = root.querySelector<HTMLElement>(selector);

    if (candidate && candidate.scrollHeight > candidate.clientHeight + 4) {
      return candidate;
    }
  }

  return root;
}

function getElementOffsetWithinContainer(target: HTMLElement, container: HTMLElement) {
  const targetRect = target.getBoundingClientRect();
  const containerRect = container.getBoundingClientRect();
  return targetRect.top - containerRect.top + container.scrollTop;
}

function getPreviewMatchOffsetWithinContainer(match: PreviewTextMatch, container: HTMLElement) {
  if (typeof document === 'undefined') {
    return null;
  }

  const range = document.createRange();
  range.setStart(match.node, match.start);
  range.setEnd(match.node, match.end);

  const rects = range.getClientRects();
  const firstRect = rects.item(0);

  if (!firstRect) {
    return null;
  }

  const containerRect = container.getBoundingClientRect();
  return firstRect.top - containerRect.top + container.scrollTop;
}

function getPreviewMatchHeight(match: PreviewTextMatch) {
  if (typeof document === 'undefined') {
    return null;
  }

  const range = document.createRange();
  range.setStart(match.node, match.start);
  range.setEnd(match.node, match.end);

  const rects = range.getClientRects();
  const firstRect = rects.item(0);

  if (!firstRect) {
    return null;
  }

  return firstRect.height;
}

function buildPreviewMatchAnchors(matches: PreviewTextMatch[], container: HTMLElement) {
  const anchors: PreviewMatchAnchor[] = [];

  matches.forEach((match) => {
    const target = resolvePreviewMatchTargetElement(match);

    if (!target) {
      return;
    }

    anchors.push({
      match,
      target,
      offsetTop: getPreviewMatchOffsetWithinContainer(match, container) ?? getElementOffsetWithinContainer(target, container),
    });
  });

  return anchors;
}

function resolvePreviewSearchAnchorIndex(
  anchors: PreviewMatchAnchor[],
  container: HTMLElement,
  direction: 'forward' | 'backward',
) {
  if (anchors.length === 0) {
    return -1;
  }

  const viewportAnchor = container.scrollTop + container.clientHeight / 2;
  const threshold = 4;

  if (direction === 'backward') {
    for (let index = anchors.length - 1; index >= 0; index -= 1) {
      if (anchors[index].offsetTop < viewportAnchor - threshold) {
        return index;
      }
    }

    return anchors.length - 1;
  }

  for (let index = 0; index < anchors.length; index += 1) {
    if (anchors[index].offsetTop > viewportAnchor + threshold) {
      return index;
    }
  }

  return 0;
}

function resolvePreviewSearchResultIndex(args: {
  anchors: PreviewMatchAnchor[];
  container: HTMLElement;
  direction: 'forward' | 'backward';
  currentIndex: number;
}) {
  const { anchors, container, direction, currentIndex } = args;

  if (anchors.length === 0) {
    return -1;
  }

  if (currentIndex >= 0 && currentIndex < anchors.length) {
    if (direction === 'backward') {
      return currentIndex === 0 ? anchors.length - 1 : currentIndex - 1;
    }

    return currentIndex === anchors.length - 1 ? 0 : currentIndex + 1;
  }

  return resolvePreviewSearchAnchorIndex(anchors, container, direction);
}

function focusPreviewTextMatchInContainer(match: PreviewTextMatch, container: HTMLElement) {
  if (!focusPreviewTextMatch(match)) {
    return false;
  }

  const matchTop = getPreviewMatchOffsetWithinContainer(match, container);

  if (matchTop != null) {
    const matchHeight = getPreviewMatchHeight(match) ?? 0;
    const nextScrollTop = Math.max(0, matchTop - container.clientHeight / 2 + matchHeight / 2);

    container.scrollTo({
      top: nextScrollTop,
      behavior: 'smooth',
    });

    return true;
  }

  const target = resolvePreviewMatchTargetElement(match);

  if (!target) {
    return false;
  }

  const targetTop = getElementOffsetWithinContainer(target, container);
  const nextScrollTop = Math.max(0, targetTop - container.clientHeight / 2 + target.clientHeight / 2);

  container.scrollTo({
    top: nextScrollTop,
    behavior: 'smooth',
  });

  return true;
}

function resolveConversationListPreviewText(preview: string) {
  const normalized = createConversationPreviewText(preview);

  if (!normalized) {
    return '대화가 아직 없습니다.';
  }

  return normalized;
}

const LOCAL_PENDING_WORK_ANALYSIS_PATTERNS = [
  /분석/u,
  /검토/u,
  /조사/u,
  /원인/u,
  /파악/u,
  /\banalysis\b/i,
  /\binvestigat(?:e|ion)\b/i,
] as const;

const LOCAL_PENDING_WORK_DESIGN_PATTERNS = [
  /설계/u,
  /프롬프트/u,
  /시안/u,
  /구조/u,
  /방향/u,
  /기획/u,
  /플로우/u,
  /아키텍처/u,
  /\bdesign\b/i,
  /\barchitecture\b/i,
] as const;

const LOCAL_PENDING_WORK_IMPLEMENTATION_PATTERNS = [
  /구현했/u,
  /수정했/u,
  /반영했/u,
  /적용했/u,
  /완료했/u,
  /마무리했/u,
  /배포했/u,
  /검증했/u,
  /빌드.*통과/u,
  /테스트.*통과/u,
  /캡처/u,
  /preview/iu,
  /변경 파일/u,
  /diff/u,
  /\bimplement(?:ed|ation)?\b/i,
  /\bfix(?:ed)?\b/i,
  /\bverified?\b/i,
  /\btested?\b/i,
] as const;

const LOCAL_PENDING_WORK_RESPONSE_HOLD_PATTERNS = [
  /원하시면/u,
  /진행해드릴/u,
  /이어(?:서|가)/u,
  /다음 단계/u,
  /선택/u,
  /옵션/u,
  /후속/u,
  /\bif you want\b/i,
  /\bnext step\b/i,
] as const;

function normalizeConversationPendingWorkText(text: string | null | undefined) {
  return String(text ?? '').replace(/\s+/g, ' ').trim();
}

function hasConversationPendingWorkPattern(text: string, patterns: readonly RegExp[]) {
  return patterns.some((pattern) => pattern.test(text));
}

function inferConversationPendingWorkReason(item: ChatConversationSummary) {
  if (item.pendingWorkReason) {
    return item.pendingWorkReason;
  }

  const requestText = normalizeConversationPendingWorkText(item.lastRequestPreview);
  const responseText = normalizeConversationPendingWorkText(item.lastResponsePreview);

  if (
    hasConversationPendingWorkPattern(requestText, LOCAL_PENDING_WORK_DESIGN_PATTERNS) &&
    !hasConversationPendingWorkPattern(responseText, LOCAL_PENDING_WORK_IMPLEMENTATION_PATTERNS)
  ) {
    return 'design' as const;
  }

  if (
    hasConversationPendingWorkPattern(requestText, LOCAL_PENDING_WORK_ANALYSIS_PATTERNS) &&
    !hasConversationPendingWorkPattern(responseText, LOCAL_PENDING_WORK_IMPLEMENTATION_PATTERNS)
  ) {
    return 'analysis' as const;
  }

  if (
    hasConversationPendingWorkPattern(responseText, LOCAL_PENDING_WORK_DESIGN_PATTERNS) ||
    hasConversationPendingWorkPattern(responseText, LOCAL_PENDING_WORK_ANALYSIS_PATTERNS)
  ) {
    if (hasConversationPendingWorkPattern(responseText, LOCAL_PENDING_WORK_RESPONSE_HOLD_PATTERNS)) {
      return hasConversationPendingWorkPattern(responseText, LOCAL_PENDING_WORK_DESIGN_PATTERNS)
        ? 'design'
        : 'analysis';
    }
  }

  return null;
}

function trimConversationRequestBadgeLabel(label: string, maxLength = 18) {
  const normalized = label.replace(/\s+/g, ' ').trim();

  if (!normalized) {
    return '';
  }

  if (normalized.length <= maxLength) {
    return normalized;
  }

  return `${normalized.slice(0, maxLength - 1).trimEnd()}…`;
}

function compactConversationBadgeLabel(label: string | null | undefined, maxWords = 2) {
  const normalized = (label ?? '').replace(/\s+/g, ' ').trim();

  if (!normalized) {
    return null;
  }

  const words = normalized.split(' ').filter(Boolean);
  return trimConversationRequestBadgeLabel(words.slice(0, maxWords).join(' '));
}

function normalizeConversationPromptFollowupText(text: string | null | undefined) {
  const normalized = String(text ?? '')
    .replace(/\s+/g, ' ')
    .trim();

  if (!normalized) {
    return '';
  }

  const followupMatch = normalized.match(
    /^(?<selection>.+?)\s*기준으로\s+(?:(?:다음\s+단계를?)\s+)?(?:이어서\s+)?진행해\s+주세요\.?$/u,
  );
  const selectedText = followupMatch?.groups?.selection?.trim();

  if (selectedText) {
    return selectedText;
  }

  return normalized;
}

function isConversationPromptFollowupText(text: string | null | undefined) {
  const normalized = String(text ?? '')
    .replace(/\s+/g, ' ')
    .trim();

  return normalized.length > 0 && normalizeConversationPromptFollowupText(normalized) !== normalized;
}

function isConversationBadgeMetaRequest(text: string) {
  const normalized = normalizeConversationPromptFollowupText(text);

  if (!normalized) {
    return false;
  }

  return (
    /((최근|촤근)?\s*작업|최근작업|촤근작업).*(뱃지|badge|라벨)/iu.test(normalized) ||
    /(요청내역|이전\s*요청|마지막\s*요청|두\s*단어|두단어|문맥을\s*읽어서|문맥\s*읽어서)/u.test(normalized)
  );
}

function normalizeConversationBadgeToken(token: string) {
  return token
    .replace(/^[^0-9A-Za-z가-힣/_-]+|[^0-9A-Za-z가-힣/_-]+$/gu, '')
    .replace(/[()[\]{}"'`~!@#$%^&*+=|\\:;,.<>?…]+/g, '')
    .trim();
}

function canonicalizeConversationBadgeToken(token: string) {
  const normalized = normalizeConversationBadgeToken(token)
    .replace(/^(최근작업|촤근작업)$/iu, '작업')
    .replace(/^(대화방|대화)$/u, '채팅')
    .replace(/^(badge|라벨)$/iu, '뱃지')
    .replace(/^(웹소켓|ws\/chat)$/iu, '소켓')
    .replace(/^(업데이투|업뎃|업데이트)$/iu, '업데이트');

  if (/(codex|cdex)?\s*cli/iu.test(normalized)) {
    return 'CLI';
  }

  if (/^release\/test$/iu.test(normalized)) {
    return '설정';
  }

  if (/^(이전세션|새세션|세션들)$/u.test(normalized)) {
    return '세션';
  }

  return normalized
    .replace(/(입니다|이에요|예요|합니다|해요|해주세요|해주세여|됐나요|되나요|맞나요|인가요|인가|입니다만|입니다요)$/u, '')
    .replace(/(하기|하기를|하기랑|하기와|하거나|하면서|되어서|되게|되도록)$/u, '')
    .replace(/(으로|로|과|와|을|를|은|는|이|가|도|만|랑|이라도|이라던지|라던지|에서|에게|께|마다|부터|까지)$/u, '')
    .trim();
}

const CONVERSATION_BADGE_ACTION_RULES = [
  { label: '재기동', pattern: /(재기동|재시작|재부팅)/iu },
  { label: '업데이트', pattern: /(업데이트|update|업데이투|업뎃)/iu },
  { label: '분리', pattern: /(분리|구분)/iu },
  { label: '수정', pattern: /(수정|고쳐|고치|개선|반영|복구|정리|보완|조정|변경|보정|맞춰|맞추|손봐|손보)/iu },
  { label: '추가', pattern: /(추가|노출|표시|표현|붙여|붙이|삽입|넣어)/iu },
  { label: '생성', pattern: /(생성|만들어|만들기|신규)/iu },
  { label: '시작', pattern: /(시작|개시)/iu },
  { label: '실행', pattern: /(실행|호출|등록|설치|빌드)/iu },
  { label: '확인', pattern: /(확인|점검|검증|테스트|조회|체크)/iu },
  { label: '문의', pattern: /(문의|질문|왜|어떻게|맞나요|되나요|안보이나요|이상한데|\?)/iu },
] as const;

function isConversationBadgeStopToken(token: string) {
  return /^(최근|촤근|작업|요청|요청내역|이전요청|마지막요청|문맥|읽어서|문의|답변|참조|이유|왜|안보이나요|안보임|일반|처리|대화방|대화|현재|실제|통해서|맞나요|이상한데|개선하세여|수정하는지|생성하는지|실행인지|항목을|어떤|아떤|조합|두개|두단어|단어조합|요청문|업데이투|이후|다시|원인|방식|맞는지|먼저|기준|단계|진행)$/iu.test(
    token,
  );
}

function collectConversationBadgeMeaningfulTokens(text: string) {
  return normalizeConversationPromptFollowupText(text)
    .replace(/[()[\]{}"'`~!@#$%^&*+=|\\:;,.<>?]+/g, ' ')
    .split(/\s+/)
    .map((token) => canonicalizeConversationBadgeToken(token))
    .filter((token) => token.length > 1 && !isConversationBadgeStopToken(token));
}

function findStrongConversationBadgeActionLabel(...texts: Array<string | null | undefined>) {
  const strongActionLabels = new Set(['재기동', '업데이트', '분리', '수정', '추가', '생성', '시작', '실행']);

  for (const text of texts) {
    const normalized = text?.trim();
    if (!normalized) {
      continue;
    }

    for (const rule of CONVERSATION_BADGE_ACTION_RULES) {
      if (strongActionLabels.has(rule.label) && rule.pattern.test(normalized)) {
        return rule.label;
      }
    }
  }

  return null;
}

function isConversationInquiryRequest(...texts: Array<string | null | undefined>) {
  const hasQuestionLikeText = texts.some((text) =>
    /(문의|질문|왜|어떻게|맞나요|되나요|안보이나요|이상한데|\?)/iu.test(normalizeConversationPromptFollowupText(text)),
  );
  if (!hasQuestionLikeText) {
    return false;
  }

  return !findStrongConversationBadgeActionLabel(...texts);
}

function findConversationBadgeActionLabel(...texts: Array<string | null | undefined>) {
  const strongActionLabel = findStrongConversationBadgeActionLabel(...texts);
  if (strongActionLabel) {
    return strongActionLabel;
  }

  if (isConversationInquiryRequest(...texts)) {
    return '문의';
  }

  const strongActionLabels = new Set(['재기동', '업데이트', '분리', '수정', '추가', '생성', '시작', '실행']);
  for (const text of texts) {
    const normalized = text?.trim();
    if (!normalized) {
      continue;
    }

    for (const rule of CONVERSATION_BADGE_ACTION_RULES) {
      if (!strongActionLabels.has(rule.label) && rule.label !== '문의' && rule.pattern.test(normalized)) {
        return rule.label;
      }
    }
  }

  return null;
}

function normalizeConversationBadgeTargetToken(token: string) {
  if (/^(뱃지|채팅|목록|세션|cbt|소켓|설정|요일|기간|토큰|프록시|캡처|화면|버튼|미리보기|preview|연결|cli)$/iu.test(token)) {
    return token.toUpperCase() === 'CLI' ? 'CLI' : token;
  }

  if (/^release\/test$/iu.test(token)) {
    return '설정';
  }

  if (/^(이어서|계속|새로|최근작업|요청문|단어조합|명령|응답|내용|항목|항목문의)$/u.test(token)) {
    return '';
  }

  return token;
}

function findConversationBadgeTargetLabel(...texts: Array<string | null | undefined>) {
  const weightedTokens = new Map<string, { score: number; firstIndex: number }>();
  let tokenIndex = 0;

  texts.forEach((text, sourceIndex) => {
    const weight = Math.max(1, texts.length - sourceIndex);
    const tokens = collectConversationBadgeMeaningfulTokens(text ?? '')
      .map((token) => normalizeConversationBadgeTargetToken(token))
      .filter((token) => {
        if (!token) {
          return false;
        }

        return !CONVERSATION_BADGE_ACTION_RULES.some((rule) => rule.label === token);
      });

    tokens.forEach((token) => {
      const current = weightedTokens.get(token);
      if (current) {
        current.score += weight;
        return;
      }

      weightedTokens.set(token, { score: weight, firstIndex: tokenIndex });
      tokenIndex += 1;
    });
  });

  const rankedTokens = Array.from(weightedTokens.entries())
    .sort((left, right) => {
      const scoreDiff = right[1].score - left[1].score;
      if (scoreDiff !== 0) {
        return scoreDiff;
      }

      return left[1].firstIndex - right[1].firstIndex;
    })
    .map(([token]) => token);

  const strongPairRules = [
    {
      pattern: /(뱃지|badge|라벨).*(정의|규칙|기준|추론|정리|보정)|((정의|규칙|기준|추론|정리|보정).*(뱃지|badge|라벨))/iu,
      label: '라벨 규칙',
    },
    { pattern: /(최근\s*작업|최근작업|촤근작업).*(뱃지|badge|라벨)/iu, label: '뱃지' },
    { pattern: /(뱃지|badge|라벨)/iu, label: '뱃지' },
    { pattern: /(채팅|대화).*(목록|리스트)/iu, label: '채팅 목록' },
    { pattern: /(최근\s*5개방|최근\s*대화방\s*5개)/u, label: '5개방' },
    { pattern: /((codex|cdex)\s*cli|cli)/iu, label: 'CLI' },
    { pattern: /(소켓|웹소켓|ws\/chat)/iu, label: '소켓' },
    { pattern: /(세션|이전세션|새세션)/u, label: '세션' },
    { pattern: /(cbt)/iu, label: 'CBT' },
    { pattern: /(release\/test|설정)/iu, label: '설정' },
  ] as const;

  const joinedText = texts
    .map((text) => text?.trim())
    .filter(Boolean)
    .join(' ');

  for (const rule of strongPairRules) {
    if (rule.pattern.test(joinedText)) {
      return trimConversationRequestBadgeLabel(rule.label);
    }
  }

  if (rankedTokens.length >= 2) {
    return trimConversationRequestBadgeLabel(rankedTokens.slice(0, 2).join(' '));
  }

  return rankedTokens[0] ? trimConversationRequestBadgeLabel(rankedTokens[0]) : null;
}

function buildConversationTaskDescriptionLabel(text: string) {
  const normalizedText = normalizeConversationPromptFollowupText(text);

  if (isConversationInquiryRequest(normalizedText)) {
    return null;
  }

  const tokens = collectConversationBadgeMeaningfulTokens(normalizedText)
    .map((token) => normalizeConversationBadgeTargetToken(token))
    .filter((token) => {
      if (!token) {
        return false;
      }

      return !CONVERSATION_BADGE_ACTION_RULES.some((rule) => rule.label === token);
    });

  if (tokens.length === 0) {
    return null;
  }

  if (/(뱃지|badge|라벨).*(정의|규칙|기준|추론|정리)|((정의|규칙|기준|추론|정리).*(뱃지|badge|라벨))/iu.test(normalizedText)) {
    return '라벨 규칙';
  }

  return compactConversationBadgeLabel(tokens.join(' '), 2);
}

function sanitizeConversationBadgeFallbackText(text: string | null | undefined) {
  const normalized = normalizeConversationPromptFollowupText(text);
  if (!normalized) {
    return '';
  }

  if (isConversationInquiryRequest(normalized)) {
    return '';
  }

  if (normalized.length <= CHAT_LIST_TEXT_SUMMARY_MAX_LENGTH) {
    return normalized;
  }

  return `${normalized.slice(0, CHAT_LIST_TEXT_SUMMARY_MAX_LENGTH - 3).trimEnd()}...`;
}

function buildConversationBadgeSourceTexts(
  item: Pick<
    ChatConversationSummary,
    'title' | 'contextLabel' | 'contextDescription' | 'lastMessagePreview' | 'lastRequestPreview' | 'lastResponsePreview'
  >,
  runtimeSummary?: string | null,
) {
  const requestText = normalizeConversationPromptFollowupText(item.lastRequestPreview);
  const responseText = normalizeConversationPromptFollowupText(item.lastResponsePreview);
  const messageText = normalizeConversationPromptFollowupText(item.lastMessagePreview);
  const contextLabel = sanitizeConversationBadgeFallbackText(item.contextLabel);
  const contextDescription = sanitizeConversationBadgeFallbackText(item.contextDescription);
  const titleText = sanitizeConversationBadgeFallbackText(item.title);
  const runtimeText = normalizeConversationPromptFollowupText(runtimeSummary);
  const isMetaRequest = isConversationBadgeMetaRequest(requestText);
  const isInquiryRequest = isConversationInquiryRequest(requestText);

  return isMetaRequest || isInquiryRequest
    ? [runtimeText, responseText, messageText, contextDescription, titleText, contextLabel]
    : [runtimeText, responseText, requestText, messageText, contextDescription, titleText, contextLabel];
}

function inferConversationRequestBadgeLabel(
  item: Pick<
    ChatConversationSummary,
    'title' | 'contextLabel' | 'contextDescription' | 'lastMessagePreview' | 'lastRequestPreview' | 'lastResponsePreview'
  >,
  runtimeSummary?: string | null,
) {
  const labelSourceTexts = buildConversationBadgeSourceTexts(item, runtimeSummary);
  const requestText = normalizeConversationPromptFollowupText(item.lastRequestPreview);
  const responseText = normalizeConversationPromptFollowupText(item.lastResponsePreview);
  const messageText = normalizeConversationPromptFollowupText(item.lastMessagePreview);
  const contextLabel = sanitizeConversationBadgeFallbackText(item.contextLabel);
  const contextDescription = sanitizeConversationBadgeFallbackText(item.contextDescription);
  const titleText = sanitizeConversationBadgeFallbackText(item.title);
  const requestActionLabel = findConversationBadgeActionLabel(requestText);
  const actionLabel = findConversationBadgeActionLabel(...labelSourceTexts);
  const targetLabel = findConversationBadgeTargetLabel(...labelSourceTexts);
  const taskDescriptionLabel = buildConversationTaskDescriptionLabel(requestText);

  if (taskDescriptionLabel && requestActionLabel && isConversationPromptFollowupText(item.lastRequestPreview)) {
    return trimConversationRequestBadgeLabel(`${taskDescriptionLabel} ${requestActionLabel}`);
  }

  if (taskDescriptionLabel && ['수정', '추가', '생성', '분리', '업데이트'].includes(actionLabel ?? '')) {
    return trimConversationRequestBadgeLabel(`${taskDescriptionLabel} ${actionLabel}`);
  }

  if (targetLabel && actionLabel) {
    if (targetLabel === '라벨 규칙') {
      return trimConversationRequestBadgeLabel(`${targetLabel} ${actionLabel}`);
    }

    const targetWords = targetLabel.split(/\s+/).filter(Boolean);
    if (targetWords.includes(actionLabel)) {
      return trimConversationRequestBadgeLabel(targetLabel);
    }

    const primaryTarget = targetWords[targetWords.length - 1] || targetLabel;
    return trimConversationRequestBadgeLabel(`${primaryTarget} ${actionLabel}`);
  }

  if (targetLabel) {
    return trimConversationRequestBadgeLabel(targetLabel);
  }

  if (actionLabel) {
    const fallbackLabel = compactConversationBadgeLabel(
      contextDescription || titleText || contextLabel || requestText || responseText || messageText,
      1,
    );
    return fallbackLabel ? trimConversationRequestBadgeLabel(`${fallbackLabel} ${actionLabel}`) : actionLabel;
  }

  const fallbackLabel = compactConversationBadgeLabel(
    contextDescription || titleText || contextLabel || requestText || responseText || messageText,
    2,
  );
  if (fallbackLabel) {
    return fallbackLabel;
  }

  return isConversationInquiryRequest(requestText) ? '문의' : compactConversationBadgeLabel(requestText, 2);
}

function buildConversationRequestBadgeLabel(
  item: Pick<
    ChatConversationSummary,
    | 'sessionId'
    | 'title'
    | 'requestBadgeLabel'
    | 'currentJobStatus'
    | 'contextLabel'
    | 'contextDescription'
    | 'lastMessagePreview'
    | 'lastRequestPreview'
    | 'lastResponsePreview'
  >,
  runtimeSnapshot: ChatRuntimeSnapshot | null,
  latestRuntimeItem?: ConversationListRuntimeItem | null,
) {
  const explicitLabel = trimConversationRequestBadgeLabel(item.requestBadgeLabel ?? '');

  if (explicitLabel) {
    return explicitLabel;
  }

  if (!runtimeSnapshot) {
    return inferConversationRequestBadgeLabel(item);
  }

  const resolvedLatestRuntimeItem = latestRuntimeItem ?? findLatestRuntimeItemForSession(runtimeSnapshot, item.sessionId);

  const runtimeSummary = resolvedLatestRuntimeItem?.summary?.trim() || '';
  const inferredLabel = inferConversationRequestBadgeLabel(item, runtimeSummary);

  if (item.currentJobStatus === 'queued' || item.currentJobStatus === 'started') {
    return inferredLabel || '작업중';
  }

  if (inferredLabel) {
    return inferredLabel;
  }

  return runtimeSummary ? compactConversationBadgeLabel(runtimeSummary, 3) : null;
}

function getRuntimeSnapshotItemStatusTime(
  item: ConversationListRuntimeItem,
) {
  if (item.status === 'running') {
    return toRuntimeStatusTime(item.startedAt ?? item.enqueuedAt);
  }

  if (item.status === 'queued') {
    return toRuntimeStatusTime(item.enqueuedAt);
  }

  return toRuntimeStatusTime('lastUpdatedAt' in item ? item.lastUpdatedAt : item.startedAt ?? item.enqueuedAt);
}

function findLatestRuntimeItemForSession(runtimeSnapshot: ChatRuntimeSnapshot | null, sessionId: string) {
  if (!runtimeSnapshot) {
    return null;
  }

  return [...runtimeSnapshot.running, ...runtimeSnapshot.queued, ...runtimeSnapshot.recent]
    .filter((runtimeItem) => runtimeItem.sessionId === sessionId)
    .sort((left, right) => getRuntimeSnapshotItemStatusTime(right) - getRuntimeSnapshotItemStatusTime(left))[0] ?? null;
}

function hasRuntimeSnapshotContent(runtimeSnapshot: ChatRuntimeSnapshot | null) {
  if (!runtimeSnapshot) {
    return false;
  }

  return (
    runtimeSnapshot.runningCount > 0 ||
    runtimeSnapshot.queuedCount > 0 ||
    runtimeSnapshot.sessionCount > 0 ||
    runtimeSnapshot.running.length > 0 ||
    runtimeSnapshot.queued.length > 0 ||
    runtimeSnapshot.sessions.length > 0 ||
    runtimeSnapshot.recent.length > 0
  );
}

function buildLatestRuntimeItemBySession(runtimeSnapshot: ChatRuntimeSnapshot | null) {
  const latestRuntimeItemBySession = new Map<string, ConversationListRuntimeItem>();

  if (!runtimeSnapshot) {
    return latestRuntimeItemBySession;
  }

  [...runtimeSnapshot.running, ...runtimeSnapshot.queued, ...runtimeSnapshot.recent].forEach((runtimeItem) => {
    const previousItem = latestRuntimeItemBySession.get(runtimeItem.sessionId);

    if (!previousItem || getRuntimeSnapshotItemStatusTime(runtimeItem) > getRuntimeSnapshotItemStatusTime(previousItem)) {
      latestRuntimeItemBySession.set(runtimeItem.sessionId, runtimeItem);
    }
  });

  return latestRuntimeItemBySession;
}

function compareConversationItemsByLatestChat(
  left: Pick<ConversationListViewItem, 'latestActivityTime' | 'sessionId'>,
  right: Pick<ConversationListViewItem, 'latestActivityTime' | 'sessionId'>,
) {
  const timeDiff = right.latestActivityTime - left.latestActivityTime;

  if (timeDiff !== 0) {
    return timeDiff;
  }

  return left.sessionId.localeCompare(right.sessionId);
}

function buildConversationListSearchText(item: ChatConversationSummary) {
  return [
    item.title,
    item.sessionId,
    item.generalSectionName,
    item.lastMessagePreview,
    sanitizeConversationBadgeFallbackText(item.contextDescription),
  ]
    .filter(Boolean)
    .map((value) => String(value).toLowerCase())
    .join('\n');
}

function mergeConversationItemsPreservingTransientState(
  nextItems: ChatConversationSummary[],
  previousItems: ChatConversationSummary[],
) {
  const nextSessionIds = new Set(nextItems.map((item) => item.sessionId));
  const preservedItems = previousItems.filter((item) => {
    if (!item.sessionId || nextSessionIds.has(item.sessionId)) {
      return false;
    }

    return item.isDraftOnly || item.currentJobStatus === 'queued' || item.currentJobStatus === 'started';
  });

  return sortChatConversationSummaries([...preservedItems, ...nextItems]);
}

function getConversationLatestActivityTime(item: ChatConversationSummary) {
  const lastMessageTime = item.lastMessageAt ? new Date(item.lastMessageAt).getTime() : 0;

  if (Number.isFinite(lastMessageTime) && lastMessageTime > 0) {
    return lastMessageTime;
  }

  const createdAtTime = item.createdAt ? new Date(item.createdAt).getTime() : 0;
  if (Number.isFinite(createdAtTime) && createdAtTime > 0) {
    return createdAtTime;
  }

  const updatedAtTime = item.updatedAt ? new Date(item.updatedAt).getTime() : 0;
  return Number.isFinite(updatedAtTime) ? updatedAtTime : 0;
}

function getLatestConversationPreviewMessage(messages: ChatMessage[]) {
  for (let index = messages.length - 1; index >= 0; index -= 1) {
    const candidate = messages[index];

    if (candidate.author !== 'user' && candidate.author !== 'codex') {
      continue;
    }

    const preview = createConversationPreviewText(candidate.text);

    if (preview) {
      return candidate;
    }
  }

  return null;
}

function formatConversationListTimestamp(timestamp: string | null | undefined) {
  if (!timestamp) {
    return '';
  }

  const date = new Date(timestamp);

  if (Number.isNaN(date.getTime())) {
    return '';
  }

  const now = new Date();
  const isSameYear = now.getFullYear() === date.getFullYear();
  const isSameDay =
    isSameYear && now.getMonth() === date.getMonth() && now.getDate() === date.getDate();
  const timeLabel = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;

  if (isSameDay) {
    return timeLabel;
  }

  const monthDayLabel = `${date.getMonth() + 1}/${date.getDate()}`;
  return isSameYear ? `${monthDayLabel} ${timeLabel}` : `${date.getFullYear()}.${monthDayLabel} ${timeLabel}`;
}

function isActivityLogMessage(message: ChatMessage) {
  return message.author === 'system' && message.text.startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`);
}

function normalizeContextMessageText(message: ChatMessage) {
  return message.text.replace(/\s+/g, ' ').trim();
}

function summarizeRecentContext(messages: ChatMessage[], maxMessages: number, maxChars: number) {
  const candidates = messages.filter((message) => {
    if (message.author !== 'user' && message.author !== 'codex') {
      return false;
    }

    return !isActivityLogMessage(message) && Boolean(normalizeContextMessageText(message));
  });

  const selected: ChatMessage[] = [];
  let totalChars = 0;

  for (let index = candidates.length - 1; index >= 0; index -= 1) {
    const message = candidates[index];
    const nextText = normalizeContextMessageText(message);
    const nextChars = nextText.length;

    if (
      selected.length >= maxMessages ||
      (selected.length > 0 && totalChars + nextChars > maxChars)
    ) {
      break;
    }

    selected.unshift(message);
    totalChars += nextChars;
  }

  return {
    includedCount: selected.length,
    omittedCount: Math.max(0, candidates.length - selected.length),
  };
}

function getConversationRequestMetadataTime(request: ChatConversationRequest) {
  const metadataTime =
    request.updatedAt?.trim() ||
    request.terminalAt?.trim() ||
    request.answeredAt?.trim() ||
    request.createdAt?.trim() ||
    '';
  const parsed = Date.parse(metadataTime);
  return Number.isFinite(parsed) ? parsed : 0;
}

function getConversationRequestIntentTime(request: ChatConversationRequest) {
  const intentTime =
    request.createdAt?.trim() ||
    request.updatedAt?.trim() ||
    request.answeredAt?.trim() ||
    request.terminalAt?.trim() ||
    '';
  const parsed = Date.parse(intentTime);
  return Number.isFinite(parsed) ? parsed : 0;
}

function getLatestConversationRequestForMetadata(requests: ChatConversationRequest[]) {
  return requests
    .filter((request) => request.userText.trim().length > 0)
    .sort((left, right) => {
      const intentTimeDiff = getConversationRequestIntentTime(right) - getConversationRequestIntentTime(left);
      if (intentTimeDiff !== 0) {
        return intentTimeDiff;
      }

      return getConversationRequestMetadataTime(right) - getConversationRequestMetadataTime(left);
    })[0] ?? null;
}

function hasDuplicateActivePromptRequest(
  requests: ChatConversationRequest[],
  {
    sessionId,
    text,
    parentRequestId,
  }: {
    sessionId: string;
    text: string;
    parentRequestId?: string | null;
  },
) {
  const normalizedSessionId = sessionId.trim();
  const normalizedText = text.trim();
  const normalizedParentRequestId = parentRequestId?.trim() || null;

  if (!normalizedSessionId || !normalizedText) {
    return false;
  }

  return requests.some((request) => {
    if (request.sessionId !== normalizedSessionId) {
      return false;
    }

    if (request.requestOrigin !== 'prompt') {
      return false;
    }

    if ((request.parentRequestId?.trim() || null) !== normalizedParentRequestId) {
      return false;
    }

    if (request.userText.trim() !== normalizedText) {
      return false;
    }

    return request.status === 'queued' || request.status === 'accepted' || request.status === 'started';
  });
}

function hasConversationAttentionPromptPart(part: ChatMessagePart) {
  return part.type === 'prompt' && part.readOnly !== true && part.resolvedBy == null && !(part.resolvedAt?.trim() ?? '');
}

function hasConversationAttentionResponseTarget(message: ChatMessage) {
  if (message.author !== 'codex' && message.author !== 'system') {
    return false;
  }

  if ((message.parts ?? []).some((part) => hasConversationAttentionPromptPart(part))) {
    return true;
  }

  const normalizedText = String(message.text ?? '').trim();

  if (!normalizedText) {
    return false;
  }

  if (normalizedText.length > 720) {
    return true;
  }

  return /```diff[\s\S]*?```|\[\[preview:|\[\[link-card:|\[\[prompt:/i.test(normalizedText);
}

function normalizePromptContextRef(value: ChatPromptContextRef | null | undefined): ChatPromptContextRef | null {
  if (!value || value.key !== 'prompt_parent_question') {
    return null;
  }

  const promptTitle = String(value.promptTitle ?? '').trim();

  if (!promptTitle) {
    return null;
  }

  const promptDescription = String(value.promptDescription ?? '').trim();
  const parentQuestionText = String(value.parentQuestionText ?? '').trim();

  return {
    key: 'prompt_parent_question',
    promptTitle,
    promptDescription: promptDescription || null,
    parentQuestionText: parentQuestionText || null,
  };
}

function mergeConversationRequestPreservingContent(
  previousItem: ChatConversationRequest | null | undefined,
  nextItem: ChatConversationRequest,
) {
  if (!previousItem) {
    return nextItem;
  }

  const nextUserText = nextItem.userText.trim() || previousItem.userText.trim();
  const nextResponseText = nextItem.responseText.trim() || previousItem.responseText.trim();
  const nextStatusMessage = nextItem.statusMessage?.trim() || previousItem.statusMessage?.trim() || null;

  return {
    ...nextItem,
    chatTypeId: nextItem.chatTypeId?.trim() || previousItem.chatTypeId?.trim() || null,
    chatTypeLabel: nextItem.chatTypeLabel?.trim() || previousItem.chatTypeLabel?.trim() || '',
    requestOrigin: nextItem.requestOrigin ?? previousItem.requestOrigin ?? null,
    parentRequestId: nextItem.parentRequestId?.trim() || previousItem.parentRequestId?.trim() || null,
    promptContextRef: normalizePromptContextRef(nextItem.promptContextRef) ?? normalizePromptContextRef(previousItem.promptContextRef),
    statusMessage: nextStatusMessage,
    retryCount: Math.max(nextItem.retryCount ?? 0, previousItem.retryCount ?? 0),
    userMessageId: nextItem.userMessageId ?? previousItem.userMessageId,
    userText: nextUserText,
    responseMessageId: nextItem.responseMessageId ?? previousItem.responseMessageId,
    responseText: nextResponseText,
    hasResponse: nextItem.hasResponse || previousItem.hasResponse || nextResponseText.length > 0,
    manualPromptCompletedAt:
      nextItem.manualPromptCompletedAt !== undefined
        ? nextItem.manualPromptCompletedAt
        : previousItem.manualPromptCompletedAt ?? null,
    manualVerificationCompletedAt:
      nextItem.manualVerificationCompletedAt !== undefined
        ? nextItem.manualVerificationCompletedAt
        : previousItem.manualVerificationCompletedAt ?? null,
    answeredAt: nextItem.answeredAt ?? previousItem.answeredAt,
    terminalAt: nextItem.terminalAt ?? previousItem.terminalAt,
  };
}

function mergeConversationRequestsPreservingContent(
  previousItems: ChatConversationRequest[],
  nextItems: ChatConversationRequest[],
  sessionId: string,
) {
  const previousByRequestId = new Map(
    previousItems
      .filter((item) => item.sessionId === sessionId)
      .map((item) => [item.requestId, item] as const),
  );
  const nextRequestIds = new Set(nextItems.map((item) => item.requestId));
  const mergedIncoming = nextItems.map((item) =>
    mergeConversationRequestPreservingContent(previousByRequestId.get(item.requestId), item),
  );
  const preservedLocalItems = previousItems.filter((item) => {
    if (item.sessionId !== sessionId) {
      return false;
    }

    if (nextRequestIds.has(item.requestId)) {
      return false;
    }

    return item.status === 'accepted' || item.status === 'queued' || item.status === 'started';
  });

  return [...mergedIncoming, ...preservedLocalItems].sort((left, right) => left.createdAt.localeCompare(right.createdAt));
}

function buildMessageSyncKey(messages: ChatMessage[]) {
  if (messages.length === 0) {
    return '0';
  }

  const latestMessage = messages[messages.length - 1];
  return `${messages.length}:${latestMessage.id}:${latestMessage.text.length}:${latestMessage.timestamp}`;
}

function isMeaningfulCodexResponseMessage(message: Pick<ChatMessage, 'author' | 'text' | 'parts'>) {
  if (message.author !== 'codex' || isPreparingChatReplyText(message.text)) {
    return false;
  }

  const structuredParts = Array.isArray(message.parts) ? message.parts : [];
  const extractedMessageParts = extractChatMessageParts(message.text);
  const visibleText = stripHiddenPreviewTags(
    extractedMessageParts.strippedText.replace(/```diff[^\n]*\n[\s\S]*?```/g, ''),
  ).trim();

  if (visibleText) {
    return true;
  }

  if (/```diff[^\n]*\n[\s\S]*?```/m.test(message.text)) {
    return true;
  }

  return [...structuredParts, ...extractedMessageParts.parts].some((part) => part.type === 'link_card');
}

const CHAT_CONVERSATION_DETAIL_PAGE_SIZE = 10;

function collectVisibleConversationRequestIds(messages: ChatMessage[]) {
  return new Set(
    messages
      .map((message) => message.clientRequestId?.trim() ?? '')
      .filter(Boolean),
  );
}

function countVisibleConversationRequests(
  messages: ChatMessage[],
  requestItems: ChatConversationRequest[],
  sessionId: string,
) {
  const visibleRequestIds = collectVisibleConversationRequestIds(messages);

  if (visibleRequestIds.size === 0) {
    return 0;
  }

  return requestItems.filter(
    (item) => item.sessionId === sessionId && item.status !== 'removed' && visibleRequestIds.has(item.requestId),
  ).length;
}

function limitConversationWindowToRecentRequests(
  messages: ChatMessage[],
  requestItems: ChatConversationRequest[],
  sessionId: string,
  requestLimit: number,
) {
  const normalizedSessionId = sessionId.trim();
  const normalizedLimit = Math.max(1, Math.trunc(requestLimit));
  const sessionRequests = requestItems
    .filter((item) => item.sessionId === normalizedSessionId && item.status !== 'removed')
    .sort((left, right) => left.createdAt.localeCompare(right.createdAt));

  let recentRequestIds = sessionRequests.slice(-normalizedLimit).map((item) => item.requestId.trim()).filter(Boolean);

  if (recentRequestIds.length === 0) {
    const orderedVisibleRequestIds = messages.reduce<string[]>((accumulator, message) => {
      const requestId = message.clientRequestId?.trim() ?? '';

      if (!requestId || accumulator.includes(requestId)) {
        return accumulator;
      }

      accumulator.push(requestId);
      return accumulator;
    }, []);

    recentRequestIds = orderedVisibleRequestIds.slice(-normalizedLimit);
  }

  if (recentRequestIds.length === 0) {
    return {
      messages,
      requests: sessionRequests,
    };
  }

  const recentRequestIdSet = new Set(recentRequestIds);

  return {
    messages: messages.filter((message) => {
      const requestId = message.clientRequestId?.trim() ?? '';
      return requestId ? recentRequestIdSet.has(requestId) : false;
    }),
    requests: sessionRequests.filter((item) => recentRequestIdSet.has(item.requestId)),
  };
}

function buildAttachmentMessageBlock(attachments: ChatComposerAttachment[]) {
  if (attachments.length === 0) {
    return '';
  }

  return ['첨부 파일:', ...attachments.map((attachment) => `- ${attachment.name}: ${attachment.publicUrl || attachment.path}`)].join('\n');
}

function getSessionIdFromSearch(search: string) {
  const sessionId = new URLSearchParams(search).get('sessionId')?.trim() ?? '';
  return sessionId || null;
}

function getRuntimeLogRequestIdFromSearch(search: string) {
  const requestId = new URLSearchParams(search).get('runtimeRequestId')?.trim() ?? '';
  return requestId || null;
}

function getRequestedChatViewFromSearch(search: string): ChatPanelView | null {
  const view = new URLSearchParams(search).get('chatView')?.trim() ?? '';
  return view === 'chat' || view === 'runtime' || view === 'errors' ? view : null;
}

function isRequestDeletionAllowed(status?: ChatConversationRequest['status'] | null) {
  return status !== 'queued' && status !== 'started' && status !== 'removed';
}

function buildOutgoingMessageText(text: string, attachments: ChatComposerAttachment[]) {
  const trimmedText = text.trim();
  const attachmentBlock = buildAttachmentMessageBlock(attachments);

  if (trimmedText && attachmentBlock) {
    return `${trimmedText}\n\n${attachmentBlock}`;
  }

  return trimmedText || attachmentBlock;
}

function mergeComposerAttachments(previous: ChatComposerAttachment[], next: ChatComposerAttachment[]) {
  const seen = new Set(previous.map((item) => item.path));

  return [
    ...previous,
    ...next.filter((item) => {
      if (seen.has(item.path)) {
        return false;
      }

      seen.add(item.path);
      return true;
    }),
  ];
}

function clearLegacyChatMessageStorage() {
  if (typeof window === 'undefined') {
    return;
  }

  // Only clear the truly legacy single-key cache. Per-session keys use the
  // same prefix and must be preserved across remounts.
  window.localStorage.removeItem('main-chat-panel:messages');
}

function mapSystemStatusMessage(text: string) {
  const normalized = text.trim();

  if (!normalized) {
    return null;
  }

  if (normalized.startsWith('대기열 등록:')) {
    const matchedQueueCount = normalized.match(/대기열 등록:\s*(\d+)건/i);
    return matchedQueueCount?.[1] ? `대기열 ${matchedQueueCount[1]}건` : '대기열 등록됨';
  }

  if (normalized.startsWith('요청 실행 중입니다.') || normalized.startsWith('즉시 요청 실행 중입니다.')) {
    return '요청 처리 중';
  }

  if (normalized.startsWith('생각 중입니다.')) {
    return '요청 검토 중';
  }

  if (normalized.includes('요청 의도와 현재 화면 문맥')) {
    return '요청 검토 중';
  }

  if (normalized.includes('DB를 직접 확인')) {
    return 'DB 확인 중';
  }

  if (normalized.includes('API 경로와 실제 응답')) {
    return 'API 확인 중';
  }

  if (normalized.includes('관련 소스 파일과 연결된 흐름')) {
    return '소스 확인 중';
  }

  if (
    normalized.startsWith('요청 처리가 끝났습니다.') ||
    normalized.startsWith('즉시 요청 처리가 끝났습니다.') ||
    normalized.startsWith('요청 처리 중 오류가 발생했습니다.')
  ) {
    return null;
  }

  if (!normalized.includes('\n') && normalized.length <= 72) {
    return normalized;
  }

  return null;
}

function mapJobStatusLabel(item: Pick<ChatConversationSummary, 'currentJobStatus' | 'currentJobMessage' | 'currentQueueSize'>) {
  if (!item.currentJobStatus) {
    return '';
  }

  const normalizedJobMessage = item.currentJobMessage?.trim() || '';

  if (normalizedJobMessage) {
    if (item.currentJobStatus === 'started' && normalizedJobMessage === '요청 처리 중') {
      return '응답 준비 중';
    }

    return normalizedJobMessage;
  }

  if (item.currentJobStatus === 'queued') {
    return item.currentQueueSize > 0 ? `대기열 ${item.currentQueueSize}건` : '대기 중';
  }

  if (item.currentJobStatus === 'started') {
    return '응답 준비 중';
  }

  if (item.currentJobStatus === 'failed') {
    return '요청 처리 실패';
  }

  if (item.currentJobStatus === 'completed') {
    return '완료';
  }

  return '';
}

function buildConversationJobStatusLabel(
  item: Pick<ChatConversationSummary, 'currentJobStatus' | 'currentJobMessage' | 'currentQueueSize'>,
) {
  return mapJobStatusLabel(item);
}

function isConversationProcessing(
  item: Pick<ChatConversationSummary, 'currentJobStatus'>,
) {
  return item.currentJobStatus === 'queued' || item.currentJobStatus === 'started';
}

function isConversationFailed(item: Pick<ChatConversationSummary, 'currentJobStatus'>) {
  return item.currentJobStatus === 'failed';
}

function normalizeGeneralSectionName(value: string | null | undefined) {
  const normalized = String(value ?? '').trim();
  return normalized || null;
}

function readStoredImmediateSendPinnedBySession() {
  if (typeof window === 'undefined') {
    return {} as Record<string, boolean>;
  }

  try {
    const raw = window.localStorage.getItem(CHAT_IMMEDIATE_SEND_PINNED_STORAGE_KEY);

    if (!raw) {
      return {} as Record<string, boolean>;
    }

    const parsed = JSON.parse(raw) as Record<string, unknown>;

    return Object.entries(parsed).reduce<Record<string, boolean>>((result, [sessionId, isPinned]) => {
      const normalizedSessionId = String(sessionId ?? '').trim();

      if (normalizedSessionId && typeof isPinned === 'boolean') {
        result[normalizedSessionId] = isPinned;
      }

      return result;
    }, {});
  } catch {
    return {} as Record<string, boolean>;
  }
}

function writeStoredImmediateSendPinnedBySession(nextMap: Record<string, boolean>) {
  if (typeof window === 'undefined') {
    return;
  }

  const normalizedMap = Object.entries(nextMap).reduce<Record<string, boolean>>((result, [sessionId, isPinned]) => {
    const normalizedSessionId = String(sessionId ?? '').trim();

    if (normalizedSessionId && typeof isPinned === 'boolean') {
      result[normalizedSessionId] = isPinned;
    }

    return result;
  }, {});

  window.localStorage.setItem(CHAT_IMMEDIATE_SEND_PINNED_STORAGE_KEY, JSON.stringify(normalizedMap));
}

function readStoredGeneralSectionNameMap() {
  if (typeof window === 'undefined') {
    return {} as Record<string, string>;
  }

  try {
    const raw = window.localStorage.getItem(CHAT_GENERAL_SECTION_STORAGE_KEY);

    if (!raw) {
      return {} as Record<string, string>;
    }

    const parsed = JSON.parse(raw) as Record<string, unknown>;

    return Object.entries(parsed).reduce<Record<string, string>>((result, [sessionId, sectionName]) => {
      const normalizedSessionId = sessionId.trim();
      const normalizedSectionName = normalizeGeneralSectionName(sectionName as string | null | undefined);

      if (normalizedSessionId && normalizedSectionName) {
        result[normalizedSessionId] = normalizedSectionName;
      }

      return result;
    }, {});
  } catch {
    return {} as Record<string, string>;
  }
}

function writeStoredGeneralSectionName(sessionId: string, sectionName: string | null) {
  if (typeof window === 'undefined') {
    return;
  }

  const normalizedSessionId = sessionId.trim();

  if (!normalizedSessionId) {
    return;
  }

  const nextMap = readStoredGeneralSectionNameMap();
  const normalizedSectionName = normalizeGeneralSectionName(sectionName);

  if (normalizedSectionName) {
    nextMap[normalizedSessionId] = normalizedSectionName;
  } else {
    delete nextMap[normalizedSessionId];
  }

  window.localStorage.setItem(CHAT_GENERAL_SECTION_STORAGE_KEY, JSON.stringify(nextMap));
}

function buildGeneralSectionKey(name: string | null) {
  return name ? `general:${name}` : 'general';
}

const RECENT_CONVERSATION_SECTION_KEY = 'recent';

function isGeneralConversationSectionKey(sectionKey: string) {
  return sectionKey === 'general' || sectionKey.startsWith('general:');
}

function isReorderableConversationSectionKey(sectionKey: string) {
  return sectionKey === RECENT_CONVERSATION_SECTION_KEY || isGeneralConversationSectionKey(sectionKey);
}

function readStoredGeneralSectionOrder() {
  if (typeof window === 'undefined') {
    return [] as string[];
  }

  try {
    const raw = window.localStorage.getItem(CHAT_GENERAL_SECTION_ORDER_STORAGE_KEY);

    if (!raw) {
      return [] as string[];
    }

    const parsed = JSON.parse(raw);

    if (!Array.isArray(parsed)) {
      return [] as string[];
    }

    return parsed.reduce<string[]>((result, value) => {
      const normalizedKey = String(value ?? '').trim();

      if (isReorderableConversationSectionKey(normalizedKey) && !result.includes(normalizedKey)) {
        result.push(normalizedKey);
      }

      return result;
    }, []);
  } catch {
    return [] as string[];
  }
}

function writeStoredGeneralSectionOrder(sectionKeys: string[]) {
  if (typeof window === 'undefined') {
    return;
  }

  const normalizedKeys = sectionKeys.reduce<string[]>((result, value) => {
    const normalizedKey = String(value ?? '').trim();

    if (isReorderableConversationSectionKey(normalizedKey) && !result.includes(normalizedKey)) {
      result.push(normalizedKey);
    }

    return result;
  }, []);

  window.localStorage.setItem(CHAT_GENERAL_SECTION_ORDER_STORAGE_KEY, JSON.stringify(normalizedKeys));
}

function normalizeGeneralSectionOrder(sectionKeys: string[], storedOrder: string[]) {
  const normalizedStoredOrder = storedOrder.filter((key) => sectionKeys.includes(key));
  const missingKeys = sectionKeys.filter((key) => !normalizedStoredOrder.includes(key));
  return [...normalizedStoredOrder, ...missingKeys];
}

function reorderGeneralConversationSectionsToIndex(sectionKeys: string[], activeKey: string, targetIndex: number) {
  const activeIndex = sectionKeys.indexOf(activeKey);

  if (activeIndex < 0) {
    return sectionKeys;
  }

  const boundedTargetIndex = Math.max(0, Math.min(targetIndex, sectionKeys.length - 1));

  if (activeIndex === boundedTargetIndex) {
    return sectionKeys;
  }

  const nextKeys = [...sectionKeys];
  const [movedKey] = nextKeys.splice(activeIndex, 1);
  nextKeys.splice(boundedTargetIndex, 0, movedKey);
  return nextKeys;
}

type ConversationListSectionKey = string;

type ConversationListRuntimeItem =
  | ChatRuntimeSnapshot['running'][number]
  | ChatRuntimeSnapshot['queued'][number]
  | ChatRuntimeSnapshot['recent'][number];

type ConversationListViewItem = ChatConversationSummary & {
  latestActivityTime: number;
  searchText: string;
  resolvedRequestBadgeLabel: string | null;
  resolvedJobStatusLabel: string;
  resolvedProcessingCountLabel: string | null;
};

type ConversationListSection = {
  key: ConversationListSectionKey;
  title: string;
  tone: 'processing' | 'failed' | 'unread' | 'muted';
  items: ConversationListViewItem[];
  generalSectionName?: string | null;
  defaultOpen: boolean;
  isReorderable?: boolean;
};

const DEFAULT_MOBILE_SECTION_OPEN_STATE: Record<string, boolean> = {
  processing: true,
  failed: true,
  unread: true,
  general: false,
};

function isManagedChatShareSessionId(sessionId: string | null | undefined) {
  return String(sessionId ?? '').trim().startsWith('chat-share-room-');
}

function toRuntimeStatusTime(value: string | null | undefined) {
  if (!value) {
    return 0;
  }

  const parsed = Date.parse(value);
  return Number.isNaN(parsed) ? 0 : parsed;
}

function mergeConversationSummaryPreservingChatType(
  previousItem: ChatConversationSummary | null | undefined,
  nextItem: ChatConversationSummary,
) {
  if (!previousItem) {
    return nextItem;
  }

  const nextChatTypeId = nextItem.chatTypeId?.trim() || previousItem.chatTypeId?.trim() || null;
  const nextLastChatTypeId =
    nextItem.lastChatTypeId?.trim() ||
    nextChatTypeId ||
    previousItem.lastChatTypeId?.trim() ||
    previousItem.chatTypeId?.trim() ||
    null;
  const previousRequestId = previousItem.currentRequestId?.trim() || '';
  const nextRequestId = nextItem.currentRequestId?.trim() || '';
  const preserveRequestMetadata = Boolean(previousRequestId && previousRequestId === nextRequestId);

  return {
    ...nextItem,
    title: resolveMergedConversationTitle(previousItem.title, nextItem.title, {
      preservePrevious: preserveRequestMetadata,
    }),
    requestBadgeLabel: preserveRequestMetadata
      ? previousItem.requestBadgeLabel?.trim() || nextItem.requestBadgeLabel?.trim() || null
      : nextItem.requestBadgeLabel?.trim() || previousItem.requestBadgeLabel?.trim() || null,
    codexModel: nextItem.codexModel?.trim() || previousItem.codexModel?.trim() || null,
    chatTypeId: nextChatTypeId,
    lastChatTypeId: nextLastChatTypeId,
    generalSectionName: normalizeGeneralSectionName(nextItem.generalSectionName) ?? normalizeGeneralSectionName(previousItem.generalSectionName),
    contextLabel: nextItem.contextLabel?.trim() || previousItem.contextLabel?.trim() || null,
    contextDescription: nextItem.contextDescription?.trim() || null,
    isPendingWork: nextItem.isPendingWork ?? previousItem.isPendingWork ?? false,
    pendingWorkReason: nextItem.pendingWorkReason ?? previousItem.pendingWorkReason ?? null,
    hasPendingAttention: nextItem.hasPendingAttention === true,
    lastRequestPreview: nextItem.lastRequestPreview?.trim() || previousItem.lastRequestPreview?.trim() || '',
    lastMessagePreview: nextItem.lastMessagePreview.trim() || previousItem.lastMessagePreview.trim(),
    lastResponsePreview: nextItem.lastResponsePreview?.trim() || previousItem.lastResponsePreview?.trim() || '',
  };
}

function applyRuntimeSnapshotToConversationItems(
  items: ChatConversationSummary[],
  snapshot: ChatRuntimeSnapshot | null,
) {
  if (!snapshot || items.length === 0) {
    return items;
  }

  const runningBySession = new Map(snapshot.running.map((item) => [item.sessionId, item] as const));
  const queuedBySession = new Map(snapshot.queued.map((item) => [item.sessionId, item] as const));
  const queuedCountBySession = snapshot.queued.reduce<Map<string, number>>((result, item) => {
    result.set(item.sessionId, (result.get(item.sessionId) ?? 0) + 1);
    return result;
  }, new Map());
  const recentBySession = snapshot.recent.reduce<
    Map<string, (typeof snapshot.recent)[number]>
  >((result, item) => {
    const current = result.get(item.sessionId);

    if (!current || toRuntimeStatusTime(item.lastUpdatedAt) >= toRuntimeStatusTime(current.lastUpdatedAt)) {
      result.set(item.sessionId, item);
    }

    return result;
  }, new Map());

  let hasChanges = false;
  const nextItems = items.map((item) => {
    const runningItem = runningBySession.get(item.sessionId);

    if (runningItem) {
      const nextStatusUpdatedAt = runningItem.startedAt || snapshot.generatedAt;
      const nextItem =
        item.currentRequestId === runningItem.requestId &&
        item.currentJobStatus === 'started' &&
        item.currentQueueSize === 0 &&
        item.currentStatusUpdatedAt === nextStatusUpdatedAt
          ? item
          : {
              ...item,
              currentRequestId: runningItem.requestId,
              currentJobStatus: 'started' as const,
              currentJobMessage: null,
              currentQueueSize: 0,
              currentStatusUpdatedAt: nextStatusUpdatedAt,
            };

      if (nextItem !== item) {
        hasChanges = true;
      }

      return nextItem;
    }

    const queuedItem = queuedBySession.get(item.sessionId);

    if (queuedItem) {
      const nextQueueSize = queuedCountBySession.get(item.sessionId) ?? 0;
      const nextStatusUpdatedAt = queuedItem.enqueuedAt || snapshot.generatedAt;
      const nextItem =
        item.currentRequestId === queuedItem.requestId &&
        item.currentJobStatus === 'queued' &&
        item.currentQueueSize === nextQueueSize &&
        item.currentStatusUpdatedAt === nextStatusUpdatedAt
          ? item
          : {
              ...item,
              currentRequestId: queuedItem.requestId,
              currentJobStatus: 'queued' as const,
              currentJobMessage: null,
              currentQueueSize: nextQueueSize,
              currentStatusUpdatedAt: nextStatusUpdatedAt,
            };

      if (nextItem !== item) {
        hasChanges = true;
      }

      return nextItem;
    }

    const recentItem = recentBySession.get(item.sessionId);

    if (recentItem?.terminalStatus === 'failed') {
      const nextStatusUpdatedAt = recentItem.lastUpdatedAt || snapshot.generatedAt;
      const nextItem =
        item.currentRequestId === recentItem.requestId &&
        item.currentJobStatus === 'failed' &&
        item.currentQueueSize === 0 &&
        item.currentStatusUpdatedAt === nextStatusUpdatedAt
          ? item
          : {
              ...item,
              currentRequestId: recentItem.requestId,
              currentJobStatus: 'failed' as const,
              currentJobMessage: null,
              currentQueueSize: 0,
              currentStatusUpdatedAt: nextStatusUpdatedAt,
            };

      if (nextItem !== item) {
        hasChanges = true;
      }

      return nextItem;
    }

    const currentJobStatus = item.currentJobStatus;
    const currentStatusUpdatedAtMs = toRuntimeStatusTime(item.currentStatusUpdatedAt);
    const snapshotGeneratedAtMs = toRuntimeStatusTime(snapshot.generatedAt);
    const shouldPreserveTransientInFlightState =
      (currentJobStatus === 'queued' || currentJobStatus === 'started') &&
      currentStatusUpdatedAtMs > 0 &&
      snapshotGeneratedAtMs > 0 &&
      snapshotGeneratedAtMs - currentStatusUpdatedAtMs <= 4000;

    if (shouldPreserveTransientInFlightState) {
      return item;
    }

    if (!item.currentJobStatus && !item.currentRequestId && item.currentQueueSize === 0) {
      return item;
    }

    hasChanges = true;
    return {
      ...item,
      currentRequestId: null,
      currentJobStatus: null,
      currentJobMessage: null,
      currentQueueSize: 0,
      currentStatusUpdatedAt: recentItem?.lastUpdatedAt || snapshot.generatedAt,
    };
  });

  if (!hasChanges) {
    return items;
  }

  return nextItems;
}

function isConversationPendingWork(item: ChatConversationSummary) {
  if (isConversationProcessing(item) || isConversationFailed(item) || item.hasUnreadResponse) {
    return false;
  }

  return item.isPendingWork === true || inferConversationPendingWorkReason(item) != null;
}

function buildRuntimeStatusLabel(snapshot: ChatRuntimeSnapshot | null, sessionId: string) {
  if (!snapshot) {
    return null;
  }

  const current = snapshot.sessions.find((item) => item.sessionId === sessionId);

  if (!current) {
    return null;
  }

  if (current.runningCount > 0 && current.queuedCount > 0) {
    return `실제 실행 ${current.runningCount}건 · 대기 ${current.queuedCount}건`;
  }

  if (current.runningCount > 0) {
    return `실제 실행 ${current.runningCount}건`;
  }

  if (current.queuedCount > 0) {
    return `실제 대기열 ${current.queuedCount}건`;
  }

  return null;
}

function buildConversationProcessingCountLabel(
  snapshot: ChatRuntimeSnapshot | null,
  item: Pick<ChatConversationSummary, 'sessionId' | 'currentJobStatus' | 'currentQueueSize'>,
) {
  const runtimeSession = snapshot?.sessions.find((session) => session.sessionId === item.sessionId) ?? null;
  const runtimeCount = runtimeSession ? runtimeSession.runningCount + runtimeSession.queuedCount : 0;
  const fallbackCount =
    item.currentJobStatus === 'queued'
      ? Math.max(1, Number(item.currentQueueSize ?? 0))
      : item.currentJobStatus === 'started'
        ? 1
        : 0;
  const processingCount = Math.max(runtimeCount, fallbackCount);

  if (processingCount < 2) {
    return null;
  }

  return `처리 ${processingCount}건`;
}

export function MainChatPanel({
  initialView = 'live',
  lockOuterScrollOnMobile = false,
  mode = 'live',
  roomsPresentation = 'default',
  roomsEntryMode = 'list',
  onRoomsMinimize = null,
  onRoomsClose = null,
}: MainChatPanelProps) {
  const location = useLocation();
  const navigate = useNavigate();
  const { currentPage, focusedComponentId } = useAppStore();
  const actionContextSnapshot = useChatActionContextSnapshot();
  const appConfig = useAppConfig();
  const { hasAccess } = useTokenAccess();
  const { tokenSettings, isLoading: isShareTokenSettingsLoading, errorMessage: shareTokenSettingsErrorMessage } = useTokenSettingRegistry();
  const { chatTypes } = useChatTypeRegistry();
  const { defaultContexts, chatTypeDefaults, roomContexts, setStore: setChatContextSettingsStore } =
    useChatContextSettingsRegistry();
  const draftRef = useRef('');
  const [draftSeed, setDraftSeed] = useState({ value: '', version: 0 });
  const [composerAttachments, setComposerAttachments] = useState<ChatComposerAttachment[]>([]);
  const [isComposerAttachmentUploading, setIsComposerAttachmentUploading] = useState(false);
  const [roomLaunchScope, setRoomLaunchScope] = useState<IsolatedChatRoomScope | null>(() => readActiveIsolatedChatRoomScope());
  const [roomErrorAssistText, setRoomErrorAssistText] = useState('');
  const userRoles = useMemo(() => resolveCurrentChatPermissionRoles(hasAccess), [hasAccess]);
  const availableChatTypes = useMemo(
    () => chatTypes.filter((item) => canUseChatType(item, userRoles)),
    [chatTypes, userRoles],
  );
  const shareTokenSettingOptions = useMemo(
    () =>
      tokenSettings.filter(
        (item) => item.enabled && item.allowedAppIds.some((allowedAppId) => allowedAppId.trim().toLowerCase() === 'chat-live'),
      ),
    [tokenSettings],
  );
  const chatTypeOptions = useMemo<ChatTypeOption[]>(
    () =>
      chatTypes.map((item) => {
        const isAllowed = canUseChatType(item, userRoles);

        return {
          value: item.id,
          label: item.name,
          description: item.description,
          disabled: !isAllowed,
        };
      }),
    [chatTypes, userRoles],
  );
  const [selectedChatTypeId, setSelectedChatTypeId] = useState<string | null>(availableChatTypes[0]?.id ?? null);
  const [selectedCodexModel, setSelectedCodexModel] = useState(DEFAULT_CODEX_MODEL);
  const selectedChatType = chatTypes.find((item) => item.id === selectedChatTypeId) ?? null;

  useEffect(() => {
    if (mode !== 'rooms') {
      return;
    }

    setRoomLaunchScope(readActiveIsolatedChatRoomScope());
  }, [location.pathname, mode]);

  useEffect(() => {
    if (mode !== 'rooms') {
      setRoomErrorAssistText('');
      return;
    }

    let active = true;
    void fetchErrorLogs(20)
      .then((items) => {
        if (!active) {
          return;
        }

        const summary = buildScopedErrorAssistText(items);
        setRoomErrorAssistText(summary);
        setRoomLaunchScope((current) => {
          if (!current) {
            return current;
          }

          const nextScope = {
            ...current,
            errorSummary: summary || null,
          };
          writeActiveIsolatedChatRoomScope(nextScope);
          return nextScope;
        });
      })
      .catch(() => {
        if (active) {
          setRoomErrorAssistText('');
        }
      });

    return () => {
      active = false;
    };
  }, [mode]);
  const isSelectedChatTypeAllowed = selectedChatType ? canUseChatType(selectedChatType, userRoles) : false;
  const isSharedRoomsPresentation = mode === 'rooms' && roomsPresentation === 'shared';
  const canReturnToRoomsList = mode === 'rooms' && roomsEntryMode === 'list';
  const [isContextDrawerOpen, setIsContextDrawerOpen] = useState(false);
  const [contextDrawerTabKey, setContextDrawerTabKey] = useState('chat-type');
  const [editingRoomChatTypeId, setEditingRoomChatTypeId] = useState<string | null>(null);
  const [editingChatTypeDescription, setEditingChatTypeDescription] = useState('');
  const [editingRoomDefaultContextIds, setEditingRoomDefaultContextIds] = useState<string[]>([]);
  const [isEditingRoomDefaultContextsDirty, setIsEditingRoomDefaultContextsDirty] = useState(false);
  const [editingRoomCustomContextTitle, setEditingRoomCustomContextTitle] = useState('');
  const [editingRoomCustomContextContent, setEditingRoomCustomContextContent] = useState('');
  const [editingRoomCodexParticipants, setEditingRoomCodexParticipants] = useState<EditableCodexParticipant[]>([
    createEditableCodexParticipant(1),
  ]);
  const [expandedParticipantDefaultContextIds, setExpandedParticipantDefaultContextIds] = useState<string[]>([]);
  const [mobileConversationSectionOpen, setMobileConversationSectionOpen] =
    useState<Record<ConversationListSectionKey, boolean>>(DEFAULT_MOBILE_SECTION_OPEN_STATE);
  const [isCreateConversationModalOpen, setIsCreateConversationModalOpen] = useState(false);
  const [createConversationChatTypeId, setCreateConversationChatTypeId] = useState<string | null>(
    availableChatTypes[0]?.id ?? null,
  );
  const [createConversationCodexModel, setCreateConversationCodexModel] = useState(DEFAULT_CODEX_MODEL);
  const [pendingShareTarget, setPendingShareTarget] = useState<PendingShareTarget | null>(null);
  const [selectedShareTokenSettingId, setSelectedShareTokenSettingId] = useState('');
  const [shareTokenName, setShareTokenName] = useState('');
  const [isShareTargetSubmitting, setIsShareTargetSubmitting] = useState(false);
  const selectedCreateConversationChatType =
    availableChatTypes.find((item) => item.id === createConversationChatTypeId) ?? availableChatTypes[0] ?? null;
  useEffect(() => {
    if (!pendingShareTarget) {
      return;
    }

    if (
      selectedShareTokenSettingId &&
      shareTokenSettingOptions.some((item) => item.id === selectedShareTokenSettingId)
    ) {
      return;
    }

    setSelectedShareTokenSettingId(shareTokenSettingOptions[0]?.id ?? '');
  }, [pendingShareTarget, selectedShareTokenSettingId, shareTokenSettingOptions]);
  useEffect(() => {
    if (!pendingShareTarget) {
      if (shareTokenName) {
        setShareTokenName('');
      }
      return;
    }
  }, [pendingShareTarget, shareTokenName]);
  const requestedSessionId = getSessionIdFromSearch(location.search);
  const requestedRuntimeLogRequestId = getRuntimeLogRequestIdFromSearch(location.search);
  const requestedChatView = getRequestedChatViewFromSearch(location.search);
  const chatRoutePath = mode === 'rooms' ? buildChatPath('rooms') : buildChatPath('live');
  const [activeSessionId, setActiveSessionId] = useState('');
  const activeRoomScope = useMemo(
    () => (mode === 'rooms' ? readIsolatedChatRoomSessionScope(activeSessionId) ?? roomLaunchScope : null),
    [activeSessionId, mode, roomLaunchScope],
  );
  useEffect(() => {
    if (mode !== 'rooms' || !activeSessionId || !activeRoomScope) {
      return;
    }

    writeActiveIsolatedChatRoomScope(activeRoomScope);
    writeIsolatedChatRoomSessionScope(activeSessionId, activeRoomScope);
  }, [activeRoomScope, activeSessionId, mode]);
  const sessionMessageCacheRef = useRef<Map<string, ChatMessage[]>>(new Map());
  const [isMobileConversationView, setIsMobileConversationView] = useState(false);
  const [isConversationPaneClosed, setIsConversationPaneClosed] = useState(true);
  const [isMobileViewport, setIsMobileViewport] = useState(false);
  const [isMobileActionGroupOpen, setIsMobileActionGroupOpen] = useState(false);
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [activeView, setActiveView] = useState<ChatPanelView>(() => {
    if (requestedChatView) {
      return requestedChatView;
    }

    return initialView === 'errors' ? 'errors' : 'chat';
  });
  const [copiedMessageId, setCopiedMessageId] = useState<number | null>(null);
  const [requestItemsState, setRequestItemsState] = useState<ChatConversationRequest[]>([]);
  const [pendingDeleteSessionId, setPendingDeleteSessionId] = useState<string | null>(null);
  const [editingConversationTitleSessionId, setEditingConversationTitleSessionId] = useState<string | null>(null);
  const [editingConversationTitle, setEditingConversationTitle] = useState('');
  const [savingConversationTitleSessionId, setSavingConversationTitleSessionId] = useState<string | null>(null);
  const [editingGeneralSectionSessionId, setEditingGeneralSectionSessionId] = useState<string | null>(null);
  const [editingGeneralSectionName, setEditingGeneralSectionName] = useState('');
  const [savingGeneralSectionSessionId, setSavingGeneralSectionSessionId] = useState<string | null>(null);
  const [generalSectionOrder, setGeneralSectionOrder] = useState<string[]>(() => readStoredGeneralSectionOrder());
  const [activeGeneralSectionMoveControlsKey, setActiveGeneralSectionMoveControlsKey] = useState<string | null>(null);
  const [isConversationContentLoading, setIsConversationContentLoading] = useState(true);
  const [conversationLoadingLabel, setConversationLoadingLabel] = useState('대화 내용을 불러오는 중입니다.');
  const [conversationRoomReloadKey, setConversationRoomReloadKey] = useState(0);
  const [isDeferringAuxiliaryChatRequests, setIsDeferringAuxiliaryChatRequests] = useState(false);
  const [hasOlderMessages, setHasOlderMessages] = useState(false);
  const [oldestLoadedMessageId, setOldestLoadedMessageId] = useState<number | null>(null);
  const [isLoadingOlderMessages, setIsLoadingOlderMessages] = useState(false);
  const roomBootstrapRequestedRef = useRef(false);
  const [isMaximized, setIsMaximized] = useState(false);
  const [isResourceStripOpen, setIsResourceStripOpen] = useState(false);
  const [isTitleClusterOpen, setIsTitleClusterOpen] = useState(false);
  const [isPreviewFindOpen, setIsPreviewFindOpen] = useState(false);
  const [previewFindQuery, setPreviewFindQuery] = useState('');
  const [notificationToggleSessionId, setNotificationToggleSessionId] = useState<string | null>(null);
  const [queuedImportedDraft, setQueuedImportedDraft] = useState('');
  const [pendingImportedDraftRequest, setPendingImportedDraftRequest] = useState<ImportedCodexDraftRequest | null>(null);
  const [isSendWithoutContextEnabled, setIsSendWithoutContextEnabled] = useState(false);
  const [immediateSendPinnedBySession, setImmediateSendPinnedBySession] = useState<Record<string, boolean>>(() =>
    readStoredImmediateSendPinnedBySession(),
  );
  const [messageApi, messageContextHolder] = message.useMessage();
  const [pendingContextConfirm, setPendingContextConfirm] = useState<PendingContextConfirm | null>(null);
  const [skipContextConfirmForToday, setSkipContextConfirmForToday] = useState(false);
  const [pendingClearConversationSessionId, setPendingClearConversationSessionId] = useState<string | null>(null);
  const [isMarkingAllConversationsRead, setIsMarkingAllConversationsRead] = useState(false);
  const [isDeletingAllConversations, setIsDeletingAllConversations] = useState(false);
  const [isDeleteAllConversationsModalOpen, setIsDeleteAllConversationsModalOpen] = useState(false);
  const [isRoomsListDrawerOpen, setIsRoomsListDrawerOpen] = useState(false);
  const [lastReadResponseMessageIdBySession, setLastReadResponseMessageIdBySession] = useState<Record<string, number>>({});
  const [preserveEmptyConversationSelection, setPreserveEmptyConversationSelection] = useState(false);
  const viewportRef = useRef<HTMLDivElement | null>(null);
  const composerRef = useRef<TextAreaRef | null>(null);
  const previewFindInputRef = useRef<InputRef | null>(null);
  const previewSearchRootRef = useRef<HTMLDivElement | null>(null);
  const previewSearchMatchesRef = useRef<PreviewTextMatch[]>([]);
  const previewSearchMatchIndexRef = useRef(-1);
  const previewSearchKeyRef = useRef('');
  const activeConversationResyncPromiseRef = useRef<Promise<void> | null>(null);
  const conversationDetailSyncTokenRef = useRef(0);
  const previousPreviewModalOpenRef = useRef(false);
  const [isHtmlPreviewMode, setIsHtmlPreviewMode] = useState(false);
  const titleClusterRef = useRef<HTMLDivElement | null>(null);
  const mobileActionGroupRef = useRef<HTMLDivElement | null>(null);
  const copyFeedbackTimerRef = useRef<number | null>(null);
  const generalSectionOrderRef = useRef<string[]>(generalSectionOrder);
  const pendingRequestsRef = useRef<PendingChatRequest[]>([]);
  const messagesRef = useRef<ChatMessage[]>(messages);
  const requestItemsRef = useRef<ChatConversationRequest[]>(requestItemsState);
  const activeSessionIdRef = useRef(activeSessionId);
  const loadOlderMessagesRef = useRef<() => void | Promise<void>>(() => {});
  const previousConnectionStateRef = useRef<'connecting' | 'connected' | 'disconnected'>('connecting');
  const shouldRestoreConversationAfterReconnectRef = useRef(false);
  const shouldForceStickToBottomOnNextLoadRef = useRef(false);
  const lastConversationForegroundResyncAtRef = useRef(0);
  const lastMarkedReadResponseMessageIdBySessionRef = useRef<Record<string, number>>({});
  const handledRequestedSessionIdRef = useRef('');
  const syncedSelectedChatTypeSessionIdRef = useRef<string | null>(null);
  const chatTypeSelectionIntentSessionIdRef = useRef<string | null>(null);
  const isClosingConversationRef = useRef(false);
  const pendingConversationCreationRef = useRef(new Map<string, Promise<boolean>>());
  const restoredDraftSessionIdRef = useRef<string | null>(null);
  const notifiedTerminalJobKeysRef = useRef<string[]>([]);
  const notifiedRestartRequirementKeysRef = useRef<string[]>([]);
  const notifiedChatPushKeysRef = useRef<string[]>([]);
  const appliedRequestMetadataBySessionRef = useRef<Record<string, string>>({});
  const lastConversationTraceSnapshotRef = useRef('');
  const requestItems = Array.isArray(requestItemsState) ? requestItemsState : [];
  const isCreatingImportedDraftConversationRef = useRef(false);
  const hasAttemptedInitialConversationRef = useRef(false);
  const setDraft = useCallback((value: SetStateAction<string>) => {
    const nextValue = typeof value === 'function' ? value(draftRef.current) : value;
    draftRef.current = nextValue;
    setDraftSeed((previous) => {
      if (previous.value === nextValue) {
        return previous;
      }

      return {
        value: nextValue,
        version: previous.version + 1,
      };
    });
  }, []);
  const setDraftValue = useCallback((value: string) => {
    setDraft(value);
  }, [setDraft]);
  const setRequestItems = useCallback((next: SetStateAction<ChatConversationRequest[]>) => {
    setRequestItemsState((previous) => {
      const safePrevious = Array.isArray(previous) ? previous : [];
      const resolved =
        typeof next === 'function'
          ? (next as (items: ChatConversationRequest[]) => ChatConversationRequest[])(safePrevious)
          : next;

      return Array.isArray(resolved) ? resolved : [];
    });
  }, []);
  const resolveConversationRoomScope = useCallback((item: Pick<ChatConversationSummary, 'sessionId' | 'roomScope'> | null | undefined) => {
    if (!item) {
      return null;
    }

    return (
      readIsolatedChatRoomSessionScope(item.sessionId)
      ?? normalizeIsolatedChatRoomScope((item.roomScope ?? null) as Partial<IsolatedChatRoomScope> | null)
    );
  }, []);

  useEffect(() => {
    activeSessionIdRef.current = activeSessionId;
  }, [activeSessionId]);

  useEffect(() => {
    if (mode !== 'live' || location.pathname !== chatRoutePath) {
      return;
    }

    const pendingDraft = consumeCodexLiveDraft();
    if (!pendingDraft) {
      return;
    }

    setActiveView('chat');
    setQueuedImportedDraft(pendingDraft.text);
    setPendingImportedDraftRequest(
      pendingDraft.autoSend
        ? {
            text: pendingDraft.text,
            autoSend: true,
            sendMode: pendingDraft.sendMode === 'direct' ? 'direct' : 'queue',
          }
        : null,
    );
    messageApi.success(pendingDraft.autoSend ? '레이아웃 명세를 Codex Live로 전송합니다.' : '레이아웃 명세를 Codex Live 입력창에 채웠습니다.');
  }, [chatRoutePath, location.pathname, messageApi, mode]);

  useEffect(() => {
    if (!queuedImportedDraft.trim()) {
      return;
    }

    setDraftValue(draftRef.current.trim() ? draftRef.current : queuedImportedDraft);
    composerRef.current?.focus({ cursor: 'end' });
    setQueuedImportedDraft('');
  }, [activeSessionId, queuedImportedDraft, setDraftValue]);

  const {
    conversationItems,
    setConversationItems,
    isConversationListLoading,
    reloadConversationItems,
    conversationSearch,
    setConversationSearch,
  } = useConversationListController({
    requestedSessionId: requestedSessionId ?? '',
    enabled: activeView === 'chat',
  });
  const conversationItemsRef = useRef<ChatConversationSummary[]>(conversationItems);
  const activeConversation = useMemo(
    () => conversationItems.find((item) => item.sessionId === activeSessionId) ?? null,
    [activeSessionId, conversationItems],
  );
  const firstConversationForMode = useMemo(
    () => conversationItems.find((item) => shouldShowConversationForMode(item.sessionId, mode)) ?? null,
    [conversationItems, mode],
  );
  const effectiveActiveRoomScope = useMemo(
    () => (mode === 'rooms' ? resolveConversationRoomScope(activeConversation) ?? activeRoomScope ?? roomLaunchScope : null),
    [activeConversation, activeRoomScope, mode, resolveConversationRoomScope, roomLaunchScope],
  );
  useEffect(() => {
    const normalizedSessionId = activeSessionId.trim();

    if (!normalizedSessionId) {
      restoredDraftSessionIdRef.current = null;
      if (draftRef.current) {
        setDraft('');
      }
      return;
    }

    if (!activeConversation || activeConversation.sessionId !== normalizedSessionId) {
      return;
    }

    if (restoredDraftSessionIdRef.current === normalizedSessionId) {
      return;
    }

    restoredDraftSessionIdRef.current = normalizedSessionId;
    setDraft(normalizeConversationDraftText(activeConversation.draftText));
  }, [activeConversation, activeSessionId, setDraft]);
  useEffect(() => {
    const normalizedSessionId = activeSessionId.trim();

    if (!normalizedSessionId) {
      return;
    }

    const activeItem = conversationItemsRef.current.find((item) => item.sessionId === normalizedSessionId) ?? null;

    if (!activeItem) {
      return;
    }

    const nextDraftText = normalizeConversationDraftText(draftRef.current);
    const currentDraftText = normalizeConversationDraftText(activeItem.draftText);

    if (currentDraftText === nextDraftText) {
      return;
    }

    setConversationItems((previous) => updateConversationDraftText(previous, normalizedSessionId, nextDraftText));

    if (activeItem.isDraftOnly || typeof window === 'undefined') {
      return;
    }

    const timerId = window.setTimeout(() => {
      void chatGateway.updateConversation(normalizedSessionId, {
        draftText: nextDraftText,
      }).then((item) => {
        setConversationItems((previous) =>
          previous.map((entry) =>
            entry.sessionId === item.sessionId ? mergeConversationSummaryPreservingChatType(entry, item) : entry,
          ),
        );
      }).catch(() => {
        // Keep the local draft even if background draft sync fails.
      });
    }, 300);

    return () => {
      window.clearTimeout(timerId);
    };
  }, [activeSessionId, draftSeed.version, setConversationItems]);
  useEffect(() => {
    if (mode !== 'rooms' || !activeSessionId || !effectiveActiveRoomScope) {
      return;
    }

    writeActiveIsolatedChatRoomScope(effectiveActiveRoomScope);
    writeIsolatedChatRoomSessionScope(activeSessionId, effectiveActiveRoomScope);
  }, [activeSessionId, effectiveActiveRoomScope, mode]);
  useEffect(() => {
    setConversationItems((previous) => {
      const storedSectionNameMap = readStoredGeneralSectionNameMap();
      let didChange = false;
      const nextItems = previous.map((item) => {
        const storedGeneralSectionName = normalizeGeneralSectionName(storedSectionNameMap[item.sessionId]);
        const currentGeneralSectionName = normalizeGeneralSectionName(item.generalSectionName);

        if (!storedGeneralSectionName || storedGeneralSectionName === currentGeneralSectionName) {
          return item;
        }

        didChange = true;
        return {
          ...item,
          generalSectionName: storedGeneralSectionName,
        };
      });

      return didChange ? nextItems : previous;
    });
  }, [setConversationItems]);
  const {
    runtimeSnapshot,
    runtimeJobDetail,
    handleRuntimeEvent,
    handleRuntimeDetailEvent,
  } = useRuntimeController({
    activeSessionId,
    isDeferringAuxiliaryChatRequests,
  });
  const activeConversationRequestBadgeLabel = useMemo(
    () => (activeConversation ? buildConversationRequestBadgeLabel(activeConversation, runtimeSnapshot) : null),
    [activeConversation, runtimeSnapshot],
  );
  const hasRuntimeDashboardContent = useMemo(() => hasRuntimeSnapshotContent(runtimeSnapshot), [runtimeSnapshot]);
  const showRuntimeViewShortcut = hasRuntimeDashboardContent || Boolean(requestedRuntimeLogRequestId?.trim()) || activeView === 'runtime';
  useEffect(() => {
    if (!activeConversation) {
      lastConversationTraceSnapshotRef.current = '';
      return;
    }

    const snapshot = JSON.stringify({
      sessionId: activeConversation.sessionId,
      currentRequestId: activeConversation.currentRequestId ?? null,
      title: activeConversation.title,
      requestBadgeLabel: activeConversation.requestBadgeLabel ?? null,
      resolvedRequestBadgeLabel: activeConversationRequestBadgeLabel ?? null,
      currentJobStatus: activeConversation.currentJobStatus ?? null,
    });

    if (lastConversationTraceSnapshotRef.current === snapshot) {
      return;
    }

    lastConversationTraceSnapshotRef.current = snapshot;
    logConversationTrace('active-conversation-snapshot', JSON.parse(snapshot) as Record<string, unknown>);
  }, [activeConversation, activeConversationRequestBadgeLabel]);
  const handleToggleConversationNotification = async () => {
    if (!activeConversation) {
      return;
    }

    const sessionId = activeConversation.sessionId;
    const nextNotifyOffline = !activeConversation.notifyOffline;
    setNotificationToggleSessionId(sessionId);
    setConversationItems((previous) =>
      previous.map((entry) => (entry.sessionId === sessionId ? { ...entry, notifyOffline: nextNotifyOffline } : entry)),
    );

    try {
      const item = await chatGateway.updateConversation(sessionId, {
        notifyOffline: nextNotifyOffline,
      });
      setConversationItems((previous) => previous.map((entry) => (entry.sessionId === item.sessionId ? item : entry)));
      messageApi.success(nextNotifyOffline ? '채팅방 알림을 켰습니다.' : '채팅방 알림을 껐습니다.');
    } catch (error) {
      setConversationItems((previous) =>
        previous.map((entry) => (entry.sessionId === sessionId ? { ...entry, notifyOffline: !nextNotifyOffline } : entry)),
      );
      messageApi.error(error instanceof Error ? error.message : '채팅방 알림 설정 저장에 실패했습니다.');
    } finally {
      setNotificationToggleSessionId((current) => (current === sessionId ? null : current));
    }
  };
  const openConversationTitleEditor = (item: ChatConversationSummary) => {
    setEditingConversationTitleSessionId(item.sessionId);
    setEditingConversationTitle(item.title ?? '');
  };
  const handleSaveConversationTitle = async () => {
    const targetSessionId = editingConversationTitleSessionId;

    if (!targetSessionId) {
      return;
    }

    const nextTitle = editingConversationTitle.trim();
    const currentConversation = conversationItemsRef.current.find((item) => item.sessionId === targetSessionId) ?? null;

    if (!currentConversation) {
      setEditingConversationTitleSessionId(null);
      setEditingConversationTitle('');
      return;
    }

    const previousTitle = currentConversation.title.trim();

    if (!nextTitle) {
      messageApi.error('채팅방 이름을 입력해 주세요.');
      return;
    }

    if (previousTitle === nextTitle) {
      setEditingConversationTitleSessionId(null);
      setEditingConversationTitle('');
      return;
    }

    setSavingConversationTitleSessionId(targetSessionId);
    setConversationItems((previous) =>
      previous.map((entry) =>
        entry.sessionId === targetSessionId
          ? {
              ...entry,
              title: nextTitle,
            }
          : entry,
      ),
    );

    try {
      const item = await chatGateway.updateConversation(targetSessionId, {
        title: nextTitle,
      });
      setConversationItems((previous) =>
        previous.map((entry) =>
          entry.sessionId === item.sessionId ? mergeConversationSummaryPreservingChatType(entry, item) : entry,
        ),
      );
      setEditingConversationTitleSessionId(null);
      setEditingConversationTitle('');
      messageApi.success('채팅방 이름을 저장했습니다.');
    } catch (error) {
      setConversationItems((previous) =>
        previous.map((entry) =>
          entry.sessionId === targetSessionId
            ? {
                ...entry,
                title: previousTitle,
              }
            : entry,
        ),
      );
      messageApi.error(error instanceof Error ? error.message : '채팅방 이름 저장에 실패했습니다.');
    } finally {
      setSavingConversationTitleSessionId((current) => (current === targetSessionId ? null : current));
    }
  };
  const openGeneralSectionEditor = (item: ChatConversationSummary) => {
    setEditingGeneralSectionSessionId(item.sessionId);
    setEditingGeneralSectionName(normalizeGeneralSectionName(item.generalSectionName) ?? '');
  };
  const handleSaveGeneralSection = async () => {
    const targetSessionId = editingGeneralSectionSessionId;

    if (!targetSessionId) {
      return;
    }

    const nextGeneralSectionName = normalizeGeneralSectionName(editingGeneralSectionName);
    const currentConversation = conversationItemsRef.current.find((item) => item.sessionId === targetSessionId) ?? null;

    if (!currentConversation) {
      setEditingGeneralSectionSessionId(null);
      setEditingGeneralSectionName('');
      return;
    }

    const previousGeneralSectionName = normalizeGeneralSectionName(currentConversation.generalSectionName);

    if (previousGeneralSectionName === nextGeneralSectionName) {
      setEditingGeneralSectionSessionId(null);
      setEditingGeneralSectionName('');
      return;
    }

    setSavingGeneralSectionSessionId(targetSessionId);
    setConversationItems((previous) =>
      previous.map((entry) =>
        entry.sessionId === targetSessionId
          ? {
              ...entry,
              generalSectionName: nextGeneralSectionName,
            }
          : entry,
      ),
    );

    try {
      const item = await chatGateway.updateConversation(targetSessionId, {
        generalSectionName: nextGeneralSectionName,
      });
      writeStoredGeneralSectionName(targetSessionId, nextGeneralSectionName);
      setConversationItems((previous) =>
        previous.map((entry) =>
          entry.sessionId === item.sessionId
            ? {
                ...mergeConversationSummaryPreservingChatType(entry, item),
                generalSectionName: nextGeneralSectionName,
              }
            : entry,
        ),
      );
      setEditingGeneralSectionSessionId(null);
      setEditingGeneralSectionName('');
      messageApi.success(nextGeneralSectionName ? `일반 보관 섹션을 "${nextGeneralSectionName}"로 저장했습니다.` : '기본 일반 섹션으로 되돌렸습니다.');
    } catch (error) {
      writeStoredGeneralSectionName(targetSessionId, previousGeneralSectionName);
      setConversationItems((previous) =>
        previous.map((entry) =>
          entry.sessionId === targetSessionId
            ? {
                ...entry,
                generalSectionName: previousGeneralSectionName,
              }
            : entry,
        ),
      );
      messageApi.error(error instanceof Error ? error.message : '일반 보관 섹션 저장에 실패했습니다.');
    } finally {
      setSavingGeneralSectionSessionId((current) => (current === targetSessionId ? null : current));
    }
  };
  const handleCreateConversation = async ({
    chatTypeOverride = null,
    codexModelOverride = null,
    persist = true,
  }: CreateConversationOptions = {}) => {
    const sessionId = createConversationSessionId(mode);
    const now = new Date().toISOString();
    const nextConversationChatType =
      chatTypeOverride ?? (selectedChatType && isSelectedChatTypeAllowed ? selectedChatType : (availableChatTypes[0] ?? null));
    const nextConversationCodexModel = normalizeCodexModel(
      codexModelOverride ?? createConversationCodexModel ?? selectedCodexModel,
    );
    const nextConversationTitle = resolveConversationDefaultTitle(
      nextConversationChatType,
      currentPage.title,
      currentPage.topMenu,
      {
        mode,
        scope: effectiveActiveRoomScope ?? roomLaunchScope,
      },
    );
    const nextConversationContextDescription = [
      resolveComposedChatTypeDescription(nextConversationChatType),
      mode === 'rooms' ? buildIsolatedChatRoomContextSupplement(effectiveActiveRoomScope ?? roomLaunchScope) : '',
    ]
      .filter(Boolean)
      .join('\n\n');
    const optimisticItem = buildOptimisticConversationSummary({
      sessionId,
      title: nextConversationTitle,
      requestBadgeLabel:
        mode === 'rooms' ? buildIsolatedChatRoomRequestBadgeLabel(effectiveActiveRoomScope ?? roomLaunchScope) : null,
      codexModel: nextConversationCodexModel,
      chatType: nextConversationChatType,
      contextDescription: nextConversationContextDescription,
      createdAt: now,
      generalSectionName: mode === 'rooms' ? (effectiveActiveRoomScope ?? roomLaunchScope)?.menuTitle ?? null : null,
      draftText: '',
      roomScope: mode === 'rooms' ? (effectiveActiveRoomScope ?? roomLaunchScope) : null,
      isDraftOnly: !persist,
    });

    setConversationItems((previous) =>
      sortChatConversationSummaries([optimisticItem, ...previous.filter((entry) => entry.sessionId !== sessionId)]),
    );
    let resolvePendingConversationCreation!: (value: boolean) => void;
    const pendingConversationCreation = new Promise<boolean>((resolve) => {
      resolvePendingConversationCreation = resolve;
    });

    if (persist) {
      pendingConversationCreationRef.current.set(sessionId, pendingConversationCreation);
    }

    openConversationSession(sessionId);

    if (!persist) {
      return sessionId;
    }

    const createConversationPromise = (async () => {
      try {
        const item = await chatGateway.createConversation({
          sessionId,
          title: nextConversationTitle,
          draftText: '',
          requestBadgeLabel: optimisticItem.requestBadgeLabel,
          codexModel: nextConversationCodexModel,
          chatTypeId: nextConversationChatType?.id ?? null,
          lastChatTypeId: nextConversationChatType?.id ?? null,
          generalSectionName: mode === 'rooms' ? (effectiveActiveRoomScope ?? roomLaunchScope)?.menuTitle ?? null : null,
          contextLabel: nextConversationChatType?.name,
          contextDescription: nextConversationContextDescription,
          roomScope: mode === 'rooms' ? (effectiveActiveRoomScope ?? roomLaunchScope) : null,
          notifyOffline: true,
        });

        const nextScope = mode === 'rooms' ? (effectiveActiveRoomScope ?? roomLaunchScope) : null;

        if (nextScope) {
          writeIsolatedChatRoomSessionScope(item.sessionId, nextScope);
          writeActiveIsolatedChatRoomScope(nextScope);
        }

        const persistedItem = nextScope ? { ...item, roomScope: item.roomScope ?? nextScope } : item;

        setConversationItems((previous) =>
          sortChatConversationSummaries([persistedItem, ...previous.filter((entry) => entry.sessionId !== persistedItem.sessionId)]),
        );

        if (activeSessionIdRef.current === persistedItem.sessionId) {
          setConversationRoomReloadKey((previous) => previous + 1);
        }

        resolvePendingConversationCreation(true);
        return true;
      } catch (error) {
        if (mode === 'rooms') {
          roomBootstrapRequestedRef.current = false;
        }
        const currentUrlSessionId =
          typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('sessionId')?.trim() ?? '' : '';
        const shouldResetFailedSession = currentUrlSessionId === sessionId;

        sessionMessageCacheRef.current.delete(sessionId);
        setConversationItems((previous) => previous.filter((entry) => entry.sessionId !== sessionId));

        if (shouldResetFailedSession) {
          replaceChatSessionInUrl('');
          setActiveSessionId((current) => (current === sessionId ? '' : current));
          setMessages([]);
          setRequestItems([]);
          setIsConversationPaneClosed(false);
          setIsMobileConversationView(!isMobileViewport);
        }

        messageApi.error(error instanceof Error ? error.message : '새 대화를 만들지 못했습니다.');
        resolvePendingConversationCreation(false);
        return false;
      } finally {
        pendingConversationCreationRef.current.delete(sessionId);
      }
    })();

    const created = await createConversationPromise;

    if (!created) {
      return null;
    }

    return sessionId;
  };
  const ensureConversationSessionReady = useCallback(async (sessionId: string) => {
    const normalizedSessionId = sessionId.trim();

    if (!normalizedSessionId) {
      return false;
    }

    const pendingCreation = pendingConversationCreationRef.current.get(normalizedSessionId);

    if (!pendingCreation) {
      return true;
    }

    try {
      return await pendingCreation;
    } catch {
      return false;
    }
  }, []);
  const openCreateConversationModal = () => {
    if (availableChatTypes.length === 0) {
      messageApi.warning('사용 가능한 채팅유형이 없습니다.');
      return;
    }

    setCreateConversationChatTypeId((current) => current ?? selectedChatType?.id ?? availableChatTypes[0]?.id ?? null);
    setCreateConversationCodexModel(effectiveCodexModel);
    setIsCreateConversationModalOpen(true);
  };
  const handleConfirmCreateConversation = async () => {
    if (!selectedCreateConversationChatType) {
      messageApi.warning('채팅유형을 먼저 선택하세요.');
      return;
    }

    setIsCreateConversationModalOpen(false);
    setSelectedChatTypeId(selectedCreateConversationChatType.id);
    setSelectedCodexModel(createConversationCodexModel);
    await handleCreateConversation({
      chatTypeOverride: selectedCreateConversationChatType,
      codexModelOverride: createConversationCodexModel,
      persist: true,
    });
  };

  useEffect(() => {
    if (mode !== 'rooms') {
      roomBootstrapRequestedRef.current = false;
      return;
    }
    const hasReusableRoomConversation =
      roomsEntryMode === 'direct'
        ? (() => {
            const targetScope = effectiveActiveRoomScope ?? roomLaunchScope;

            if (!targetScope) {
              return false;
            }

            return conversationItems.some((item) => {
              if (!shouldShowConversationForMode(item.sessionId, mode)) {
                return false;
              }

              return doesIsolatedChatRoomScopeMatch(resolveConversationRoomScope(item), targetScope);
            });
          })()
        : conversationItems.some((item) => shouldShowConversationForMode(item.sessionId, mode));

    if (requestedSessionId || activeSessionId || hasReusableRoomConversation) {
      roomBootstrapRequestedRef.current = false;
      return;
    }

    const fallbackChatType = selectedChatType ?? availableChatTypes[0] ?? null;

    if (!fallbackChatType || roomBootstrapRequestedRef.current) {
      return;
    }

    roomBootstrapRequestedRef.current = true;
    void handleCreateConversation({
      chatTypeOverride: fallbackChatType,
      persist: true,
    });
    }, [
    effectiveActiveRoomScope,
    activeSessionId,
    availableChatTypes,
    conversationItems,
    mode,
    requestedSessionId,
    roomLaunchScope,
    roomsEntryMode,
    selectedChatType,
  ]);
  const openContextDrawer = () => {
    if (!activeConversation) {
      return;
    }

    setIsMobileActionGroupOpen(false);
    const nextChatTypeId = effectiveChatType?.id ?? activeConversation.chatTypeId?.trim() ?? activeConversation.lastChatTypeId?.trim() ?? null;
    const nextChatType =
      chatTypes.find((item) => item.id === nextChatTypeId) ??
      availableChatTypes.find((item) => item.id === nextChatTypeId) ??
      effectiveChatType ??
      null;
    setEditingRoomChatTypeId(nextChatTypeId);
    setEditingChatTypeDescription(nextChatType?.description ?? '');
    setEditingRoomDefaultContextIds(activeRoomContextSettings?.defaultContextIds ?? resolveChatTypeDefaultContextIds(chatTypeDefaults, nextChatTypeId));
    setIsEditingRoomDefaultContextsDirty(false);
    setEditingRoomCustomContextTitle(effectiveRoomCustomContextTitle);
    setEditingRoomCustomContextContent(effectiveRoomCustomContextContent);
    setEditingRoomCodexParticipants(
      normalizeEditableCodexParticipantsForDrawer(activeRoomContextSettings?.codexParticipants, {
        fallbackModel: activeConversation.codexModel ?? effectiveCodexModel,
        fallbackChatTypeId: nextChatTypeId,
      }),
    );
    setExpandedParticipantDefaultContextIds([]);
    setContextDrawerTabKey('chat-type');
    setIsContextDrawerOpen(true);
  };
  const handleSaveContextDrawer = async () => {
    if (!activeConversation) {
      setIsContextDrawerOpen(false);
      return;
    }

    let nextChatType =
      chatTypes.find((item) => item.id === editingRoomChatTypeId) ??
      effectiveRegisteredChatType ??
      null;

    if (!nextChatType) {
      messageApi.warning('채팅유형을 먼저 선택하세요.');
      return;
    }

    try {
      const resolvedChatType = nextChatType;
      const normalizedDefaultContextIds = Array.from(
        new Set(
          editingRoomDefaultContextIds
            .map((value) => value.trim())
            .filter((value) => enabledDefaultContexts.some((context) => context.id === value)),
        ),
      );
      const nextCustomContextTitle = editingRoomCustomContextTitle.trim();
      const nextCustomContextContent = editingRoomCustomContextContent.trim();
      const nextCodexParticipants = normalizeEditableCodexParticipantsForDrawer(editingRoomCodexParticipants, {
        fallbackModel: activeConversation.codexModel ?? effectiveCodexModel,
        fallbackChatTypeId: resolvedChatType.id,
      });
      const inheritedDefaultContextIds = resolveChatTypeDefaultContextIds(chatTypeDefaults, resolvedChatType.id);
      const shouldPersistRoomDefaultContextIds = !areStringListsEqual(normalizedDefaultContextIds, inheritedDefaultContextIds);
      const shouldPersistRoomCustomContext = Boolean(nextCustomContextTitle || nextCustomContextContent);
      const shouldPersistRoomCodexParticipants = nextCodexParticipants.length > 0;

      const nextRoomContexts =
        shouldPersistRoomDefaultContextIds || shouldPersistRoomCustomContext || shouldPersistRoomCodexParticipants
          ? upsertChatRoomContextSettings(roomContexts, {
              sessionId: activeConversation.sessionId,
              defaultContextIds: normalizedDefaultContextIds,
              customContextTitle: nextCustomContextTitle,
              customContextContent: nextCustomContextContent,
              codexParticipants: nextCodexParticipants,
            })
          : roomContexts.filter((item) => item.sessionId !== activeConversation.sessionId);

      await setChatContextSettingsStore({
        defaultContexts,
        chatTypeDefaults,
        roomContexts: nextRoomContexts,
      });

      const nextPrimaryCodexModel = nextCodexParticipants[0]?.model ?? activeConversation.codexModel ?? effectiveCodexModel;
      const shouldSyncConversationSummary =
        (activeConversation.codexModel?.trim() || null) !== (nextPrimaryCodexModel?.trim() || null) ||
        (activeConversation.chatTypeId?.trim() || activeConversation.lastChatTypeId?.trim() || null) !== resolvedChatType.id ||
        (activeConversation.contextLabel?.trim() || '') !== resolvedChatType.name ||
        activeConversation.contextDescription !== null;

      setConversationItems((previous) =>
        previous.map((entry) =>
          entry.sessionId === activeConversation.sessionId
            ? {
                ...entry,
                codexModel: nextPrimaryCodexModel,
                chatTypeId: resolvedChatType.id,
                lastChatTypeId: resolvedChatType.id,
                contextLabel: resolvedChatType.name,
                contextDescription: null,
              }
            : entry,
        ),
      );

      if (shouldSyncConversationSummary) {
        try {
          const item = await chatGateway.updateConversation(activeConversation.sessionId, {
            codexModel: nextPrimaryCodexModel,
            chatTypeId: resolvedChatType.id,
            lastChatTypeId: resolvedChatType.id,
            contextLabel: resolvedChatType.name,
            contextDescription: null,
          });
          setConversationItems((previous) => previous.map((entry) => (entry.sessionId === item.sessionId ? item : entry)));
        } catch (error) {
          messageApi.warning(
            error instanceof Error
              ? `Codex 참가자 설정은 저장됐지만 대화방 요약 동기화는 실패했습니다. ${error.message}`
              : 'Codex 참가자 설정은 저장됐지만 대화방 요약 동기화는 실패했습니다.',
          );
        }
      }

      setStoredChatSessionLastTypeId(activeConversation.sessionId, resolvedChatType.id);
      setIsEditingRoomDefaultContextsDirty(false);
      setIsContextDrawerOpen(false);
    } catch (error) {
      messageApi.error(error instanceof Error ? error.message : '채팅방 Context 저장에 실패했습니다.');
    }
  };
  const upsertRequestItem = (request: ChatConversationRequest) => {
    setRequestItems((previous) => {
      const existingIndex = previous.findIndex(
        (item) => item.sessionId === request.sessionId && item.requestId === request.requestId,
      );

      if (existingIndex < 0) {
        return [...previous, request];
      }

      const nextItems = [...previous];
      nextItems[existingIndex] = mergeConversationRequestPreservingContent(nextItems[existingIndex], request);
      return nextItems;
    });
  };
  const applyConversationMetadataForRequest = useCallback(
    (
      sessionId: string,
      request: Pick<ChatConversationRequest, 'requestId' | 'userText'> & {
        responseText?: string | null;
      },
    ) => {
      const normalizedSessionId = sessionId.trim();
      const normalizedRequestId = request.requestId.trim();
      const requestText = request.userText.trim();

      if (!normalizedSessionId || !normalizedRequestId || !requestText) {
        logConversationTrace('metadata-skip-invalid-request', {
          sessionId: normalizedSessionId,
          requestId: normalizedRequestId,
          hasRequestText: Boolean(requestText),
        });
        return;
      }

      if (appliedRequestMetadataBySessionRef.current[normalizedSessionId] === normalizedRequestId) {
        logConversationTrace('metadata-skip-duplicate-request', {
          sessionId: normalizedSessionId,
          requestId: normalizedRequestId,
        });
        return;
      }

      const currentItem =
        conversationItemsRef.current.find((item) => item.sessionId === normalizedSessionId) ?? null;

      if (!currentItem) {
        logConversationTrace('metadata-skip-missing-conversation', {
          sessionId: normalizedSessionId,
          requestId: normalizedRequestId,
        });
        return;
      }

      const responsePreviewForRequest = request.responseText?.trim()
        ? createConversationPreviewText(request.responseText)
        : '';
      const nextResponsePreview = responsePreviewForRequest || currentItem.lastResponsePreview;
      const nextRequestPreview = createConversationPreviewText(requestText) || currentItem.lastRequestPreview;
      const nextRequestBadgeLabel =
        resolveConversationRequestBadgeLabelForUserText({
          requestText,
          currentMenuLabel: resolveCurrentMenuRequestLabel(currentPage.title, currentPage.topMenu),
          title: currentItem.title,
          contextLabel: currentItem.contextLabel,
          contextDescription: currentItem.contextDescription,
        }) ??
        buildConversationRequestBadgeLabel(
          {
            ...currentItem,
            requestBadgeLabel: null,
            currentJobStatus: null,
            lastRequestPreview: nextRequestPreview,
            lastResponsePreview: responsePreviewForRequest,
          },
          null,
        ) ??
        null;

      logConversationTrace('metadata-apply', {
        sessionId: normalizedSessionId,
        requestId: normalizedRequestId,
        requestPreview: createConversationPreviewText(requestText),
        previousTitle: currentItem.title,
        nextTitle: currentItem.title,
        previousBadge: currentItem.requestBadgeLabel ?? null,
        nextBadge: nextRequestBadgeLabel,
        responsePreview: responsePreviewForRequest || null,
      });

      appliedRequestMetadataBySessionRef.current[normalizedSessionId] = normalizedRequestId;

      setConversationItems((previous) =>
        sortChatConversationSummaries(
          previous.map((item) =>
            item.sessionId === normalizedSessionId
              ? {
                  ...item,
                  requestBadgeLabel: nextRequestBadgeLabel,
                  lastRequestPreview: nextRequestPreview,
                  lastResponsePreview: nextResponsePreview,
                }
              : item,
          ),
        ),
      );

      if ((currentItem.requestBadgeLabel ?? null) === nextRequestBadgeLabel) {
        return;
      }

      void chatGateway
        .updateConversation(normalizedSessionId, {
          requestBadgeLabel: nextRequestBadgeLabel,
        })
        .then((item) => {
          logConversationTrace('metadata-persisted', {
            sessionId: normalizedSessionId,
            requestId: normalizedRequestId,
            title: item.title,
            requestBadgeLabel: item.requestBadgeLabel ?? null,
          });
          return item;
        })
        .then((item) => {
          setConversationItems((previous) =>
            previous.map((entry) =>
              entry.sessionId === item.sessionId ? mergeConversationSummaryPreservingChatType(entry, item) : entry,
            ),
          );
        })
        .catch(() => {
          logConversationTrace('metadata-persist-failed', {
            sessionId: normalizedSessionId,
            requestId: normalizedRequestId,
          });
          delete appliedRequestMetadataBySessionRef.current[normalizedSessionId];
        });
    },
    [currentPage.title, currentPage.topMenu, setConversationItems],
  );
  const syncConversationPreviewForRequest = (
    sessionId: string,
    text: string,
    requestedAt = new Date().toISOString(),
    options?: {
      requestId?: string;
      mode?: 'queue' | 'direct';
      queueSize?: number;
      jobMessage?: string | null;
    },
  ) => {
    const nextPreview = createConversationPreviewText(text);
    const normalizedSessionId = sessionId.trim();
    const normalizedRequestId = options?.requestId?.trim() || '';
    const normalizedRequestText = text.trim();

    if (!nextPreview) {
      return;
    }

    setConversationItems((previous) =>
      sortChatConversationSummaries(
        previous.map((item) =>
          item.sessionId === sessionId
            ? {
                ...item,
                requestBadgeLabel: item.requestBadgeLabel ?? buildConversationRequestBadgeLabel(item, null) ?? null,
                lastRequestPreview: nextPreview,
                lastMessagePreview: nextPreview,
                lastMessageAt: requestedAt,
                updatedAt: requestedAt,
                currentRequestId: options?.requestId?.trim() || item.currentRequestId,
                currentJobStatus:
                  options?.mode === 'queue'
                    ? 'queued'
                    : options?.mode === 'direct'
                      ? 'started'
                      : item.currentJobStatus,
                currentJobMessage:
                  options?.jobMessage?.trim() ||
                  (options?.mode === 'queue'
                    ? '대기열 등록 중'
                    : options?.mode === 'direct'
                      ? '즉시 요청 실행 대기 중'
                      : item.currentJobMessage),
                currentQueueSize:
                  options?.mode === 'queue' ? Math.max(1, Number(options?.queueSize ?? 1)) : item.currentQueueSize,
                currentStatusUpdatedAt:
                  options?.mode === 'queue' || options?.mode === 'direct'
                    ? requestedAt
                    : item.currentStatusUpdatedAt,
              }
            : item,
        ),
      ),
    );

    if (normalizedSessionId && normalizedRequestId && normalizedRequestText) {
      applyConversationMetadataForRequest(normalizedSessionId, {
        requestId: normalizedRequestId,
        userText: normalizedRequestText,
        responseText: '',
      });
    }
  };
  const syncConversationDetailIntoState = useCallback(
    (sessionId: string, detail: ChatConversationDetailResponse) => {
      setConversationItems((previous) => {
        const previousItem = previous.find((item) => item.sessionId === sessionId) ?? null;
        const mergedMessages = detail.messages;
        const fallbackPreview =
          detail.item.lastMessagePreview.trim() ||
          createConversationPreviewText(getLatestConversationPreviewMessage(mergedMessages)?.text ?? '') ||
          '';
        const fallbackResponsePreview =
          detail.item.lastResponsePreview.trim() ||
          createConversationPreviewText(
            [...mergedMessages]
              .reverse()
              .find((message) => message.author === 'codex' && !isPreparingChatReplyText(message.text))
              ?.text ?? '',
          ) ||
          '';
        const mergedDetailItem = mergeConversationSummaryPreservingChatType(previousItem, {
          ...detail.item,
          lastMessagePreview: fallbackPreview,
          lastResponsePreview: fallbackResponsePreview,
        });
        const exists = previousItem != null;

        if (!exists) {
          return sortChatConversationSummaries([mergedDetailItem, ...previous]);
        }

        return sortChatConversationSummaries(
          previous.map((item) => (item.sessionId === sessionId ? mergedDetailItem : item)),
        );
      });

      sessionMessageCacheRef.current.set(sessionId, detail.messages);

      setRequestItems((previous) => {
        const preserved = previous.filter((item) => item.sessionId !== sessionId);
        return [...preserved, ...mergeConversationRequestsPreservingContent(previous, detail.requests, sessionId)];
      });

      const latestRequestForMetadata = getLatestConversationRequestForMetadata(detail.requests);
      if (latestRequestForMetadata) {
        logConversationTrace('detail-selected-metadata-request', {
          sessionId,
          requestId: latestRequestForMetadata.requestId,
          createdAt: latestRequestForMetadata.createdAt,
          updatedAt: latestRequestForMetadata.updatedAt,
          answeredAt: latestRequestForMetadata.answeredAt,
          terminalAt: latestRequestForMetadata.terminalAt,
          requestPreview: createConversationPreviewText(latestRequestForMetadata.userText),
        });
        applyConversationMetadataForRequest(sessionId, latestRequestForMetadata);
      }

      if (sessionId === activeSessionId) {
        setMessages(detail.messages);
        setHasOlderMessages(detail.hasOlderMessages);
        setOldestLoadedMessageId(detail.oldestLoadedMessageId);
      }
    },
    [
      activeSessionId,
      applyConversationMetadataForRequest,
      setConversationItems,
      setHasOlderMessages,
      setMessages,
      setOldestLoadedMessageId,
      setRequestItems,
    ],
  );

  const syncConversationFromServer = useCallback(
    async (sessionId: string) => {
      const normalizedSessionId = sessionId.trim();

      if (!normalizedSessionId) {
        return;
      }

      const syncToken = ++conversationDetailSyncTokenRef.current;
      const visibleMessages =
        normalizedSessionId === activeSessionId
          ? messagesRef.current
          : getCachedSessionMessages(sessionMessageCacheRef.current, normalizedSessionId);
      const visibleRequestCount = countVisibleConversationRequests(
        visibleMessages,
        requestItemsRef.current,
        normalizedSessionId,
      );
      const detailLimit = Math.min(
        60,
        Math.max(CHAT_CONVERSATION_DETAIL_PAGE_SIZE, visibleRequestCount),
      );

      try {
        const detail = await chatGateway.getConversationDetail(normalizedSessionId, {
          limit: detailLimit,
        });

        if (syncToken !== conversationDetailSyncTokenRef.current) {
          return;
        }

        syncConversationDetailIntoState(normalizedSessionId, detail);
      } catch {
        // Ignore background resync failures and keep the current state.
      }
    },
    [activeSessionId, syncConversationDetailIntoState],
  );
  const resyncActiveConversationDetail = useCallback(async () => {
    const normalizedSessionId = activeSessionId.trim();

    if (!normalizedSessionId) {
      return;
    }

    if (activeConversationResyncPromiseRef.current) {
      return activeConversationResyncPromiseRef.current;
    }

    const requestPromise = syncConversationFromServer(normalizedSessionId).finally(() => {
      activeConversationResyncPromiseRef.current = null;
    });

    activeConversationResyncPromiseRef.current = requestPromise;
    return requestPromise;
  }, [activeSessionId, syncConversationFromServer]);
  const resyncConversationEntryState = useCallback(() => {
    const now = Date.now();

    if (now - lastConversationForegroundResyncAtRef.current < 600) {
      return;
    }

    lastConversationForegroundResyncAtRef.current = now;
    void reloadConversationItems();

    if (activeSessionId.trim()) {
      void resyncActiveConversationDetail();
    }
  }, [activeSessionId, reloadConversationItems, resyncActiveConversationDetail]);

  const handleRefreshActiveConversation = useCallback(() => {
    void reloadConversationItems();

    if (activeSessionId.trim()) {
      void resyncActiveConversationDetail();
    }
  }, [activeSessionId, reloadConversationItems, resyncActiveConversationDetail]);

  const handleJobEvent = (event: ChatJobEvent, eventSessionId = activeSessionId) => {
    const sessionId = eventSessionId.trim() || activeSessionId;
    const existingRequest =
      requestItemsRef.current.find((item) => item.sessionId === sessionId && item.requestId === event.requestId) ?? null;

    if (event.status === 'completed') {
      pendingRequestsRef.current = pendingRequestsRef.current.filter((request) => request.requestId !== event.requestId);
    } else if (event.status === 'failed') {
      updatePendingMessageStatus(event.requestId, 'failed', 0);
    }

    setConversationItems((previous) =>
      previous.map((item) =>
        item.sessionId === sessionId
          ? {
              ...item,
              currentRequestId: event.requestId,
              currentJobStatus: event.status,
              currentJobMessage: event.message,
              currentQueueSize: event.status === 'queued' ? event.queueSize : 0,
              currentStatusUpdatedAt: new Date().toISOString(),
            }
          : item,
      ),
    );

    upsertRequestItem({
      sessionId,
      requestId: event.requestId,
      requestOrigin: existingRequest?.requestOrigin ?? null,
      parentRequestId: existingRequest?.parentRequestId ?? null,
      status: event.status,
      statusMessage: event.message,
      userMessageId: existingRequest?.userMessageId ?? null,
      userText: existingRequest?.userText ?? '',
      responseMessageId: existingRequest?.responseMessageId ?? null,
      responseText: existingRequest?.responseText ?? '',
      hasResponse: existingRequest?.hasResponse ?? false,
      canDelete: event.status !== 'queued' && event.status !== 'started' && !(existingRequest?.hasResponse),
      createdAt: existingRequest?.createdAt ?? new Date().toISOString(),
      updatedAt: new Date().toISOString(),
      answeredAt: existingRequest?.answeredAt ?? null,
      terminalAt: event.status === 'completed' || event.status === 'failed' ? new Date().toISOString() : null,
    });

    if (event.status !== 'completed' && event.status !== 'failed') {
      return;
    }

    if (sessionId === activeSessionId) {
      shouldStickToBottomRef.current = false;
      window.requestAnimationFrame(() => {
        handleViewportScroll();
      });
    }

    const isDocumentHidden = typeof document !== 'undefined' && document.visibilityState === 'hidden';

    const notificationKey = `${sessionId}:${event.requestId}:${event.status}`;

    if (notifiedTerminalJobKeysRef.current.includes(notificationKey)) {
      return;
    }

    notifiedTerminalJobKeysRef.current = [...notifiedTerminalJobKeysRef.current, notificationKey].slice(-80);

    const conversationTitle =
      activeConversation?.sessionId === sessionId
        ? activeConversation.title
        : conversationItemsRef.current.find((item) => item.sessionId === sessionId)?.title || '현재 채팅방';
    const toastContent =
      event.status === 'completed'
        ? `${conversationTitle} 응답이 완료되었습니다.`
        : `${conversationTitle} 요청 처리 중 오류가 발생했습니다.`;

    if (!isDocumentHidden) {
      messageApi.open({
        key: notificationKey,
        type: event.status === 'completed' ? 'success' : 'error',
        content: toastContent,
        duration: event.status === 'completed' ? 3 : 4,
      });
    }

    void syncConversationFromServer(sessionId);
  };
  const handleIncomingMessageEvent = (incomingMessage: ChatMessage, eventSessionId = activeSessionId) => {
    const sessionId = eventSessionId.trim() || activeSessionId;
    let relatedQuestionText = '';
    let requestOwnerClientId = '';

    if (incomingMessage.clientRequestId) {
      const existing =
        requestItemsRef.current.find(
          (item) => item.sessionId === sessionId && item.requestId === incomingMessage.clientRequestId,
        ) ?? null;
      relatedQuestionText = incomingMessage.author === 'codex' ? existing?.userText ?? '' : '';
      requestOwnerClientId =
        existing?.requesterClientId?.trim() ||
        (incomingMessage.author === 'user' ? getOrCreateClientId().trim() : '');
      const hasMeaningfulResponse =
        incomingMessage.author === 'codex' && !isPreparingChatReplyText(incomingMessage.text);

      upsertRequestItem({
        sessionId,
        requestId: incomingMessage.clientRequestId,
        requesterClientId: requestOwnerClientId || existing?.requesterClientId || null,
        requestOrigin: existing?.requestOrigin ?? null,
        parentRequestId: existing?.parentRequestId ?? null,
        status:
          incomingMessage.author === 'codex'
            ? existing?.status === 'completed'
              ? 'completed'
              : 'started'
            : existing?.status ?? 'accepted',
        statusMessage: existing?.statusMessage ?? null,
        userMessageId:
          incomingMessage.author === 'user'
            ? incomingMessage.id
            : existing?.userMessageId ?? null,
        userText:
          incomingMessage.author === 'user'
            ? incomingMessage.text
            : existing?.userText ?? '',
        responseMessageId:
          incomingMessage.author === 'codex'
            ? incomingMessage.id
            : existing?.responseMessageId ?? null,
        responseText:
          incomingMessage.author === 'codex'
            ? incomingMessage.text
            : existing?.responseText ?? '',
        hasResponse: hasMeaningfulResponse || existing?.hasResponse === true,
        canDelete:
          incomingMessage.author !== 'codex' &&
          !(existing?.hasResponse === true) &&
          isRequestDeletionAllowed(existing?.status),
        createdAt: existing?.createdAt ?? new Date().toISOString(),
        updatedAt: new Date().toISOString(),
        answeredAt:
          incomingMessage.author === 'codex'
            ? existing?.answeredAt ?? new Date().toISOString()
            : existing?.answeredAt ?? null,
        terminalAt: existing?.terminalAt ?? null,
      });
    }

    sessionMessageCacheRef.current.set(
      sessionId,
      upsertChatMessage(getCachedSessionMessages(sessionMessageCacheRef.current, sessionId), incomingMessage),
    );

    const hasMeaningfulCodexResponse = isMeaningfulCodexResponseMessage(incomingMessage);
    const hasPendingAttentionResponse = hasConversationAttentionResponseTarget(incomingMessage);
    const isForegroundSession = isActiveChatSessionInForeground({
      sessionId,
      activeSessionId,
      activeView,
      isConversationPaneClosed,
      isMobileViewport,
      isMobileConversationView,
    });
    const responseTimestamp = new Date().toISOString();

    setConversationItems((previous) =>
      sortChatConversationSummaries(
        previous.map((item) =>
          item.sessionId === sessionId
            ? {
                ...item,
                lastMessagePreview: createConversationPreviewText(incomingMessage.text),
                lastResponsePreview:
                  incomingMessage.author === 'codex' && !isPreparingChatReplyText(incomingMessage.text)
                    ? createConversationPreviewText(incomingMessage.text)
                    : item.lastResponsePreview,
                lastMessageAt: responseTimestamp,
                updatedAt: responseTimestamp,
                hasUnreadResponse:
                  hasMeaningfulCodexResponse && !isForegroundSession ? true : item.hasUnreadResponse,
                hasPendingAttention:
                  hasPendingAttentionResponse && incomingMessage.author === 'codex' ? true : item.hasPendingAttention,
              }
            : item,
        ),
      ),
    );

    const eventConversation = conversationItemsRef.current.find((item) => item.sessionId === sessionId) ?? null;

    if (
      appConfig.chat.receiveRoomNotifications &&
      incomingMessage.author === 'codex' &&
      hasMeaningfulCodexResponse &&
      isRestartRequiredResponseText(incomingMessage.text)
    ) {
      const restartNotificationKey = `${sessionId}:${incomingMessage.clientRequestId ?? incomingMessage.id}:restart-required`;

      if (!notifiedRestartRequirementKeysRef.current.includes(restartNotificationKey)) {
        notifiedRestartRequirementKeysRef.current = [
          ...notifiedRestartRequirementKeysRef.current,
          restartNotificationKey,
        ].slice(-80);

        const conversationTitle = eventConversation?.title?.trim() || '현재 채팅방';
        const answerPreview = createConversationPreviewText(incomingMessage.text);

        void createNotificationMessage({
          title: '앱 재기동 필요',
          body: `${conversationTitle}에서 Codex가 앱 재기동 필요를 안내했습니다.\n답변: ${answerPreview}`,
          category: 'chat',
          source: 'codex-live',
          priority: 'high',
          metadata: {
            sessionId,
            requestId: incomingMessage.clientRequestId ?? '',
            conversationTitle,
            questionText: relatedQuestionText,
            answerText: incomingMessage.text,
            previewText: `재기동 필요 · ${conversationTitle}`,
            needsRestart: true,
            restartRequired: true,
            linkUrl: buildChatSessionLink(sessionId),
            linkLabel: '채팅 바로 열기',
          },
        }).catch(() => undefined);
      }
    }

    if (
      !appConfig.chat.receiveRoomNotifications ||
      incomingMessage.author !== 'codex' ||
      eventConversation?.notifyOffline !== true
    ) {
      return;
    }

    if (
      isActiveChatSessionInForeground({
        sessionId,
        activeSessionId,
        activeView,
        isConversationPaneClosed,
        isMobileViewport,
        isMobileConversationView,
      })
    ) {
      return;
    }

    if (shouldSuppressChatNotificationWhenAppOpen()) {
      return;
    }

    const chatNotificationKey = `${sessionId}:${incomingMessage.clientRequestId ?? incomingMessage.id}:codex-response`;

    if (notifiedChatPushKeysRef.current.includes(chatNotificationKey)) {
      return;
    }

    notifiedChatPushKeysRef.current = [...notifiedChatPushKeysRef.current, chatNotificationKey].slice(-80);

    const conversationTitle = eventConversation?.title?.trim() || '현재 채팅방';
    const targetUrl = buildChatSessionTargetUrl(sessionId);
    const notificationTitle = `${conversationTitle} 새 답변`;
    const notificationBody = createChatQuestionAnswerNotificationBody({
      questionText: relatedQuestionText,
      answerText: incomingMessage.text,
      fallback: `${conversationTitle}에 새 답변이 도착했습니다.`,
    });
    const notificationData = {
      category: 'chat',
      priority: 'normal',
      suppressIfVisible: 'true',
      sessionId,
      conversationTitle,
      requestId: incomingMessage.clientRequestId ?? '',
      questionText: relatedQuestionText,
      answerText: incomingMessage.text,
      targetUrl,
      linkUrl: targetUrl,
      linkLabel: '채팅 바로 열기',
    };
    const serializedNotificationData = Object.fromEntries(
      Object.entries(notificationData).flatMap(([key, value]) => (value ? [[key, String(value)]] : [])),
    );
    const currentClientId = getOrCreateClientId().trim();
    const notificationDeviceId = getSavedNotificationDeviceId().trim();

    if (!requestOwnerClientId || !currentClientId || requestOwnerClientId !== currentClientId) {
      return;
    }

    void Promise.allSettled([
      createNotificationMessage({
        title: notificationTitle,
        body: notificationBody,
        category: 'chat',
        source: 'codex-live',
        priority: 'normal',
        metadata: {
          ...notificationData,
          previewText: `새 답변 · ${conversationTitle}`,
        },
      }),
      sendClientNotification({
        title: notificationTitle,
        body: notificationBody,
        threadId: `chat:${sessionId}`,
        data: serializedNotificationData,
        targetDeviceIds: notificationDeviceId ? [notificationDeviceId] : undefined,
        targetAppOrigins: typeof window !== 'undefined' ? [window.location.origin] : undefined,
      }),
    ]).then(async ([storedResult, pushResult]) => {
      if (pushResult.status === 'rejected') {
        await showLocalChatNotification({
          title: notificationTitle,
          body: notificationBody,
          threadId: `chat:${sessionId}`,
          data: serializedNotificationData,
        });
      } else if (shouldFallbackToLocalNotification(pushResult.value)) {
        await showLocalChatNotification({
          title: notificationTitle,
          body: notificationBody,
          threadId: `chat:${sessionId}`,
          data: serializedNotificationData,
        });
      }

      if (storedResult.status === 'rejected' && pushResult.status === 'rejected') {
        notifiedChatPushKeysRef.current = notifiedChatPushKeysRef.current.filter((key) => key !== chatNotificationKey);
      }
    });
  };
  const previewItems = useMemo(
    () => extractPreviewItems(messages.filter((message) => !isActivityLogMessage(message) && !isMissingRequestMessage(message))),
    [messages],
  );
  const isTabletAppLayout = isMobileViewport;
  const [isIpadLikeViewport, setIsIpadLikeViewport] = useState(() => isIpadLikeChatViewport());
  const chatMessages = useMemo(
    () => messages.filter((message) => message.author !== 'system' || isActivityLogMessage(message) || isMissingRequestMessage(message)),
    [messages],
  );
  const chatMessageSyncKey = useMemo(() => buildMessageSyncKey(chatMessages), [chatMessages]);
  const activeConversationHasLocalActivity =
    chatMessages.length > 0 || requestItems.some((item) => item.sessionId === activeSessionId);
  const isImmediateSendPinned = activeSessionId.trim() ? immediateSendPinnedBySession[activeSessionId] === true : false;
  const persistedActiveChatTypeId =
    activeConversation?.chatTypeId?.trim() || activeConversation?.lastChatTypeId?.trim() || null;
  const effectiveChatType = useMemo(() => {
    if (persistedActiveChatTypeId) {
      const persistedChatType = chatTypes.find((item) => item.id === persistedActiveChatTypeId);
      if (persistedChatType) {
        return persistedChatType;
      }
    }

    return selectedChatType ?? availableChatTypes[0] ?? null;
  }, [
    chatTypes,
    persistedActiveChatTypeId,
    selectedChatType,
    availableChatTypes,
  ]);
  const effectiveChatTypeId = effectiveChatType?.id ?? null;
  const effectiveRegisteredChatType =
    effectiveChatType ? chatTypes.find((item) => item.id === effectiveChatType.id) ?? null : null;
  const isEffectiveChatTypeAllowed = effectiveRegisteredChatType
    ? canUseChatType(effectiveRegisteredChatType, userRoles)
    : false;
  const enabledDefaultContexts = useMemo(
    () => defaultContexts.filter((context) => context.enabled),
    [defaultContexts],
  );
  const activeRoomContextSettings = useMemo(
    () => resolveChatRoomContextSettings(roomContexts, activeSessionId),
    [activeSessionId, roomContexts],
  );
  const effectiveDefaultContextIds = useMemo(() => {
    if (activeRoomContextSettings) {
      return activeRoomContextSettings.defaultContextIds;
    }

    return resolveChatTypeDefaultContextIds(chatTypeDefaults, effectiveChatTypeId);
  }, [activeRoomContextSettings, chatTypeDefaults, effectiveChatTypeId]);
  const effectiveDefaultContexts = useMemo(
    () =>
      effectiveDefaultContextIds
        .map((contextId) => defaultContexts.find((context) => context.id === contextId && context.enabled))
        .filter((context): context is ChatDefaultContextRecord => Boolean(context)),
    [defaultContexts, effectiveDefaultContextIds],
  );
  const effectiveRoomCustomContextTitle = activeRoomContextSettings?.customContextTitle?.trim() ?? '';
  const effectiveRoomCustomContextContent = activeRoomContextSettings?.customContextContent?.trim() ?? '';
  const effectiveCodexParticipants = useMemo(
    () =>
      normalizeEditableCodexParticipants(
        activeRoomContextSettings?.codexParticipants,
        activeConversation?.codexModel ?? selectedCodexModel,
      ),
    [activeConversation?.codexModel, activeRoomContextSettings?.codexParticipants, selectedCodexModel],
  );
  const effectiveCodexModel = useMemo(
    () => normalizeCodexModel(effectiveCodexParticipants[0]?.model ?? activeConversation?.codexModel ?? selectedCodexModel),
    [activeConversation?.codexModel, effectiveCodexParticipants, selectedCodexModel],
  );
  const contextDrawerChatType =
    chatTypes.find((item) => item.id === editingRoomChatTypeId) ??
    availableChatTypes.find((item) => item.id === editingRoomChatTypeId) ??
    null;
  const effectiveChatTypeDescription = useMemo(
    () =>
      composeConversationContextDescription(
        effectiveChatType?.description ?? '',
        effectiveDefaultContexts,
        effectiveRoomCustomContextTitle,
        effectiveRoomCustomContextContent,
      ) ?? '',
    [
      effectiveChatType?.description,
      effectiveDefaultContexts,
      effectiveRoomCustomContextContent,
      effectiveRoomCustomContextTitle,
    ],
  );
  const isChatTypeSelectionLocked = true;
  const scopedPageTitle = mode === 'rooms' ? effectiveActiveRoomScope?.featureTitle || effectiveActiveRoomScope?.menuTitle || currentPage.title : currentPage.title;
  const scopedTopMenu = mode === 'rooms' ? effectiveActiveRoomScope?.topMenu || currentPage.topMenu : currentPage.topMenu;
  const scopedFocusedComponentId = mode === 'rooms' ? effectiveActiveRoomScope?.focusedComponentId ?? focusedComponentId : focusedComponentId;
  const composerAssistModalTitle =
    mode === 'rooms' ? effectiveActiveRoomScope?.menuTitle || roomLaunchScope?.menuTitle || '현재 메뉴' : '작업환경 구성';
  const scopedPageUrl =
    mode === 'rooms'
      ? effectiveActiveRoomScope?.pageUrl || (typeof window !== 'undefined' ? window.location.href : '')
      : (typeof window !== 'undefined' ? window.location.href : '');
  const currentContext = useMemo<ChatViewContext>(
    () => ({
      pageId: currentPage.id,
      pageTitle: scopedPageTitle,
      topMenu: scopedTopMenu,
      focusedComponentId: scopedFocusedComponentId,
      pageUrl: scopedPageUrl,
      isStandaloneMode: isStandaloneDisplayMode(),
      pageVisibilityState:
        typeof document !== 'undefined' && document.visibilityState === 'hidden' ? 'hidden' : 'visible',
      codexModel: effectiveCodexModel,
      codexParticipants: effectiveCodexParticipants,
      chatTypeId: effectiveChatType?.id ?? null,
      chatTypeLabel: effectiveChatType?.name ?? '',
      chatTypeDescription: effectiveChatTypeDescription,
      chatTypeBaseDescription: effectiveChatType?.description ?? '',
      defaultContextIds: effectiveDefaultContextIds,
      defaultContexts: effectiveDefaultContexts,
      customContextTitle: effectiveRoomCustomContextTitle || null,
      customContextContent: effectiveRoomCustomContextContent || null,
    }),
    [
      currentPage.id,
      scopedFocusedComponentId,
      scopedPageTitle,
      scopedPageUrl,
      scopedTopMenu,
      effectiveCodexModel,
      effectiveCodexParticipants,
      effectiveChatType?.id,
      effectiveChatType?.name,
      effectiveChatType?.description,
      effectiveChatTypeDescription,
      effectiveDefaultContextIds,
      effectiveDefaultContexts,
      effectiveRoomCustomContextTitle,
      effectiveRoomCustomContextContent,
      currentPage.id,
    ],
  );
  const composerAssistActions = useMemo(
    () =>
      mode !== 'rooms'
        ? []
        : [
            {
              key: 'menu',
              label: '@menu',
              description: '현재 메뉴와 기능 범위를 삽입합니다.',
              resolveText: () =>
                [
                  `[현재 메뉴] ${effectiveActiveRoomScope?.menuTitle || currentPage.title}`,
                  `[현재 기능] ${effectiveActiveRoomScope?.featureTitle || actionContextSnapshot.featureTitle || currentPage.title}`,
                ].join('\n'),
            },
            {
              key: 'focus',
              label: '@focus',
              description: '현재 포커스된 컴포넌트를 삽입합니다.',
              resolveText: () =>
                scopedFocusedComponentId ? `[focusedComponentId] ${scopedFocusedComponentId}` : '[focusedComponentId] 없음',
            },
            {
              key: 'selection',
              label: '#selection',
              description: '현재 선택된 항목과 ID를 삽입합니다.',
              resolveText: () => {
                const summary = effectiveActiveRoomScope?.selectionSummary || actionContextSnapshot.selectionSummary || '';
                const ids = effectiveActiveRoomScope?.selectionIds?.length
                  ? effectiveActiveRoomScope.selectionIds
                  : actionContextSnapshot.selectionIds;

                return [
                  summary ? `[현재 선택] ${summary}` : '[현재 선택] 없음',
                  ids.length > 0 ? `[선택 ID] ${ids.join(', ')}` : '',
                ]
                  .filter(Boolean)
                  .join('\n');
              },
            },
            {
              key: 'errors',
              label: '#errors',
              description: '최근 서버/클라이언트 에러 요약을 삽입합니다.',
              resolveText: () => roomErrorAssistText || '[최근 에러 요약]\n- 참조 가능한 최근 에러가 없습니다.',
            },
          ],
    [
      actionContextSnapshot.featureTitle,
      actionContextSnapshot.selectionIds,
      actionContextSnapshot.selectionSummary,
      effectiveActiveRoomScope,
      currentPage.title,
      mode,
      roomErrorAssistText,
      scopedFocusedComponentId,
    ],
  );
  const resolveComposedChatTypeDescription = useCallback(
    (
      chatType: CreateConversationTarget | ChatTypeRecord | { id: string; name: string; description: string } | null,
      options?: {
        sessionId?: string | null;
        defaultContextIds?: string[] | null;
        customContextTitle?: string | null;
        customContextContent?: string | null;
      },
    ) => {
      if (!chatType) {
        return '';
      }

      const roomOverrides =
        options?.sessionId && !options?.defaultContextIds && !options?.customContextTitle && !options?.customContextContent
          ? resolveChatRoomContextSettings(roomContexts, options.sessionId)
          : null;
      const nextDefaultContextIds =
        options?.defaultContextIds ??
        roomOverrides?.defaultContextIds ??
        resolveChatTypeDefaultContextIds(chatTypeDefaults, chatType.id);
      const nextDefaultContexts = nextDefaultContextIds
        .map((contextId) => defaultContexts.find((context) => context.id === contextId && context.enabled))
        .filter((context): context is ChatDefaultContextRecord => Boolean(context));

      return (
        composeConversationContextDescription(
          chatType.description,
          nextDefaultContexts,
          options?.customContextTitle ?? roomOverrides?.customContextTitle,
          options?.customContextContent ?? roomOverrides?.customContextContent,
        ) ?? ''
      );
    },
    [chatTypeDefaults, defaultContexts, roomContexts],
  );
  const handleRequestEvent = useCallback(
    (request: ChatConversationRequest, sessionId: string) => {
      setRequestItems((previous) => {
        const existingIndex = previous.findIndex(
          (item) => item.sessionId === sessionId && item.requestId === request.requestId,
        );

        if (existingIndex < 0) {
          return previous;
        }

        const nextItems = [...previous];
        nextItems[existingIndex] = mergeConversationRequestPreservingContent(
          nextItems[existingIndex],
          request,
        );
        return nextItems;
      });
    },
    [setRequestItems],
  );
  const { socketRef, connectionState } = chatConnectionGateway.useConnection({
    sessionId: activeSessionId,
    currentContext,
    setMessages,
    onMessageEvent: handleIncomingMessageEvent,
    onJobEvent: handleJobEvent,
    onRuntimeEvent: handleRuntimeEvent,
    onRuntimeDetailEvent: handleRuntimeDetailEvent,
    onRequestEvent: handleRequestEvent,
  });
  const {
    errorLogs,
    selectedErrorLog,
    selectedErrorLogReferenceSummary,
    activeErrorResource,
    errorSourceSummary,
    isLoadingErrorLogs,
    errorLogLoadError,
    isErrorDetailExpanded,
    setSelectedErrorLogId,
    setActiveErrorResourceUrl,
    setIsErrorDetailExpanded,
    loadErrorLogs,
  } = useErrorLogs({
    activeView,
    hasAccess,
  });
  const activeRuntimeStatus = useMemo(
    () => buildRuntimeStatusLabel(runtimeSnapshot, activeSessionId),
    [runtimeSnapshot, activeSessionId],
  );
  const activeRuntimeQueuedRequestIds = useMemo(() => {
    if (!runtimeSnapshot) {
      return null;
    }

    return new Set(
      runtimeSnapshot.queued
        .filter((item) => item.sessionId === activeSessionId)
        .map((item) => item.requestId),
    );
  }, [activeSessionId, runtimeSnapshot]);
  useEffect(() => {
    if (!runtimeSnapshot) {
      return;
    }

    setConversationItems((previous) => applyRuntimeSnapshotToConversationItems(previous, runtimeSnapshot));
  }, [runtimeSnapshot, setConversationItems]);
  const activeQueuedComposerRequests = useMemo(
    () => {
      const queuedItems = requestItems
        .filter((item) => item.sessionId === activeSessionId && item.status === 'queued')
        .sort((left, right) => left.createdAt.localeCompare(right.createdAt));

      if (!activeRuntimeQueuedRequestIds) {
        return queuedItems;
      }

      return queuedItems.filter((item) => activeRuntimeQueuedRequestIds.has(item.requestId));
    },
    [activeRuntimeQueuedRequestIds, activeSessionId, requestItems],
  );
  const activeRequestItems = useMemo(
    () =>
      requestItems
        .filter((item) => item.sessionId === activeSessionId)
        .sort((left, right) => left.createdAt.localeCompare(right.createdAt)),
    [activeSessionId, requestItems],
  );
  const {
    activeSystemStatus,
    captureViewportRestoreSnapshot,
    handleViewportScroll,
    handleViewportTouchEnd,
    handleViewportTouchMove,
    handleViewportTouchStart,
    isSystemStatusPending,
    isPullToLoadArmed,
    pendingViewportRestoreRef,
    pullToLoadDistance,
    queueViewportPrependRestore,
    releaseAutoScrollSuspension,
    scrollViewportToBottom,
    setActiveSystemStatus,
    setIsSystemStatusPending,
    setShowScrollToBottom,
    shouldStickToBottomRef,
    showScrollToBottom,
  } = useConversationViewportController({
    activeConversation,
    activeQueuedComposerRequestsCount: activeQueuedComposerRequests.length,
    activeRuntimeStatus,
    chatMessageCount: chatMessages.length,
    chatMessageSyncKey,
    connectionState,
    hasOlderMessages,
    isConversationContentLoading,
    isLoadingOlderMessages,
    messages,
    onLoadOlderMessages: () => loadOlderMessagesRef.current(),
    runtimeSnapshot,
    viewportRef,
    mapJobStatusLabel,
    mapSystemStatusMessage,
    isActivityLogMessage,
  });
  const shouldPreservePendingConversation = useCallback(
    (sessionId: string) => pendingConversationCreationRef.current.has(sessionId),
    [],
  );
  const { loadOlderMessages } = useConversationRoomController({
    activeSessionId,
    activeConversationIsDraftOnly: activeConversation?.isDraftOnly === true,
    activeConversationHasLocalActivity,
    oldestLoadedMessageId,
    reloadKey: conversationRoomReloadKey,
    shouldForceStickToBottomOnNextLoadRef,
    connectionState,
    captureViewportRestoreSnapshot,
    sessionMessageCacheRef,
    messagesRef,
    pendingViewportRestoreRef,
    shouldRestoreConversationAfterReconnectRef,
    setConversationItems,
    setMessages,
    setRequestItems,
    setConversationLoadingLabel,
    setIsConversationContentLoading,
    setIsDeferringAuxiliaryChatRequests,
    setHasOlderMessages,
    setOldestLoadedMessageId,
    setIsLoadingOlderMessages,
    queueViewportPrependRestore,
    viewportRef,
    shouldPreserveMissingConversation: shouldPreservePendingConversation,
  });
  loadOlderMessagesRef.current = loadOlderMessages;

  useEffect(() => {
    setSharedActiveConversationSnapshot({
      sessionId: activeSessionId,
      title: activeConversation?.title || '',
    });
  }, [activeConversation?.title, activeSessionId]);

  const activeRequestMap = useMemo(
    () =>
      new Map(
        activeRequestItems.map((item) => [item.requestId, item]),
      ),
    [activeRequestItems],
  );
  const deferredConversationSearch = useDeferredValue(conversationSearch);
  const latestRuntimeItemBySession = useMemo(() => buildLatestRuntimeItemBySession(runtimeSnapshot), [runtimeSnapshot]);
  const conversationListViewItems = useMemo<ConversationListViewItem[]>(
    () =>
      conversationItems
        .filter((item) => !isManagedChatShareSessionId(item.sessionId) && shouldShowConversationForMode(item.sessionId, mode))
        .map((item) => {
          const latestRuntimeItem = latestRuntimeItemBySession.get(item.sessionId) ?? null;

          return {
            ...item,
            latestActivityTime: getConversationLatestActivityTime(item),
            searchText: buildConversationListSearchText(item),
            resolvedRequestBadgeLabel: buildConversationRequestBadgeLabel(item, runtimeSnapshot, latestRuntimeItem),
            resolvedJobStatusLabel: item.currentJobStatus ? buildConversationJobStatusLabel(item) : '',
            resolvedProcessingCountLabel: buildConversationProcessingCountLabel(runtimeSnapshot, item),
          };
        })
        .sort(compareConversationItemsByLatestChat),
    [conversationItems, latestRuntimeItemBySession, mode, runtimeSnapshot],
  );
  const filteredConversationItems = useMemo(() => {
    const keyword = deferredConversationSearch.trim().toLowerCase();

    if (!keyword) {
      return conversationListViewItems;
    }

    return conversationListViewItems.filter((item) => item.searchText.includes(keyword));
  }, [conversationListViewItems, deferredConversationSearch]);
  const matchingRoomConversation = useMemo(() => {
    if (mode !== 'rooms') {
      return null;
    }

    const targetScope = effectiveActiveRoomScope ?? roomLaunchScope;

    if (!targetScope) {
      return null;
    }

    return (
      conversationItems.find((item) => {
        if (!shouldShowConversationForMode(item.sessionId, mode)) {
          return false;
        }

        return doesIsolatedChatRoomScopeMatch(resolveConversationRoomScope(item), targetScope);
      }) ?? null
    );
  }, [conversationItems, effectiveActiveRoomScope, mode, resolveConversationRoomScope, roomLaunchScope]);
  const unreadFilteredConversationItems = useMemo(
    () => filteredConversationItems.filter((item) => item.hasUnreadResponse || item.hasPendingAttention),
    [filteredConversationItems],
  );
  useEffect(() => {
    generalSectionOrderRef.current = generalSectionOrder;
  }, [generalSectionOrder]);
  const conversationSections = useMemo<ConversationListSection[]>(() => {
    const groupedItems: Record<ConversationListSectionKey, ConversationListViewItem[]> = {
      processing: [],
      failed: [],
      unread: [],
    };
    const generalGroups = new Map<string, { generalSectionName: string | null; items: ConversationListViewItem[] }>();
    const generalCandidateItems: ConversationListViewItem[] = [];

    filteredConversationItems.forEach((item) => {
      if (isConversationProcessing(item)) {
        groupedItems.processing.push(item);
        return;
      }

      if (isConversationFailed(item)) {
        groupedItems.failed.push(item);
        return;
      }

      if (item.hasUnreadResponse || item.hasPendingAttention) {
        groupedItems.unread.push(item);
        return;
      }

      generalCandidateItems.push(item);

      const generalSectionName = normalizeGeneralSectionName(item.generalSectionName);
      const generalKey = buildGeneralSectionKey(generalSectionName);
      const existingGroup = generalGroups.get(generalKey);

      if (existingGroup) {
        existingGroup.items.push(item);
        return;
      }

      generalGroups.set(generalKey, {
        generalSectionName,
        items: [item],
      });
    });

    const generalSections: ConversationListSection[] = Array.from(generalGroups.entries()).map(([key, group]) => ({
      key,
      title: group.generalSectionName ? `일반 · ${group.generalSectionName}` : '일반',
      tone: 'muted' as const,
      items: group.items,
      generalSectionName: group.generalSectionName,
      defaultOpen: false,
      isReorderable: true,
    }));
    const recentConversationItems = generalCandidateItems.slice(0, 5);
    const reorderableSections: ConversationListSection[] = [
      {
        key: RECENT_CONVERSATION_SECTION_KEY,
        title: '최근 대화방 5개',
        tone: 'muted',
        items: recentConversationItems,
        defaultOpen: true,
        isReorderable: true,
      },
      ...generalSections,
    ];
    const normalizedGeneralSectionOrder = normalizeGeneralSectionOrder(
      reorderableSections.map((section) => section.key),
      generalSectionOrder,
    );
    const generalSectionMap = new Map(reorderableSections.map((section) => [section.key, section]));

    return [
      { key: 'processing', title: '처리 중', tone: 'processing', items: groupedItems.processing, defaultOpen: true },
      { key: 'failed', title: '오류', tone: 'failed', items: groupedItems.failed, defaultOpen: true },
      { key: 'unread', title: '답변 도착·미확인', tone: 'unread', items: groupedItems.unread, defaultOpen: true },
      ...normalizedGeneralSectionOrder
        .map((key) => generalSectionMap.get(key))
        .filter((section): section is ConversationListSection => Boolean(section)),
    ].filter((section): section is ConversationListSection => Boolean(section) && section.items.length > 0);
  }, [filteredConversationItems, generalSectionOrder]);
  useEffect(() => {
    const availableGeneralSectionKeys = conversationSections
      .filter((section) => section.isReorderable)
      .map((section) => section.key);
    const normalizedOrder = normalizeGeneralSectionOrder(availableGeneralSectionKeys, generalSectionOrderRef.current);

    if (
      normalizedOrder.length !== generalSectionOrderRef.current.length ||
      normalizedOrder.some((key, index) => key !== generalSectionOrderRef.current[index])
    ) {
      generalSectionOrderRef.current = normalizedOrder;
      setGeneralSectionOrder(normalizedOrder);
      writeStoredGeneralSectionOrder(normalizedOrder);
    }
  }, [conversationSections]);
  const reorderableGeneralSectionKeys = useMemo(
    () => conversationSections.filter((section) => section.isReorderable).map((section) => section.key),
    [conversationSections],
  );
  const toggleGeneralSectionMoveControls = useCallback((sectionKey: string) => {
    setActiveGeneralSectionMoveControlsKey((previous) => (previous === sectionKey ? null : sectionKey));
  }, []);
  const moveGeneralConversationSection = useCallback(
    (sectionKey: string, direction: 'up' | 'down') => {
      const currentOrder = normalizeGeneralSectionOrder(reorderableGeneralSectionKeys, generalSectionOrderRef.current);
      const currentIndex = currentOrder.indexOf(sectionKey);

      if (currentIndex < 0) {
        return;
      }

      const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;

      if (targetIndex < 0 || targetIndex >= currentOrder.length) {
        return;
      }

      const nextOrder = reorderGeneralConversationSectionsToIndex(currentOrder, sectionKey, targetIndex);
      generalSectionOrderRef.current = nextOrder;
      setGeneralSectionOrder(nextOrder);
      writeStoredGeneralSectionOrder(nextOrder);
    },
    [reorderableGeneralSectionKeys],
  );
  useEffect(() => {
    if (!activeGeneralSectionMoveControlsKey) {
      return;
    }

    if (!reorderableGeneralSectionKeys.includes(activeGeneralSectionMoveControlsKey)) {
      setActiveGeneralSectionMoveControlsKey(null);
    }
  }, [activeGeneralSectionMoveControlsKey, reorderableGeneralSectionKeys]);
  const pendingDeleteConversation =
    conversationItems.find((item) => item.sessionId === pendingDeleteSessionId) ?? null;
  const pendingClearConversation =
    conversationItems.find((item) => item.sessionId === pendingClearConversationSessionId) ?? null;
  const editingConversationTitleTarget =
    conversationItems.find((item) => item.sessionId === editingConversationTitleSessionId) ?? null;
  const editingGeneralSectionConversation =
    conversationItems.find((item) => item.sessionId === editingGeneralSectionSessionId) ?? null;
  const availableGeneralSectionNames = useMemo(
    () =>
      Array.from(
        new Set(
          conversationItems
            .map((item) => normalizeGeneralSectionName(item.generalSectionName))
            .filter((value): value is string => Boolean(value)),
        ),
      ),
    [conversationItems],
  );
  useEffect(() => {
    if (!pendingContextConfirm && !pendingDeleteConversation && !pendingClearConversation) {
      return undefined;
    }

    const handleEnterConfirm = (event: KeyboardEvent) => {
      const activeElement = document.activeElement;
      const visibleModal = Array.from(document.querySelectorAll<HTMLElement>('.ant-modal-root')).find((element) => {
        return element.offsetParent !== null;
      });
      const shouldIgnoreInteractiveTarget =
        activeElement instanceof HTMLElement &&
        visibleModal?.contains(activeElement) &&
        Boolean(activeElement.closest('button, a, input, textarea, select, [role="button"], [contenteditable="true"]'));

      if (event.key !== 'Enter' || event.isComposing || shouldIgnoreInteractiveTarget) {
        return;
      }

      const okButton = visibleModal?.querySelector<HTMLButtonElement>('.ant-modal-footer .ant-btn-primary');

      if (!okButton || okButton.disabled) {
        return;
      }

      event.preventDefault();
      event.stopPropagation();
      okButton.click();
    };

    window.addEventListener('keydown', handleEnterConfirm, true);
    return () => {
      window.removeEventListener('keydown', handleEnterConfirm, true);
    };
  }, [pendingClearConversation, pendingContextConfirm, pendingDeleteConversation]);

  useEffect(() => {
    if (!pendingContextConfirm) {
      setSkipContextConfirmForToday(false);
    }
  }, [pendingContextConfirm]);
  const {
    activePreview,
    isPreviewLoading,
    isPreviewModalOpen,
    previewModalTarget,
    previewContentType,
    previewError,
    previewText,
    handlePreviewModalAfterOpenChange,
    setActivePreviewId,
    setActivePreviewOverride,
    setIsPreviewModalOpen,
  } = useConversationViewController({
    activeSessionId,
    activeView,
    isMobileViewport,
    previewItems,
    composerRef,
    setActiveSystemStatus,
    setComposerAttachments,
    setCopiedMessageId,
    setDraft,
    setIsResourceStripOpen,
    setIsSystemStatusPending,
  });
  useEffect(() => {
    const handleConversationCleared = (event: Event) => {
      const detail = readChatConversationClearedEvent(event);

      if (!detail) {
        return;
      }

      const clearedItem = detail.item;
      sessionMessageCacheRef.current.set(clearedItem.sessionId, []);
      setConversationItems((previous) =>
        previous.map((entry) => (entry.sessionId === clearedItem.sessionId ? clearedItem : entry)),
      );

      if (clearedItem.sessionId !== activeSessionIdRef.current) {
        return;
      }

      chatConnectionGateway.resetLastReceivedEventId(clearedItem.sessionId);
      setMessages([]);
      setRequestItems([]);
      setDraft('');
      setComposerAttachments([]);
      setCopiedMessageId(null);
      setActivePreviewId(null);
      setIsPreviewModalOpen(false);
      setActiveSystemStatus('채팅방 데이터를 초기화했습니다.');
      setIsSystemStatusPending(false);
      setIsResourceStripOpen(false);
    };

    window.addEventListener(CHAT_CONVERSATION_CLEARED_EVENT, handleConversationCleared as EventListener);

    return () => {
      window.removeEventListener(CHAT_CONVERSATION_CLEARED_EVENT, handleConversationCleared as EventListener);
    };
  }, [
    setActivePreviewId,
    setActiveSystemStatus,
    setComposerAttachments,
    setConversationItems,
    setCopiedMessageId,
    setDraft,
    setIsPreviewModalOpen,
    setIsResourceStripOpen,
    setIsSystemStatusPending,
    setMessages,
    setRequestItems,
  ]);
  const openPreviewModal = useCallback(
    (
      preview:
        | string
        | {
            id: string;
            label: string;
            url: string;
            kind: 'image' | 'video' | 'markdown' | 'code' | 'diff' | 'document' | 'pdf' | 'file';
            source?: 'message' | 'context';
          },
    ) => {
      if (typeof preview === 'string') {
        setActivePreviewOverride(null);
        setActivePreviewId(preview);
      } else {
        setActivePreviewOverride({
          ...preview,
          source: preview.source ?? 'message',
        });
        setActivePreviewId(null);
      }

      setIsPreviewModalOpen(true);
    },
    [setActivePreviewId, setActivePreviewOverride, setIsPreviewModalOpen],
  );
  const handleCopyActivePreview = useCallback(async () => {
    if (!activePreview) {
      return;
    }

    try {
      const result = await copyPreviewContent({
        kind: activePreview.kind,
        url: activePreview.url,
        fallbackText: previewText,
      });

      if (result === 'image') {
        message.success('이미지를 복사했습니다.');
        return;
      }

      if (result === 'url') {
        message.success('이미지 URL을 복사했습니다.');
        return;
      }

      message.success('복사했습니다.');
    } catch (error) {
      message.error(error instanceof Error ? error.message : '복사에 실패했습니다.');
    }
  }, [activePreview, previewText]);

  const handleDownloadActivePreview = useCallback(() => {
    if (!activePreview) {
      return;
    }

    try {
      const fileName = activePreview.url.split('/').pop()?.trim() || activePreview.label;
      triggerResourceDownload(activePreview.url, fileName);
    } catch {
      void messageApi.error('다운로드에 실패했습니다.');
    }
  }, [activePreview, messageApi]);

  const handleShareActivePreview = useCallback(() => {
    if (!activePreview) {
      return;
    }

    void sharePreviewLink({
      url: activePreview.url,
      title: activePreview.label,
    })
      .then((result) => {
        messageApi.success(result === 'shared' ? 'preview 링크를 공유했습니다.' : 'preview 링크를 복사했습니다.');
      })
      .catch((error: unknown) => {
        if (error instanceof DOMException && error.name === 'AbortError') {
          return;
        }

        messageApi.error(error instanceof Error ? error.message : 'preview 링크를 공유하지 못했습니다.');
      });
  }, [activePreview, messageApi]);

  const handleShareChatTarget = useCallback(
    async (payload: PendingShareTarget) => {
      const normalizedSessionId = activeSessionId.trim();

      if (!normalizedSessionId) {
        messageApi.error('활성 대화방이 없어 공유 링크를 만들 수 없습니다.');
        return;
      }

      if (shareTokenSettingOptions.length === 0) {
        messageApi.error(shareTokenSettingsErrorMessage || '공유 채팅방에 사용할 토큰 설정을 먼저 등록해 주세요.');
        return;
      }

      setPendingShareTarget(payload);
    },
    [activeSessionId, messageApi, shareTokenSettingOptions.length, shareTokenSettingsErrorMessage],
  );
  const handleConfirmShareTarget = useCallback(async () => {
    const normalizedSessionId = activeSessionId.trim();
    const normalizedTokenSettingId = selectedShareTokenSettingId.trim();
    const normalizedShareName = shareTokenName.trim();

    if (!pendingShareTarget || !normalizedSessionId) {
      return;
    }

    if (!normalizedTokenSettingId) {
      messageApi.error('공유에 사용할 토큰 설정을 선택해 주세요.');
      return;
    }

    if (!normalizedShareName) {
      messageApi.error('공유 이름을 입력해 주세요.');
      return;
    }

    setIsShareTargetSubmitting(true);

    try {
      const result = await createChatShareLink({
        kind: pendingShareTarget.kind,
        sessionId: normalizedSessionId,
        requestId: pendingShareTarget.requestId,
        sourceMessageId: pendingShareTarget.sourceMessageId,
        promptIndex: pendingShareTarget.promptIndex,
        promptSignature: pendingShareTarget.promptSignature,
        tokenSettingId: normalizedTokenSettingId,
        name: normalizedShareName,
      });

      const shareUrl = new URL(result.sharePath, window.location.origin).toString();
      const shareResult = await sharePreviewLink({
        url: shareUrl,
        title: pendingShareTarget.title,
      });

      setPendingShareTarget(null);
      setShareTokenName('');
      messageApi.success(shareResult === 'shared' ? '공유 링크를 전송했습니다.' : '공유 링크를 복사했습니다.');
    } catch (error) {
      if (error instanceof DOMException && error.name === 'AbortError') {
        return;
      }

      messageApi.error(error instanceof Error ? error.message : '공유 링크를 만들지 못했습니다.');
    } finally {
      setIsShareTargetSubmitting(false);
    }
  }, [activeSessionId, messageApi, pendingShareTarget, selectedShareTokenSettingId, shareTokenName]);

  const isActivePreviewHtml = isHtmlPreviewItem(activePreview);
  const isHtmlPreviewFullscreen = isActivePreviewHtml && isPreviewModalOpen;

  const canSearchActivePreview =
    Boolean(activePreview) &&
    !isPreviewLoading &&
    !previewError.trim() &&
    !(isActivePreviewHtml && isHtmlPreviewMode) &&
    (activePreview?.kind === 'markdown' || activePreview?.kind === 'code' || activePreview?.kind === 'document');

  const resetActivePreviewSearchState = useCallback(() => {
    previewSearchMatchesRef.current = [];
    previewSearchMatchIndexRef.current = -1;
    previewSearchKeyRef.current = '';
  }, []);

  const clearActivePreviewSearchSelection = useCallback(() => {
    if (typeof window !== 'undefined') {
      window.getSelection()?.removeAllRanges();
    }
  }, []);

  useEffect(() => {
    if (!isPreviewModalOpen) {
      setIsPreviewFindOpen(false);
      setPreviewFindQuery('');
      resetActivePreviewSearchState();
      clearActivePreviewSearchSelection();
      return;
    }

    if (isPreviewFindOpen) {
      window.setTimeout(() => {
        previewFindInputRef.current?.focus();
      }, 0);
    }
  }, [clearActivePreviewSearchSelection, isPreviewFindOpen, isPreviewModalOpen, resetActivePreviewSearchState]);

  useEffect(() => {
    if (!previousPreviewModalOpenRef.current && isPreviewModalOpen) {
      previousPreviewModalOpenRef.current = true;
      return;
    }

    if (previousPreviewModalOpenRef.current && !isPreviewModalOpen) {
      previousPreviewModalOpenRef.current = false;
    }
  }, [isPreviewModalOpen]);

  useEffect(() => {
    resetActivePreviewSearchState();
    clearActivePreviewSearchSelection();
  }, [activePreview?.id, clearActivePreviewSearchSelection, resetActivePreviewSearchState]);

  useEffect(() => {
    setIsHtmlPreviewMode(isHtmlPreviewItem(activePreview));
  }, [activePreview]);

  useEffect(() => {
    resetActivePreviewSearchState();
  }, [previewFindQuery, resetActivePreviewSearchState]);

  const handlePreviewSearchRootPointerDown = useCallback(
    (event: ReactPointerEvent<HTMLDivElement>) => {
      if (!isMobileViewport || !isPreviewFindOpen) {
        return;
      }

      const inputElement = previewFindInputRef.current?.input;

      if (!inputElement || document.activeElement !== inputElement) {
        return;
      }

      if (event.target instanceof Node && inputElement.contains(event.target)) {
        return;
      }

      const selection = window.getSelection();

      if (!selection || selection.rangeCount === 0) {
        return;
      }

      event.preventDefault();
      inputElement.blur();
    },
    [isMobileViewport, isPreviewFindOpen],
  );

  const handleFindActivePreview = useCallback(
    (direction: 'forward' | 'backward' = 'forward') => {
      const keyword = previewFindQuery.trim();

      if (!keyword) {
        void messageApi.info('찾을 단어를 입력해주세요.');
        return;
      }

      const root = previewSearchRootRef.current;

      if (!root) {
        void messageApi.error('미리보기 본문을 아직 찾을 수 없습니다.');
        return;
      }

      const searchKey = `${activePreview?.id ?? 'preview'}:${keyword.toLocaleLowerCase()}`;
      const matches =
        previewSearchKeyRef.current === searchKey
          ? previewSearchMatchesRef.current
          : collectPreviewTextMatches(root, keyword);

      if (previewSearchKeyRef.current !== searchKey) {
        previewSearchKeyRef.current = searchKey;
        previewSearchMatchesRef.current = matches;
        previewSearchMatchIndexRef.current = -1;
      }

      if (matches.length === 0) {
        void messageApi.info('일치하는 단어를 찾지 못했습니다.');
        return;
      }

      const scrollContainer = resolvePreviewScrollContainer(root);
      const anchors = buildPreviewMatchAnchors(matches, scrollContainer);
      const nextIndex = resolvePreviewSearchResultIndex({
        anchors,
        container: scrollContainer,
        direction,
        currentIndex: previewSearchMatchIndexRef.current,
      });
      const nextAnchor = nextIndex >= 0 ? anchors[nextIndex] : null;

      if (!nextAnchor || !focusPreviewTextMatchInContainer(nextAnchor.match, scrollContainer)) {
        void messageApi.error('검색 결과 위치로 이동하지 못했습니다.');
        return;
      }

      previewSearchMatchIndexRef.current = nextIndex;
    },
    [activePreview?.id, messageApi, previewFindQuery],
  );

  useEffect(() => {
    if (!activeConversation?.sessionId || activeConversation.hasUnreadResponse) {
      return;
    }

    const latestResponseMessageId = activeRequestItems.reduce<number>(
      (maxId, item) => (item.responseMessageId != null ? Math.max(maxId, item.responseMessageId) : maxId),
      0,
    );

    if (latestResponseMessageId <= 0) {
      return;
    }

    setLastReadResponseMessageIdBySession((previous) => {
      if ((previous[activeConversation.sessionId] ?? 0) === latestResponseMessageId) {
        return previous;
      }

      return {
        ...previous,
        [activeConversation.sessionId]: latestResponseMessageId,
      };
    });
  }, [activeConversation?.hasUnreadResponse, activeConversation?.sessionId, activeRequestItems]);

  useEffect(() => {
    const sessionId = activeConversation?.sessionId?.trim() ?? '';

    if (!sessionId || !activeConversation?.hasUnreadResponse || isConversationContentLoading) {
      return;
    }

    const latestResponseMessageId = activeRequestItems.reduce<number>(
      (maxId, item) => (item.responseMessageId != null ? Math.max(maxId, item.responseMessageId) : maxId),
      0,
    );

    if (latestResponseMessageId <= 0) {
      return;
    }

    if ((lastMarkedReadResponseMessageIdBySessionRef.current[sessionId] ?? 0) >= latestResponseMessageId) {
      return;
    }

    lastMarkedReadResponseMessageIdBySessionRef.current[sessionId] = latestResponseMessageId;
    rememberConversationUnreadCleared(activeConversation);

    setConversationItems((previous) =>
      previous.map((item) =>
        item.sessionId === sessionId
          ? {
              ...item,
              hasUnreadResponse: false,
            }
          : item,
      ),
    );
    setLastReadResponseMessageIdBySession((previous) => ({
      ...previous,
      [sessionId]: Math.max(previous[sessionId] ?? 0, latestResponseMessageId),
    }));

    void Promise.all([
      chatGateway.markConversationRead(sessionId),
      chatGateway.markRoomNotificationMessagesRead(sessionId),
      chatGateway.dismissRoomNotifications(sessionId),
    ]).catch(() => {
      delete lastMarkedReadResponseMessageIdBySessionRef.current[sessionId];
      void reloadConversationItems();
    });
  }, [
    activeConversation?.hasUnreadResponse,
    activeConversation?.sessionId,
    activeRequestItems,
    isConversationContentLoading,
    reloadConversationItems,
  ]);

  const renderConversationListItem = (
    item: ConversationListViewItem,
    sectionKey = 'general',
  ) => {
    const isUnread = item.hasUnreadResponse || item.hasPendingAttention;
    const isProcessing = isConversationProcessing(item);
    const isFailed = isConversationFailed(item);
    const unreadFlagLabel = item.hasUnreadResponse ? '답변 도착' : '미확인';
    const generalSectionName = normalizeGeneralSectionName(item.generalSectionName);
    const isUnreadSection = sectionKey === 'unread';
    const isFailedSection = sectionKey === 'failed';
    const isGeneralSection = sectionKey.startsWith('general');
    const requestBadgeLabel = item.resolvedRequestBadgeLabel;
    const jobStatusLabel = item.resolvedJobStatusLabel;
    const processingCountLabel = item.resolvedProcessingCountLabel;
    const roomContextSettings = resolveChatRoomContextSettings(roomContexts, item.sessionId);
    const codexParticipantCount = Math.max(1, roomContextSettings?.codexParticipants.length ?? 0, item.codexModel ? 1 : 0);

    return (
      <div
        key={item.sessionId}
        className={`app-chat-panel__conversation-item${
          item.sessionId === activeSessionId ? ' app-chat-panel__conversation-item--active' : ''
        }${isUnread ? ' app-chat-panel__conversation-item--unread' : ''}${
          isProcessing ? ' app-chat-panel__conversation-item--processing' : ''
        }${isFailed ? ' app-chat-panel__conversation-item--failed' : ''}${
          isFailedSection ? ' app-chat-panel__conversation-item--failed-section' : ''
        }${isUnreadSection ? ' app-chat-panel__conversation-item--unread-section' : ''}${
          isGeneralSection ? ' app-chat-panel__conversation-item--general' : ''
        }`}
      >
        <button
          type="button"
          className="app-chat-panel__conversation-item-main"
          onClick={() => {
            openConversationSession(item.sessionId);
          }}
        >
          <span className="app-chat-panel__conversation-item-heading">
            <span className="app-chat-panel__conversation-item-title-wrap">
              {isUnread ? <span className="app-chat-panel__conversation-item-unread-dot" aria-hidden="true" /> : null}
              <span className="app-chat-panel__conversation-item-title">{item.title || '새 대화'}</span>
            </span>
            <span className="app-chat-panel__conversation-item-time">
              {formatConversationListTimestamp(item.lastMessageAt || item.createdAt || item.updatedAt)}
            </span>
          </span>
          <span className="app-chat-panel__conversation-item-id">{item.sessionId}</span>
          <span className="app-chat-panel__conversation-item-flags">
            <span className="app-chat-panel__conversation-item-flag app-chat-panel__conversation-item-flag--section">
              Codex {codexParticipantCount}명
            </span>
            {requestBadgeLabel ? (
              <span className="app-chat-panel__conversation-item-flag app-chat-panel__conversation-item-flag--request">
                {requestBadgeLabel}
              </span>
            ) : null}
            {item.currentJobStatus ? (
              <span
                className={`app-chat-panel__conversation-item-status app-chat-panel__conversation-item-status--${item.currentJobStatus}`}
              >
                {jobStatusLabel}
              </span>
            ) : null}
            {processingCountLabel ? (
              <span className="app-chat-panel__conversation-item-flag app-chat-panel__conversation-item-flag--request">
                {processingCountLabel}
              </span>
            ) : null}
            {isConversationPendingWork(item) ? (
              <span className="app-chat-panel__conversation-item-flag app-chat-panel__conversation-item-flag--request">
                작업중
              </span>
            ) : null}
            {isUnread ? (
              <span className="app-chat-panel__conversation-item-flag app-chat-panel__conversation-item-flag--unread">
                {unreadFlagLabel}
              </span>
            ) : null}
            {generalSectionName ? (
              <span className="app-chat-panel__conversation-item-flag app-chat-panel__conversation-item-flag--section">
                보관 · {generalSectionName}
              </span>
            ) : null}
          </span>
          <span className="app-chat-panel__conversation-item-preview">
            {resolveConversationListPreviewText(item.lastMessagePreview)}
          </span>
        </button>
        <div className="app-chat-panel__conversation-item-actions">
          <Button
            type="text"
            size="small"
            className="app-chat-panel__conversation-item-folder"
            icon={<FolderOutlined />}
            aria-label="일반 보관 섹션 변경"
            onClick={(event) => {
              event.preventDefault();
              event.stopPropagation();
              openGeneralSectionEditor(item);
            }}
          />
          <Button
            type="text"
            size="small"
            className="app-chat-panel__conversation-item-delete"
            icon={<DeleteOutlined />}
            aria-label="대화 삭제"
            onClick={(event) => {
              event.preventDefault();
              event.stopPropagation();
              setPendingDeleteSessionId(item.sessionId);
            }}
          />
        </div>
      </div>
    );
  };

  const renderConversationListSection = (section: ConversationListSection) => {
    const sectionOrderIndex = reorderableGeneralSectionKeys.indexOf(section.key);
    const canMoveSectionUp = section.isReorderable && sectionOrderIndex > 0;
    const canMoveSectionDown =
      section.isReorderable && sectionOrderIndex >= 0 && sectionOrderIndex < reorderableGeneralSectionKeys.length - 1;
    const isMoveControlsActive = activeGeneralSectionMoveControlsKey === section.key;
    const isMobileSectionOpen = mobileConversationSectionOpen[section.key] ?? section.defaultOpen;
    const collapsedActiveItem =
      !isMobileSectionOpen ? section.items.find((item) => item.sessionId === activeSessionId) ?? null : null;
    const visibleMobileItems = isMobileSectionOpen ? section.items : collapsedActiveItem ? [collapsedActiveItem] : [];

    return (
      <div
        key={section.key}
        className={`app-chat-panel__conversation-section${
          section.isReorderable ? ' app-chat-panel__conversation-section--reorderable' : ''
        }`}
      >
        {isMobileViewport ? (
          <>
          <div className="app-chat-panel__conversation-section-mobile-header">
            <button
              type="button"
              className={`app-chat-panel__conversation-section-toggle app-chat-panel__conversation-section-toggle--${section.tone}${
                isMobileSectionOpen ? ' is-open' : ''
              }${section.isReorderable ? ' app-chat-panel__conversation-section-toggle--reorderable' : ''}`}
              aria-expanded={isMobileSectionOpen}
              onClick={() => {
                if (isMoveControlsActive) {
                  setActiveGeneralSectionMoveControlsKey(null);
                  return;
                }

                setMobileConversationSectionOpen((previous) => ({
                  ...previous,
                  [section.key]: !(previous[section.key] ?? section.defaultOpen),
                }));
              }}
            >
              <span className="app-chat-panel__conversation-section-toggle-main">
                <span className="app-chat-panel__conversation-section-toggle-icon" aria-hidden="true">
                  {isMobileSectionOpen ? (
                    <FolderOpenOutlined />
                  ) : section.key === 'processing' ? (
                    <ClockCircleOutlined />
                  ) : section.key === 'failed' ? (
                    <WarningOutlined />
                  ) : section.key === 'unread' ? (
                    <MessageOutlined />
                  ) : (
                    <FolderOutlined />
                  )}
                </span>
                <span className="app-chat-panel__conversation-section-title">{section.title}</span>
                <span className="app-chat-panel__conversation-section-count">{section.items.length}</span>
              </span>
              <span className="app-chat-panel__conversation-section-toggle-caret" aria-hidden="true">
                {isMobileSectionOpen ? <CaretDownOutlined /> : <CaretRightOutlined />}
              </span>
            </button>
            {section.isReorderable ? (
              <span className={`app-chat-panel__conversation-section-move-controls${isMoveControlsActive ? ' is-active' : ''}`}>
                <button
                  type="button"
                  className={`app-chat-panel__conversation-section-move-activator${isMoveControlsActive ? ' is-active' : ''}`}
                  aria-label={`${section.title} 섹션 위치 변경 활성화`}
                  aria-pressed={isMoveControlsActive}
                  onContextMenu={(event) => {
                    event.preventDefault();
                  }}
                  onClick={(event) => {
                    event.preventDefault();
                    event.stopPropagation();
                    toggleGeneralSectionMoveControls(section.key);
                  }}
                >
                  <ControlOutlined />
                </button>
                {isMoveControlsActive ? (
                  <>
                    <button
                      type="button"
                      className="app-chat-panel__conversation-section-move-button"
                      onClick={(event) => {
                        event.stopPropagation();
                        moveGeneralConversationSection(section.key, 'up');
                      }}
                      disabled={!canMoveSectionUp}
                      aria-label={`${section.title} 섹션 위로 이동`}
                    >
                      위로
                    </button>
                    <button
                      type="button"
                      className="app-chat-panel__conversation-section-move-button"
                      onClick={(event) => {
                        event.stopPropagation();
                        moveGeneralConversationSection(section.key, 'down');
                      }}
                      disabled={!canMoveSectionDown}
                      aria-label={`${section.title} 섹션 아래로 이동`}
                    >
                      아래로
                    </button>
                  </>
                ) : null}
              </span>
            ) : null}
          </div>
          {visibleMobileItems.length > 0 ? (
            <div
              className={`app-chat-panel__conversation-section-items${
                !isMobileSectionOpen ? ' app-chat-panel__conversation-section-items--collapsed-active' : ''
              }`}
            >
              {visibleMobileItems.map((item) => renderConversationListItem(item, section.key))}
            </div>
          ) : null}
          </>
        ) : (
          <>
          <div
            className={`app-chat-panel__conversation-section-header app-chat-panel__conversation-section-header--${section.tone}${
              section.isReorderable ? ' app-chat-panel__conversation-section-header--reorderable' : ''
            }`}
            onClick={() => {
              if (isMoveControlsActive) {
                setActiveGeneralSectionMoveControlsKey(null);
              }
            }}
          >
            <span className="app-chat-panel__conversation-section-header-main">
              <span className="app-chat-panel__conversation-section-title">{section.title}</span>
              <span className="app-chat-panel__conversation-section-count">{section.items.length}</span>
            </span>
            {section.isReorderable ? (
              <span className={`app-chat-panel__conversation-section-move-controls${isMoveControlsActive ? ' is-active' : ''}`}>
                <button
                  type="button"
                  className={`app-chat-panel__conversation-section-move-activator${isMoveControlsActive ? ' is-active' : ''}`}
                  aria-label={`${section.title} 섹션 위치 변경 활성화`}
                  aria-pressed={isMoveControlsActive}
                  onContextMenu={(event) => {
                    event.preventDefault();
                  }}
                  onClick={(event) => {
                    event.preventDefault();
                    event.stopPropagation();
                    toggleGeneralSectionMoveControls(section.key);
                  }}
                >
                  <ControlOutlined />
                </button>
                {isMoveControlsActive ? (
                  <>
                    <button
                      type="button"
                      className="app-chat-panel__conversation-section-move-button"
                      onClick={(event) => {
                        event.stopPropagation();
                        moveGeneralConversationSection(section.key, 'up');
                      }}
                      disabled={!canMoveSectionUp}
                      aria-label={`${section.title} 섹션 위로 이동`}
                    >
                      위로
                    </button>
                    <button
                      type="button"
                      className="app-chat-panel__conversation-section-move-button"
                      onClick={(event) => {
                        event.stopPropagation();
                        moveGeneralConversationSection(section.key, 'down');
                      }}
                      disabled={!canMoveSectionDown}
                      aria-label={`${section.title} 섹션 아래로 이동`}
                    >
                      아래로
                    </button>
                  </>
                ) : null}
              </span>
            ) : null}
          </div>
          {section.items.map((item) => renderConversationListItem(item, section.key))}
          </>
        )}
      </div>
    );
  };

  const replaceChatSessionInUrl = (sessionId: string) => {
    if (location.pathname !== chatRoutePath) {
      return;
    }

    const nextSessionId = sessionId.trim();
    const searchParams = new URLSearchParams(location.search);
    const currentSessionId = searchParams.get('sessionId')?.trim() ?? '';

    if (nextSessionId) {
      if (currentSessionId === nextSessionId) {
        return;
      }

      searchParams.set('sessionId', nextSessionId);
    } else {
      if (!currentSessionId) {
        return;
      }

      searchParams.delete('sessionId');
    }

    const nextSearch = searchParams.toString();
    navigate(
      {
        pathname: location.pathname,
        search: nextSearch ? `?${nextSearch}` : '',
      },
      { replace: true },
    );
  };

  const replaceChatViewInUrl = useCallback(
    (view: ChatPanelView) => {
      if (location.pathname !== chatRoutePath) {
        return;
      }

      const searchParams = new URLSearchParams(location.search);

      if (view === 'chat') {
        searchParams.delete('chatView');
      } else {
        searchParams.set('chatView', view);
      }

      const nextSearch = searchParams.toString();
      navigate(
        {
          pathname: location.pathname,
          search: nextSearch ? `?${nextSearch}` : '',
        },
        { replace: true },
      );
    },
    [location.pathname, location.search, navigate],
  );

  const clearRequestedRuntimeLogInUrl = useCallback(() => {
    if (location.pathname !== chatRoutePath) {
      return;
    }

    const searchParams = new URLSearchParams(location.search);

    if (!searchParams.has('runtimeRequestId') && !searchParams.has('chatView')) {
      return;
    }

    searchParams.delete('runtimeRequestId');
    searchParams.delete('chatView');

    const nextSearch = searchParams.toString();
    navigate(
      {
        pathname: location.pathname,
        search: nextSearch ? `?${nextSearch}` : '',
      },
      { replace: true },
    );
  }, [location.pathname, location.search, navigate]);

  const openConversationSession = (sessionId: string) => {
    if (isMobileViewport && dismissMobileActiveTextInput()) {
      applyViewportCssVars();
      scheduleViewportRecoverySync();
    }

    isClosingConversationRef.current = false;
    setPreserveEmptyConversationSelection(false);
    const openedConversation = conversationItemsRef.current.find((item) => item.sessionId === sessionId) ?? null;
    if (openedConversation?.hasUnreadResponse) {
      rememberConversationUnreadCleared(openedConversation);
    }
    replaceChatSessionInUrl(sessionId);
    clearRequestedRuntimeLogInUrl();
    setActiveView('chat');
    setIsRoomsListDrawerOpen(false);
    setConversationItems((previous) =>
      previous.map((item) =>
        item.sessionId === sessionId && item.hasUnreadResponse
          ? {
              ...item,
              hasUnreadResponse: false,
            }
          : item,
      ),
    );
    const now = new Date().toISOString();
    const cachedMessages = sessionMessageCacheRef.current.get(sessionId) ?? [];
    const hasCachedMessages = cachedMessages.length > 0;
    const cachedConversationWindow = limitConversationWindowToRecentRequests(
      cachedMessages,
      requestItemsRef.current,
      sessionId,
      CHAT_CONVERSATION_DETAIL_PAGE_SIZE,
    );

    if (sessionId === activeSessionId && !isConversationPaneClosed) {
      sessionMessageCacheRef.current.set(sessionId, cachedConversationWindow.messages);
      setMessages(cachedConversationWindow.messages);
      setRequestItems((previous) => {
        const preservedOtherSessions = previous.filter((item) => item.sessionId !== sessionId);
        return [...preservedOtherSessions, ...cachedConversationWindow.requests];
      });
      setHasOlderMessages(cachedConversationWindow.requests.length >= CHAT_CONVERSATION_DETAIL_PAGE_SIZE);
      setOldestLoadedMessageId(cachedConversationWindow.messages[0]?.id ?? null);
      if (isMobileViewport) {
        setIsMobileConversationView(true);
      }
      setActiveView('chat');
      setConversationRoomReloadKey((previous) => previous + 1);
      resyncConversationEntryState();
      return;
    }

    setConversationLoadingLabel('대화 내용을 불러오는 중입니다.');
    shouldForceStickToBottomOnNextLoadRef.current = sessionId === activeSessionId;
    setIsConversationContentLoading(true);
    setIsDeferringAuxiliaryChatRequests(true);
    setHasOlderMessages(false);
    setOldestLoadedMessageId(null);
    setIsLoadingOlderMessages(false);
    shouldStickToBottomRef.current = true;
    setShowScrollToBottom(false);
    setConversationItems((previous) => {
      const exists = previous.some((item) => item.sessionId === sessionId);

      if (exists) {
        return previous;
      }

      return [
        {
          sessionId,
          clientId: null,
          title: '대화 내용을 불러오는 중입니다.',
          requestBadgeLabel: null,
          chatTypeId: null,
          lastChatTypeId: null,
          generalSectionName: null,
          contextLabel: null,
          contextDescription: null,
          notifyOffline: true,
          hasUnreadResponse: false,
          currentRequestId: null,
          currentJobStatus: null,
          currentJobMessage: null,
          currentQueueSize: 0,
          currentStatusUpdatedAt: null,
          isPendingWork: false,
          pendingWorkReason: null,
          lastRequestPreview: '',
          lastMessagePreview: '',
          lastResponsePreview: '',
          createdAt: now,
          updatedAt: now,
          lastMessageAt: null,
        },
        ...previous,
      ];
    });
    setActiveSessionId(sessionId);
    if (sessionId === activeSessionId) {
      setConversationRoomReloadKey((previous) => previous + 1);
    }
    setIsConversationPaneClosed(false);

    if (isMobileViewport) {
      setIsMobileConversationView(true);
    }

    if (hasCachedMessages) {
      sessionMessageCacheRef.current.set(sessionId, cachedConversationWindow.messages);
    }
    setMessages(hasCachedMessages ? cachedConversationWindow.messages : []);
    setRequestItems((previous) => {
      const preservedOtherSessions = previous.filter((item) => item.sessionId !== sessionId);

      if (!hasCachedMessages) {
        return preservedOtherSessions;
      }

      return [...preservedOtherSessions, ...cachedConversationWindow.requests];
    });
    setActivePreviewId(null);
    setIsPreviewModalOpen(false);
    setActiveSystemStatus(null);
    setIsSystemStatusPending(false);
    setIsResourceStripOpen(false);
    shouldStickToBottomRef.current = true;
    setShowScrollToBottom(false);
    void reloadConversationItems();
  };

  useEffect(() => {
    const requestedView = getRequestedChatViewFromSearch(location.search);

    if (requestedView) {
      setActiveView(requestedView);
    }
  }, [location.search]);

  useEffect(() => {
    if (!isTitleClusterOpen) {
      return;
    }

    const handleWindowPointerDown = (event: PointerEvent) => {
      if (!(event.target instanceof Node)) {
        return;
      }

      if (titleClusterRef.current?.contains(event.target)) {
        return;
      }

      setIsTitleClusterOpen(false);
    };

    window.addEventListener('pointerdown', handleWindowPointerDown);

    return () => {
      window.removeEventListener('pointerdown', handleWindowPointerDown);
    };
  }, [isTitleClusterOpen]);

  useEffect(() => {
    if (!isMobileActionGroupOpen) {
      return;
    }

    const handleWindowPointerDown = (event: PointerEvent) => {
      if (!(event.target instanceof Node)) {
        return;
      }

      if (mobileActionGroupRef.current?.contains(event.target)) {
        return;
      }

      setIsMobileActionGroupOpen(false);
    };

    window.addEventListener('pointerdown', handleWindowPointerDown);

    return () => {
      window.removeEventListener('pointerdown', handleWindowPointerDown);
    };
  }, [isMobileActionGroupOpen]);

  useEffect(() => {
    if (!isMobileViewport) {
      setIsMobileActionGroupOpen(false);
    }
  }, [isMobileViewport]);

  useEffect(() => {
    setIsMobileActionGroupOpen(false);
  }, [activeSessionId]);

  useEffect(() => {
    clearLegacyChatMessageStorage();
  }, []);

  useEffect(() => {
    conversationItemsRef.current = conversationItems;
  }, [conversationItems]);

  useEffect(() => {
    messagesRef.current = messages;
  }, [messages]);

  useEffect(() => {
    requestItemsRef.current = requestItems;
  }, [requestItems]);

  useEffect(() => {
    if (!activeSessionId) {
      return;
    }

    sessionMessageCacheRef.current.set(activeSessionId, messages);
  }, [messages]);

  const updatePendingMessageStatus = (
    requestId: string,
    status: 'retrying' | 'failed' | null,
    retryCount = 0,
  ) => {
    setMessages((previous) =>
      previous.map((message) =>
        message.clientRequestId === requestId
          ? {
              ...message,
              deliveryStatus: status,
              retryCount,
            }
          : message,
      ),
    );
  };

  const sendChatRequest = (socket: WebSocket, request: PendingChatRequest) => {
    socket.send(
      JSON.stringify({
        type: 'message:send',
        payload: {
          sessionId: request.sessionId,
          text: request.text,
          requestOrigin: request.origin ?? 'composer',
          parentRequestId: request.parentRequestId?.trim() || null,
          promptContextRef: request.promptContextRef ?? null,
          omitPromptHistory: request.omitPromptHistory === true,
          codexModel: request.codexModel,
          chatTypeId: request.chatTypeId,
          chatTypeLabel: request.chatTypeLabel,
          chatTypeDescription: request.chatTypeDescription,
          chatTypeBaseDescription: request.chatTypeBaseDescription,
          defaultContextIds: request.defaultContextIds,
          defaultContexts: request.defaultContexts,
          customContextTitle: request.customContextTitle,
          customContextContent: request.customContextContent,
          requestId: request.requestId,
          mode: request.mode,
        },
      }),
    );
  };

  const {
    cancelPendingRequest,
    handleClearConversation,
    deleteStoredRequest,
    handleDeleteConversation,
    removeQueuedComposerRequest,
    retryPendingRequest,
  } = useConversationRoomActionsController({
    activeSessionId,
    requestedSessionId: requestedSessionId ?? '',
    handledRequestedSessionIdRef,
    isClosingConversationRef,
    conversationItems,
    isMobileViewport,
    pendingRequestsRef,
    sessionMessageCacheRef,
    socketRef,
    setConversationItems,
    setMessages,
    setRequestItems,
    setActiveSessionId,
    setDraft,
    setComposerAttachments,
    setCopiedMessageId,
    setActivePreviewId,
    setIsPreviewModalOpen,
    setActiveSystemStatus,
    setIsSystemStatusPending,
    setIsResourceStripOpen,
    setIsConversationPaneClosed,
    setIsMobileConversationView,
    setPreserveEmptyConversationSelection: (value: boolean) => {
      setPreserveEmptyConversationSelection(mode === 'rooms' ? value : false);
    },
    updatePendingMessageStatus,
    sendChatRequest,
    createLocalMessage,
    replaceChatSessionInUrl,
    messageApi,
  });
  const openClearConversationDataModal = useCallback(() => {
    if (!activeConversation) {
      return;
    }

    setPendingClearConversationSessionId(activeConversation.sessionId);
  }, [activeConversation]);
  const openDeleteConversationModal = useCallback(() => {
    if (!activeConversation) {
      return;
    }

    setPendingDeleteSessionId(activeConversation.sessionId);
  }, [activeConversation]);
  const handleMarkAllFilteredConversationsRead = useCallback(async () => {
    const targetSessionIds = Array.from(
      new Set(
        unreadFilteredConversationItems
          .map((item) => item.sessionId.trim())
          .filter(Boolean),
      ),
    );

    if (targetSessionIds.length === 0) {
      messageApi.info('읽음 처리할 대화가 없습니다.');
      return;
    }

    const targetSessionIdSet = new Set(targetSessionIds);
    unreadFilteredConversationItems.forEach((item) => {
      if (targetSessionIdSet.has(item.sessionId)) {
        rememberConversationUnreadCleared(item);
      }
    });
    setIsMarkingAllConversationsRead(true);
    setConversationItems((previous) =>
      previous.map((item) =>
        targetSessionIdSet.has(item.sessionId)
          ? {
              ...item,
              hasUnreadResponse: false,
            }
          : item,
      ),
    );

    try {
      const results = await Promise.allSettled(
        targetSessionIds.map((sessionId) =>
          Promise.all([
            chatGateway.markConversationRead(sessionId),
            chatGateway.markRoomNotificationMessagesRead(sessionId),
            chatGateway.dismissRoomNotifications(sessionId),
          ]),
        ),
      );
      const failedCount = results.filter((result) => result.status === 'rejected').length;

      if (failedCount > 0) {
        void reloadConversationItems();
        messageApi.warning(`전체 읽기 처리 중 ${failedCount}개 대화는 다시 확인이 필요합니다.`);
      } else {
        messageApi.success(`${targetSessionIds.length}개 대화를 읽음 처리했습니다.`);
      }
    } finally {
      setIsMarkingAllConversationsRead(false);
    }
  }, [messageApi, reloadConversationItems, unreadFilteredConversationItems]);
  const handleDeleteAllFilteredConversations = useCallback(async () => {
    const targetConversations = filteredConversationItems.filter((item) => item.sessionId.trim());

    if (targetConversations.length === 0) {
      messageApi.info('삭제할 대화가 없습니다.');
      setIsDeleteAllConversationsModalOpen(false);
      return;
    }

    setIsDeletingAllConversations(true);

    try {
      const deleteResults = await Promise.allSettled(
        targetConversations.map(async (conversation) => {
          if (!conversation.isDraftOnly) {
            await chatGateway.deleteConversation(conversation.sessionId);
          }

          return conversation.sessionId;
        }),
      );

      const succeededSessionIds = deleteResults.flatMap((result) =>
        result.status === 'fulfilled' ? [result.value] : [],
      );
      const failedCount = deleteResults.filter((result) => result.status === 'rejected').length;

      const succeededSessionIdSet = new Set(succeededSessionIds);

      if (succeededSessionIdSet.size > 0) {
        succeededSessionIds.forEach((sessionId) => {
          sessionMessageCacheRef.current.delete(sessionId);
        });
        pendingRequestsRef.current = pendingRequestsRef.current.filter(
          (request) => !succeededSessionIdSet.has(request.sessionId),
        );
        setConversationItems((previous) => previous.filter((item) => !succeededSessionIdSet.has(item.sessionId)));
        setRequestItems((previous) => previous.filter((item) => !succeededSessionIdSet.has(item.sessionId)));

        if (succeededSessionIdSet.has(activeSessionId)) {
          isClosingConversationRef.current = true;
          handledRequestedSessionIdRef.current = '';
          setPreserveEmptyConversationSelection(mode === 'rooms');
          replaceChatSessionInUrl('');
          chatConnectionGateway.resetLastReceivedEventId('');
          setActiveSessionId('');
          setMessages([]);
          setRequestItems([]);
          setDraft('');
          setComposerAttachments([]);
          setCopiedMessageId(null);
          setActivePreviewId(null);
          setIsPreviewModalOpen(false);
          setActiveSystemStatus(null);
          setIsSystemStatusPending(false);
          setIsResourceStripOpen(false);
          setIsConversationPaneClosed(true);
          setIsMobileConversationView(false);
        } else if (requestedSessionId && succeededSessionIdSet.has(requestedSessionId)) {
          handledRequestedSessionIdRef.current = '';
          replaceChatSessionInUrl(activeSessionId);
        }
      }

      setIsDeleteAllConversationsModalOpen(false);

      if (failedCount > 0) {
        void reloadConversationItems();
        messageApi.warning(`전체 삭제 중 ${failedCount}개 대화는 삭제되지 않았습니다.`);
      } else if (succeededSessionIdSet.size > 0) {
        messageApi.success(`${succeededSessionIdSet.size}개 대화를 삭제했습니다.`);
      }
    } finally {
      setIsDeletingAllConversations(false);
    }
  }, [
    activeSessionId,
    filteredConversationItems,
    isMobileViewport,
    messageApi,
    reloadConversationItems,
    replaceChatSessionInUrl,
    requestedSessionId,
    setActivePreviewId,
    setActiveSystemStatus,
    setComposerAttachments,
    setConversationItems,
    setCopiedMessageId,
    setDraft,
    setIsConversationPaneClosed,
    setIsMobileConversationView,
    setIsPreviewModalOpen,
    setIsResourceStripOpen,
    setIsSystemStatusPending,
    setMessages,
    setRequestItems,
  ]);

  useEffect(() => {
    if (connectionState !== 'connected') {
      return;
    }

    const socket = socketRef.current;

    if (!socket || socket.readyState !== WebSocket.OPEN) {
      return;
    }

    const activePendingRequests = pendingRequestsRef.current.filter(
      (request) => request.sessionId === activeSessionId && !request.failed,
    );

    if (activePendingRequests.length === 0) {
      return;
    }

    pendingRequestsRef.current = pendingRequestsRef.current.flatMap((request) => {
      if (request.sessionId !== activeSessionId || request.failed) {
        return [request];
      }

      try {
        sendChatRequest(socket, request);
        updatePendingMessageStatus(request.requestId, null, request.retryCount);
        return [];
      } catch {
        const nextRetryCount = Math.min(request.retryCount + 1, CHAT_MAX_RETRY_ATTEMPTS);
        updatePendingMessageStatus(request.requestId, 'retrying', nextRetryCount);
        return [{ ...request, retryCount: nextRetryCount }];
      }
    });
  }, [activeSessionId, connectionState, socketRef]);

  useEffect(() => {
    if (typeof window === 'undefined') {
      return;
    }

    const mediaQuery = window.matchMedia('(max-width: 1180px)');
    const syncViewport = () => {
      const matches = mediaQuery.matches;
      setIsMobileViewport(matches);
      setIsMobileConversationView(matches ? false : true);
      setIsConversationPaneClosed(false);
    };

    syncViewport();
    mediaQuery.addEventListener('change', syncViewport);

    return () => {
      mediaQuery.removeEventListener('change', syncViewport);
    };
  }, []);

  useEffect(() => {
    if (typeof window === 'undefined') {
      return;
    }

    const syncIpadLikeViewport = () => {
      setIsIpadLikeViewport(isIpadLikeChatViewport());
    };

    syncIpadLikeViewport();
    window.addEventListener('resize', syncIpadLikeViewport);
    window.addEventListener('orientationchange', syncIpadLikeViewport);

    return () => {
      window.removeEventListener('resize', syncIpadLikeViewport);
      window.removeEventListener('orientationchange', syncIpadLikeViewport);
    };
  }, []);

  useEffect(() => {
    if (activeSessionId) {
      if (!activeConversation) {
        return;
      }

      const persistedChatTypeId =
        activeConversation.chatTypeId?.trim() || activeConversation.lastChatTypeId?.trim() || null;

      if (persistedChatTypeId) {
        setStoredChatSessionLastTypeId(activeSessionId, persistedChatTypeId);
      }

      if (syncedSelectedChatTypeSessionIdRef.current !== activeSessionId) {
        syncedSelectedChatTypeSessionIdRef.current = activeSessionId;

        if (persistedChatTypeId) {
          if (selectedChatTypeId !== persistedChatTypeId) {
            setSelectedChatTypeId(persistedChatTypeId);
          }
          return;
        }

        const storedChatTypeId = getStoredChatSessionLastTypeId(activeSessionId);

        if (storedChatTypeId && selectedChatTypeId !== storedChatTypeId) {
          setSelectedChatTypeId(storedChatTypeId);
          return;
        }
      }
    } else {
      syncedSelectedChatTypeSessionIdRef.current = null;
    }

    if (selectedChatTypeId && availableChatTypes.some((item) => item.id === selectedChatTypeId)) {
      return;
    }

    setSelectedChatTypeId(availableChatTypes[0]?.id ?? null);
  }, [activeConversation?.chatTypeId, activeConversation?.lastChatTypeId, activeSessionId, availableChatTypes, selectedChatTypeId]);

  useEffect(() => {
    if (activeSessionId) {
      const persistedCodexModel = normalizeCodexModel(activeConversation?.codexModel);

      if (selectedCodexModel !== persistedCodexModel) {
        setSelectedCodexModel(persistedCodexModel);
      }
      return;
    }

    if (selectedCodexModel !== DEFAULT_CODEX_MODEL) {
      setSelectedCodexModel(DEFAULT_CODEX_MODEL);
    }
  }, [activeConversation?.codexModel, activeSessionId, selectedCodexModel]);

  useEffect(() => {
    if (createConversationChatTypeId && availableChatTypes.some((item) => item.id === createConversationChatTypeId)) {
      return;
    }

    setCreateConversationChatTypeId(selectedChatType?.id ?? availableChatTypes[0]?.id ?? null);
  }, [availableChatTypes, createConversationChatTypeId, selectedChatType?.id]);

  useEffect(() => {
    const normalizedCreateConversationModel = normalizeCodexModel(createConversationCodexModel);

    if (normalizedCreateConversationModel !== createConversationCodexModel) {
      setCreateConversationCodexModel(normalizedCreateConversationModel);
    }
  }, [createConversationCodexModel]);

  useEffect(() => {
    if (!activeSessionId || !selectedChatTypeId || !selectedChatType || isChatTypeSelectionLocked) {
      return;
    }

    const currentChatTypeId = activeConversation?.chatTypeId?.trim() || null;
    const currentLastChatTypeId = activeConversation?.lastChatTypeId?.trim() || null;
    const hasPersistedConversationChatType = Boolean(currentChatTypeId || currentLastChatTypeId);
    const hasExplicitSelectionIntent = chatTypeSelectionIntentSessionIdRef.current === activeSessionId;

    if (!hasPersistedConversationChatType && !hasExplicitSelectionIntent) {
      return;
    }

    if (currentChatTypeId === selectedChatTypeId && currentLastChatTypeId === selectedChatTypeId) {
      if (hasExplicitSelectionIntent) {
        chatTypeSelectionIntentSessionIdRef.current = null;
      }
      return;
    }

    setConversationItems((previous) =>
      previous.map((entry) =>
        entry.sessionId === activeSessionId
          ? {
              ...entry,
              chatTypeId: selectedChatTypeId,
              lastChatTypeId: selectedChatTypeId,
              contextLabel: selectedChatType.name,
              contextDescription: null,
            }
          : entry,
      ),
    );

    void chatGateway.updateConversation(activeSessionId, {
      chatTypeId: selectedChatTypeId,
      lastChatTypeId: selectedChatTypeId,
      contextLabel: selectedChatType.name,
      contextDescription: null,
    }).then((item) => {
      setStoredChatSessionLastTypeId(activeSessionId, selectedChatTypeId);
      if (chatTypeSelectionIntentSessionIdRef.current === activeSessionId) {
        chatTypeSelectionIntentSessionIdRef.current = null;
      }
      setConversationItems((previous) =>
        previous.map((entry) => (entry.sessionId === item.sessionId ? item : entry)),
      );
    }).catch((error: unknown) => {
      if (chatTypeSelectionIntentSessionIdRef.current === activeSessionId) {
        chatTypeSelectionIntentSessionIdRef.current = null;
      }
      messageApi.error(error instanceof Error ? error.message : '채팅유형 저장에 실패했습니다.');
      setConversationItems((previous) =>
        previous.map((entry) =>
          entry.sessionId === activeSessionId && activeConversation
            ? {
                ...entry,
                chatTypeId: activeConversation.chatTypeId,
                lastChatTypeId: activeConversation.lastChatTypeId,
                contextLabel: activeConversation.contextLabel,
                contextDescription: activeConversation.contextDescription,
              }
            : entry,
        ),
      );
      setSelectedChatTypeId(activeConversation?.chatTypeId?.trim() || activeConversation?.lastChatTypeId?.trim() || null);
    });
  }, [
    activeConversation?.chatTypeId,
    activeConversation?.contextDescription,
    activeConversation?.contextLabel,
    activeConversation?.lastChatTypeId,
    activeSessionId,
    isChatTypeSelectionLocked,
    messageApi,
    resolveComposedChatTypeDescription,
    selectedChatType,
    selectedChatTypeId,
    setConversationItems,
  ]);

  useEffect(() => {
    if (!activeSessionId) {
      return;
    }

    const nextCodexModel = normalizeCodexModel(selectedCodexModel);
    const currentCodexModel = normalizeCodexModel(activeConversation?.codexModel);

    if (currentCodexModel === nextCodexModel) {
      return;
    }

    setConversationItems((previous) =>
      previous.map((entry) =>
        entry.sessionId === activeSessionId
          ? {
              ...entry,
              codexModel: nextCodexModel,
            }
          : entry,
      ),
    );

    void chatGateway.updateConversation(activeSessionId, {
      codexModel: nextCodexModel,
    }).then((item) => {
      setConversationItems((previous) =>
        previous.map((entry) => (entry.sessionId === item.sessionId ? item : entry)),
      );
    }).catch((error: unknown) => {
      messageApi.error(error instanceof Error ? error.message : 'Codex 모델 저장에 실패했습니다.');
      setConversationItems((previous) =>
        previous.map((entry) =>
          entry.sessionId === activeSessionId && activeConversation
            ? {
                ...entry,
                codexModel: activeConversation.codexModel ?? DEFAULT_CODEX_MODEL,
              }
            : entry,
        ),
      );
      setSelectedCodexModel(currentCodexModel);
    });
  }, [activeConversation, activeSessionId, messageApi, selectedCodexModel, setConversationItems]);

  useEffect(() => {
    const nextView = requestedChatView ?? (initialView === 'errors' ? 'errors' : 'chat');

    if (nextView === 'errors' && !hasAccess) {
      setActiveView('chat');
      return;
    }

    if (nextView === 'runtime' && !showRuntimeViewShortcut) {
      setActiveView('chat');
      if (requestedChatView === 'runtime') {
        replaceChatViewInUrl('chat');
      }
      return;
    }

    setActiveView(nextView);
  }, [hasAccess, initialView, replaceChatViewInUrl, requestedChatView, showRuntimeViewShortcut]);

  useEffect(() => {
    return () => {
      if (copyFeedbackTimerRef.current !== null) {
        window.clearTimeout(copyFeedbackTimerRef.current);
      }
    };
  }, []);

  useEffect(() => {
    if (typeof document === 'undefined') {
      return;
    }

    const { body, documentElement } = document;
    const previousBodyOverflow = body.style.overflow;
    const previousHtmlOverflow = documentElement.style.overflow;
    const shouldLockOuterScroll = isMaximized || (lockOuterScrollOnMobile && isMobileViewport);

    if (shouldLockOuterScroll) {
      body.style.overflow = 'hidden';
      documentElement.style.overflow = 'hidden';
    }

    return () => {
      body.style.overflow = previousBodyOverflow;
      documentElement.style.overflow = previousHtmlOverflow;
    };
  }, [isMaximized, isMobileViewport, lockOuterScrollOnMobile]);

  useEffect(() => {
    if (location.pathname !== chatRoutePath) {
      handledRequestedSessionIdRef.current = '';
      return;
    }

    if (requestedSessionId && !shouldShowConversationForMode(requestedSessionId, mode)) {
      handledRequestedSessionIdRef.current = '';

      if (activeSessionId && shouldShowConversationForMode(activeSessionId, mode)) {
        replaceChatSessionInUrl(activeSessionId);
        return;
      }

      replaceChatSessionInUrl(firstConversationForMode?.sessionId ?? '');
      return;
    }

    if (requestedSessionId && !requestedChatView) {
      setActiveView('chat');
    }

    if (!requestedSessionId) {
      isClosingConversationRef.current = false;
      handledRequestedSessionIdRef.current = '';
      return;
    }

    if (isConversationListLoading) {
      return;
    }

    if (isClosingConversationRef.current && requestedSessionId === activeSessionId) {
      return;
    }

    if (
      requestedSessionId === activeSessionId &&
      handledRequestedSessionIdRef.current === requestedSessionId &&
      activeView === 'chat' &&
      !isConversationPaneClosed &&
      (!isMobileViewport || isMobileConversationView)
    ) {
      return;
    }

    handledRequestedSessionIdRef.current = requestedSessionId;

    if (requestedSessionId === activeSessionId) {
      openConversationSession(requestedSessionId);
      return;
    }

    openConversationSession(requestedSessionId);
  }, [
    activeSessionId,
    activeView,
    conversationItems,
    isConversationListLoading,
    isConversationPaneClosed,
    isMobileConversationView,
    isMobileViewport,
    location.pathname,
    requestedChatView,
    requestedSessionId,
    firstConversationForMode,
    mode,
  ]);

  useEffect(() => {
    if (!isMobileViewport) {
      return;
    }

    setMobileConversationSectionOpen((previous) => {
      const nextState = { ...DEFAULT_MOBILE_SECTION_OPEN_STATE, ...previous };
      conversationSections.forEach((section) => {
        if (!(section.key in nextState)) {
          nextState[section.key] = section.defaultOpen;
        }
      });
      return nextState;
    });
  }, [conversationSections, isMobileViewport]);

  useEffect(() => {
    if (location.pathname !== chatRoutePath) {
      return;
    }

    if (requestedSessionId || !activeSessionId || isConversationPaneClosed) {
      return;
    }

    replaceChatSessionInUrl(activeSessionId);
  }, [activeSessionId, isConversationPaneClosed, location.pathname, requestedSessionId]);

  useEffect(() => {
    if (requestedSessionId) {
      setPreserveEmptyConversationSelection(false);
      return;
    }

    if (isConversationListLoading || pendingConversationCreationRef.current.size > 0) {
      return;
    }

    if (firstConversationForMode == null) {
      if (preserveEmptyConversationSelection) {
        setActiveSessionId('');
        setMessages([]);
        setRequestItems([]);
        setIsConversationPaneClosed(false);
        setIsMobileConversationView(false);
        return;
      }

      setActiveSessionId('');
      setMessages([]);
      setRequestItems([]);
      setIsConversationPaneClosed(false);
      setIsMobileConversationView(false);
      return;
    }

    if (activeSessionId) {
      return;
    }

    if (preserveEmptyConversationSelection) {
      return;
    }

    if (location.pathname === chatRoutePath) {
      replaceChatSessionInUrl(firstConversationForMode.sessionId);
    }
  }, [activeSessionId, firstConversationForMode, isMobileViewport, preserveEmptyConversationSelection, requestedSessionId]);

  useEffect(() => {
    if (mode !== 'rooms' || roomsEntryMode !== 'direct') {
      return;
    }

    if (requestedSessionId || activeSessionId || isConversationListLoading || preserveEmptyConversationSelection) {
      return;
    }

    if (!matchingRoomConversation) {
      return;
    }

    openConversationSession(matchingRoomConversation.sessionId);
  }, [
    activeSessionId,
    isConversationListLoading,
    matchingRoomConversation,
    mode,
    openConversationSession,
    preserveEmptyConversationSelection,
    requestedSessionId,
    roomsEntryMode,
  ]);

  useEffect(() => {
    if (requestedSessionId) {
      return;
    }

    if (location.pathname !== chatRoutePath) {
      return;
    }

    if (isConversationListLoading) {
      return;
    }

    if (activeSessionId || conversationItems.length > 0) {
      // Keep the initial auto-create guard while an optimistic room is still
      // waiting for server persistence. Otherwise a failed create can bounce
      // back to the empty state and immediately trigger another auto-create.
      if (pendingConversationCreationRef.current.size === 0) {
        hasAttemptedInitialConversationRef.current = false;
      }
      return;
    }

    if (
      preserveEmptyConversationSelection ||
      hasAttemptedInitialConversationRef.current ||
      isCreatingImportedDraftConversationRef.current
    ) {
      return;
    }

    const initialChatType = (selectedChatType && isSelectedChatTypeAllowed ? selectedChatType : null) ?? availableChatTypes[0] ?? null;

    if (!initialChatType) {
      return;
    }

    hasAttemptedInitialConversationRef.current = true;
    setActiveView('chat');
    setSelectedChatTypeId(initialChatType.id);

    void handleCreateConversation({
      chatTypeOverride: initialChatType,
      persist: true,
    }).catch(() => {
      hasAttemptedInitialConversationRef.current = false;
    });
  }, [
    activeSessionId,
    activeView,
    availableChatTypes,
    conversationItems,
    handleCreateConversation,
    isConversationListLoading,
    location.pathname,
    preserveEmptyConversationSelection,
    requestedSessionId,
    selectedChatType,
    isSelectedChatTypeAllowed,
  ]);

  useEffect(() => {
    const previousConnectionState = previousConnectionStateRef.current;

    if (previousConnectionState === 'connected' && connectionState !== 'connected') {
      shouldRestoreConversationAfterReconnectRef.current = true;
    }

    previousConnectionStateRef.current = connectionState;
  }, [connectionState]);

  useEffect(() => {
    if (connectionState !== 'connected') {
      return;
    }

    if (isDeferringAuxiliaryChatRequests) {
      return;
    }

    let isCancelled = false;

    if (!activeSessionId.trim()) {
      void chatGateway.listConversations().then((items) => {
        if (!isCancelled) {
          setConversationItems((previous) => mergeConversationItemsPreservingTransientState(items, previous));
        }
      }).catch(() => {
        // 재연결 직후 목록 재조회 실패는 현재 목록 상태를 유지한다.
      });
    }

    return () => {
      isCancelled = true;
    };
  }, [activeSessionId, connectionState, isDeferringAuxiliaryChatRequests]);

  useEffect(() => {
    if (connectionState !== 'connected' || isDeferringAuxiliaryChatRequests) {
      return;
    }

    if (!shouldRestoreConversationAfterReconnectRef.current) {
      return;
    }

    shouldRestoreConversationAfterReconnectRef.current = false;

    if (activeSessionId.trim()) {
      setConversationRoomReloadKey((previous) => previous + 1);
    }
  }, [activeSessionId, connectionState, isDeferringAuxiliaryChatRequests]);

  useEffect(() => {
    if (connectionState !== 'disconnected') {
      return;
    }

    setConversationItems((previous) =>
      previous.map((item) => ({
        ...item,
        currentRequestId: null,
        currentJobStatus: null,
        currentJobMessage: null,
        currentQueueSize: 0,
      })),
    );
  }, [connectionState]);

  useEffect(() => {
    if (!activeSessionId) {
      return;
    }

    setConversationItems((previous) =>
      previous.map((item) => {
        if (item.sessionId !== activeSessionId) {
          return item;
        }

        const latestMessage = getLatestConversationPreviewMessage(messages);
        const latestResponseMessage =
          [...messages].reverse().find((message) => message.author === 'codex' && !isPreparingChatReplyText(message.text)) ?? null;
        const nextPreview = latestMessage ? createConversationPreviewText(latestMessage.text) : item.lastMessagePreview;
        const nextLastMessageAt = latestMessage?.timestamp?.trim() || item.lastMessageAt;
        const nextResponsePreview = latestResponseMessage?.text?.trim()
          ? createConversationPreviewText(latestResponseMessage.text)
          : item.lastResponsePreview;

        if (
          item.lastMessagePreview === nextPreview &&
          item.lastResponsePreview === nextResponsePreview &&
          item.lastMessageAt === nextLastMessageAt
        ) {
          return item;
        }

        return {
          ...item,
          lastMessagePreview: nextPreview,
          lastResponsePreview: nextResponsePreview,
          lastMessageAt: nextLastMessageAt,
        };
      }),
    );
  }, [activeSessionId, messages]);

  const {
    executeSendMessage,
    handleComposerFilesPicked,
    sendMessage,
  } = useConversationComposerController({
    activeSessionId,
    appConfigChat: appConfig.chat,
    getDraft: () => draftRef.current,
    composerAttachments,
    isComposerAttachmentUploading,
    selectedCodexModel: effectiveCodexModel,
    selectedChatType: effectiveChatType
      ? {
          id: effectiveChatType.id,
          name: effectiveChatType.name,
          description: effectiveChatTypeDescription,
          baseDescription: effectiveChatType.description,
          defaultContextIds: effectiveDefaultContextIds,
          defaultContexts: effectiveDefaultContexts,
          customContextTitle: effectiveRoomCustomContextTitle || null,
          customContextContent: effectiveRoomCustomContextContent || null,
        }
      : null,
    socketRef,
    composerRef,
    messagesRef,
    pendingRequestsRef,
    shouldStickToBottomRef,
    setDraft: setDraftValue,
    setComposerAttachments,
    setIsComposerAttachmentUploading,
    setMessages,
    setActiveSystemStatus,
    setIsSystemStatusPending,
    setShowScrollToBottom,
    setPendingContextConfirm,
    upsertRequestItem,
    syncConversationPreviewForRequest,
    updatePendingMessageStatus,
    createLocalMessage,
    createChatMessage,
    createActivityLogPlaceholder,
    buildOutgoingMessageText,
    summarizeRecentContext,
    mergeComposerAttachments,
    ensureSessionReady: ensureConversationSessionReady,
    sendChatRequest,
    releaseAutoScrollSuspension,
    scrollViewportToBottom,
  });

  const executeSendMessageSafely = useCallback(
    async (request: Parameters<typeof executeSendMessage>[0]) => {
      try {
        return await executeSendMessage(request);
      } catch (error) {
        const reason =
          error instanceof Error && error.message.trim()
            ? error.message.trim()
            : '요청 전송 중 오류가 발생했습니다.';
        setActiveSystemStatus(null);
        setIsSystemStatusPending(false);
        setMessages((previous) => [...previous.slice(-39), createLocalMessage(reason)]);
        return false;
      }
    },
    [createLocalMessage, executeSendMessage, setActiveSystemStatus, setIsSystemStatusPending, setMessages],
  );

  useEffect(() => {
    if (!pendingImportedDraftRequest?.autoSend) {
      return;
    }

    if (pendingContextConfirm) {
      return;
    }

    const requestedSessionIdValue = requestedSessionId?.trim() ?? '';

    if (requestedSessionIdValue && activeSessionId !== requestedSessionIdValue) {
      return;
    }

    if (!activeSessionId.trim()) {
      if (requestedSessionIdValue || isCreatingImportedDraftConversationRef.current) {
        return;
      }

      const importChatType = selectedChatType ?? availableChatTypes[0] ?? null;

      if (!importChatType) {
        return;
      }

      isCreatingImportedDraftConversationRef.current = true;
      setSelectedChatTypeId(importChatType.id);
      void handleCreateConversation({
        chatTypeOverride: importChatType,
        persist: true,
      }).finally(() => {
        isCreatingImportedDraftConversationRef.current = false;
      });
      return;
    }

        if (!effectiveChatType) {
      return;
    }

    setPendingImportedDraftRequest(null);
    setDraftValue('');
    executeSendMessageSafely({
      sessionId: activeSessionId,
      mode: pendingImportedDraftRequest.sendMode,
      text: pendingImportedDraftRequest.text,
      codexModel: effectiveCodexModel,
      chatTypeId: effectiveChatType.id,
      chatTypeLabel: effectiveChatType.name,
      chatTypeDescription: effectiveChatTypeDescription,
      chatTypeBaseDescription: effectiveChatType.description,
      defaultContextIds: effectiveDefaultContextIds,
      defaultContexts: effectiveDefaultContexts,
      customContextTitle: effectiveRoomCustomContextTitle || null,
      customContextContent: effectiveRoomCustomContextContent || null,
      includedContextCount: 0,
      omittedContextCount: 0,
    });
  }, [
    activeSessionId,
    availableChatTypes,
    effectiveDefaultContextIds,
    effectiveDefaultContexts,
    effectiveChatType,
    effectiveChatTypeDescription,
    effectiveRoomCustomContextContent,
    effectiveRoomCustomContextTitle,
    executeSendMessageSafely,
    handleCreateConversation,
    pendingContextConfirm,
    pendingImportedDraftRequest,
    requestedSessionId,
    selectedChatType,
    setDraftValue,
    setSelectedChatTypeId,
  ]);

  const handleSendWithoutPreviousContext = useCallback(
    (mode: 'queue' | 'direct', draftText?: string): SendMessageResult => {
      if (isComposerAttachmentUploading) {
        return 'blocked';
      }

      const nextConversationChatType =
        effectiveChatType ??
        (selectedChatType && isSelectedChatTypeAllowed
          ? {
              id: selectedChatType.id,
              name: selectedChatType.name,
              description: resolveComposedChatTypeDescription(selectedChatType),
            }
          : (availableChatTypes[0] ?? null));
      const trimmed = buildOutgoingMessageText(draftText ?? draftRef.current, composerAttachments).trim();

      if (!trimmed) {
        return 'blocked';
      }

      if (!nextConversationChatType) {
        setMessages((previous) => [
          ...previous.slice(-39),
          createLocalMessage('현재 사용자에게 허용된 컨텍스트가 없습니다. 관리 페이지에서 컨텍스트 권한을 확인하세요.'),
        ]);
        return 'blocked';
      }

      if (!activeSessionId.trim()) {
        setMessages((previous) => [
          ...previous.slice(-39),
          createLocalMessage('활성 대화방이 없어서 문맥 없이 보내기를 실행할 수 없습니다. 먼저 보낼 대화방을 열어 주세요.'),
        ]);
        return 'blocked';
      }

      executeSendMessageSafely({
        sessionId: activeSessionId,
        mode,
        text: trimmed,
        codexModel: effectiveCodexModel,
        chatTypeId: nextConversationChatType.id,
        chatTypeLabel: nextConversationChatType.name,
        chatTypeDescription: resolveComposedChatTypeDescription(nextConversationChatType, { sessionId: activeSessionId }),
        chatTypeBaseDescription: nextConversationChatType.description,
        defaultContextIds: effectiveDefaultContextIds,
        defaultContexts: effectiveDefaultContexts,
        customContextTitle: effectiveRoomCustomContextTitle || null,
        customContextContent: effectiveRoomCustomContextContent || null,
        includedContextCount: 0,
        omittedContextCount: 0,
        omitPromptHistory: true,
      });
      return 'sent';
    },
    [
      activeSessionId,
      availableChatTypes,
      buildOutgoingMessageText,
      composerAttachments,
      createLocalMessage,
      draftRef,
      effectiveDefaultContextIds,
      effectiveDefaultContexts,
      effectiveChatType,
      effectiveRoomCustomContextContent,
      effectiveRoomCustomContextTitle,
      executeSendMessageSafely,
      isComposerAttachmentUploading,
      isSelectedChatTypeAllowed,
      resolveComposedChatTypeDescription,
      selectedChatType,
      setMessages,
    ],
  );

  const handleComposerSend = useCallback(
    (draftText?: string): SendMessageResult => {
      const nextMode = isImmediateSendPinned ? 'direct' : 'queue';

      if (isSendWithoutContextEnabled) {
        setIsSendWithoutContextEnabled(false);
        return handleSendWithoutPreviousContext(nextMode, draftText);
      }

      return sendMessage({ sessionId: activeSessionId, mode: nextMode, draftText });
    },
    [activeSessionId, handleSendWithoutPreviousContext, isImmediateSendPinned, isSendWithoutContextEnabled, sendMessage],
  );

  const handleComposerSendImmediate = useCallback(
    (draftText?: string): SendMessageResult => {
      if (isSendWithoutContextEnabled) {
        setIsSendWithoutContextEnabled(false);
        return handleSendWithoutPreviousContext('direct', draftText);
      }

      return sendMessage({ sessionId: activeSessionId, mode: 'direct', draftText });
    },
    [activeSessionId, handleSendWithoutPreviousContext, isSendWithoutContextEnabled, sendMessage],
  );

  const resolvePromptContextRefForRequest = useCallback(
    (requestId: string): ChatPromptContextRef | null => {
      const normalizedRequestId = requestId.trim();

      if (!normalizedRequestId) {
        return null;
      }

      const requestMap = new Map(
        requestItemsRef.current
          .filter((item) => item.sessionId === activeSessionId)
          .map((item) => [item.requestId.trim(), item] as const),
      );
      const visitedRequestIds = new Set<string>();
      let currentRequestId = normalizedRequestId;

      while (currentRequestId && !visitedRequestIds.has(currentRequestId)) {
        visitedRequestIds.add(currentRequestId);
        const currentRequest = requestMap.get(currentRequestId);

        if (!currentRequest) {
          break;
        }

        const promptContextRef = normalizePromptContextRef(currentRequest.promptContextRef);

        if (promptContextRef) {
          return promptContextRef;
        }

        currentRequestId = currentRequest.parentRequestId?.trim() || '';
      }

      return null;
    },
    [activeSessionId],
  );

  const sendReplyToRequest = useCallback(
    ({
      text,
      parentRequestId,
      mode,
      origin = 'composer',
    }: {
      text: string;
      parentRequestId: string;
      mode: 'queue' | 'direct';
      origin?: 'composer' | 'prompt';
    }): SendMessageResult => {
      const trimmed = text.trim();
      const normalizedParentRequestId = parentRequestId.trim();
      const targetSessionId = activeSessionId.trim();

      if (!trimmed || !normalizedParentRequestId) {
        return 'blocked';
      }

      if (!effectiveChatType) {
        setMessages((previous) => [
          ...previous.slice(-39),
          createLocalMessage('현재 사용자에게 허용된 컨텍스트가 없어 답글 요청을 전송하지 못했습니다.'),
        ]);
        return 'blocked';
      }

      if (!targetSessionId) {
        setMessages((previous) => [
          ...previous.slice(-39),
          createLocalMessage('활성 대화방이 없어서 답글 요청을 전송하지 못했습니다.'),
        ]);
        return 'blocked';
      }

      void executeSendMessageSafely({
        sessionId: targetSessionId,
        mode,
        text: trimmed,
        origin,
        parentRequestId: normalizedParentRequestId,
        promptContextRef: resolvePromptContextRefForRequest(normalizedParentRequestId),
        codexModel: effectiveCodexModel,
        chatTypeId: effectiveChatType.id,
        chatTypeLabel: effectiveChatType.name,
        chatTypeDescription: effectiveChatTypeDescription,
        chatTypeBaseDescription: effectiveChatType.description,
        defaultContextIds: effectiveDefaultContextIds,
        defaultContexts: effectiveDefaultContexts,
        customContextTitle: effectiveRoomCustomContextTitle || null,
        customContextContent: effectiveRoomCustomContextContent || null,
        includedContextCount: 0,
        omittedContextCount: 0,
      });

      return 'sent';
    },
    [
      activeSessionId,
      createLocalMessage,
      effectiveCodexModel,
      effectiveDefaultContextIds,
      effectiveDefaultContexts,
      effectiveChatType,
      effectiveChatTypeDescription,
      effectiveRoomCustomContextContent,
      effectiveRoomCustomContextTitle,
      executeSendMessageSafely,
      resolvePromptContextRefForRequest,
      setMessages,
    ],
  );

  const handleSendReplyToResponse = useCallback(
    ({ draftText, parentRequestId }: { draftText?: string; parentRequestId: string }): SendMessageResult =>
      sendReplyToRequest({
        text: buildOutgoingMessageText(draftText ?? draftRef.current, composerAttachments),
        parentRequestId,
        mode: isImmediateSendPinned ? 'direct' : 'queue',
        origin: 'composer',
      }),
    [buildOutgoingMessageText, composerAttachments, draftRef, isImmediateSendPinned, sendReplyToRequest],
  );

  const handlePromoteQueuedRequest = useCallback(
    async (requestId: string, text: string) => {
      const normalizedRequestId = requestId.trim();
      const normalizedText = text.trim();

      if (!normalizedRequestId || !normalizedText) {
        return;
      }

      const existingRequest =
        requestItemsRef.current.find(
          (item) => item.sessionId === activeSessionId && item.requestId === normalizedRequestId,
        ) ?? null;

      try {
        await removeChatRuntimeJob(normalizedRequestId);
      } catch (error) {
        messageApi.error(error instanceof Error ? error.message : '대기 요청을 즉시 실행으로 전환하지 못했습니다.');
        return;
      }

      setRequestItems((previous) =>
        previous.map((item) =>
          item.sessionId === activeSessionId && item.requestId === normalizedRequestId
            ? {
                ...item,
                status: 'removed',
                statusMessage: '대기열에서 즉시 실행으로 전환되었습니다.',
                terminalAt: new Date().toISOString(),
                updatedAt: new Date().toISOString(),
              }
            : item,
        ),
      );

      if (existingRequest?.parentRequestId?.trim()) {
        sendReplyToRequest({
          text: normalizedText,
          parentRequestId: existingRequest.parentRequestId,
          mode: 'direct',
          origin: existingRequest.requestOrigin === 'prompt' ? 'prompt' : 'composer',
        });
        return;
      }

      handleComposerSendImmediate(normalizedText);
    },
    [activeSessionId, handleComposerSendImmediate, messageApi, sendReplyToRequest, setRequestItems],
  );

  const handleToggleImmediateSendPinned = useCallback(() => {
    const normalizedSessionId = activeSessionId.trim();

    if (!normalizedSessionId) {
      return;
    }

    setImmediateSendPinnedBySession((previous) => {
      const nextValue = previous[normalizedSessionId] !== true;
      const nextState = {
        ...previous,
        [normalizedSessionId]: nextValue,
      };

      writeStoredImmediateSendPinnedBySession(nextState);
      return nextState;
    });
  }, [activeSessionId]);

  const handlePromptSubmit = useCallback(
    async ({
      text,
      mode,
      parentRequestId,
      promptIndex,
      promptTitle,
      promptSignature,
      contextRef,
      sourceMessageId,
      selection,
    }: {
      text: string;
      mode: 'queue' | 'direct';
      parentRequestId?: string | null;
      promptIndex: number;
      promptTitle: string;
      promptSignature: string;
      contextRef?: ChatPromptContextRef | null;
      sourceMessageId: number;
      selection: {
        selectedValues: string[];
        freeText: string;
        stepSelections?: Array<{
          stepKey: string;
          stepTitle: string;
          selectedValues: string[];
          freeText: string;
          skipped?: boolean;
        }>;
        summaryText?: string | null;
        attachments?: ChatComposerAttachment[];
      };
    }) => {
      const trimmed = text.trim();
      const resolvedMode = isImmediateSendPinned ? 'direct' : mode;
      const targetSessionId = activeSessionId.trim();

      if (!trimmed) {
        return false;
      }

      if (!effectiveChatType) {
        setMessages((previous) => [
          ...previous.slice(-39),
          createLocalMessage('현재 사용자에게 허용된 컨텍스트가 없어 prompt 선택을 전송하지 못했습니다.'),
        ]);
        return false;
      }

      if (!targetSessionId) {
        setMessages((previous) => [
          ...previous.slice(-39),
          createLocalMessage('활성 대화방이 없어서 prompt 선택을 전송하지 못했습니다.'),
        ]);
        return false;
      }

      if (
        hasDuplicateActivePromptRequest(requestItemsRef.current, {
          sessionId: targetSessionId,
          text: trimmed,
          parentRequestId,
        })
      ) {
        setMessages((previous) => [
          ...previous.slice(-39),
          createLocalMessage('같은 prompt 선택이 이미 전송 중이라 중복 실행을 막았습니다.'),
        ]);
        return false;
      }

      if (parentRequestId?.trim()) {
        try {
          const persistedSelection = await submitChatPromptSelection(targetSessionId, {
            parentRequestId: parentRequestId.trim(),
            promptIndex,
            promptTitle,
            promptSignature,
            sourceMessageId,
            selectedValues: selection.selectedValues,
            freeText: selection.freeText,
            stepSelections: selection.stepSelections,
            summaryText: selection.summaryText ?? null,
            attachments: selection.attachments ?? [],
            followupText: trimmed,
            mode: resolvedMode,
            contextRef: contextRef ?? null,
          });
          const queuedRequestId = persistedSelection.queuedRequestId.trim();
          const submittedAt = new Date().toISOString();

          if (queuedRequestId) {
            upsertRequestItem({
              sessionId: targetSessionId,
              requestId: queuedRequestId,
              chatTypeId: effectiveChatType.id,
              chatTypeLabel: effectiveChatType.name,
              requestOrigin: 'prompt',
              parentRequestId: parentRequestId.trim(),
              promptContextRef: contextRef ?? null,
              status: resolvedMode === 'queue' ? 'queued' : 'accepted',
              statusMessage: resolvedMode === 'queue' ? 'prompt 후속 요청을 접수했습니다.' : 'prompt 후속 요청을 즉시 접수했습니다.',
              userMessageId: null,
              userText: trimmed,
              responseMessageId: null,
              responseText: '',
              hasResponse: false,
              canDelete: resolvedMode !== 'queue',
              createdAt: submittedAt,
              updatedAt: submittedAt,
              answeredAt: null,
              terminalAt: null,
            });
            syncConversationPreviewForRequest(targetSessionId, trimmed, submittedAt, {
              requestId: queuedRequestId,
              mode: resolvedMode,
              queueSize: resolvedMode === 'queue' ? 1 : 0,
              jobMessage: resolvedMode === 'queue' ? 'prompt 후속 요청 대기 중' : 'prompt 후속 요청 실행 대기 중',
            });
          }

          setRequestItems((previous) =>
            previous.map((item) =>
              item.sessionId === targetSessionId && item.requestId === parentRequestId.trim()
                ? mergeConversationRequestPreservingContent(item, persistedSelection.item)
              : item,
            ),
          );
          setMessages((previous) => upsertChatMessage(previous, persistedSelection.message));
          return true;
        } catch (error) {
          messageApi.error(error instanceof Error ? error.message : 'prompt 선택 상태를 저장하지 못했습니다.');
          return false;
        }
      }

      logConversationTrace('prompt-submit', {
        sessionId: targetSessionId,
        parentRequestId: parentRequestId?.trim() || null,
        mode,
        resolvedMode,
        requestPreview: createConversationPreviewText(trimmed),
        codexModel: effectiveCodexModel,
        chatTypeId: effectiveChatType.id,
        chatTypeLabel: effectiveChatType.name,
      });

      return executeSendMessageSafely({
        sessionId: targetSessionId,
        mode: resolvedMode,
        text: trimmed,
        origin: 'prompt',
        parentRequestId: parentRequestId?.trim() || null,
        promptContextRef: contextRef ?? null,
        codexModel: effectiveCodexModel,
        chatTypeId: effectiveChatType.id,
        chatTypeLabel: effectiveChatType.name,
        chatTypeDescription: effectiveChatTypeDescription,
        chatTypeBaseDescription: effectiveChatType.description,
        defaultContextIds: effectiveDefaultContextIds,
        defaultContexts: effectiveDefaultContexts,
        customContextTitle: effectiveRoomCustomContextTitle || null,
        customContextContent: effectiveRoomCustomContextContent || null,
        includedContextCount: 0,
        omittedContextCount: 0,
      });
    },
    [
      activeSessionId,
      createLocalMessage,
      effectiveDefaultContextIds,
      effectiveDefaultContexts,
      effectiveChatType,
      effectiveChatTypeDescription,
      isImmediateSendPinned,
      effectiveRoomCustomContextContent,
      effectiveRoomCustomContextTitle,
      executeSendMessageSafely,
      messageApi,
      resolvePromptContextRefForRequest,
      setMessages,
      setRequestItems,
    ],
  );

  const handleSubmitChildRequest = useCallback(
    async ({ text, parentRequestId }: { text: string; parentRequestId: string }) => {
      return (
        sendReplyToRequest({
          text,
          parentRequestId,
          mode: 'direct',
          origin: 'composer',
        }) === 'sent'
      );
    },
    [sendReplyToRequest],
  );

  const handleCompleteManualRequestBadge = useCallback(
    async (requestId: string, type: 'prompt' | 'verification') => {
      const normalizedSessionId = activeSessionId.trim();
      const normalizedRequestId = requestId.trim();

      if (!normalizedSessionId || !normalizedRequestId) {
        return;
      }

      try {
        const updatedRequest = await completeChatConversationRequestManualBadge(
          normalizedSessionId,
          normalizedRequestId,
          type,
        );

        setRequestItems((previous) => {
          const existingIndex = previous.findIndex(
            (item) => item.sessionId === normalizedSessionId && item.requestId === normalizedRequestId,
          );

          if (existingIndex < 0) {
            return previous;
          }

          const nextItems = [...previous];
          nextItems[existingIndex] = mergeConversationRequestPreservingContent(
            nextItems[existingIndex],
            updatedRequest,
          );
          return nextItems;
        });
        void reloadConversationItems();
      } catch (error) {
        throw error;
      }
    },
    [activeSessionId, reloadConversationItems, setRequestItems],
  );

  const handleRetryFailedRequest = useCallback(
    (request: ChatConversationRequest) => {
      const normalizedText = request.userText.trim();

      if (!normalizedText) {
        messageApi.error('재처리할 요청 본문이 없습니다.');
        return;
      }

      if (!effectiveChatType) {
        setMessages((previous) => [
          ...previous.slice(-39),
          createLocalMessage('현재 사용자에게 허용된 컨텍스트가 없어 실패 요청을 재처리하지 못했습니다.'),
        ]);
        return;
      }

      if (!activeSessionId.trim()) {
        setMessages((previous) => [
          ...previous.slice(-39),
          createLocalMessage('활성 대화방이 없어 실패 요청을 재처리하지 못했습니다.'),
        ]);
        return;
      }

      executeSendMessageSafely({
        sessionId: activeSessionId,
        requestId: request.requestId,
        mode: 'direct',
        text: normalizedText,
        origin: request.requestOrigin === 'prompt' ? 'prompt' : 'composer',
        parentRequestId: request.parentRequestId?.trim() || null,
        promptContextRef:
          normalizePromptContextRef(request.promptContextRef)
          ?? (request.parentRequestId?.trim() ? resolvePromptContextRefForRequest(request.parentRequestId) : null),
        codexModel: effectiveCodexModel,
        chatTypeId: effectiveChatType.id,
        chatTypeLabel: effectiveChatType.name,
        chatTypeDescription: effectiveChatTypeDescription,
        chatTypeBaseDescription: effectiveChatType.description,
        defaultContextIds: effectiveDefaultContextIds,
        defaultContexts: effectiveDefaultContexts,
        customContextTitle: effectiveRoomCustomContextTitle || null,
        customContextContent: effectiveRoomCustomContextContent || null,
        includedContextCount: 0,
        omittedContextCount: 0,
      });
    },
    [
      activeSessionId,
      createLocalMessage,
      effectiveDefaultContextIds,
      effectiveDefaultContexts,
      effectiveChatType,
      effectiveChatTypeDescription,
      effectiveRoomCustomContextContent,
      effectiveRoomCustomContextTitle,
      executeSendMessageSafely,
      messageApi,
      resolvePromptContextRefForRequest,
      setMessages,
    ],
  );

  const handleCopyMessage = async (message: ChatMessage) => {
    await copyText(message.text);
    setCopiedMessageId(message.id);

    if (copyFeedbackTimerRef.current !== null) {
      window.clearTimeout(copyFeedbackTimerRef.current);
    }

    copyFeedbackTimerRef.current = window.setTimeout(() => {
      setCopiedMessageId((current) => (current === message.id ? null : current));
      copyFeedbackTimerRef.current = null;
    }, 2000);
  };

  const handleCloseConversationPane = useCallback(() => {
    setIsMobileActionGroupOpen(false);

    if (mode === 'rooms' && !canReturnToRoomsList) {
      if (onRoomsClose) {
        onRoomsClose();
        return;
      }

      requestChatWindowAction('close');
      return;
    }

    isClosingConversationRef.current = true;
    handledRequestedSessionIdRef.current = '';
    replaceChatSessionInUrl('');
    setIsConversationPaneClosed(true);

    if (isMobileViewport) {
      setIsMobileConversationView(false);
    }
  }, [canReturnToRoomsList, isMobileViewport, mode, onRoomsClose, replaceChatSessionInUrl]);

  const isRoomsMode = mode === 'rooms';
  const roomSettingsMenuItems = activeConversation
    ? [
        ...(canReturnToRoomsList
          ? [{
              key: 'list',
              icon: <AppstoreOutlined />,
              label: '대화 목록',
            }]
          : []),
        {
          key: 'create',
          icon: <PlusOutlined />,
          label: '새 대화',
          disabled: availableChatTypes.length === 0,
        },
        {
          key: 'refresh',
          icon: <ReloadOutlined />,
          label: '현재 대화 새로고침',
        },
        {
          key: 'context',
          icon: <ControlOutlined />,
          label: '채팅방 Context',
        },
        {
          key: 'clear',
          icon: <DeleteOutlined />,
          danger: true,
          label: '대화 데이터 초기화',
        },
        {
          key: 'delete',
          icon: <CloseCircleOutlined />,
          danger: true,
          label: '채팅방 삭제',
        },
        {
          key: 'maximize',
          icon: isMaximized ? <FullscreenExitOutlined /> : <FullscreenOutlined />,
          label: isMaximized ? '최대화 해제' : '최대화',
        },
        {
          key: 'minimize',
          icon: <MinusOutlined />,
          label: '최소화',
        },
        {
          key: 'close-window',
          icon: <CloseOutlined />,
          danger: true,
          label: '창 닫기',
        },
        {
          key: 'divider-danger',
          type: 'divider' as const,
        },
      ]
    : [
        ...(canReturnToRoomsList
          ? [{
              key: 'list',
              icon: <AppstoreOutlined />,
              label: '대화 목록',
            }]
          : []),
        {
          key: 'create',
          icon: <PlusOutlined />,
          label: '새 대화',
          disabled: availableChatTypes.length === 0,
        },
        {
          key: 'maximize',
          icon: isMaximized ? <FullscreenExitOutlined /> : <FullscreenOutlined />,
          label: isMaximized ? '최대화 해제' : '최대화',
        },
        {
          key: 'minimize',
          icon: <MinusOutlined />,
          label: '최소화',
        },
        {
          key: 'close-window',
          icon: <CloseOutlined />,
          danger: true,
          label: '창 닫기',
        },
      ];
  const conversationListPane = (
    <aside className="app-chat-panel__conversation-list">
      <div className="app-chat-panel__conversation-list-header">
        <Text strong>대화 목록</Text>
        <Space size={6} className="app-chat-panel__conversation-list-header-actions">
          <Button
            size="small"
            icon={<ReloadOutlined />}
            aria-label="대화 목록 새로고침"
            title="대화 목록 새로고침"
            loading={isConversationListLoading}
            onClick={() => {
              void reloadConversationItems();
            }}
          />
          <Button
            size="small"
            icon={<PlusOutlined />}
            aria-label="새 대화 생성"
            title="새 대화 생성"
            disabled={availableChatTypes.length === 0}
            onClick={() => {
              openCreateConversationModal();
            }}
          />
          {!isRoomsMode ? (
            <Button
              size="small"
              icon={<CheckOutlined />}
              aria-label="현재 목록 전체 읽기"
              title="현재 목록 전체 읽기"
              onClick={() => {
                void handleMarkAllFilteredConversationsRead();
              }}
              loading={isMarkingAllConversationsRead}
              disabled={unreadFilteredConversationItems.length === 0}
            />
          ) : null}
          {!isRoomsMode ? (
            <Button
              size="small"
              icon={<DeleteOutlined />}
              danger
              aria-label="현재 목록 전체 삭제"
              title="현재 목록 전체 삭제"
              onClick={() => {
                setIsDeleteAllConversationsModalOpen(true);
              }}
              loading={isDeletingAllConversations}
              disabled={filteredConversationItems.length === 0}
            />
          ) : null}
          <Text type="secondary">{isConversationListLoading ? '불러오는 중' : `${conversationItems.length}건`}</Text>
        </Space>
      </div>
      <div className="app-chat-panel__conversation-list-search">
        <Input
          size="small"
          value={conversationSearch}
          placeholder="대화 검색"
          prefix={<SearchOutlined />}
          onChange={(event) => {
            setConversationSearch(event.target.value);
          }}
        />
      </div>
      <div className="app-chat-panel__conversation-list-body">
        {filteredConversationItems.length > 0 ? (
          conversationSections.map(renderConversationListSection)
        ) : (
          <div className="app-chat-panel__conversation-empty-list">
            {isRoomsMode ? (
              <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="저장된 대화가 없습니다." />
            ) : (
              <Space direction="vertical" size={12} align="center">
                <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="저장된 대화가 없습니다." />
                <Space size={8} wrap>
                  <Button
                    type="primary"
                    disabled={availableChatTypes.length === 0}
                    onClick={() => {
                      const initialChatType = (selectedChatType && isSelectedChatTypeAllowed ? selectedChatType : null)
                        ?? availableChatTypes[0]
                        ?? null;

                      if (!initialChatType) {
                        openCreateConversationModal();
                        return;
                      }

                      setSelectedChatTypeId(initialChatType.id);
                      void handleCreateConversation({
                        chatTypeOverride: initialChatType,
                        persist: true,
                      });
                    }}
                  >
                    대화 시작
                  </Button>
                  <Button
                    onClick={() => {
                      openCreateConversationModal();
                    }}
                    disabled={availableChatTypes.length === 0}
                  >
                    채팅유형 선택
                  </Button>
                </Space>
              </Space>
            )}
          </div>
        )}
      </div>
    </aside>
  );

  if (activeView === 'errors' && !hasAccess) {
    return (
      <Card
        title={<Text strong>에러 로그</Text>}
        className="app-chat-panel"
        styles={{ body: { height: '100%' } }}
      >
        <Alert
          showIcon
          type="warning"
          message="권한 토큰이 없어 Codex 채팅이 숨겨졌습니다."
          description="설정 > 토큰 관리에서 권한 토큰을 등록한 사용자만 Codex 채팅과 에러 로그를 사용할 수 있습니다."
        />
      </Card>
    );
  }

  return (
    <>
      {messageContextHolder}
      <Card
        title={
          isRoomsMode ? null : (
            <div className="app-chat-panel__title-group">
              <>
                <div
                  ref={titleClusterRef}
                  className={`app-chat-panel__title-cluster${isTitleClusterOpen ? ' is-open' : ''}`}
                >
                  <button
                    type="button"
                    className={`app-chat-panel__title-cluster-button${activeView === 'chat' ? ' is-active' : ''}`}
                    aria-label="화면 이동 메뉴 열기"
                    onClick={() => {
                      setIsTitleClusterOpen((current) => !current);
                    }}
                  >
                    <AppstoreOutlined />
                  </button>
                  <button
                    type="button"
                    className={`app-chat-panel__title-cluster-mini app-chat-panel__title-cluster-mini--chat${
                      activeView === 'chat' ? ' is-active' : ''
                    }`}
                    aria-label="대화 보기"
                    onClick={() => {
                      replaceChatViewInUrl('chat');
                      setActiveView('chat');
                      setIsTitleClusterOpen(false);
                    }}
                  >
                    <MessageOutlined />
                  </button>
                  {showRuntimeViewShortcut ? (
                    <button
                      type="button"
                      className={`app-chat-panel__title-cluster-mini app-chat-panel__title-cluster-mini--runtime${
                        activeView === 'runtime' ? ' is-active' : ''
                      }`}
                      aria-label="런타임 보기"
                      onClick={() => {
                        replaceChatViewInUrl('runtime');
                        setActiveView('runtime');
                        setIsTitleClusterOpen(false);
                      }}
                    >
                      <PaperClipOutlined />
                    </button>
                  ) : null}
                  {hasAccess ? (
                    <button
                      type="button"
                      className={`app-chat-panel__title-cluster-mini app-chat-panel__title-cluster-mini--error${
                        activeView === 'errors' ? ' is-active' : ''
                      }`}
                      aria-label="에러 로그 보기"
                      onClick={() => {
                        replaceChatViewInUrl('errors');
                        setActiveView('errors');
                        setIsTitleClusterOpen(false);
                      }}
                    >
                      <ExclamationCircleOutlined />
                    </button>
                  ) : null}
                </div>
                <div className="app-chat-panel__title-copy">
                  <div className="app-chat-panel__title-row">
                    <div className="app-chat-panel__title-heading">
                      {activeView === 'chat' ? (
                        <div className="app-chat-panel__title-heading-copy">
                          <div className="app-chat-panel__title-heading-main">
                            <Text strong>{activeConversation?.title || 'Codex Chat'}</Text>
                            {activeConversation ? (
                              <Button
                                type="text"
                                size="small"
                                className="app-chat-panel__title-edit-button"
                                icon={<EditOutlined />}
                                aria-label="채팅방 이름 수정"
                                title="채팅방 이름 수정"
                                onClick={() => {
                                  openConversationTitleEditor(activeConversation);
                                }}
                              />
                            ) : null}
                          </div>
                          {activeConversationRequestBadgeLabel ? (
                            <span className="app-chat-panel__conversation-title-badge">
                              {activeConversationRequestBadgeLabel}
                            </span>
                          ) : null}
                          {isRoomsMode ? (
                            <div className="app-chat-panel__rooms-title-meta">
                              <span
                                className={`app-chat-panel__rooms-share-live-dot${
                                  connectionState === 'connected' ? ' app-chat-panel__rooms-share-live-dot--connected' : ''
                                }`}
                                aria-label={
                                  connectionState === 'connected'
                                    ? '웹소켓 연결 정상'
                                    : connectionState === 'connecting'
                                      ? '웹소켓 연결 중'
                                      : '웹소켓 연결 끊김'
                                }
                                title={
                                  connectionState === 'connected'
                                    ? '웹소켓 연결 정상'
                                    : connectionState === 'connecting'
                                      ? '웹소켓 연결 중'
                                      : '웹소켓 연결 끊김'
                                }
                              />
                              <span
                                className={`app-chat-panel__rooms-share-status app-chat-panel__rooms-share-status--${
                                  connectionState === 'connected'
                                    ? 'completed'
                                    : connectionState === 'connecting'
                                      ? 'started'
                                      : 'failed'
                                }`}
                              >
                                {connectionState === 'connected'
                                  ? '웹소켓 연결됨'
                                  : connectionState === 'connecting'
                                    ? '웹소켓 연결 중'
                                    : '웹소켓 끊김'}
                              </span>
                              <span className="app-chat-panel__rooms-share-summary">
                                {activeRoomScope?.menuTitle || roomLaunchScope?.menuTitle || '채팅방'}
                              </span>
                            </div>
                          ) : null}
                        </div>
                      ) : activeView === 'runtime' ? (
                        <Text strong>Codex Live 런타임</Text>
                      ) : (
                        <Text strong>에러 로그</Text>
                      )}
                    </div>
                  </div>
                </div>
              </>
            </div>
          )
        }
      extra={
        activeView === 'errors' || activeView === 'runtime' ? null : (
          isRoomsMode ? (
            <Space size={8} className="app-chat-panel__rooms-header-actions">
              <Dropdown
                trigger={['click']}
                placement="bottomRight"
                menu={{
                  items: roomSettingsMenuItems,
                  onClick: ({ key }) => {
                    if (key === 'list') {
                      setIsRoomsListDrawerOpen(true);
                      return;
                    }

                    if (key === 'create') {
                      openCreateConversationModal();
                      return;
                    }

                    if (key === 'refresh') {
                      handleRefreshActiveConversation();
                      return;
                    }

                    if (key === 'context') {
                      openContextDrawer();
                      return;
                    }

                    if (key === 'clear') {
                      openClearConversationDataModal();
                      return;
                    }

                    if (key === 'delete') {
                      openDeleteConversationModal();
                      return;
                    }

                    if (key === 'maximize') {
                      setIsMaximized((current) => !current);
                      return;
                    }

                    if (key === 'minimize') {
                      if (onRoomsMinimize) {
                        onRoomsMinimize();
                        return;
                      }

                      requestChatWindowAction('minimize');
                      return;
                    }

                    if (key === 'close-window') {
                      if (onRoomsClose) {
                        onRoomsClose();
                        return;
                      }

                      requestChatWindowAction('close');
                    }
                  },
                }}
              >
                <Button
                  size="small"
                  icon={<SettingOutlined />}
                  aria-label="채팅방 설정"
                  title="채팅방 설정"
                />
              </Dropdown>
              {isSharedRoomsPresentation ? (
                <Button
                  size="small"
                  icon={<MinusOutlined />}
                  aria-label="채팅 최소화"
                  title="채팅 최소화"
                  onClick={() => {
                    if (onRoomsMinimize) {
                      onRoomsMinimize();
                      return;
                    }

                    requestChatWindowAction('minimize');
                  }}
                />
              ) : null}
              {isSharedRoomsPresentation ? (
                <Button
                  size="small"
                  danger
                  icon={<CloseOutlined />}
                  aria-label="채팅 닫기"
                  title="채팅 닫기"
                  onClick={() => {
                    if (onRoomsClose) {
                      onRoomsClose();
                      return;
                    }

                    requestChatWindowAction('close');
                  }}
                />
              ) : null}
            </Space>
          ) : (
            <Space size={8}>
              {activeConversation ? (
                <Button
                  size="small"
                  icon={<ReloadOutlined />}
                  aria-label="대화방 새로고침"
                  title="대화방 새로고침"
                  onClick={handleRefreshActiveConversation}
                />
              ) : null}
              {activeConversation ? (
                <Button
                  size="small"
                  loading={notificationToggleSessionId === activeConversation.sessionId}
                  icon={activeConversation.notifyOffline ? <BellFilled /> : <BellOutlined />}
                  aria-label={activeConversation.notifyOffline ? '채팅방 알림 켜짐' : '채팅방 알림 꺼짐'}
                  onClick={() => {
                    void handleToggleConversationNotification();
                  }}
                />
              ) : null}
              {activeConversation ? (
              isMobileViewport ? (
                <div className="app-chat-panel__mobile-actions">
                  <div
                    ref={mobileActionGroupRef}
                    className={`app-chat-panel__action-group app-chat-panel__action-group--mobile${
                      isMobileActionGroupOpen ? ' is-open' : ''
                    }`}
                    role="group"
                    aria-label="채팅방 도구"
                  >
                    {isMobileActionGroupOpen ? (
                      <>
                        <Button
                          size="small"
                          loading={savingGeneralSectionSessionId === activeConversation.sessionId}
                          icon={<FolderOutlined />}
                          aria-label="일반 보관 섹션"
                          onClick={() => {
                            setIsMobileActionGroupOpen(false);
                            openGeneralSectionEditor(activeConversation);
                          }}
                        />
                        <Button size="small" icon={<ControlOutlined />} aria-label="Context 관리" onClick={openContextDrawer} />
                        <Button
                          size="small"
                          danger
                          icon={<DeleteOutlined />}
                          aria-label="채팅방 데이터 초기화"
                          onClick={() => {
                            setIsMobileActionGroupOpen(false);
                            openClearConversationDataModal();
                          }}
                        />
                        <Button
                          size="small"
                          danger
                          icon={<CloseCircleOutlined />}
                          aria-label="채팅방 삭제"
                          onClick={() => {
                            setIsMobileActionGroupOpen(false);
                            openDeleteConversationModal();
                          }}
                        />
                        <Button
                          size="small"
                          icon={isMaximized ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
                          aria-label={isMaximized ? '최대화 해제' : '최대화'}
                          onClick={() => {
                            setIsMobileActionGroupOpen(false);
                            setIsMaximized((current) => !current);
                          }}
                        />
                      </>
                    ) : null}
                    <Button
                      size="small"
                      icon={<AppstoreOutlined />}
                      aria-label={isMobileActionGroupOpen ? '채팅방 도구 닫기' : '채팅방 도구 열기'}
                      aria-expanded={isMobileActionGroupOpen}
                      onClick={() => {
                        setIsMobileActionGroupOpen((current) => !current);
                      }}
                    />
                    <Button size="small" icon={<CloseOutlined />} aria-label="대화창 닫기" onClick={handleCloseConversationPane} />
                  </div>
                </div>
              ) : (
                <div className="app-chat-panel__action-group" role="group" aria-label="채팅방 도구">
                  <Button
                    size="small"
                    loading={savingGeneralSectionSessionId === activeConversation.sessionId}
                    icon={<FolderOutlined />}
                    aria-label="일반 보관 섹션"
                    onClick={() => {
                      openGeneralSectionEditor(activeConversation);
                    }}
                  />
                  <Button
                    size="small"
                    icon={<ControlOutlined />}
                    aria-label="Context 관리"
                    onClick={openContextDrawer}
                  />
                  <Button
                    size="small"
                    danger
                    icon={<DeleteOutlined />}
                    aria-label="채팅방 데이터 초기화"
                    onClick={openClearConversationDataModal}
                  />
                  <Button
                    size="small"
                    danger
                    icon={<CloseCircleOutlined />}
                    aria-label="채팅방 삭제"
                    onClick={openDeleteConversationModal}
                  />
                  <Button
                    size="small"
                    icon={isMaximized ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
                    aria-label={isMaximized ? '최대화 해제' : '최대화'}
                    onClick={() => {
                      setIsMaximized((current) => !current);
                    }}
                  />
                  <Button size="small" icon={<CloseOutlined />} aria-label="대화창 닫기" onClick={handleCloseConversationPane} />
                </div>
              )
            ) : null}
              {!activeConversation ? (
                <Button
                  size="small"
                  icon={isMaximized ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
                  aria-label={isMaximized ? '최대화 해제' : '최대화'}
                  onClick={() => {
                    setIsMaximized((current) => !current);
                  }}
                />
              ) : null}
            </Space>
          )
        )
      }
      className={`app-chat-panel${mode === 'rooms' ? ' app-chat-panel--rooms' : ''}${isSharedRoomsPresentation ? ' app-chat-panel--rooms-shared' : ''}${isMaximized ? ' app-chat-panel--maximized' : ''}${
        !isMaximized && isTabletAppLayout ? ' app-chat-panel--tablet-app' : ''
      }${isIpadLikeViewport ? ' app-chat-panel--ipad-readable' : ''}`}
      styles={{ body: { height: '100%' } }}
    >
      <div className={`app-chat-panel__stack${activeView === 'chat' ? ' app-chat-panel__stack--chat' : ''}`}>
        {activeView === 'chat' ? (
          <>
            {!isRoomsMode && (!isMobileViewport || !isMobileConversationView || isConversationPaneClosed) ? conversationListPane : null}
            {!isConversationPaneClosed && (isRoomsMode || !isMobileViewport || isMobileConversationView) ? (
              <div className="app-chat-panel__conversation-main">
                {activeConversation ? (
                  <ChatConversationView
                    sessionId={activeConversation.sessionId}
                    viewportRef={viewportRef}
                    composerRef={composerRef}
                    visibleMessages={chatMessages}
                    activeSystemStatus={activeSystemStatus}
                    isSystemStatusPending={isSystemStatusPending}
                    showScrollToBottom={showScrollToBottom}
                    copiedMessageId={copiedMessageId}
                    draft={draftSeed.value}
                    draftVersion={draftSeed.version}
                    composerAttachments={composerAttachments}
                    lastReadResponseMessageId={lastReadResponseMessageIdBySession[activeConversation.sessionId] ?? null}
                    isConversationLoading={isConversationContentLoading}
                    conversationLoadingLabel={conversationLoadingLabel}
                    hasOlderMessages={hasOlderMessages}
                    isLoadingOlderMessages={isLoadingOlderMessages}
                    isPullToLoadArmed={isPullToLoadArmed}
                    pullToLoadDistance={pullToLoadDistance}
                    requestStateMap={activeRequestMap}
                    selectedChatTypeId={effectiveChatTypeId}
                    selectedCodexModel={effectiveCodexModel}
                    queuedRequests={activeQueuedComposerRequests.map((item, index) => ({
                      requestId: item.requestId,
                      order: index + 1,
                      text: item.userText,
                    }))}
                    composerAssistActions={composerAssistActions}
                    composerAssistModalTitle={composerAssistModalTitle}
                    chatTypeOptions={chatTypeOptions}
                    codexModelOptions={
                      isSharedRoomsPresentation
                        ? CODEX_MODEL_OPTIONS.map((option) => ({
                            value: option.value,
                            label: `${option.label} · ${option.description}`,
                            description: option.description,
                          }))
                        : [{
                            value: effectiveCodexModel,
                            label: `${effectiveCodexParticipants[0]?.name ?? 'Codex'} · ${resolveCodexModelLabel(effectiveCodexModel)}`,
                            description: '채팅방 Context 참가자 기준',
                          }]
                    }
                    previewItems={previewItems.map((item) => ({ id: item.id, label: item.label, url: item.url, kind: item.kind }))}
                    isResourceStripOpen={isResourceStripOpen}
                    showResourceStrip={!isRoomsMode}
                    showRoomsShareHeader={false}
                    useSharedComposerChrome={isSharedRoomsPresentation}
                    roomsShareHeaderTitle={effectiveActiveRoomScope?.menuTitle || roomLaunchScope?.menuTitle || '현재 메뉴'}
                    isLiveConnected={connectionState === 'connected'}
                    roomsHeaderMenuItems={roomSettingsMenuItems}
                    onRoomsHeaderMenuAction={(key) => {
                      if (key === 'list') {
                        setIsRoomsListDrawerOpen(true);
                        return;
                      }

                      if (key === 'create') {
                        openCreateConversationModal();
                        return;
                      }

                      if (key === 'refresh') {
                        handleRefreshActiveConversation();
                        return;
                      }

                      if (key === 'context') {
                        openContextDrawer();
                        return;
                      }

                      if (key === 'clear') {
                        openClearConversationDataModal();
                        return;
                      }

                      if (key === 'delete') {
                        openDeleteConversationModal();
                        return;
                      }

                      if (key === 'maximize') {
                        setIsMaximized((current) => !current);
                        return;
                      }

                      if (key === 'minimize') {
                        if (onRoomsMinimize) {
                          onRoomsMinimize();
                          return;
                        }

                        requestChatWindowAction('minimize');
                        return;
                      }

                      if (key === 'close-window') {
                        if (onRoomsClose) {
                          onRoomsClose();
                          return;
                        }

                        requestChatWindowAction('close');
                      }
                    }}
                    onRoomsHeaderMinimize={
                      isSharedRoomsPresentation
                        ? (() => {
                            if (onRoomsMinimize) {
                              onRoomsMinimize();
                              return;
                            }

                            requestChatWindowAction('minimize');
                          })
                        : undefined
                    }
                    onRoomsHeaderClose={
                      isSharedRoomsPresentation
                        ? (() => {
                            if (onRoomsClose) {
                              onRoomsClose();
                              return;
                            }

                            requestChatWindowAction('close');
                          })
                        : undefined
                    }
                    isComposerDisabled={!effectiveChatType || !isEffectiveChatTypeAllowed}
                    isMobileViewport={isMobileViewport}
                    isIpadLikeViewport={isIpadLikeViewport}
                    isChatTypeSelectionLocked={isChatTypeSelectionLocked}
                    isComposerAttachmentUploading={isComposerAttachmentUploading}
                    onViewportScroll={handleViewportScroll}
                    onViewportTouchEnd={handleViewportTouchEnd}
                    onViewportTouchMove={handleViewportTouchMove}
                    onViewportTouchStart={handleViewportTouchStart}
                    onDraftChange={setDraftValue}
                    onPickComposerFiles={handleComposerFilesPicked}
                    onRemoveComposerAttachment={(attachmentId) => {
                      setComposerAttachments((previous) => previous.filter((attachment) => attachment.id !== attachmentId));
                    }}
                    onSelectChatType={(nextChatTypeId) => {
                      if (isChatTypeSelectionLocked) {
                        return;
                      }

                      if (activeSessionId) {
                        chatTypeSelectionIntentSessionIdRef.current = activeSessionId;
                      }
                      setSelectedChatTypeId(nextChatTypeId);
                    }}
                    onSelectCodexModel={(nextCodexModel) => {
                      setSelectedCodexModel(normalizeCodexModel(nextCodexModel));
                    }}
                    onSend={handleComposerSend}
                    onSendReplyToResponse={handleSendReplyToResponse}
                    onSendImmediate={handleComposerSendImmediate}
                    isSendWithoutContextEnabled={isSendWithoutContextEnabled}
                    isImmediateSendPinned={isImmediateSendPinned}
                    onToggleImmediateSendPinned={handleToggleImmediateSendPinned}
                    onToggleSendWithoutContext={() => {
                      setIsSendWithoutContextEnabled((current) => !current);
                    }}
                    onClearDraft={() => {
                      setDraftValue('');
                    }}
                    onToggleResourceStrip={() => {
                      setIsResourceStripOpen((current) => !current);
                    }}
                    onScrollToBottom={() => {
                      releaseAutoScrollSuspension();
                      shouldStickToBottomRef.current = true;
                      setShowScrollToBottom(false);
                      scrollViewportToBottom('smooth');
                    }}
                    onLoadOlderMessages={() => loadOlderMessagesRef.current()}
                    onOpenPreview={openPreviewModal}
                    onCopyMessage={(message) => {
                      void handleCopyMessage(message);
                    }}
                    onRetryMessage={(message) => {
                      if (!message.clientRequestId) {
                        return;
                      }

                      retryPendingRequest(message.clientRequestId);
                    }}
                    onRetryFailedRequest={(request) => {
                      handleRetryFailedRequest(request);
                    }}
                    onCancelMessage={(message) => {
                      if (!message.clientRequestId) {
                        return;
                      }

                      cancelPendingRequest(message.clientRequestId);
                    }}
                    onDeleteRequest={(message) => {
                      if (!message.clientRequestId) {
                        return;
                      }

                      void deleteStoredRequest(message.clientRequestId);
                    }}
                    onCompleteManualRequestBadge={(requestId, type) => {
                      void handleCompleteManualRequestBadge(requestId, type);
                    }}
                    onRemoveQueuedRequest={removeQueuedComposerRequest}
                    onPromoteQueuedRequest={(requestId, text) => {
                      void handlePromoteQueuedRequest(requestId, text);
                    }}
                    onShareRequestBundle={(requestId) => {
                      void handleShareChatTarget({
                        kind: 'request-bundle',
                        requestId,
                        title: '요청묶음 공유',
                      });
                    }}
                    onShareInquiryMessage={({ requestId }) => {
                      void handleShareChatTarget({
                        kind: 'inquiry-message',
                        requestId,
                        title: '문의 공유',
                      });
                    }}
                    onSharePromptTarget={({ requestId, sourceMessageId, promptIndex, promptTitle, promptSignature }) => {
                      void handleShareChatTarget({
                        kind: 'prompt',
                        requestId,
                        sourceMessageId,
                        promptIndex,
                        promptSignature,
                        title: promptTitle,
                      });
                    }}
                    onSubmitPrompt={handlePromptSubmit}
                    onSubmitChildRequest={handleSubmitChildRequest}
                  />
                ) : (
                  <div className="app-chat-panel__conversation-empty">
                    {isRoomsMode ? (
                      <Empty
                        description={
                          canReturnToRoomsList
                            ? '설정 메뉴에서 대화 목록을 열어 대화를 선택하세요.'
                            : '채팅방을 준비하는 중입니다.'
                        }
                      />
                    ) : conversationItems.length === 0 ? (
                      <Space direction="vertical" size={12} align="center">
                        <Empty description="저장된 대화가 없습니다. 바로 새 대화를 시작할 수 있습니다." />
                        <Space size={8} wrap>
                          <Button
                            type="primary"
                            disabled={availableChatTypes.length === 0}
                            onClick={() => {
                              const initialChatType = (selectedChatType && isSelectedChatTypeAllowed ? selectedChatType : null)
                                ?? availableChatTypes[0]
                                ?? null;

                              if (!initialChatType) {
                                openCreateConversationModal();
                                return;
                              }

                              setSelectedChatTypeId(initialChatType.id);
                              void handleCreateConversation({
                                chatTypeOverride: initialChatType,
                                persist: true,
                              });
                            }}
                          >
                            대화 시작
                          </Button>
                          <Button
                            onClick={() => {
                              openCreateConversationModal();
                            }}
                            disabled={availableChatTypes.length === 0}
                          >
                            채팅유형 선택
                          </Button>
                        </Space>
                      </Space>
                    ) : (
                      <Empty description="왼쪽 목록에서 대화를 선택하세요." />
                    )}
                  </div>
                )}
              </div>
            ) : null}
          </>
        ) : activeView === 'runtime' ? (
          <ChatRuntimeDashboard
            snapshot={runtimeSnapshot}
            activeSessionId={activeSessionId}
            connectionState={connectionState}
            onSelectSession={openConversationSession}
            socketRef={socketRef}
            liveDetail={runtimeJobDetail}
            requestedLogRequestId={requestedRuntimeLogRequestId}
            onRequestedLogHandled={clearRequestedRuntimeLogInUrl}
          />
        ) : (
          <ErrorLogViewer
            errorLogs={errorLogs}
            selectedErrorLog={selectedErrorLog}
            selectedErrorLogReferenceSummary={selectedErrorLogReferenceSummary}
            activeErrorResource={activeErrorResource}
            isErrorDetailExpanded={isErrorDetailExpanded}
            isLoadingErrorLogs={isLoadingErrorLogs}
            errorLogLoadError={errorLogLoadError}
            errorSourceSummary={errorSourceSummary}
            onRefresh={() => {
              void loadErrorLogs();
            }}
            onSelectErrorLog={setSelectedErrorLogId}
            onSelectResource={setActiveErrorResourceUrl}
            onToggleExpanded={setIsErrorDetailExpanded}
          />
        )}
      </div>

      {isRoomsMode && canReturnToRoomsList ? (
        <Drawer
          open={isRoomsListDrawerOpen}
          title="대화 목록"
          rootClassName="app-chat-panel__conversation-list-drawer-shell"
          className="app-chat-panel__conversation-list-drawer"
          placement={isMobileViewport ? 'bottom' : 'left'}
          height={isMobileViewport ? '88vh' : undefined}
          width={isMobileViewport ? undefined : 360}
          onClose={() => {
            setIsRoomsListDrawerOpen(false);
          }}
        >
          {conversationListPane}
        </Drawer>
      ) : null}

      <Drawer
        open={isContextDrawerOpen}
        title="채팅방 Context 관리"
        rootClassName="app-chat-panel__context-drawer-shell"
        placement={isMobileViewport ? 'bottom' : 'right'}
        height={isMobileViewport ? '88vh' : undefined}
        width={isMobileViewport ? undefined : 420}
        destroyOnHidden={false}
        onClose={() => {
          setIsContextDrawerOpen(false);
        }}
        extra={isMobileViewport ? undefined : (
          <Space size={8}>
            <Button
              onClick={() => {
                setIsContextDrawerOpen(false);
              }}
            >
              취소
            </Button>
            <Button type="primary" onClick={() => void handleSaveContextDrawer()}>
              저장
            </Button>
          </Space>
        )}
      >
        <div className="app-chat-panel__context-drawer">
          <Tabs
            activeKey={contextDrawerTabKey}
            onChange={setContextDrawerTabKey}
            className="app-chat-panel__context-drawer-tabs"
            items={[
              {
                key: 'chat-type',
                label: '채팅유형',
                children: (
                  <div className="app-chat-panel__context-drawer-section app-chat-panel__context-drawer-section--editor">
                    <div className="app-chat-panel__context-drawer-section-head">
                      <Text strong>채팅기본유형</Text>
                      <Text type="secondary">이 채팅방이 기본으로 사용할 유형을 선택합니다. 아래 설명은 선택한 유형의 전역 기본 문맥이라 이 화면에서는 읽기 전용입니다.</Text>
                    </div>
                    <Select
                      value={editingRoomChatTypeId ?? undefined}
                      placeholder="채팅유형을 선택하세요."
                      options={availableChatTypes.map((item) => ({
                        value: item.id,
                        label: item.name,
                      }))}
                      onChange={(nextChatTypeId) => {
                        setEditingRoomChatTypeId(nextChatTypeId);
                        const nextChatType =
                          chatTypes.find((item) => item.id === nextChatTypeId) ??
                          availableChatTypes.find((item) => item.id === nextChatTypeId) ??
                          null;
                        setEditingChatTypeDescription(nextChatType?.description ?? '');

                        if (!activeRoomContextSettings && !isEditingRoomDefaultContextsDirty) {
                          setEditingRoomDefaultContextIds(resolveChatTypeDefaultContextIds(chatTypeDefaults, nextChatTypeId));
                        }
                      }}
                    />
                    {contextDrawerChatType ? (
                      <>
                        <div className="app-chat-panel__context-drawer-card app-chat-panel__context-drawer-card--readonly">
                          <span className="app-chat-panel__context-drawer-card-title">{contextDrawerChatType.name}</span>
                          <Text type="secondary" className="app-chat-panel__context-drawer-card-copy">
                            아래 문맥 설명은 선택한 채팅유형의 전역 기본값을 그대로 보여주는 참조 영역입니다.
                          </Text>
                        </div>
                        <div className="app-chat-panel__context-drawer-textarea-shell">
                          <Input.TextArea
                            value={editingChatTypeDescription}
                            className="app-chat-panel__context-drawer-textarea"
                            placeholder={'## 기본 처리\n- 현재 채팅유형의 기본 문맥이 표시됩니다.'}
                            readOnly
                          />
                        </div>
                      </>
                    ) : (
                      <Alert showIcon type="warning" message="현재 채팅유형을 찾을 수 없습니다." />
                    )}
                  </div>
                ),
              },
              {
                key: 'room-context',
                label: '방 전용',
                children: (
                  <div className="app-chat-panel__context-drawer-section app-chat-panel__context-drawer-section--editor">
                    <div className="app-chat-panel__context-drawer-section-head">
                      <Text strong>채팅방 전용 Context</Text>
                      <Text type="secondary">이 방에서만 추가로 참조되는 Markdown 문맥입니다.</Text>
                    </div>
                    <Input
                      value={editingRoomCustomContextTitle}
                      placeholder="전용 Context 제목"
                      onChange={(event) => {
                        setEditingRoomCustomContextTitle(event.target.value);
                      }}
                    />
                    <div className="app-chat-panel__context-drawer-textarea-shell">
                      <Input.TextArea
                        value={editingRoomCustomContextContent}
                        className="app-chat-panel__context-drawer-textarea"
                        placeholder={'## 이 채팅방 전용 지시\n- 이 방에서만 추가로 참조할 내용을 Markdown으로 입력하세요.'}
                        onChange={(event) => {
                          setEditingRoomCustomContextContent(event.target.value);
                        }}
                      />
                    </div>
                  </div>
                ),
              },
              {
                key: 'codex-participants',
                label: 'Codex',
                children: (
                  <div className="app-chat-panel__context-drawer-section app-chat-panel__context-drawer-section--editor app-chat-panel__context-drawer-section--participants">
                    <div className="app-chat-panel__context-drawer-section-head">
                      <Text strong>Codex 참가자</Text>
                      <Text type="secondary">
                        이 방에서 함께 응답할 Codex를 설정합니다. 기본은 순차 흐름이지만, 여러 Codex를 추가한 뒤 `즉시실행`하면
                        병렬로 처리됩니다. `중재·최종정리` 역할은 토론 시작과 종료를 맡고, `프리토킹` 역할은 앞선 Codex 의견을
                        이어받아 자유롭게 토론합니다.
                      </Text>
                    </div>
                    <div className="app-chat-panel__context-drawer-scroll">
                      <Space direction="vertical" size={12} className="app-chat-panel__context-drawer-space">
                        {editingRoomCodexParticipants.map((participant, index) => (
                          <div key={participant.id} className="app-chat-panel__context-drawer-card">
                            <Space direction="vertical" size={8} className="app-chat-panel__context-drawer-space">
                              {index === 0 ? (
                                <Text type="secondary">
                                  기본 채팅방 Codex는 이름만 변경할 수 있고, 방 기본 모델과 채팅유형을 따르며 삭제할 수 없습니다.
                                </Text>
                              ) : null}
                              <Input
                                value={participant.name}
                                placeholder={`Codex ${index + 1} 이름`}
                                onChange={(event) => {
                                  const nextValue = event.target.value;
                                  setEditingRoomCodexParticipants((previous) =>
                                    previous.map((item) => (item.id === participant.id ? { ...item, name: nextValue } : item)),
                                  );
                                }}
                              />
                              <Select
                                value={participant.model}
                                disabled={index === 0}
                                options={CODEX_MODEL_OPTIONS.map((option) => ({
                                  value: option.value,
                                  label: `${option.label} · ${option.description}`,
                                }))}
                                onChange={(value) => {
                                  setEditingRoomCodexParticipants((previous) =>
                                    previous.map((item) =>
                                      item.id === participant.id ? { ...item, model: normalizeCodexModel(value) } : item,
                                    ),
                                  );
                                }}
                              />
                              <Select
                                value={index === 0 ? '__inherit__' : participant.chatTypeId ?? '__inherit__'}
                                placeholder="이 Codex 전용 유형"
                                disabled={index === 0}
                                options={[
                                  {
                                    value: '__inherit__',
                                    label: `방 기본 유형 상속 · ${
                                      contextDrawerChatType?.name ?? effectiveChatType?.name ?? '채팅 유형'
                                    }`,
                                  },
                                  ...availableChatTypes.map((option) => ({
                                    value: option.id,
                                    label: `${option.name} · ${option.description || '설명 없음'}`,
                                  })),
                                ]}
                                onChange={(value) => {
                                  setEditingRoomCodexParticipants((previous) =>
                                    previous.map((item) =>
                                      item.id === participant.id
                                        ? { ...item, chatTypeId: value === '__inherit__' ? null : value }
                                        : item,
                                    ),
                                  );
                                }}
                              />
                              <Select
                                value={participant.role}
                                placeholder="이 Codex 역할"
                                disabled={index === 0}
                                options={[
                                  { value: 'moderator', label: '중재·최종정리 · 논점 정리 후 마지막 보고 담당' },
                                  { value: 'conversation', label: '프리토킹 · 앞선 발언을 받아 자유 토론' },
                                  { value: 'reviewer', label: '최종검토 · 앞선 결과를 점검하고 승인/보완 의견 제시' },
                                  { value: 'default', label: '기본 · 순서대로 일반 응답' },
                                ]}
                                onChange={(value) => {
                                  setEditingRoomCodexParticipants((previous) =>
                                    previous.map((item) =>
                                      item.id === participant.id
                                        ? {
                                            ...item,
                                            role:
                                              value === 'moderator' ||
                                              value === 'conversation' ||
                                              value === 'reviewer'
                                                ? value
                                                : 'default',
                                          }
                                        : item,
                                    ),
                                  );
                                }}
                              />
                              <div className="app-chat-panel__context-drawer-card app-chat-panel__context-drawer-card--readonly">
                                <div className="app-chat-panel__context-drawer-collapsible-head">
                                  <div className="app-chat-panel__context-drawer-collapsible-copy">
                                    <Text strong>공통 문맥</Text>
                                    <Text type="secondary">
                                      {index === 0
                                        ? '기본 채팅방 Codex는 방 기본 공통 문맥을 그대로 사용합니다.'
                                        : '이 Codex에만 추가로 연결할 공통 문맥을 선택하세요.'}
                                    </Text>
                                    <Text className="app-chat-panel__context-drawer-collapsible-summary">
                                      {resolveParticipantDefaultContextLabel(participant.defaultContextIds, enabledDefaultContexts)}
                                    </Text>
                                  </div>
                                  <Button
                                    type="text"
                                    size="small"
                                    className="app-chat-panel__context-drawer-collapsible-toggle"
                                    icon={
                                      expandedParticipantDefaultContextIds.includes(participant.id)
                                        ? <CaretDownOutlined />
                                        : <CaretRightOutlined />
                                    }
                                    aria-label={
                                      expandedParticipantDefaultContextIds.includes(participant.id)
                                        ? '공통 문맥 접기'
                                        : '공통 문맥 펼치기'
                                    }
                                    onClick={() => {
                                      setExpandedParticipantDefaultContextIds((previous) =>
                                        previous.includes(participant.id)
                                          ? previous.filter((item) => item !== participant.id)
                                          : [...previous, participant.id],
                                      );
                                    }}
                                  >
                                    {expandedParticipantDefaultContextIds.includes(participant.id) ? '접기' : '펼치기'}
                                  </Button>
                                </div>
                                {expandedParticipantDefaultContextIds.includes(participant.id) ? (
                                  enabledDefaultContexts.length > 0 ? (
                                    <Checkbox.Group
                                      className="app-chat-panel__context-drawer-checkbox"
                                      value={participant.defaultContextIds}
                                      onChange={(checkedValues) => {
                                        setEditingRoomCodexParticipants((previous) =>
                                          previous.map((item) =>
                                            item.id === participant.id
                                              ? {
                                                  ...item,
                                                  defaultContextIds:
                                                    index === 0
                                                      ? []
                                                      : checkedValues
                                                          .map((value) => String(value).trim())
                                                          .filter((value) =>
                                                            enabledDefaultContexts.some((context) => context.id === value),
                                                          ),
                                                }
                                              : item,
                                          ),
                                        );
                                      }}
                                    >
                                      <Space direction="vertical" size={10} className="app-chat-panel__context-drawer-space">
                                        {enabledDefaultContexts.map((context) => (
                                          <label
                                            key={`participant-default-context-${participant.id}-${context.id}`}
                                            className="app-chat-panel__context-drawer-card"
                                          >
                                            <Checkbox value={context.id} disabled={index === 0}>
                                              {context.title}
                                            </Checkbox>
                                            <Text type="secondary" className="app-chat-panel__context-drawer-card-copy">
                                              {context.content.split('\n')[0]?.replace(/^#+\s*/, '') || '설명 없음'}
                                            </Text>
                                          </label>
                                        ))}
                                      </Space>
                                    </Checkbox.Group>
                                  ) : (
                                    <Alert
                                      showIcon
                                      type="info"
                                      message="등록된 공통 문맥이 없습니다."
                                      description="채팅 관리 메뉴의 공통 문맥 관리에서 먼저 추가하세요."
                                    />
                                  )
                                ) : null}
                              </div>
                              <Input.TextArea
                                value={participant.prompt}
                                placeholder="이 Codex만의 추가 역할 메모 또는 말투 지시"
                                readOnly={index === 0}
                                onChange={(event) => {
                                  const nextValue = event.target.value;
                                  setEditingRoomCodexParticipants((previous) =>
                                    previous.map((item) => (item.id === participant.id ? { ...item, prompt: nextValue } : item)),
                                  );
                                }}
                              />
                              <Space size={8}>
                                <Button
                                  onClick={() => {
                                    setEditingRoomCodexParticipants((previous) => [
                                      ...previous,
                                      createEditableCodexParticipant(
                                        previous.length + 1,
                                        participant.model,
                                        participant.chatTypeId,
                                        participant.defaultContextIds,
                                        'conversation',
                                      ),
                                    ]);
                                  }}
                                >
                                  다음 Codex 추가
                                </Button>
                                <Button
                                  danger
                                  disabled={index === 0 || editingRoomCodexParticipants.length <= 1}
                                  onClick={() => {
                                    setExpandedParticipantDefaultContextIds((previous) =>
                                      previous.filter((item) => item !== participant.id),
                                    );
                                    setEditingRoomCodexParticipants((previous) => {
                                      const nextItems = previous.filter((item) => item.id !== participant.id);
                                      return normalizeEditableCodexParticipantsForDrawer(nextItems, {
                                        fallbackModel: activeConversation?.codexModel ?? effectiveCodexModel,
                                        fallbackChatTypeId:
                                          contextDrawerChatType?.id ?? effectiveChatType?.id ?? activeConversation?.chatTypeId ?? null,
                                      });
                                    });
                                  }}
                                >
                                  제거
                                </Button>
                              </Space>
                            </Space>
                          </div>
                        ))}
                      </Space>
                    </div>
                  </div>
                ),
              },
              {
                key: 'default-contexts',
                label: '기본 유형',
                children: (
                  <div className="app-chat-panel__context-drawer-section">
                    <div className="app-chat-panel__context-drawer-section-head">
                      <Text strong>적용 기본 유형</Text>
                      <Text type="secondary">여러 개를 함께 참조할 수 있습니다.</Text>
                    </div>
                    {enabledDefaultContexts.length > 0 ? (
                      <Checkbox.Group
                        className="app-chat-panel__context-drawer-checkbox"
                        value={editingRoomDefaultContextIds}
                        onChange={(checkedValues) => {
                          setIsEditingRoomDefaultContextsDirty(true);
                          setEditingRoomDefaultContextIds(
                            checkedValues
                              .map((value) => String(value).trim())
                              .filter((value) => enabledDefaultContexts.some((context) => context.id === value)),
                          );
                        }}
                      >
                        <Space direction="vertical" size={10} className="app-chat-panel__context-drawer-space">
                          {enabledDefaultContexts.map((context) => (
                            <label key={`drawer-default-context-${context.id}`} className="app-chat-panel__context-drawer-card">
                              <Checkbox value={context.id}>{context.title}</Checkbox>
                              <Text type="secondary" className="app-chat-panel__context-drawer-card-copy">
                                {context.content.split('\n')[0]?.replace(/^#+\s*/, '') || '설명 없음'}
                              </Text>
                            </label>
                          ))}
                        </Space>
                      </Checkbox.Group>
                    ) : (
                      <Alert
                        showIcon
                        type="info"
                        message="등록된 기본 유형이 없습니다."
                        description="채팅 관리 메뉴의 기본 유형 관리에서 먼저 추가하세요."
                      />
                    )}
                  </div>
                ),
              },
              {
                key: 'summary',
                label: '미리보기',
                children: (
                  <div className="app-chat-panel__context-drawer-section app-chat-panel__context-drawer-section--summary">
                    <div className="app-chat-panel__context-drawer-section-head">
                      <Text strong>현재 적용 미리보기</Text>
                      <Text type="secondary">저장 후 이후 요청부터 반영됩니다.</Text>
                    </div>
                    <div className="app-chat-panel__context-drawer-summary">
                      <Tag color="blue">{contextDrawerChatType?.name ?? activeConversation?.contextLabel ?? '채팅 유형 미선택'}</Tag>
                      {editingRoomCodexParticipants.map((participant) => (
                        <Tag key={`participant-${participant.id}`} color="geekblue">
                          {participant.name} · {resolveCodexModelLabel(participant.model)} ·{' '}
                          {resolveParticipantRoleLabel(participant.role)} ·{' '}
                          {resolveParticipantChatTypeLabel(
                            participant.chatTypeId,
                            availableChatTypes,
                            contextDrawerChatType?.name ?? activeConversation?.contextLabel ?? '방 기본 유형',
                          )}{' '}
                          · {resolveParticipantDefaultContextLabel(participant.defaultContextIds, enabledDefaultContexts)}
                        </Tag>
                      ))}
                      {editingRoomDefaultContextIds.map((contextId) => {
                        const context = enabledDefaultContexts.find((item) => item.id === contextId);

                        return context ? (
                          <Tag key={`summary-${context.id}`} color="gold">
                            {context.title}
                          </Tag>
                        ) : null;
                      })}
                    </div>
                  </div>
                ),
              },
            ]}
          />
          {isMobileViewport ? (
            <div className="app-chat-panel__context-drawer-mobile-actions">
              <Button
                onClick={() => {
                  setIsContextDrawerOpen(false);
                }}
              >
                취소
              </Button>
              <Button type="primary" onClick={() => void handleSaveContextDrawer()}>
                저장
              </Button>
            </div>
          ) : null}
        </div>
      </Drawer>

      <Modal
        open={isCreateConversationModalOpen}
        title="채팅유형 선택"
        okText="대화 시작"
        cancelText="취소"
        zIndex={1700}
        okButtonProps={{ disabled: !selectedCreateConversationChatType }}
        modalRender={renderModalWithEnterConfirm}
        onCancel={() => {
          setIsCreateConversationModalOpen(false);
        }}
        onOk={() => {
          void handleConfirmCreateConversation();
        }}
      >
        {availableChatTypes.length > 0 ? (
          <div className="app-chat-panel__create-conversation-modal">
            <Text type="secondary">
              신규 채팅방은 선택한 채팅유형으로 생성됩니다. Codex 참가자와 모델은 생성 후 Context 관리에서 설정합니다.
            </Text>
            <Radio.Group
              className="app-chat-panel__create-conversation-options"
              value={selectedCreateConversationChatType?.id}
              onChange={(event) => {
                setCreateConversationChatTypeId(event.target.value);
              }}
            >
              <Space direction="vertical" size={12} className="app-chat-panel__create-conversation-space">
                {availableChatTypes.map((item) => (
                  <label key={item.id} className="app-chat-panel__create-conversation-option">
                    <Radio value={item.id}>
                      <span className="app-chat-panel__create-conversation-option-label">{item.name}</span>
                    </Radio>
                    {item.description.trim() ? (
                      <Text type="secondary" className="app-chat-panel__create-conversation-option-description">
                        {item.description.split('\n')[0].replace(/^#+\s*/, '')}
                      </Text>
                    ) : null}
                  </label>
                ))}
              </Space>
            </Radio.Group>
          </div>
        ) : (
          <Alert
            showIcon
            type="warning"
            message="사용 가능한 채팅유형이 없습니다."
            description="채팅유형 관리에서 현재 사용자 권한으로 사용할 수 있는 유형을 먼저 등록하세요."
          />
        )}
      </Modal>
      <Modal
        open={Boolean(pendingContextConfirm)}
        title="최근 대화 문맥 일부만 참조됩니다"
        okText="확인 후 전송"
        cancelText="취소"
        okButtonProps={{ autoFocus: true }}
        modalRender={renderModalWithEnterConfirm}
        zIndex={1690}
        onCancel={() => {
          setPendingContextConfirm(null);
        }}
        onOk={() => {
          if (!pendingContextConfirm) {
            return;
          }

          if (skipContextConfirmForToday && pendingContextConfirm.sessionId.trim()) {
            setSkipContextConfirmForSessionToday(pendingContextConfirm.sessionId, true);
          }

          setDraftValue('');
          executeSendMessageSafely(pendingContextConfirm);
          setPendingContextConfirm(null);
        }}
      >
        <Space direction="vertical" size={8}>
          <Text>
            최근 대화가 길어서 이번 요청은 저장된 문맥 제한인 최근 {appConfig.chat.maxContextMessages}개 메시지,
            최대 {appConfig.chat.maxContextChars}자 안에서만 참조됩니다. 이번 전송에는 최근{' '}
            {pendingContextConfirm?.includedContextCount ?? 0}개 메시지만 문맥에 포함되고, 이전{' '}
            {pendingContextConfirm?.omittedContextCount ?? 0}개 메시지는 제외됩니다.
          </Text>
          <Text type="secondary">
            전체 맥락이 꼭 필요하면 요청 내용을 더 구체화하거나 새로 정리한 뒤 보내는 편이 안전합니다.
          </Text>
          <Checkbox
            checked={skipContextConfirmForToday}
            onChange={(event) => {
              setSkipContextConfirmForToday(event.target.checked);
            }}
          >
            오늘은 이 채팅방에서 더 이상 묻지 않기
          </Checkbox>
        </Space>
      </Modal>
      <FullscreenPreviewModal
        open={isPreviewModalOpen}
        afterOpenChange={handlePreviewModalAfterOpenChange}
        title={
          previewModalTarget
            ? `${previewModalTarget.label} · ${previewModalTarget.kind} · ${previewModalTarget.source === 'context' ? '현재 화면' : '채팅 결과'}`
            : 'preview'
        }
        meta={null}
        actions={
          <>
            {previewModalTarget && isHtmlPreviewItem(previewModalTarget) ? (
              <>
                <Button
                  type="text"
                  className="fullscreen-preview-modal__icon-button"
                  aria-label="HTML 실행 미리보기"
                  icon={<EyeOutlined />}
                  onClick={() => {
                    setIsHtmlPreviewMode(true);
                  }}
                />
                <Button
                  type="text"
                  className="fullscreen-preview-modal__icon-button"
                  aria-label="HTML 코드 보기"
                  icon={<EditOutlined />}
                  onClick={() => {
                    setIsHtmlPreviewMode(false);
                  }}
                />
              </>
            ) : null}
            {previewModalTarget && canSearchActivePreview ? (
              <Button
                type="text"
                className="fullscreen-preview-modal__icon-button"
                aria-label="단어 찾기"
                icon={<SearchOutlined />}
                onClick={() => {
                  setIsPreviewFindOpen((current) => !current);
                }}
              />
            ) : null}
            <Button
              type="text"
              className="fullscreen-preview-modal__icon-button"
              aria-label="공유"
              icon={<ShareAltOutlined />}
              onClick={handleShareActivePreview}
            />
            <Button
              type="text"
              className="fullscreen-preview-modal__icon-button"
              aria-label="복사"
              icon={<CopyOutlined />}
              onClick={() => void handleCopyActivePreview()}
            />
            <Button
              type="text"
              className="fullscreen-preview-modal__icon-button"
              aria-label="다운로드"
              icon={<DownloadOutlined />}
              onClick={handleDownloadActivePreview}
            />
          </>
        }
        onClose={() => {
          setIsPreviewModalOpen(false);
        }}
        className={`app-chat-panel__preview-modal${isHtmlPreviewFullscreen ? ' app-chat-panel__preview-modal--html-mobile' : ''}`}
      >
        {previewModalTarget ? (
          <div className="app-chat-panel__preview-modal-body">
            {canSearchActivePreview && isPreviewFindOpen ? (
              <div className="app-chat-panel__preview-modal-findbar">
                <Input
                  ref={previewFindInputRef}
                  size="small"
                  value={previewFindQuery}
                  placeholder="단어 찾기"
                  allowClear
                  onChange={(event) => {
                    setPreviewFindQuery(event.target.value);
                  }}
                  onPressEnter={(event) => {
                    handleFindActivePreview(event.shiftKey ? 'backward' : 'forward');
                  }}
                />
                <Button
                  size="small"
                  onClick={() => {
                    handleFindActivePreview('backward');
                  }}
                >
                  이전
                </Button>
                <Button
                  size="small"
                  type="primary"
                  onClick={() => {
                    handleFindActivePreview('forward');
                  }}
                >
                  다음
                </Button>
              </div>
            ) : null}
            <div
              ref={previewSearchRootRef}
              className={`app-chat-panel__preview-stage app-chat-panel__preview-stage--modal app-chat-panel__preview-search-root${
                isHtmlPreviewFullscreen ? ' app-chat-panel__preview-stage--html-mobile' : ''
              }`}
              onPointerDownCapture={handlePreviewSearchRootPointerDown}
            >
              <ChatPreviewBody
                target={previewModalTarget}
                previewText={previewText}
                isPreviewLoading={isPreviewLoading}
                previewError={previewError}
                previewContentType={previewContentType}
                maxMarkdownBlocks={undefined}
                renderHtmlAsFrame={isActivePreviewHtml && isHtmlPreviewMode}
                fullscreen
              />
            </div>
          </div>
        ) : (
          <Empty description="preview 항목이 없습니다." />
        )}
      </FullscreenPreviewModal>
      <Modal
        open={Boolean(editingConversationTitleTarget)}
        title="채팅방 이름 수정"
        okText="저장"
        cancelText="취소"
        confirmLoading={Boolean(
          editingConversationTitleSessionId && savingConversationTitleSessionId === editingConversationTitleSessionId,
        )}
        modalRender={renderModalWithEnterConfirm}
        zIndex={1700}
        onCancel={() => {
          setEditingConversationTitleSessionId(null);
          setEditingConversationTitle('');
        }}
        onOk={async () => {
          await handleSaveConversationTitle();
        }}
      >
        <div className="app-chat-panel__general-section-modal">
          <Text type="secondary">자동 이름 변경과 별개로, 채팅방 이름은 여기서 직접 수정할 수 있습니다.</Text>
          <Input
            value={editingConversationTitle}
            maxLength={200}
            placeholder="채팅방 이름"
            onChange={(event) => {
              setEditingConversationTitle(event.target.value);
            }}
            onPressEnter={() => {
              void handleSaveConversationTitle();
            }}
          />
        </div>
      </Modal>
      <Modal
        open={Boolean(editingGeneralSectionConversation)}
        title="일반 보관 섹션"
        okText="저장"
        cancelText="취소"
        confirmLoading={Boolean(
          editingGeneralSectionSessionId && savingGeneralSectionSessionId === editingGeneralSectionSessionId,
        )}
        modalRender={renderModalWithEnterConfirm}
        zIndex={1700}
        onCancel={() => {
          setEditingGeneralSectionSessionId(null);
          setEditingGeneralSectionName('');
        }}
        onOk={async () => {
          await handleSaveGeneralSection();
        }}
      >
        <div className="app-chat-panel__general-section-modal">
          <Text type="secondary">
            처리 중, 오류, 답변 도착 섹션에서는 그대로 보이고, 작업이 끝나면 여기서 지정한 일반 섹션으로 돌아갑니다.
          </Text>
          <Input
            value={editingGeneralSectionName}
            maxLength={120}
            placeholder="비워두면 기본 일반"
            onChange={(event) => {
              setEditingGeneralSectionName(event.target.value);
            }}
            onPressEnter={() => {
              void handleSaveGeneralSection();
            }}
          />
          {availableGeneralSectionNames.length > 0 ? (
            <div className="app-chat-panel__general-section-presets">
              <Button
                size="small"
                type={editingGeneralSectionName.trim() ? 'default' : 'primary'}
                onClick={() => {
                  setEditingGeneralSectionName('');
                }}
              >
                기본 일반
              </Button>
              {availableGeneralSectionNames.map((name) => (
                <Button
                  key={name}
                  size="small"
                  type={editingGeneralSectionName.trim() === name ? 'primary' : 'default'}
                  onClick={() => {
                    setEditingGeneralSectionName(name);
                  }}
                >
                  {name}
                </Button>
              ))}
            </div>
          ) : null}
        </div>
      </Modal>
      <Modal
        open={Boolean(pendingShareTarget)}
        title="공유 링크 설정"
        okText="공유 링크 생성"
        cancelText="취소"
        okButtonProps={{
          autoFocus: true,
          loading: isShareTargetSubmitting,
          disabled:
            isShareTokenSettingsLoading ||
            shareTokenSettingOptions.length === 0 ||
            !selectedShareTokenSettingId.trim() ||
            !shareTokenName.trim(),
        }}
        cancelButtonProps={{ disabled: isShareTargetSubmitting }}
        modalRender={renderModalWithEnterConfirm}
        zIndex={1700}
        onCancel={() => {
          if (isShareTargetSubmitting) {
            return;
          }

          setPendingShareTarget(null);
          setShareTokenName('');
        }}
        onOk={async () => {
          await handleConfirmShareTarget();
        }}
      >
        <Space direction="vertical" size={12} style={{ width: '100%' }}>
          <Text>공유 채팅방은 토큰 설정과 공유 이름을 입력한 뒤 생성됩니다.</Text>
          {shareTokenSettingsErrorMessage ? <Alert showIcon type="error" message={shareTokenSettingsErrorMessage} /> : null}
          <Select
            value={selectedShareTokenSettingId || undefined}
            loading={isShareTokenSettingsLoading}
            placeholder="공유에 사용할 토큰 설정 선택"
            onChange={setSelectedShareTokenSettingId}
            options={shareTokenSettingOptions.map((item) => ({
              value: item.id,
              label: `${item.name} (${item.id})`,
            }))}
          />
          <Input
            value={shareTokenName}
            maxLength={160}
            placeholder="공유 이름 입력"
            onChange={(event) => setShareTokenName(event.target.value)}
          />
          {selectedShareTokenSettingId ? (
            <Text type="secondary">
              {(() => {
                const selectedSetting = shareTokenSettingOptions.find((item) => item.id === selectedShareTokenSettingId) ?? null;

                if (!selectedSetting) {
                  return '선택한 설정 권한으로 공유가 제한됩니다.';
                }

                return [
                  selectedSetting.description || '선택한 설정 권한으로 공유가 제한됩니다.',
                  `7일 ${selectedSetting.maxTokensPer7Days.toLocaleString('ko-KR')}`,
                  `5시간 ${selectedSetting.maxTokensPer5Hours.toLocaleString('ko-KR')}`,
                ].join(' · ');
              })()}
            </Text>
          ) : null}
        </Space>
      </Modal>
      <Modal
        open={isDeleteAllConversationsModalOpen}
        title="목록의 대화를 전체 삭제할까요?"
        okText="전체 삭제"
        cancelText="취소"
        okButtonProps={{ danger: true, autoFocus: true, loading: isDeletingAllConversations }}
        cancelButtonProps={{ disabled: isDeletingAllConversations }}
        modalRender={renderModalWithEnterConfirm}
        zIndex={1700}
        onCancel={() => {
          if (isDeletingAllConversations) {
            return;
          }

          setIsDeleteAllConversationsModalOpen(false);
        }}
        onOk={async () => {
          await handleDeleteAllFilteredConversations();
        }}
      >
        <Text>
          현재 목록의 대화 {filteredConversationItems.length}개를 삭제합니다. 검색 중이면 보이는 항목만 삭제됩니다.
        </Text>
      </Modal>
      <Modal
        open={Boolean(pendingClearConversation)}
        title="채팅방 데이터를 초기화할까요?"
        okText="초기화"
        cancelText="취소"
        okButtonProps={{ danger: true, autoFocus: true }}
        modalRender={renderModalWithEnterConfirm}
        zIndex={1700}
        onCancel={() => {
          setPendingClearConversationSessionId(null);
        }}
        onOk={async () => {
          const targetSessionId = pendingClearConversationSessionId;

          if (!targetSessionId) {
            return;
          }

          await handleClearConversation(targetSessionId);
          setPendingClearConversationSessionId(null);
        }}
      >
        <Text>
          {pendingClearConversation?.title
            ? `"${pendingClearConversation.title}" 채팅방의 이름과 설정은 유지되고, 메시지·요청·활동 로그만 초기화됩니다.`
            : '채팅방 이름과 설정은 유지하고, 메시지·요청·활동 로그만 초기화됩니다.'}
        </Text>
      </Modal>
      <Modal
        open={Boolean(pendingDeleteConversation)}
        title="대화방을 삭제할까요?"
        okText="삭제"
        cancelText="취소"
        okButtonProps={{ danger: true, autoFocus: true }}
        modalRender={renderModalWithEnterConfirm}
        zIndex={1700}
        className="app-chat-panel__delete-confirm-modal"
        onCancel={() => {
          setPendingDeleteSessionId(null);
        }}
        onOk={async () => {
          const targetSessionId = pendingDeleteSessionId;

          if (!targetSessionId) {
            return;
          }

          await handleDeleteConversation(targetSessionId);
          setPendingDeleteSessionId(null);
        }}
      >
        <Text>
          {pendingDeleteConversation?.title
            ? `"${pendingDeleteConversation.title}" 대화와 저장된 메시지가 삭제됩니다.`
            : '이 대화와 저장된 메시지가 삭제됩니다.'}
        </Text>
      </Modal>
      </Card>
    </>
  );
}
