import {
  Fragment,
  memo,
  useCallback,
  useDeferredValue,
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type DragEvent as ReactDragEvent,
  type MutableRefObject,
  type ReactNode,
} from 'react';
import { createPortal } from 'react-dom';
import { useAnalytics } from '../analytics/provider';
import { trackChatPanelClick, trackRunFailedToastSurfaceView } from '../analytics/events';
import { attributedAmrUrl, recordAmrEntry } from '../analytics/amr-attribution';
import { useT } from '../i18n';
import type { Dict } from '../i18n/types';
import { copyToClipboard } from '../lib/copy-to-clipboard';
import { projectRawUrl } from '../providers/registry';
import type { TodoItem } from '../runtime/todos';
import type { AppliedPluginSnapshot, ChatSessionMode, WorkspaceContextItem } from '@open-design/contracts';
import type { TrackingProjectKind } from '@open-design/contracts/analytics';
import {
  DESIGN_SYSTEM_WORKSPACE_DISPLAY_DESCRIPTION,
  DESIGN_SYSTEM_WORKSPACE_DISPLAY_TITLE,
  isDesignSystemWorkspacePrompt,
} from '../design-system-auto-prompt';
import { latestTodoWriteInputForPinnedCard } from '../runtime/todos';
import { TodoCard } from './ToolCard';
import type { AppConfig, ChatAttachment, ChatCommentAttachment, ChatMessage, ChatMessageFeedbackChange, Conversation, DesignSystemSummary, PreviewComment, Project, ProjectFile, ProjectMetadata, SkillSummary } from '../types';
import { dayKey, dayLabel, exactDateTime, messageTime, relativeTimeLong, shortTime } from '../utils/chatTime';
import { commentTargetDisplayName, commentsToAttachments, simplePositionLabel } from '../comments';
import { AssistantMessage } from './AssistantMessage';
import { AmrGuidance } from './AmrGuidance';
import { AMR_RECHARGE_URL, resolveRunFailureUi } from '../runtime/amr-guidance';
import {
  ChatComposer,
  type ChatComposerHandle,
  type ChatSendMeta,
} from './ChatComposer';
import { listDesignArtifactCandidates } from './design-files/designArtifacts';
import type { PluginFolderAgentAction } from './design-files/pluginFolderActions';
import { Icon, type IconName } from './Icon';
import { repoConnectCopy } from './design-system-github-evidence';
import { isRenderableSketchJson, SketchPreview } from './SketchPreview';
import type { SettingsSection } from './SettingsDialog';

type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;

// Featured starter prompts shown on the empty chat. Clicking one fills
// the composer (does not auto-send) so users can tweak before sending.
// Each prompt is intentionally dense — it should showcase ambitious
// layout, typographic, and information-design moves rather than a
// generic landing page.
//
// Starter sets are picked per project kind (and per video model) so a
// fresh seedance video, a hyperframes html-in-canvas video, an image
// project and an audio project each see relevant prompts instead of the
// generic prototype trio. The default (prototype/deck/template/other/
// live-artifact) set stays i18n-translated via existing chat.example*
// keys so the user-facing copy keeps its localizations. The new media
// sets are inline English literals — they are technical agent prompts
// that work well across locales without translation, and going through
// i18n for each of them would balloon every Dict entry by 12+ keys.
type StarterPrompt = {
  icon: string;
  title: string;
  tag: string;
  prompt: string;
};

const DEFAULT_STARTER_KEYS: Array<{
  icon: string;
  titleKey: keyof Dict;
  tagKey: keyof Dict;
  promptKey: keyof Dict;
}> = [
  {
    icon: '▤',
    titleKey: 'chat.example1Title',
    tagKey: 'chat.example1Tag',
    promptKey: 'chat.example1Prompt',
  },
  {
    icon: '▦',
    titleKey: 'chat.example2Title',
    tagKey: 'chat.example2Tag',
    promptKey: 'chat.example2Prompt',
  },
  {
    icon: '◈',
    titleKey: 'chat.example3Title',
    tagKey: 'chat.example3Tag',
    promptKey: 'chat.example3Prompt',
  },
];

const IMPORTED_ARTIFACTS_INITIAL_VISIBLE_COUNT = 5;
const IMPORTED_ARTIFACTS_REVEAL_COUNT = 5;

const IMAGE_STARTERS: StarterPrompt[] = [
  {
    icon: '◯',
    title: 'Editorial portrait',
    tag: 'Portrait',
    prompt:
      'A close-up editorial portrait of a young creative director in their late 20s, soft natural light through tall studio windows, warm neutral palette (cream, taupe, soft black), shot at 85mm f/1.8 with shallow depth of field, sharp gaze straight to camera, subtle film grain, no makeup look.',
  },
  {
    icon: '▭',
    title: 'Product hero',
    tag: 'E-commerce',
    prompt:
      'A premium product hero shot of a single matte ceramic coffee mug on a warm cream paper backdrop. Hard rim light from the upper-left, gentle elongated shadow stretching to the lower-right, faint steam rising from the cup. Square crop, centered composition, room above for headline copy, no props or hands in frame.',
  },
  {
    icon: '◐',
    title: 'Flat illustration',
    tag: 'Illustration',
    prompt:
      'A flat vector illustration of a cozy reading nook by a rainy window — geometric shapes, restrained 5-color palette (cream, terracotta, deep teal, burnt sienna, soft black), thin 1.5px line accents, no gradients, no textures, soft drop shadows only on the foreground armchair.',
  },
];

// Pure-video / cinematic-shot starters for seedance, sora, kling, veo,
// grok-imagine and similar text-to-video models. Each prompt is one
// shot, restrained motion, and a clear visual concept the model can
// nail in 5-10 seconds.
const VIDEO_SEEDANCE_STARTERS: StarterPrompt[] = [
  {
    icon: '◉',
    title: 'Product reveal',
    tag: 'Cinematic',
    prompt:
      'A 5-second product reveal: a minimal high-end skincare bottle on a clean cream stone surface, soft side light from camera-left, slow camera push-in, subtle depth-of-field shift from the cap to the label, restrained motion, no text overlays, no people in frame.',
  },
  {
    icon: '▣',
    title: 'Lantern close-up',
    tag: 'Mood',
    prompt:
      'A 6-second cinematic close-up of a young woman holding a glowing paper lantern in a misty pine forest at golden hour. Shallow depth of field on her eyes, gentle dolly-in, ambient particles drifting through the warm shaft of light, no dialogue, ambient forest sound only.',
  },
  {
    icon: '⌘',
    title: 'Neon street drift',
    tag: 'Action',
    prompt:
      'A 5-second street-racing tracking shot at night in a neon-lit cyberpunk Hong Kong alley. Low-angle camera following a matte-black sports car drifting around a tight corner, motion blur on the wheels, lens flares from oncoming neon signs, rain-slick asphalt reflecting the lights, no on-screen text.',
  },
];

// HyperFrames HTML-in-canvas starters — these target the
// hyperframes-html video model where the renderer captures live DOM
// into a WebGL texture and runs shader effects on top. References:
// https://www.remotion.dev/docs/html-in-canvas (concept), the seven
// vfx-* catalog blocks shipped via `npx hyperframes add vfx-*`, and
// skills/hyperframes/references/html-in-canvas.md.
const VIDEO_HYPERFRAMES_STARTERS: StarterPrompt[] = [
  {
    icon: '◉',
    title: 'Magnifying glass reveal',
    tag: 'HTML-in-canvas',
    prompt:
      'Make a 5-second composition with a single line of bold display text on a clean canvas. Animate a round magnifying glass that travels left to right across the line, with subtle glass refraction warping the letters underneath as it passes. Use HyperFrames html-in-canvas — capture the text DOM and run the lens shader on top via a vfx-liquid-glass-style pass. Pure CSS for the text; the glass is a WebGL layer.',
  },
  {
    icon: '▦',
    title: 'CRT terminal scene',
    tag: 'Vintage VFX',
    prompt:
      "Make a CRT-screen composition: dark canvas, monospace terminal text typing `npx hyperframes init my-video`, then `claude` invoked with the prompt 'Add a CRT effect using HTML-in-canvas'. Apply a subtle convex-curvature shader, scanlines, slight chromatic aberration, and a soft phosphor glow on top of the live DOM via html-in-canvas. The terminal text stays as real CSS so it's pixel-sharp before the shader pass.",
  },
  {
    icon: '◈',
    title: 'Glitch breakdown',
    tag: 'Glitch',
    prompt:
      'Build a 6-second composition that displays a hero headline and a one-line subhead on a dark canvas, then breaks into a hard digital glitch — RGB channel split, horizontal displacement bands, brief frame-stutter, and a final clean reset. Capture the live DOM via html-in-canvas and run the glitch pass on top, so the type is real CSS underneath the shader.',
  },
];

// Speech-focused audio starters — the New Project audio panel only
// surfaces the `speech` kind today (see MediaProjectOptions), so we
// match that. If/when the music + sfx tabs come back, broaden this set.
const AUDIO_STARTERS: StarterPrompt[] = [
  {
    icon: '♪',
    title: 'Brand voiceover',
    tag: 'Speech',
    prompt:
      "A 30-second warm-toned narrative voiceover for a product launch video — confident but conversational, mid-tempo, with a beat of pause after the brand name. Script: 'Three years in the making. One simple promise. Meet [product name] — the way work was supposed to feel.' English, neutral North American accent.",
  },
  {
    icon: '♫',
    title: 'Onboarding narration',
    tag: 'Speech',
    prompt:
      "A 20-second friendly onboarding narration for a mobile app's first-launch screen. Reassuring, smiling tone, slow enough to feel attentive without sounding scripted. Script: 'Welcome to Loop. Let's set up your space — three quick questions and you're in. You can change any of this later.'",
  },
  {
    icon: '♬',
    title: 'Story passage read',
    tag: 'Speech',
    prompt:
      "A 45-second cinematic read of an opening passage. Low, measured delivery with breath between sentences, slightly intimate close-mic'd quality. Script: 'The city sleeps in pieces. A neon sign flickers above the ramen counter. Across the avenue, a window glows — the only one still on this side of midnight.'",
  },
];

function pickStarters(
  metadata: ProjectMetadata | undefined,
  t: TranslateFn,
): StarterPrompt[] {
  const kind = metadata?.kind;
  if (kind === 'image') return IMAGE_STARTERS;
  if (kind === 'video') {
    return metadata?.videoModel === 'hyperframes-html'
      ? VIDEO_HYPERFRAMES_STARTERS
      : VIDEO_SEEDANCE_STARTERS;
  }
  if (kind === 'audio') return AUDIO_STARTERS;
  return DEFAULT_STARTER_KEYS.map((entry) => ({
    icon: entry.icon,
    title: t(entry.titleKey),
    tag: t(entry.tagKey),
    prompt: t(entry.promptKey),
  }));
}

function sortArtifactsByModified(files: ProjectFile[]): ProjectFile[] {
  return [...files].sort(
    (a, b) => b.mtime - a.mtime || a.name.localeCompare(b.name),
  );
}

function ImportedFolderArtifacts({
  projectId,
  files,
  onOpenFile,
  t,
}: {
  projectId: string | null;
  files: ProjectFile[];
  onOpenFile?: (name: string) => void;
  t: TranslateFn;
}) {
  const [visibleCount, setVisibleCount] = useState(IMPORTED_ARTIFACTS_INITIAL_VISIBLE_COUNT);

  useEffect(() => {
    setVisibleCount(IMPORTED_ARTIFACTS_INITIAL_VISIBLE_COUNT);
  }, [files]);

  if (files.length === 0) {
    return (
      <div className="chat-design-artifacts-empty" data-testid="chat-design-artifacts-empty">
        {t('designFiles.empty')}
      </div>
    );
  }

  const visibleFiles = files.slice(0, visibleCount);
  const hiddenCount = Math.max(0, files.length - visibleFiles.length);
  const revealCount = Math.min(IMPORTED_ARTIFACTS_REVEAL_COUNT, hiddenCount);
  const revealLabel = t('chat.designArtifactsShowMore', { count: revealCount });

  return (
    <div className="chat-design-artifacts" data-testid="chat-design-artifacts">
      {visibleFiles.map((file, index) => {
        const openable = Boolean(onOpenFile);
        const openLabel = `${t('designFiles.previewOpen')} ${file.name}`;
        const openFile = () => {
          onOpenFile?.(file.name);
        };
        return (
          <div
            key={file.name}
            className="chat-design-artifact"
            data-kind={file.kind}
            data-file-name={file.name}
            data-testid={`chat-design-artifact-${index}`}
            role={openable ? 'button' : 'listitem'}
            tabIndex={openable ? 0 : undefined}
            title={openLabel}
            aria-label={openLabel}
            onDoubleClick={openable ? openFile : undefined}
            onKeyDown={
              openable
                ? (event) => {
                    if (event.key !== 'Enter' && event.key !== ' ') return;
                    event.preventDefault();
                    openFile();
                  }
                : undefined
            }
          >
            <div className="chat-design-artifact-preview" aria-hidden>
              <ChatArtifactPreview projectId={projectId} file={file} />
            </div>
            <div className="chat-design-artifact-meta">
              <span className="chat-design-artifact-name" title={file.name}>
                {file.name}
              </span>
              <span className="chat-design-artifact-kind">
                {chatArtifactKindLabel(file.kind, t)}
              </span>
            </div>
          </div>
        );
      })}
      {hiddenCount > 0 ? (
        <button
          type="button"
          className="chat-design-artifact chat-design-artifact-more"
          data-testid="chat-design-artifacts-more"
          aria-label={revealLabel}
          title={revealLabel}
          onClick={() => {
            setVisibleCount((current) =>
              Math.min(files.length, current + IMPORTED_ARTIFACTS_REVEAL_COUNT),
            );
          }}
        >
          <span className="chat-design-artifact-more-icon" aria-hidden>
            +
          </span>
          <span className="chat-design-artifact-more-count">
            {revealLabel}
          </span>
        </button>
      ) : null}
    </div>
  );
}

function ChatArtifactPreview({
  projectId,
  file,
}: {
  projectId: string | null;
  file: ProjectFile;
}) {
  if (!projectId) {
    return <ChatArtifactFallback kind={file.kind} />;
  }

  const url = `${projectRawUrl(projectId, file.name)}?v=${Math.round(file.mtime)}`;
  if (isRenderableSketchJson(file)) {
    return <SketchPreview projectId={projectId} file={file} />;
  }
  if (file.kind === 'image' || file.kind === 'sketch') {
    return <img src={url} alt="" loading="lazy" />;
  }
  if (file.kind === 'html') {
    return (
      <iframe
        title={file.name}
        src={url}
        sandbox="allow-scripts allow-downloads"
        loading="lazy"
      />
    );
  }
  if (file.kind === 'video') {
    return <video src={url} muted playsInline preload="metadata" />;
  }
  return <ChatArtifactFallback kind={file.kind} />;
}

function ChatArtifactFallback({ kind }: { kind: ProjectFile['kind'] }) {
  return (
    <span className="chat-design-artifact-fallback">
      <Icon name={chatArtifactIcon(kind)} size={28} />
      <span>{chatArtifactShortKind(kind)}</span>
    </span>
  );
}

function chatArtifactIcon(kind: ProjectFile['kind']): IconName {
  if (kind === 'html' || kind === 'code') return 'file-code';
  if (kind === 'image' || kind === 'sketch') return 'image';
  if (kind === 'video' || kind === 'audio') return 'play';
  if (kind === 'presentation') return 'present';
  return 'file';
}

function chatArtifactShortKind(kind: ProjectFile['kind']): string {
  if (kind === 'html') return 'HTML';
  if (kind === 'image') return 'IMG';
  if (kind === 'sketch') return 'SKETCH';
  if (kind === 'video') return 'VIDEO';
  if (kind === 'pdf') return 'PDF';
  if (kind === 'presentation') return 'PPT';
  if (kind === 'document') return 'DOC';
  return 'FILE';
}

function chatArtifactKindLabel(kind: ProjectFile['kind'], t: TranslateFn): string {
  if (kind === 'html') return t('designFiles.kindHtml');
  if (kind === 'image') return t('designFiles.kindImage');
  if (kind === 'sketch') return t('designFiles.kindSketch');
  if (kind === 'video') return 'Video';
  if (kind === 'audio') return 'Audio';
  if (kind === 'pdf') return t('designFiles.kindPdf');
  if (kind === 'document') return t('designFiles.kindDocument');
  if (kind === 'presentation') return t('designFiles.kindPresentation');
  if (kind === 'spreadsheet') return t('designFiles.kindSpreadsheet');
  return t('designFiles.kindBinary');
}

interface Props {
  messages: ChatMessage[];
  streaming: boolean;
  loading?: boolean;
  error: string | null;
  projectId: string | null;
  sessionMode?: ChatSessionMode;
  onSessionModeChange?: (mode: ChatSessionMode) => void;
  // Analytics-only — forwarded to AssistantMessage so the feedback
  // events know which project surface the rating applies to. Optional
  // (defaults to null/'prototype') so unit tests can mount ChatPane
  // without project context.
  projectKindForTracking?: TrackingProjectKind | null;
  projectFiles: ProjectFile[];
  activeProjectFileName?: string | null;
  hasActiveDesignSystem?: boolean;
  activeDesignSystem?: DesignSystemSummary | null;
  sendDisabled?: boolean;
  queuedItems?: QueuedSendItem[];
  onRemoveQueuedSend?: (id: string) => void;
  onUpdateQueuedSend?: (id: string, update: QueuedSendUpdate) => void;
  onReorderQueuedSends?: (orderedIds: string[]) => void;
  onSendQueuedNow?: (id: string) => void;
  // Names that exist in the project folder. Tool cards and chips use this
  // set to decide whether a path can be opened as a tab.
  projectFileNames?: Set<string>;
  onEnsureProject: () => Promise<string | null>;
  previewComments?: PreviewComment[];
  attachedComments?: PreviewComment[];
  onAttachComment?: (comment: PreviewComment) => void;
  onDetachComment?: (commentId: string) => void;
  onDeleteComment?: (commentId: string) => void;
  onSend: (
    prompt: string,
    attachments: ChatAttachment[],
    commentAttachments: ChatCommentAttachment[],
    meta?: ChatSendMeta,
  ) => void;
  onRetry?: (assistantMessage: ChatMessage) => void;
  onStop: () => void;
  // Skills available for @-mention assembly. ProjectView filters out the
  // user's disabled set before passing them in here.
  skills?: SkillSummary[];
  // Click-to-open chain: passes a basename up to ProjectView, which sets
  // FileWorkspace's openRequest. Tool cards, attachment chips, and
  // produced-file chips all call this.
  onRequestOpenFile?: (name: string) => void;
  onRequestPluginDetails?: (pluginId: string) => void;
  onRequestDesignSystemDetails?: (system: DesignSystemSummary) => void;
  onRequestPluginFolderAgentAction?: (
    relativePath: string,
    action: PluginFolderAgentAction,
  ) => Promise<{ message?: string; url?: string } | void> | { message?: string; url?: string } | void;
  activePluginActionPaths?: Set<string>;
  hiddenPluginActionPaths?: Set<string>;
  forceStreamingMessageIds?: Set<string>;
  // Live-only streaming tool-input partials keyed by tool-use id. Threaded to
  // AssistantMessage so an in-flight Write/Edit can render its code in real
  // time before the full `tool_use` arrives. Never persisted.
  liveToolInput?: Record<string, { name: string; text: string; seq?: number }>;
  initialDraft?: string;
  // Question-form submissions become a normal user message; the parent
  // routes that text through onSend (no attachments).
  onSubmitForm?: (text: string) => void;
  // Focus the right-hand Questions tab from the chat banner.
  onOpenQuestions?: () => void;
  onContinueRemainingTasks?: (assistantMessage: ChatMessage, todos: TodoItem[]) => void;
  onAssistantFeedback?: (assistantMessage: ChatMessage, change: ChatMessageFeedbackChange) => void;
  // "Next step" affordance handlers forwarded to the last assistant message.
  onArtifactShare?: (fileName: string) => void;
  onArtifactChip?: (fileName: string, prompt: string) => void;
  onForkFromMessage?: (assistantMessage: ChatMessage) => void;
  forkingMessageId?: string | null;
  // Header "+" button — kicks off ProjectView's create-conversation flow.
  onNewConversation?: () => void;
  newConversationDisabled?: boolean;
  // Conversation list that used to live in the topbar. The chat tab now
  // owns the list so users can browse + switch conversations without
  // leaving the pane.
  conversations: Conversation[];
  activeConversationId: string | null;
  // The conversation whose history the live `messages` array currently
  // reflects. Null while a switch is mid-flight (or after a load failure),
  // which is exactly when `messages.length` must NOT be trusted as the active
  // conversation's count — see `conversationMessageCount`. Callers that do not
  // track this (mounts whose loader resets/retags `messages` asynchronously)
  // leave it undefined and fall back to the persisted `conversation.messageCount`
  // for a stable list count.
  messagesConversationId?: string | null;
  onSelectConversation: (id: string) => void;
  onDeleteConversation: (id: string) => void;
  // Composer settings/CLI button forwards to here. The dialog lives in App
  // (it owns the AppConfig lifecycle) so we just pass the open trigger.
  onOpenSettings?: (section?: SettingsSection) => void;
  showByokRecoveryAction?: boolean;
  onSwitchToLocalCli?: () => void;
  onOpenAmrSettings?: () => void;
  onSwitchToAmrAndRetry?: (failedAssistant: ChatMessage) => void;
  // PR #3157: Antigravity's `agy -p` can't complete OAuth on its own,
  // so the auth banner offers a "Sign in via terminal" button that
  // POSTs to /api/agents/antigravity/oauth-launch. Handler resolves
  // after the daemon kicks off `osascript`/`x-terminal-emulator`/
  // `cmd /c start` so the UI can disable the button while in flight.
  onLaunchAntigravityOauth?: () => Promise<void>;
  // Same dialog, but landing on the External MCP tab. Forwarded to the
  // composer's `/mcp` slash and MCP picker button.
  onOpenMcpSettings?: () => void;
  // True when this project is a GitHub-backed design system whose repository
  // evidence has not fully landed. Surfaces a "Connect your repo" CTA in the
  // empty chat state alongside the starter examples.
  connectRepoNeeded?: boolean;
  // Live GitHub connector status, used only to pick the connect-repo CTA copy
  // (connect vs re-import). Undefined until the status fetch resolves.
  githubConnected?: boolean;
  // Fires when the connect-repo CTA button is clicked. The parent decides what
  // it does based on connector status (open Connectors, or prefill the composer
  // with the import instruction).
  onConnectRepo?: () => void;
  // Bumped by the parent to push a draft into the composer (used by the
  // "Import repo" CTA). The nonce lets the same text fire more than once.
  composerDraftSignal?: { text: string; nonce: number };
  // Optional pet wiring forwarded straight through to ChatComposer's
  // /pet button. When omitted the composer hides the button entirely.
  petConfig?: AppConfig['pet'];
  onAdoptPet?: (petId: string) => void;
  onTogglePet?: () => void;
  onOpenPetSettings?: () => void;
  projectMetadata?: ProjectMetadata;
  onProjectMetadataChange?: (metadata: ProjectMetadata) => void;
  activeWorkspaceContext?: WorkspaceContextItem | null;
  workspaceContexts?: WorkspaceContextItem[];
  currentSkillId?: string | null;
  onProjectSkillChange?: (skillId: string | null) => void;
  researchAvailable?: boolean;
  // Immutable snapshot of the plugin pinned to this project. When set
  // we suppress the in-composer plugin rail (the user already picked a
  // plugin on Home) and render the active plugin as a context chip on
  // each user message — that satisfies §8 "show context inside the run
  // message" without forcing a separate side widget.
  activePluginSnapshot?: AppliedPluginSnapshot | null;
  // SenseAudio BYOK only — wired straight through to ChatComposer for the
  // in-composer image-model picker. Active protocol is read so the picker
  // hides when the user is on any other BYOK tab (azure / openai / …).
  byokApiProtocol?: AppConfig['apiProtocol'];
  byokImageModel?: string;
  onChangeByokImageModel?: (model: string) => void;
  byokVideoModel?: string;
  onChangeByokVideoModel?: (model: string) => void;
  byokSpeechModel?: string;
  onChangeByokSpeechModel?: (model: string) => void;
  byokSpeechVoice?: string;
  onChangeByokSpeechVoice?: (voice: string) => void;
  composerFooterAccessory?: ReactNode;
  // Forwarded straight to the chat composer's mid-chat design-system
  // switcher. ProjectView owns the project record so the parent is the
  // natural place to mirror the patched project after a PATCH lands.
  currentDesignSystemId?: string | null;
  onActiveDesignSystemChange?: (project: Project) => void;
  onShowToast?: (message: string) => void;
  // Project header slot. The former standalone chrome header row was removed;
  // its back button, project title (editable) and design-system picker moved
  // into the top of the chat pane. ProjectView owns the project record so it
  // renders these as slots rather than ChatPane re-deriving the data.
  onBack?: () => void;
  backLabel?: string;
  projectHeader?: ReactNode;
  designSystemPicker?: ReactNode;
}

type Tab = 'chat' | 'comments';

const CHAT_MESSAGE_VIRTUALIZE_THRESHOLD = 80;
const CHAT_MESSAGE_OVERSCAN_PX = 900;
const CHAT_VIRTUAL_ROW_GAP_PX = 14;
const CHAT_VIRTUAL_MIN_ROW_HEIGHT = 36;
const CHAT_VIRTUAL_DEFAULT_VIEWPORT_PX = 640;
const CHAT_VIRTUAL_INITIAL_TAIL_ROWS = 16;
const CONVERSATION_ROW_HEIGHT_PX = 34;
const CONVERSATION_VIRTUALIZE_THRESHOLD = 36;
const CONVERSATION_OVERSCAN_ROWS = 8;

interface QueuedSendItem {
  id: string;
  prompt: string;
  attachments?: ChatAttachment[];
  commentAttachments?: ChatCommentAttachment[];
  meta?: ChatSendMeta;
}

interface QueuedSendUpdate {
  prompt: string;
  attachments: ChatAttachment[];
  commentAttachments: ChatCommentAttachment[];
  meta?: ChatSendMeta;
}

// Gap left above the anchored user message when it is pinned to the top.
const ANCHOR_TOP_PADDING = 12;

export function ChatPane({
  messages,
  streaming,
  loading = false,
  sendDisabled = false,
  queuedItems = [],
  error,
  projectId,
  sessionMode = 'design',
  onSessionModeChange,
  projectKindForTracking = null,
  projectFiles,
  activeProjectFileName = null,
  hasActiveDesignSystem = false,
  activeDesignSystem = null,
  projectFileNames,
  onEnsureProject,
  previewComments = [],
  attachedComments = [],
  onAttachComment,
  onDetachComment,
  onDeleteComment,
  onSend,
  onRetry,
  onStop,
  onRemoveQueuedSend,
  onUpdateQueuedSend,
  onReorderQueuedSends,
  onSendQueuedNow,
  onRequestOpenFile,
  onRequestPluginDetails,
  onRequestDesignSystemDetails,
  onRequestPluginFolderAgentAction,
  activePluginActionPaths,
  hiddenPluginActionPaths,
  forceStreamingMessageIds,
  liveToolInput,
  initialDraft,
  onSubmitForm,
  onOpenQuestions,
  onContinueRemainingTasks,
  onAssistantFeedback,
  onArtifactShare,
  onArtifactChip,
  onForkFromMessage,
  forkingMessageId = null,
  onNewConversation,
  newConversationDisabled = false,
  conversations,
  activeConversationId,
  messagesConversationId = null,
  onSelectConversation,
  onDeleteConversation,
  onOpenSettings,
  showByokRecoveryAction = false,
  onSwitchToLocalCli,
  onOpenAmrSettings,
  onSwitchToAmrAndRetry,
  onLaunchAntigravityOauth,
  onOpenMcpSettings,
  connectRepoNeeded,
  githubConnected,
  onConnectRepo,
  composerDraftSignal,
  petConfig,
  onAdoptPet,
  onTogglePet,
  onOpenPetSettings,
  projectMetadata,
  onProjectMetadataChange,
  activeWorkspaceContext,
  workspaceContexts = [],
  currentSkillId = null,
  onProjectSkillChange,
  researchAvailable,
  activePluginSnapshot,
  skills = [],
  byokApiProtocol,
  byokImageModel,
  onChangeByokImageModel,
  byokVideoModel,
  onChangeByokVideoModel,
  byokSpeechModel,
  onChangeByokSpeechModel,
  byokSpeechVoice,
  onChangeByokSpeechVoice,
  composerFooterAccessory,
  currentDesignSystemId,
  onActiveDesignSystemChange,
  onShowToast,
  onBack,
  backLabel,
  projectHeader,
  designSystemPicker,
}: Props) {
  const t = useT();
  const analytics = useAnalytics();
  const logRef = useRef<HTMLDivElement | null>(null);
  const chatLogScrollIdleTimerRef = useRef<number | null>(null);
  const historyWrapRef = useRef<HTMLDivElement | null>(null);
  const composerRef = useRef<ChatComposerHandle | null>(null);
  const composerSlotRef = useRef<HTMLDivElement | null>(null);
  const composerLayerRef = useRef<HTMLDivElement | null>(null);
  const pinnedTodoRef = useRef<HTMLDivElement | null>(null);
  const queuedSendStripRef = useRef<HTMLDivElement | null>(null);
  const didInitialScrollRef = useRef(false);
  const runFailedToastSurfaceKeysRef = useRef<Set<string>>(new Set());
  // Tracks whether the user is glued close enough to the bottom that
  // streamed content should auto-follow. Distinct from the jump-button
  // state below, which uses a wider threshold (120px) so the affordance
  // stays visible for short scroll-ups. Auto-follow needs the tighter
  // 80px cutoff: scrolling ~90px up is an intentional pause that
  // shouldn't be yanked back the moment the next chunk streams in.
  const pinnedToBottomRef = useRef(true);
  const scrolledToFormRef = useRef<Set<string>>(new Set());
  // "Anchor the just-sent turn to the top" (ChatGPT-style). On send we pin
  // the user's message to the top of the viewport and let the reply stream
  // below it instead of following the bottom. `pending` is armed by the
  // composer's onSend; the messages effect promotes it to `active` once the
  // new user turn actually renders. A dynamic tail spacer reserves just
  // enough real, scrollable blank space below the turn so the message can
  // reach the top even when the reply is short. The spacer is only resized
  // while the message sits at its pinned position — once the user scrolls
  // below it, the reserved blank stays put (no collapse, no jump).
  const anchorPendingRef = useRef(false);
  const anchorActiveRef = useRef(false);
  const tailSpacerRef = useRef<HTMLDivElement | null>(null);
  const prevStreamingRef = useRef(streaming);
  const prevLastUserIdRef = useRef<string | undefined>(undefined);
  // AssistantMessage's interaction callbacks are re-created per render and
  // excluded from its memo comparison (so streaming doesn't re-render every
  // message). Route them through this ref so a memoized message still calls the
  // LATEST handler. See areAssistantMessagePropsEqual in AssistantMessage.tsx.
  const assistantCallbacksRef = useRef({
    onSubmitForm,
    onContinueRemainingTasks,
    onAssistantFeedback,
    onArtifactShare,
    onArtifactChip,
    onForkFromMessage,
  });
  assistantCallbacksRef.current = {
    onSubmitForm,
    onContinueRemainingTasks,
    onAssistantFeedback,
    onArtifactShare,
    onArtifactChip,
    onForkFromMessage,
  };
  const [tab, setTab] = useState<Tab>('chat');
  const [showConvList, setShowConvList] = useState(false);
  const [conversationSearch, setConversationSearch] = useState('');
  const deferredConversationSearch = useDeferredValue(conversationSearch);
  const [scrolledFromBottom, setScrolledFromBottom] = useState(false);
  const [chatLogScrollable, setChatLogScrollable] = useState(false);
  const [chatLogScrolling, setChatLogScrolling] = useState(false);
  const [composerPortalTarget, setComposerPortalTarget] = useState<HTMLElement | null>(null);
  const [composerPortalRect, setComposerPortalRect] = useState<{
    left: number;
    width: number;
    bottom: number;
  } | null>(null);
  const [composerSlotHeight, setComposerSlotHeight] = useState(0);
  // The user can dismiss the pinned task list once everything is complete.
  // We key the dismissal on the snapshot (serialized TodoWrite input) so
  // the next time the agent emits a different snapshot the card returns,
  // but the same snapshot stays hidden across renders / streaming ticks.
  // Persisted to sessionStorage so the dismissal survives tab switches and
  // component remounts (the ChatPane key includes conversationId, so switching
  // conversations unmounts and remounts the component). The stored value is the
  // snapshot key, so a fresh TodoWrite snapshot still re-shows the card.
  const dismissedStorageKey = `dismissedTodo:${activeConversationId ?? 'none'}`;
  const [dismissedPinnedTodoKey, setDismissedPinnedTodoKey] = useState<string | null>(() => {
    try {
      return sessionStorage.getItem(dismissedStorageKey);
    } catch {
      return null;
    }
  });
  // Sync dismissed state when conversationId changes (e.g., tab switching).
  // The parent key includes conversationId so unmount/remount resets this,
  // but if conversationId changes without unmounting or the storage key
  // changes, re-read to keep the dismissed state in sync.
  useEffect(() => {
    try {
      const stored = sessionStorage.getItem(dismissedStorageKey);
      setDismissedPinnedTodoKey(stored);
    } catch {
      // sessionStorage access can fail in private browsing
    }
  }, [dismissedStorageKey]);
  const [editingQueuedSendId, setEditingQueuedSendId] = useState<string | null>(null);
  // Reverse scan (no array copy) + memo so this and the maps below don't
  // recompute on every non-`messages` render (scroll, hover, toggles).
  const lastAssistantId = useMemo(() => {
    for (let i = messages.length - 1; i >= 0; i--) {
      if (messages[i]!.role === 'assistant') return messages[i]!.id;
    }
    return undefined;
  }, [messages]);
  const hasActiveRunMessage = messages.some(
    (m) => m.role === 'assistant' && isActiveRunStatus(m.runStatus),
  );
  const retryAssistant = retryableAssistantMessage(messages, lastAssistantId, streaming);
  // The failed run's error event lives on the (persisted) assistant message, so
  // the error card + AMR card survive a reload — unlike the ephemeral global
  // `error` state. Drive both off this event.
  const failedRunErrorEvent = (() => {
    const evs = retryAssistant?.events ?? [];
    for (let i = evs.length - 1; i >= 0; i--) {
      const ev = evs[i];
      if (ev?.kind === 'status' && ev.label === 'error') return ev;
    }
    return null;
  })();
  // Per-case failure UI (button + copy + whether to promote AMR). Only
  // meaningful for a failed run (retryAssistant present).
  const runFailureUi = retryAssistant
    ? resolveRunFailureUi(failedRunErrorEvent?.code, retryAssistant.agentId)
    : null;
  // Prefer a case-specific message (AMR auth / balance) over the raw upstream
  // string; fall back to the live global error (also covers conversation-load
  // / audio errors) then the persisted run error so a reload still shows it.
  const rawError = error ?? failedRunErrorEvent?.detail ?? null;
  const displayError = runFailureUi?.messageKey ? t(runFailureUi.messageKey) : rawError;
  // The failed run whose error this top-level card represents. AssistantMessage
  // suppresses only THIS message's per-message error pill (to avoid the
  // duplicate); other failed turns — older history, or once a follow-up makes
  // this no longer the last assistant — keep their pill so the error survives.
  const errorCardOwnerId =
    retryAssistant && failedRunErrorEvent ? retryAssistant.id : null;
  // AMR promotion card payload (only the non-AMR model/auth/quota case).
  const amrSwitchPayload =
    runFailureUi?.showSwitchCard
    && failedRunErrorEvent?.code !== 'UPSTREAM_UNAVAILABLE'
    && retryAssistant
    && failedRunErrorEvent?.code
      ? {
          errorCode: failedRunErrorEvent.code,
          projectId: projectId ?? '',
          projectKind: projectKindForTracking,
          conversationId: activeConversationId,
          assistantMessageId: retryAssistant.id,
          runId: retryAssistant.runId ?? null,
        }
      : null;
  const showByokRecoveryCta = showByokRecoveryAction && Boolean(onSwitchToLocalCli);
  const showErrorActions =
    showByokRecoveryCta || Boolean(retryAssistant && onRetry && runFailureUi);
  useEffect(() => {
    if (!displayError || !failedRunErrorEvent?.code || !retryAssistant) return;
    // The hosted-AMR nudge owns this same surface_view when it renders below
    // the error card. For all other failed-run guidance (AMR auth/balance,
    // Antigravity auth/quota, upstream outage, generic retry), the chat error
    // card itself is the visible run_failed_toast surface.
    if (amrSwitchPayload) return;

    const key = [
      projectId ?? '',
      activeConversationId ?? '',
      retryAssistant.id,
      retryAssistant.runId ?? '',
      failedRunErrorEvent.code,
    ].join(':');
    if (runFailedToastSurfaceKeysRef.current.has(key)) return;
    runFailedToastSurfaceKeysRef.current.add(key);

    trackRunFailedToastSurfaceView(analytics.track, {
      page_name: 'chat_panel',
      area: 'chat_panel',
      element: 'run_failed_toast',
      error_code: failedRunErrorEvent.code,
      project_id: projectId ?? '',
      project_kind: projectKindForTracking,
      conversation_id: activeConversationId,
      assistant_message_id: retryAssistant.id,
      run_id: retryAssistant.runId ?? null,
    });
  }, [
    activeConversationId,
    analytics.track,
    amrSwitchPayload,
    displayError,
    failedRunErrorEvent?.code,
    projectId,
    projectKindForTracking,
    retryAssistant,
  ]);
  const importedFolderArtifacts = useMemo(
    () =>
      projectMetadata?.importedFrom === 'folder'
        ? sortArtifactsByModified(
            listDesignArtifactCandidates(projectFiles, projectMetadata.entryFile),
          )
        : [],
    [projectFiles, projectMetadata?.entryFile, projectMetadata?.importedFrom],
  );
  const showImportedFolderArtifacts = projectMetadata?.importedFrom === 'folder';
  const composerDraftStorageKey = projectId && activeConversationId
    ? `od:chat-composer:draft:${projectId}:${activeConversationId}`
    : undefined;
  // Only the first user message gets the active-plugin chip — the
  // plugin is project-scoped so re-stamping it on every reply would be
  // noise. Subsequent messages still run under the same snapshot.
  const firstUserMessageId = useMemo(
    () => messages.find((m) => m.role === 'user')?.id,
    [messages],
  );
  // Map each assistant message id to the user message that follows it
  // (if any) so QuestionFormView can render its locked "answered" state
  // with the user's picks.
  const nextUserContentByAssistantId = useMemo(() => {
    const map = new Map<string, string>();
    for (let i = 0; i < messages.length - 1; i++) {
      const m = messages[i]!;
      const next = messages[i + 1]!;
      if (m.role === 'assistant' && next.role === 'user') {
        map.set(m.id, next.content);
      }
    }
    return map;
  }, [messages]);

  useEffect(() => {
    didInitialScrollRef.current = false;
    // A new conversation should land at the bottom (its own initial
    // scroll), not inherit the previous conversation's saved position —
    // including any anchor-to-top reserve still held by the tail spacer, which
    // would otherwise strand the freshly opened conversation below a dead gap.
    savedChatScrollRef.current = null;
    scrolledToFormRef.current = new Set();
    anchorActiveRef.current = false;
    anchorPendingRef.current = false;
    resetTailSpacer();
  }, [activeConversationId]);

  // ChatComposer's internal `seededRef` latches after the first
  // non-empty `initialDraft`, so a parent setting `initialDraft` back
  // to `undefined` will not flow into the composer's draft state. When
  // the parent does that transition (because the seed is now stale —
  // e.g. ProjectView discovered the conversation already has a sent
  // user message after a reload), reach into the composer and clear
  // the textarea so the user does not see the prompt they already
  // submitted.
  const lastSeenInitialDraftRef = useRef<string | undefined>(initialDraft);
  useEffect(() => {
    const previous = lastSeenInitialDraftRef.current;
    lastSeenInitialDraftRef.current = initialDraft;
    if (previous && initialDraft === undefined) {
      composerRef.current?.setDraft('');
    }
  }, [initialDraft]);

  // Parent-driven composer prefill (the "Import repo" CTA). Reuse the same
  // imperative setDraft the starter cards use; the nonce guards against
  // re-applying the same signal on unrelated re-renders.
  const lastDraftSignalNonceRef = useRef<number | null>(null);
  useEffect(() => {
    if (!composerDraftSignal) return;
    if (lastDraftSignalNonceRef.current === composerDraftSignal.nonce) return;
    lastDraftSignalNonceRef.current = composerDraftSignal.nonce;
    composerRef.current?.setDraft(composerDraftSignal.text);
  }, [composerDraftSignal]);

  useEffect(() => {
    if (!editingQueuedSendId) return;
    if (queuedItems.some((item) => item.id === editingQueuedSendId)) return;
    setEditingQueuedSendId(null);
  }, [editingQueuedSendId, queuedItems]);

  const restoreQueuedSendToComposer = (item: QueuedSendItem) => {
    setEditingQueuedSendId(item.id);
    composerRef.current?.restoreDraft({
      text: item.prompt,
      attachments: item.attachments ?? [],
      commentAttachments: item.commentAttachments ?? [],
      meta: item.meta,
    });
  };

  useEffect(() => {
    const el = logRef.current;
    if (!el || didInitialScrollRef.current || messages.length === 0) return;
    didInitialScrollRef.current = true;
    requestAnimationFrame(() => {
      // If the last assistant message contains a question form, scroll to
      // the form instead of the bottom, so the user sees the form first.
      const lastAssistantMsg = [...messages].reverse().find((m) => m.role === 'assistant');
      if (lastAssistantMsg?.content.includes('<question-form')) {
        const assistantEls = el.querySelectorAll('.msg.assistant');
        const lastAssistantEl = assistantEls[assistantEls.length - 1];
        const formEl = lastAssistantEl?.querySelector<HTMLElement>('[data-form-id]');
        if (formEl && !scrolledToFormRef.current.has(formEl.dataset.formId!)) {
          scrolledToFormRef.current.add(formEl.dataset.formId!);
          formEl.scrollIntoView({ block: 'start', behavior: 'smooth' });
          pinnedToBottomRef.current = false;
          setScrolledFromBottom(true);
          return;
        }
        // Already handled by the auto-scroll effect — don't bottom-scroll.
        if (formEl) return;
      }
      // Initial-load bottom-pin must be instant — smooth scrollTo emits
      // intermediate scroll events that flip pinnedToBottomRef to false.
      el.scrollTop = el.scrollHeight;
      setScrolledFromBottom(false);
      pinnedToBottomRef.current = true;
    });
    // `tab` is in the deps so that switching conversations while
    // Comments is open doesn't strand the new conversation at scrollTop:
    // 0. The activeConversationId-reset effect above clears
    // didInitialScrollRef while the chat-log is unmounted; this effect
    // then re-runs when the user returns to Chat and the element is
    // available, scrolling the new conversation to its initial bottom.
  }, [activeConversationId, messages.length, tab]);

  // When a turn finishes streaming, release the anchor-to-top reserve. The
  // tail spacer only exists to give a streaming reply room to grow while the
  // user message stays pinned at the top; once the reply is final it must not
  // linger, or a short turn (typical of a fresh fork) is left with a large
  // dead gap below it. Collapsing the spacer lets the bottom-anchored layout
  // settle the finished transcript against the composer.
  useEffect(() => {
    const was = prevStreamingRef.current;
    prevStreamingRef.current = streaming;
    // The tail spacer only ever holds the anchor-to-top reserve for an actively
    // streaming reply, so once the turn ends it must collapse unconditionally —
    // even if a mid-turn scroll already cleared `anchorActiveRef` (which leaves
    // the spacer sized). Collapsing it lets the bottom-anchored layout settle a
    // finished short turn against the composer instead of below a dead gap.
    if (was && !streaming) {
      anchorActiveRef.current = false;
      resetTailSpacer();
    }
  }, [streaming]);

  useEffect(() => {
    const el = logRef.current;
    if (!el) return;
    // Auto-scroll only when the user was already pinned near the bottom,
    // so a scrollback session reading earlier output isn't yanked to the
    // latest message. We key off the pre-content `pinnedToBottomRef`
    // (a ref so it doesn't itself re-fire this effect on scroll) instead
    // of recomputing distance from the just-grown scrollHeight: a single
    // streamed chunk can add 100+ px in one render, which made the
    // post-content distance check skip auto-scroll even when the user
    // was glued to the bottom. We deliberately use the tighter 80px
    // cutoff tracked by the ref (not the wider 120px jump-button
    // threshold) so a deliberate ~90px scroll-up isn't snapped back the
    // next time content streams in. Issue #983.

    // A brand-new user turn from a local send: switch to "anchor to top"
    // mode and smooth-scroll their message to the top of the viewport.
    const lastUser = [...messages].reverse().find((m) => m.role === 'user');
    const prevUserId = prevLastUserIdRef.current;
    prevLastUserIdRef.current = lastUser?.id;
    if (anchorPendingRef.current && lastUser && lastUser.id !== prevUserId) {
      anchorPendingRef.current = false;
      anchorActiveRef.current = true;
      pinnedToBottomRef.current = false;
      setScrolledFromBottom(true);
      requestAnimationFrame(() => {
        sizeAnchorSpacer();
        scrollAnchorToTop();
      });
      return;
    }
    // While anchored, the message stays at the top on its own (nothing above
    // it changes), so we only shrink the spacer as the reply grows — never
    // re-scroll. This is what keeps scrolling down and the final settle smooth.
    if (anchorActiveRef.current) {
      requestAnimationFrame(sizeAnchorSpacer);
      return;
    }

    if (pinnedToBottomRef.current) {
      // If the last assistant message contains a question form, scroll to
      // the form instead of the bottom, so the user lands on the form.
      const lastAssistantMsg = [...messages].reverse().find((m) => m.role === 'assistant');
      if (lastAssistantMsg?.content.includes('<question-form')) {
        const assistantEls = el.querySelectorAll('.msg.assistant');
        const lastAssistantEl = assistantEls[assistantEls.length - 1];
        const formEl = lastAssistantEl?.querySelector<HTMLElement>('[data-form-id]');
        if (formEl && !scrolledToFormRef.current.has(formEl.dataset.formId!)) {
          scrolledToFormRef.current.add(formEl.dataset.formId!);
          formEl.scrollIntoView({ block: 'start', behavior: 'smooth' });
          pinnedToBottomRef.current = false;
          setScrolledFromBottom(true);
          return;
        }
        // Form tag in content but the DOM element isn't ready yet (partial
        // stream) — skip bottom-scroll to avoid a jarring jump that gets
        // undone when the form finishes rendering.
        if (streaming) return;
      }
      // Streaming bottom-pin must be instant — smooth scrollTo emits
      // intermediate scroll events that flip pinnedToBottomRef to false,
      // breaking auto-follow for subsequent chunks.
      el.scrollTop = el.scrollHeight;
    }
  }, [messages, error, streaming]);

  // Saved chat-log scroll state, preserved across tab switches. The
  // chat-log <div> is conditionally rendered so it unmounts when the
  // user switches to Comments. On remount it would default to
  // scrollTop: 0 and the initial-bottom-scroll effect skips because
  // didInitialScrollRef is already true. We capture either the absolute
  // scrollTop or a "pinned to bottom" flag while Chat is visible, so
  // bottom-followers stay pinned even when new messages stream in
  // off-tab. Issue #790.
  const savedChatScrollRef = useRef<
    { pinnedToBottom: true } | { pinnedToBottom: false; scrollTop: number } | null
  >(null);
  useEffect(() => {
    if (tab !== 'chat') return;
    const el = logRef.current;
    if (!el) return;

    function syncScrollable(target: HTMLDivElement) {
      const next = target.scrollHeight - target.clientHeight > 1;
      setChatLogScrollable((prev) => (prev === next ? prev : next));
      if (!next) setChatLogScrolling(false);
    }

    function markScrolling() {
      setChatLogScrolling(true);
      if (chatLogScrollIdleTimerRef.current !== null) {
        window.clearTimeout(chatLogScrollIdleTimerRef.current);
      }
      chatLogScrollIdleTimerRef.current = window.setTimeout(() => {
        chatLogScrollIdleTimerRef.current = null;
        setChatLogScrolling(false);
      }, 650);
    }

    // Restore previously-saved position on remount. Defer to the next
    // frame so the conditional <> contents finish layout before the
    // scrollTop write lands.
    const saved = savedChatScrollRef.current;
    if (saved !== null) {
      requestAnimationFrame(() => {
        const target = logRef.current;
        if (!target) return;
        if (saved.pinnedToBottom) {
          target.scrollTop = target.scrollHeight;
        } else {
          target.scrollTop = saved.scrollTop;
        }
        syncScrollable(target);
        // Resync the jump-to-latest affordance with the restored
        // position. Without this, a user who left Chat ~60px from the
        // bottom and returns to find new messages stacked underneath
        // would land hundreds of pixels above the latest turn while
        // scrolledFromBottom remained false until they scrolled.
        const distance =
          target.scrollHeight - target.scrollTop - target.clientHeight;
        setScrolledFromBottom(distance > 120);
        pinnedToBottomRef.current = distance < 80;
      });
    }

    function snapshot(target: HTMLDivElement) {
      const distance =
        target.scrollHeight - target.scrollTop - target.clientHeight;
      savedChatScrollRef.current =
        distance < 50
          ? { pinnedToBottom: true }
          : { pinnedToBottom: false, scrollTop: target.scrollTop };
    }

    function onScroll() {
      const target = logRef.current;
      if (!target) return;
      // A genuine user scroll (one that moves away from where the anchored
      // message currently sits) releases the auto-resize behavior. We do NOT
      // collapse the tail spacer: the reserved blank below stays as real,
      // scrollable space so scrolling down feels natural instead of snapping.
      if (anchorActiveRef.current) {
        const pinnedTop = lastUserMsgTopInContent(target);
        if (
          pinnedTop !== null &&
          Math.abs(target.scrollTop - (pinnedTop - ANCHOR_TOP_PADDING)) > 40
        ) {
          anchorActiveRef.current = false;
        }
      }
      syncScrollable(target);
      markScrolling();
      snapshot(target);
      const distance =
        target.scrollHeight - target.scrollTop - target.clientHeight;
      // Functional updater bails out when the value is unchanged so a flood
      // of scroll events (e.g. programmatic scrollTop + ResizeObserver
      // follow-up during streaming) does not schedule a re-render per tick
      // and trip React's "Maximum update depth exceeded" guard.
      const next = distance > 120;
      setScrolledFromBottom((prev) => (prev === next ? prev : next));
      pinnedToBottomRef.current = distance < 80;
    }
    syncScrollable(el);
    el.addEventListener('scroll', onScroll);
    return () => {
      // Capture final scroll state before unmount; the ref normally
      // tracks via onScroll, but programmatic scrolls or layout shifts
      // right before unmount can leave it stale.
      snapshot(el);
      el.removeEventListener('scroll', onScroll);
      if (chatLogScrollIdleTimerRef.current !== null) {
        window.clearTimeout(chatLogScrollIdleTimerRef.current);
        chatLogScrollIdleTimerRef.current = null;
      }
      setChatLogScrolling(false);
    };
  }, [tab]);

  useEffect(() => {
    if (tab !== 'chat') return;
    const el = logRef.current;
    if (!el) return;

    let followFrame: number | null = null;
    const followLatestIfPinned = () => {
      // While anchored, only shrink the tail spacer as the reply grows
      // (resize-only, never scroll) so the user message stays put without
      // fighting a manual scroll-down.
      if (anchorActiveRef.current) {
        if (followFrame !== null) return;
        followFrame = requestAnimationFrame(() => {
          followFrame = null;
          if (!anchorActiveRef.current) return;
          sizeAnchorSpacer();
        });
        return;
      }
      if (!pinnedToBottomRef.current || followFrame !== null) return;
      followFrame = requestAnimationFrame(() => {
        followFrame = null;
        const target = logRef.current;
        if (!target || !pinnedToBottomRef.current) return;
        target.scrollTop = target.scrollHeight;
        setScrolledFromBottom(false);
      });
    };

    const resizeObserver =
      typeof ResizeObserver !== 'undefined'
        ? new ResizeObserver(() => {
            const target = logRef.current;
            if (target) {
              const next = target.scrollHeight - target.clientHeight > 1;
              setChatLogScrollable((prev) => (prev === next ? prev : next));
              if (!next) setChatLogScrolling(false);
            }
            followLatestIfPinned();
          })
        : null;
    const observedChildren = new Set<Element>();
    const syncObservedChildren = () => {
      if (!resizeObserver) return;
      const currentChildren = new Set(Array.from(el.children));
      // The tail spacer's height is driven by the anchor logic; observing it
      // would feed its own resize back into followLatestIfPinned.
      if (tailSpacerRef.current) currentChildren.delete(tailSpacerRef.current);
      for (const child of currentChildren) {
        if (observedChildren.has(child)) continue;
        resizeObserver.observe(child);
        observedChildren.add(child);
      }
      for (const child of observedChildren) {
        if (currentChildren.has(child)) continue;
        resizeObserver.unobserve(child);
        observedChildren.delete(child);
      }
    };

    // The PinnedTodoSlot renders outside the scroll container. When the todo
    // card grows, the chat-log's clientHeight shrinks (flex layout) and the
    // user drifts away from the bottom. Observe the pinned-todo div so
    // followLatestIfPinned fires whenever the card changes height.
    let observedPinnedTodo: Element | null = null;
    let observedQueuedSendStrip: Element | null = null;
    const syncPinnedTodo = () => {
      if (!resizeObserver) return;
      const pinnedEl = pinnedTodoRef.current;
      if (pinnedEl && observedPinnedTodo !== pinnedEl) {
        if (observedPinnedTodo) resizeObserver.unobserve(observedPinnedTodo);
        resizeObserver.observe(pinnedEl);
        observedPinnedTodo = pinnedEl;
      } else if (!pinnedEl && observedPinnedTodo) {
        resizeObserver.unobserve(observedPinnedTodo);
        observedPinnedTodo = null;
      }
    };
    const syncQueuedSendStrip = () => {
      if (!resizeObserver) return;
      const queuedEl = queuedSendStripRef.current;
      if (queuedEl && observedQueuedSendStrip !== queuedEl) {
        if (observedQueuedSendStrip) {
          resizeObserver.unobserve(observedQueuedSendStrip);
        }
        resizeObserver.observe(queuedEl);
        observedQueuedSendStrip = queuedEl;
      } else if (!queuedEl && observedQueuedSendStrip) {
        resizeObserver.unobserve(observedQueuedSendStrip);
        observedQueuedSendStrip = null;
      }
    };

    syncObservedChildren();
    syncPinnedTodo();
    syncQueuedSendStrip();

    const mutationObserver =
      typeof MutationObserver !== 'undefined'
        ? new MutationObserver(() => {
            syncObservedChildren();
            syncPinnedTodo();
            syncQueuedSendStrip();
            followLatestIfPinned();
          })
        : null;
    // childList + subtree only — NOT characterData. Auto-follow during
    // streaming is driven by the ResizeObserver on each message child (text
    // growth changes height), so observing per-character text mutations would
    // re-run the full sync sweep on every streamed frame for no extra benefit.
    mutationObserver?.observe(el, {
      childList: true,
      subtree: true,
    });
    // PinnedTodoSlot and QueuedSendStrip live outside the chat-log subtree
    // (they are siblings of .chat-log-wrap inside .pane). The
    // MutationObserver above only fires for changes inside el, so it cannot
    // detect those surfaces mounting or unmounting. Watch the nearest common
    // ancestor (.pane) with childList-only to keep their observers current.
    const paneEl = el.parentElement?.parentElement ?? null;
    if (paneEl && mutationObserver) {
      mutationObserver.observe(paneEl, { childList: true });
    }

    return () => {
      if (followFrame !== null) cancelAnimationFrame(followFrame);
      mutationObserver?.disconnect();
      resizeObserver?.disconnect();
    };
  }, [tab]);

  // Close the conversation history dropdown on outside click / Escape.
  useEffect(() => {
    if (!showConvList) return;
    function onPointer(e: MouseEvent) {
      const target = e.target as Node;
      if (historyWrapRef.current?.contains(target)) return;
      setShowConvList(false);
    }
    function onKey(e: KeyboardEvent) {
      if (e.key === 'Escape') setShowConvList(false);
    }
    document.addEventListener('mousedown', onPointer);
    document.addEventListener('keydown', onKey);
    return () => {
      document.removeEventListener('mousedown', onPointer);
      document.removeEventListener('keydown', onKey);
    };
  }, [showConvList]);

  useEffect(() => {
    if (showConvList) return;
    setConversationSearch('');
  }, [showConvList]);

  const activeConversation =
    conversations.find((c) => c.id === activeConversationId) ?? null;
  const filteredConversations = useMemo(
    () => filterConversations(conversations, deferredConversationSearch, t),
    [conversations, deferredConversationSearch, t],
  );

  function resetTailSpacer() {
    const s = tailSpacerRef.current;
    if (s) s.style.height = '0px';
  }

  // Content offset (distance from the top of the scroll content) of the most
  // recent user message. Invariant to the current scrollTop, so it's safe to
  // call regardless of where the user has scrolled.
  function lastUserMsgTopInContent(el: HTMLDivElement): number | null {
    const userEls = el.querySelectorAll<HTMLElement>('.msg.user');
    const msgEl = userEls[userEls.length - 1];
    if (!msgEl) return null;
    const elRect = el.getBoundingClientRect();
    const msgRect = msgEl.getBoundingClientRect();
    return el.scrollTop + (msgRect.top - elRect.top);
  }

  // Resize the tail spacer so the anchored message can sit at the top with
  // just enough room below it — no more. This is a resize ONLY (never a
  // scroll): shrinking empty space below the fold can't shift what's visible
  // while the user is pinned near the top, so it never causes jitter. As the
  // reply streams in, `needed` shrinks monotonically toward 0.
  function sizeAnchorSpacer() {
    const el = logRef.current;
    const spacer = tailSpacerRef.current;
    if (!el || !spacer) return;
    const msgTopInContent = lastUserMsgTopInContent(el);
    if (msgTopInContent === null) return;
    const spacerH = spacer.offsetHeight;
    const contentBelow = el.scrollHeight - spacerH - msgTopInContent;
    const needed = Math.max(0, el.clientHeight - contentBelow - ANCHOR_TOP_PADDING);
    spacer.style.height = `${needed}px`;
  }

  // Smooth-scroll the anchored message to the top. Called ONCE per turn (on
  // send). The message then stays at the top on its own as the reply streams
  // below it, so we never re-scroll — re-scrolling each chunk is what caused
  // the scroll-down fight and the settle jitter.
  function scrollAnchorToTop() {
    const el = logRef.current;
    if (!el) return;
    const msgTopInContent = lastUserMsgTopInContent(el);
    if (msgTopInContent === null) return;
    const target = Math.max(0, msgTopInContent - ANCHOR_TOP_PADDING);
    el.scrollTo({ top: target, behavior: 'smooth' });
  }

  function jumpToBottom() {
    const el = logRef.current;
    if (!el) return;
    anchorActiveRef.current = false;
    pinnedToBottomRef.current = true;
    resetTailSpacer();
    el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
  }

  useEffect(() => {
    if (typeof document === 'undefined') return;
    setComposerPortalTarget(document.body);
  }, []);

  useLayoutEffect(() => {
    if (tab !== 'chat') {
      setComposerPortalRect(null);
      return;
    }
    const slot = composerSlotRef.current;
    if (!slot || typeof window === 'undefined') return;

    let frame: number | null = null;
    const updateRect = () => {
      frame = null;
      const rect = slot.getBoundingClientRect();
      setComposerPortalRect((prev) => {
        const next = {
          left: Math.round(rect.left),
          width: Math.round(rect.width),
          bottom: Math.max(0, Math.round(window.innerHeight - rect.bottom)),
        };
        if (
          prev
          && prev.left === next.left
          && prev.width === next.width
          && prev.bottom === next.bottom
        ) {
          return prev;
        }
        return next;
      });
    };
    const scheduleUpdate = () => {
      if (frame !== null) return;
      frame = window.requestAnimationFrame(updateRect);
    };

    updateRect();
    const resizeObserver =
      typeof ResizeObserver !== 'undefined'
        ? new ResizeObserver(scheduleUpdate)
        : null;
    resizeObserver?.observe(slot);
    const pane = slot.closest('.pane');
    if (pane) resizeObserver?.observe(pane);
    window.addEventListener('resize', scheduleUpdate);
    window.visualViewport?.addEventListener('resize', scheduleUpdate);

    return () => {
      if (frame !== null) window.cancelAnimationFrame(frame);
      resizeObserver?.disconnect();
      window.removeEventListener('resize', scheduleUpdate);
      window.visualViewport?.removeEventListener('resize', scheduleUpdate);
    };
  }, [tab]);

  useLayoutEffect(() => {
    if (tab !== 'chat' || !composerPortalTarget || !composerPortalRect) return;
    const layer = composerLayerRef.current;
    if (!layer || typeof window === 'undefined') return;

    let frame: number | null = null;
    const updateHeight = () => {
      frame = null;
      const nextHeight = Math.ceil(layer.getBoundingClientRect().height);
      setComposerSlotHeight((prev) => (prev === nextHeight ? prev : nextHeight));
    };
    const scheduleUpdate = () => {
      if (frame !== null) return;
      frame = window.requestAnimationFrame(updateHeight);
    };

    updateHeight();
    const resizeObserver =
      typeof ResizeObserver !== 'undefined'
        ? new ResizeObserver(scheduleUpdate)
        : null;
    resizeObserver?.observe(layer);

    return () => {
      if (frame !== null) window.cancelAnimationFrame(frame);
      resizeObserver?.disconnect();
    };
  }, [composerPortalRect, composerPortalTarget, tab]);

  const composerNode = (
    <ChatComposer
      ref={composerRef}
      designSystemPicker={designSystemPicker}
      projectId={projectId}
      projectFiles={projectFiles}
      activeProjectFileName={activeProjectFileName}
      sessionMode={sessionMode}
      onSessionModeChange={onSessionModeChange}
      skills={skills}
      streaming={streaming}
      sendDisabled={sendDisabled}
      initialDraft={initialDraft}
      draftStorageKey={composerDraftStorageKey}
      onEnsureProject={onEnsureProject}
      commentAttachments={commentsToAttachments(attachedComments)}
      onRemoveCommentAttachment={onDetachComment}
      onSend={(prompt, attachments, commentAttachments, meta) => {
        pinnedToBottomRef.current = true;
        scrolledToFormRef.current = new Set();
        if (editingQueuedSendId && onUpdateQueuedSend) {
          const original = queuedItems.find((item) => item.id === editingQueuedSendId);
          const update: QueuedSendUpdate = {
            prompt,
            attachments,
            commentAttachments,
          };
          const nextMeta = meta ?? original?.meta;
          if (nextMeta !== undefined) update.meta = nextMeta;
          onUpdateQueuedSend(editingQueuedSendId, update);
          setEditingQueuedSendId(null);
          return;
        }
        // Arm "anchor to top": the messages effect promotes this once
        // the new user turn renders, pinning it to the top of the view.
        anchorPendingRef.current = true;
        onSend(prompt, attachments, commentAttachments, meta);
      }}
      onStop={onStop}
      onOpenSettings={onOpenSettings}
      onOpenMcpSettings={onOpenMcpSettings}
      petConfig={petConfig}
      onAdoptPet={onAdoptPet}
      onTogglePet={onTogglePet}
      onOpenPetSettings={onOpenPetSettings}
      researchAvailable={researchAvailable}
      projectMetadata={projectMetadata}
      onProjectMetadataChange={onProjectMetadataChange}
      activeWorkspaceContext={activeWorkspaceContext}
      workspaceContexts={workspaceContexts}
      byokApiProtocol={byokApiProtocol}
      byokImageModel={byokImageModel}
      onChangeByokImageModel={onChangeByokImageModel}
      byokVideoModel={byokVideoModel}
      onChangeByokVideoModel={onChangeByokVideoModel}
      byokSpeechModel={byokSpeechModel}
      onChangeByokSpeechModel={onChangeByokSpeechModel}
      byokSpeechVoice={byokSpeechVoice}
      onChangeByokSpeechVoice={onChangeByokSpeechVoice}
      currentSkillId={currentSkillId}
      onProjectSkillChange={onProjectSkillChange}
      pinnedPluginId={activePluginSnapshot?.pluginId ?? null}
      footerAccessory={composerFooterAccessory}
      currentDesignSystemId={currentDesignSystemId}
      onActiveDesignSystemChange={onActiveDesignSystemChange}
      onShowToast={onShowToast}
    />
  );
  const shouldPortalComposer =
    tab === 'chat'
    && composerPortalTarget !== null
    && composerPortalRect !== null
    && composerPortalRect.width > 0;
  const composerSlotStyle: CSSProperties | undefined = shouldPortalComposer
    ? { minHeight: composerSlotHeight > 0 ? composerSlotHeight : undefined }
    : undefined;

  return (
    <div className="pane">
      <div className="chat-project-header">
        {onBack ? (
          <button
            type="button"
            className="chat-project-back"
            onClick={onBack}
            title={backLabel}
            aria-label={backLabel}
          >
            <Icon name="arrow-left" size={16} />
          </button>
        ) : null}
        {projectHeader ? (
          <span className="chat-project-header-title">{projectHeader}</span>
        ) : null}
        <div
          className={`chat-history-wrap chat-session-switcher${showConvList ? ' open' : ''}`}
          ref={historyWrapRef}
        >
          <button
            type="button"
            className="chat-session-trigger icon-only"
            data-testid="conversation-history-trigger"
            title={
              activeConversation?.title
                ? `${t('chat.conversationsTitle')} · ${activeConversation.title}`
                : t('chat.conversationsTitle')
            }
            aria-label={t('chat.conversationsAria')}
            aria-haspopup="menu"
            aria-expanded={showConvList}
            onClick={() => {
              setShowConvList((v) => {
                const next = !v;
                if (next) {
                  trackChatPanelClick(analytics.track, {
                    page_name: 'chat_panel',
                    area: 'chat_panel',
                    element: 'history',
                  });
                }
                return next;
              });
            }}
          >
            <Icon name="comment" size={16} />
          </button>
          {showConvList ? (
            <div className="chat-history-menu" role="menu" data-testid="conversation-history-menu">
              <div className="chat-history-menu-head">
                <span className="chat-history-menu-title">
                  {t('chat.conversationsHeading')}
                </span>
                <span className="chat-history-menu-count">
                  {filteredConversations.length === conversations.length
                    ? compactCount(conversations.length)
                    : `${compactCount(filteredConversations.length)} / ${compactCount(conversations.length)}`}
                </span>
                {onNewConversation ? (
                  <button
                    type="button"
                    className="chat-history-new"
                    data-testid="conversation-history-new"
                    disabled={newConversationDisabled}
                    onClick={() => {
                      if (newConversationDisabled) return;
                      trackChatPanelClick(analytics.track, {
                        page_name: 'chat_panel',
                        area: 'chat_panel',
                        element: 'new_chat',
                      });
                      onNewConversation();
                      setShowConvList(false);
                    }}
                  >
                    <Icon name="plus" size={11} />
                    <span>{t('chat.new')}</span>
                  </button>
                ) : null}
              </div>
              <label className="chat-history-search">
                <Icon name="search" size={12} />
                <input
                  type="search"
                  value={conversationSearch}
                  onChange={(event) => setConversationSearch(event.currentTarget.value)}
                  placeholder="Search conversations"
                  data-testid="conversation-history-search"
                />
                {conversationSearch ? (
                  <button
                    type="button"
                    className="chat-history-search-clear"
                    onClick={() => setConversationSearch('')}
                    aria-label={t('chat.comments.clear')}
                  >
                    <Icon name="close" size={10} />
                  </button>
                ) : null}
              </label>
              <div className="chat-history-list" data-testid="conversation-list">
                {conversations.length === 0 ? (
                  <div className="chat-history-empty">
                    {t('chat.emptyConversations')}
                  </div>
                ) : filteredConversations.length === 0 ? (
                  <div className="chat-history-empty">
                    No conversations match.
                  </div>
                ) : (
                  filteredConversations.map((c) => (
                    <ConversationRow
                      key={c.id}
                      conversation={c}
                      active={c.id === activeConversationId}
                      messageCount={conversationMessageCount(c, activeConversationId, messagesConversationId, messages.length)}
                      onSelect={() => {
                        onSelectConversation(c.id);
                        setShowConvList(false);
                      }}
                      onDelete={() => onDeleteConversation(c.id)}
                      t={t}
                    />
                  ))
                )}
              </div>
            </div>
          ) : null}
        </div>
      </div>
      {tab === 'chat' ? (
        <>
          <div className="chat-log-wrap">
            <div
              className={[
                'chat-log',
                loading ? 'is-loading' : '',
                chatLogScrollable ? 'is-scrollable' : '',
                chatLogScrolling ? 'is-scrolling' : '',
              ].filter(Boolean).join(' ')}
              ref={logRef}
              aria-busy={loading}
              onClickCapture={(e) => {
                // Expanding an accordion (tool card / thinking block) should
                // grow downward with the clicked header staying put. While a
                // run is glued to the bottom, the ResizeObserver would re-pin
                // to the bottom on the height change and push the header up,
                // so unpin the moment the user toggles one open.
                const toggle = (e.target as HTMLElement).closest(
                  '.thinking-toggle, .action-card-toggle, button.op-card-head, [aria-expanded]',
                );
                if (toggle && logRef.current?.contains(toggle)) {
                  pinnedToBottomRef.current = false;
                  anchorActiveRef.current = false;
                  setScrolledFromBottom(true);
                }
              }}
            >
              {loading ? <ChatConversationLoading t={t} /> : null}
              {messages.length === 0 && !loading ? (
                <div className="chat-empty-wrap">
                  {showImportedFolderArtifacts ? (
                    <ImportedFolderArtifacts
                      projectId={projectId}
                      files={importedFolderArtifacts}
                      onOpenFile={onRequestOpenFile}
                      t={t}
                    />
                  ) : (
                    <>
                      <div className="chat-empty">
                        <span className="chat-empty-title">
                          {t('chat.startTitle')}
                        </span>
                      </div>
                      <div className="chat-examples" role="list">
                        {pickStarters(projectMetadata, t).map((ex, i) => (
                          <button
                            key={`${ex.title}-${i}`}
                            type="button"
                            role="listitem"
                            className="chat-example"
                            style={{ animationDelay: `${i * 70}ms` }}
                            onClick={() => {
                              trackChatPanelClick(analytics.track, {
                                page_name: 'chat_panel',
                                area: 'chat_panel',
                                element: 'template_card',
                              });
                              composerRef.current?.setDraft(ex.prompt);
                            }}
                            title={t('chat.fillInputTitle')}
                          >
                            <span className="chat-example-icon" aria-hidden>
                              {ex.icon}
                            </span>
                            <span className="chat-example-body">
                              <span className="chat-example-head">
                                <span className="chat-example-title">{ex.title}</span>
                                <span className="chat-example-tag">{ex.tag}</span>
                              </span>
                              <span className="chat-example-prompt">{ex.prompt}</span>
                            </span>
                            <span className="chat-example-cta" aria-hidden>
                              ↵
                            </span>
                          </button>
                        ))}
                      </div>
                      {connectRepoNeeded ? (
                        <div className="chat-connect-repo" role="note">
                          <span className="chat-connect-repo-icon" aria-hidden>
                            <Icon name="github" size={18} />
                          </span>
                          <span className="chat-connect-repo-body">
                            <span className="chat-connect-repo-title">
                              {repoConnectCopy(githubConnected).cardTitle}
                            </span>
                            <span className="chat-connect-repo-text">
                              {repoConnectCopy(githubConnected).cardBody}
                            </span>
                          </span>
                          <button
                            type="button"
                            className="primary-ghost"
                            disabled={githubConnected === undefined}
                            onClick={() => onConnectRepo?.()}
                          >
                            <Icon name="github" size={13} />
                            {repoConnectCopy(githubConnected).buttonLabel}
                          </button>
                        </div>
                      ) : null}
                    </>
                  )}
                </div>
              ) : null}
              <ChatRows
                messages={messages}
                streaming={streaming}
                liveToolInput={liveToolInput}
                projectId={projectId}
                projectKindForTracking={projectKindForTracking}
                activeConversationId={activeConversationId}
                activeConversationKey={activeConversationId ?? 'no-conversation'}
                projectFiles={projectFiles}
                projectFileNames={projectFileNames}
                onRequestOpenFile={onRequestOpenFile}
                onRequestPluginDetails={onRequestPluginDetails}
                onRequestDesignSystemDetails={onRequestDesignSystemDetails}
                onRequestPluginFolderAgentAction={onRequestPluginFolderAgentAction}
                activePluginActionPaths={activePluginActionPaths}
                hiddenPluginActionPaths={hiddenPluginActionPaths}
                forceStreamingMessageIds={forceStreamingMessageIds}
                lastAssistantId={lastAssistantId}
                firstUserMessageId={firstUserMessageId}
                activePluginSnapshot={activePluginSnapshot}
                activeDesignSystem={activeDesignSystem}
                hasActiveDesignSystem={hasActiveDesignSystem}
                errorCardOwnerId={errorCardOwnerId}
                nextUserContentByAssistantId={nextUserContentByAssistantId}
                assistantCallbacksRef={assistantCallbacksRef}
                onContinueRemainingTasks={onContinueRemainingTasks}
                onArtifactShare={onArtifactShare}
                onArtifactChip={onArtifactChip}
                onForkFromMessage={onForkFromMessage}
                onAssistantFeedback={onAssistantFeedback}
                forkingMessageId={forkingMessageId}
                t={t}
                onAssistantFormSubmitStart={() => {
                  pinnedToBottomRef.current = true;
                  scrolledToFormRef.current = new Set();
                }}
                onOpenQuestions={onOpenQuestions}
                scrollContainerRef={logRef}
              />
              {displayError ? (
                <div className="msg error">
                  <span className="chat-error-text">{displayError}</span>
                  {showErrorActions ? (
                    <div className="chat-error-actions">
                      {showByokRecoveryCta ? (
                        <button
                          type="button"
                          className="chat-error-action"
                          onClick={onSwitchToLocalCli}
                        >
                          {t('avatar.useLocal')}
                        </button>
                      ) : null}
                      {retryAssistant && onRetry && runFailureUi ? (
                        <>
                          {runFailureUi.primaryAction === 'authorize' ? (
                            <button
                              type="button"
                              className="chat-error-action"
                              onClick={() => {
                                recordAmrEntry(analytics.track, 'chat_error_authorize_retry');
                                if (onSwitchToAmrAndRetry) {
                                  onSwitchToAmrAndRetry(retryAssistant);
                                } else {
                                  onOpenAmrSettings?.();
                                }
                              }}
                            >
                              {t('chat.amrError.authorizeCta')}
                            </button>
                          ) : runFailureUi.primaryAction === 'launch-terminal-auth' ? (
                            <button
                              type="button"
                              className="chat-error-action"
                              onClick={() => {
                                onLaunchAntigravityOauth?.();
                              }}
                            >
                              {t('chat.antigravityError.launchTerminalCta')}
                            </button>
                          ) : runFailureUi.primaryAction === 'launch-terminal-switch-model' ? (
                            <button
                              type="button"
                              className="chat-error-action"
                              onClick={() => {
                                onLaunchAntigravityOauth?.();
                              }}
                            >
                              {t('chat.antigravityError.launchSwitchModelCta')}
                            </button>
                          ) : runFailureUi.primaryAction === 'recharge' ? (
                            <button
                              type="button"
                              className="chat-error-action"
                              onClick={() => {
                                const attribution = recordAmrEntry(
                                  analytics.track,
                                  'chat_error_recharge',
                                );
                                window.open(
                                  attributedAmrUrl(AMR_RECHARGE_URL, attribution),
                                  '_blank',
                                  'noopener,noreferrer',
                                );
                              }}
                            >
                              {t('chat.amrError.rechargeCta')}
                            </button>
                          ) : null}
                          {runFailureUi.primaryAction === 'retry' || runFailureUi.secondaryRetry ? (
                            <button
                              type="button"
                              className="ghost chat-error-retry"
                              onClick={() => onRetry(retryAssistant)}
                            >
                              {t('promptTemplates.retry')}
                            </button>
                          ) : null}
                        </>
                      ) : null}
                    </div>
                  ) : null}
                </div>
              ) : null}
              {amrSwitchPayload ? (
                <AmrGuidance
                  {...amrSwitchPayload}
                  sourceDetail="chat_error_switch_retry_card"
                  onActivate={() => {
                    if (retryAssistant && onSwitchToAmrAndRetry) {
                      onSwitchToAmrAndRetry(retryAssistant);
                    } else {
                      onOpenAmrSettings?.();
                    }
                  }}
                />
              ) : null}
              {/* Dynamic spacer: when a turn is anchored to the top, this
                  grows just enough to let the user message reach the top of
                  the viewport, then shrinks as the reply streams in below. */}
              <div className="chat-log-tail-spacer" ref={tailSpacerRef} aria-hidden />
            </div>
            {/* Always mounted so the CSS transition can play in both
                directions; the `chat-jump-btn-active` class flips the
                slide + opacity, and `aria-hidden` + `tabIndex={-1}`
                keep it out of the a11y tree when it's not visible. */}
            <button
              type="button"
              className={`chat-jump-btn${scrolledFromBottom ? ' chat-jump-btn-active' : ''}`}
              onClick={jumpToBottom}
              title={t('chat.scrollToLatest')}
              aria-hidden={!scrolledFromBottom}
              tabIndex={scrolledFromBottom ? 0 : -1}
            >
              <Icon name="arrow-up" size={12} style={{ transform: 'rotate(180deg)' }} />
              <span>{t('chat.jumpToLatest')}</span>
            </button>
          </div>
          <PinnedTodoSlot
            messages={messages}
            streaming={streaming}
            dismissedKey={dismissedPinnedTodoKey}
            onDismiss={(key) => {
              setDismissedPinnedTodoKey(key);
              try {
                if (key) {
                  sessionStorage.setItem(dismissedStorageKey, key);
                } else {
                  sessionStorage.removeItem(dismissedStorageKey);
                }
              } catch {
                // sessionStorage access can fail in private browsing / sandboxed contexts
              }
            }}
            containerRef={pinnedTodoRef}
          />
          <QueuedSendStrip
            containerRef={queuedSendStripRef}
            items={queuedItems}
            editingId={editingQueuedSendId}
            onEdit={restoreQueuedSendToComposer}
            onRemove={onRemoveQueuedSend}
            onReorder={onReorderQueuedSends}
            onSendNow={onSendQueuedNow}
          />
          <div
            className="chat-composer-slot"
            ref={composerSlotRef}
            style={composerSlotStyle}
            aria-hidden={shouldPortalComposer ? true : undefined}
          >
            {shouldPortalComposer ? null : composerNode}
          </div>
          {shouldPortalComposer && composerPortalTarget && composerPortalRect
            ? createPortal(
                <div
                  className="chat-composer-fixed-layer"
                  ref={composerLayerRef}
                  style={{
                    left: composerPortalRect.left,
                    bottom: composerPortalRect.bottom,
                    width: composerPortalRect.width,
                  }}
                >
                  {composerNode}
                </div>,
                composerPortalTarget,
              )
            : null}
        </>
      ) : null}
    </div>
  );
}

interface AssistantCallbacks {
  onSubmitForm: ((text: string) => void) | undefined;
  onContinueRemainingTasks:
    | ((assistantMessage: ChatMessage, todos: TodoItem[]) => void)
    | undefined;
  onAssistantFeedback:
    | ((message: ChatMessage, change: ChatMessageFeedbackChange) => void)
    | undefined;
  onArtifactShare: ((fileName: string) => void) | undefined;
  onArtifactChip: ((fileName: string, prompt: string) => void) | undefined;
  onForkFromMessage: ((message: ChatMessage) => void) | undefined;
}

type ChatRenderItem =
  | {
      kind: 'separator';
      key: string;
      timestamp: number;
    }
  | {
      kind: 'message';
      key: string;
      message: ChatMessage;
    };

function ChatConversationLoading({ t }: { t: TranslateFn }) {
  return (
    <div className="chat-loading-state" role="status" aria-live="polite">
      <span className="chat-loading-mark" aria-hidden>
        <span />
        <span />
        <span />
      </span>
      <span className="chat-loading-copy">{t('common.loading')}</span>
      <span className="chat-loading-lines" aria-hidden>
        <span />
        <span />
        <span />
      </span>
    </div>
  );
}

function ChatRows({
  messages,
  streaming,
  liveToolInput,
  projectId,
  projectKindForTracking,
  activeConversationId,
  activeConversationKey,
  projectFiles,
  projectFileNames,
  onRequestOpenFile,
  onRequestPluginDetails,
  onRequestDesignSystemDetails,
  onRequestPluginFolderAgentAction,
  activePluginActionPaths,
  hiddenPluginActionPaths,
  forceStreamingMessageIds,
  lastAssistantId,
  firstUserMessageId,
  activePluginSnapshot,
  activeDesignSystem,
  hasActiveDesignSystem,
  errorCardOwnerId,
  nextUserContentByAssistantId,
  assistantCallbacksRef,
  onContinueRemainingTasks,
  onArtifactShare,
  onArtifactChip,
  onForkFromMessage,
  onAssistantFeedback,
  forkingMessageId,
  t,
  onAssistantFormSubmitStart,
  onOpenQuestions,
  scrollContainerRef,
}: {
  messages: ChatMessage[];
  streaming: boolean;
  liveToolInput?: Record<string, { name: string; text: string; seq?: number }>;
  projectId: string | null;
  projectKindForTracking: TrackingProjectKind | null;
  activeConversationId: string | null;
  activeConversationKey: string;
  projectFiles: ProjectFile[];
  projectFileNames?: Set<string>;
  onRequestOpenFile?: (name: string) => void;
  onRequestPluginDetails?: (pluginId: string) => void;
  onRequestDesignSystemDetails?: (system: DesignSystemSummary) => void;
  onRequestPluginFolderAgentAction?: (relativePath: string, action: PluginFolderAgentAction) => void;
  activePluginActionPaths?: Set<string>;
  hiddenPluginActionPaths?: Set<string>;
  forceStreamingMessageIds?: Set<string>;
  lastAssistantId: string | undefined;
  firstUserMessageId: string | undefined;
  activePluginSnapshot?: AppliedPluginSnapshot | null;
  activeDesignSystem?: DesignSystemSummary | null;
  hasActiveDesignSystem: boolean;
  errorCardOwnerId: string | null;
  nextUserContentByAssistantId: Map<string, string>;
  assistantCallbacksRef: MutableRefObject<AssistantCallbacks>;
  onContinueRemainingTasks?: (assistantMessage: ChatMessage, todos: TodoItem[]) => void;
  onArtifactShare?: (fileName: string) => void;
  onArtifactChip?: (fileName: string, prompt: string) => void;
  onForkFromMessage?: (message: ChatMessage) => void;
  onAssistantFeedback?: (message: ChatMessage, change: ChatMessageFeedbackChange) => void;
  forkingMessageId?: string | null;
  t: TranslateFn;
  onAssistantFormSubmitStart: () => void;
  onOpenQuestions?: () => void;
  scrollContainerRef: MutableRefObject<HTMLDivElement | null>;
}) {
  const items = useMemo(() => buildChatRenderItems(messages), [messages]);
  const virtualized = items.length > CHAT_MESSAGE_VIRTUALIZE_THRESHOLD;
  const virtualWindow = useMeasuredVirtualWindow(items, {
    enabled: virtualized,
    containerRef: scrollContainerRef,
    estimateSize: estimateChatRenderItemHeight,
    overscanPx: CHAT_MESSAGE_OVERSCAN_PX,
    resetKey: activeConversationKey,
    initialTailRows: CHAT_VIRTUAL_INITIAL_TAIL_ROWS,
  });

  const renderItem = (item: ChatRenderItem) => {
    if (item.kind === 'separator') {
      return <DaySeparator ts={item.timestamp} />;
    }
    const m = item.message;
    const messageStreaming = isAssistantMessageStreaming(
      m,
      streaming,
      lastAssistantId,
      forceStreamingMessageIds,
    );
    if (m.role === 'user') {
      return (
        <UserMessage
          message={m}
          projectId={projectId}
          projectFileNames={projectFileNames}
          onRequestOpenFile={onRequestOpenFile}
          onRequestPluginDetails={onRequestPluginDetails}
          onRequestDesignSystemDetails={onRequestDesignSystemDetails}
          t={t}
          activePluginSnapshot={
            m.id === firstUserMessageId
              ? activePluginSnapshot ?? null
              : null
          }
          activeDesignSystem={
            m.id === firstUserMessageId
              ? activeDesignSystem ?? null
              : null
          }
        />
      );
    }
    return (
      <AssistantMessage
        message={m}
        streaming={messageStreaming}
        // Only the streaming row consumes live tool input. Non-streaming rows
        // get a stable `undefined`, so adding `liveToolInput` to the memo
        // comparator re-renders just this row per `tool_input_delta`, not all N.
        liveToolInput={messageStreaming ? liveToolInput : undefined}
        projectId={projectId}
        projectKind={projectKindForTracking}
        conversationId={activeConversationId}
        projectFiles={projectFiles}
        projectFileNames={projectFileNames}
        onRequestOpenFile={onRequestOpenFile}
        onRequestPluginFolderAgentAction={onRequestPluginFolderAgentAction}
        activePluginActionPaths={activePluginActionPaths}
        hiddenPluginActionPaths={hiddenPluginActionPaths}
        isLast={m.id === lastAssistantId}
        errorCardOwnerId={errorCardOwnerId}
        nextUserContent={nextUserContentByAssistantId.get(m.id)}
        suppressDirectionForms={hasActiveDesignSystem}
        hasDesignSystemContext={hasActiveDesignSystem || !!activeDesignSystem}
        onSubmitForm={(text) => {
          onAssistantFormSubmitStart();
          assistantCallbacksRef.current.onSubmitForm?.(text);
        }}
        onOpenQuestions={onOpenQuestions}
        onContinueRemainingTasks={
          m.id === lastAssistantId && onContinueRemainingTasks
            ? (todos) => assistantCallbacksRef.current.onContinueRemainingTasks?.(m, todos)
            : undefined
        }
        onForkFromMessage={
          onForkFromMessage
            ? () => assistantCallbacksRef.current.onForkFromMessage?.(m)
            : undefined
        }
        forking={forkingMessageId === m.id}
        onFeedback={
          onAssistantFeedback
            ? (rating) => assistantCallbacksRef.current.onAssistantFeedback?.(m, rating)
            : undefined
        }
        onArtifactShare={
          onArtifactShare
            ? (fileName) => assistantCallbacksRef.current.onArtifactShare?.(fileName)
            : undefined
        }
        onArtifactChip={
          onArtifactChip
            ? (fileName, prompt) => assistantCallbacksRef.current.onArtifactChip?.(fileName, prompt)
            : undefined
        }
      />
    );
  };

  if (items.length === 0) return null;

  if (!virtualized) {
    return (
      <>
        {items.map((item) => (
          <Fragment key={item.key}>{renderItem(item)}</Fragment>
        ))}
      </>
    );
  }

  return (
    <div
      className="chat-virtual-spacer"
      data-testid="chat-virtual-spacer"
      style={{ height: virtualWindow.totalHeight }}
    >
      {virtualWindow.rows.map((row) => (
        <VirtualChatRow
          key={row.item.key}
          itemKey={row.item.key}
          top={row.top}
          onMeasure={virtualWindow.onMeasure}
        >
          {renderItem(row.item)}
        </VirtualChatRow>
      ))}
    </div>
  );
}

function VirtualChatRow({
  itemKey,
  top,
  onMeasure,
  children,
}: {
  itemKey: string;
  top: number;
  onMeasure: (key: string, height: number) => void;
  children: ReactNode;
}) {
  const rowRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    const node = rowRef.current;
    if (!node) return;
    const measure = () => {
      const height = node.getBoundingClientRect().height;
      onMeasure(itemKey, height);
    };
    measure();
    if (typeof ResizeObserver === 'undefined') return undefined;
    const observer = new ResizeObserver(measure);
    observer.observe(node);
    return () => observer.disconnect();
  }, [itemKey, onMeasure]);

  return (
    <div
      ref={rowRef}
      className="chat-virtual-row"
      style={{ transform: `translateY(${top}px)` }}
    >
      {children}
    </div>
  );
}

function buildChatRenderItems(messages: ChatMessage[]): ChatRenderItem[] {
  const items: ChatRenderItem[] = [];
  for (let i = 0; i < messages.length; i += 1) {
    const message = messages[i]!;
    if (shouldShowDaySeparator(messages[i - 1], message)) {
      const timestamp = messageTime(message);
      if (timestamp === undefined) continue;
      items.push({
        kind: 'separator',
        key: `day:${dayKey(timestamp)}:${message.id}`,
        timestamp,
      });
    }
    items.push({
      kind: 'message',
      key: `message:${message.id}`,
      message,
    });
  }
  return items;
}

function estimateChatRenderItemHeight(item: ChatRenderItem): number {
  if (item.kind === 'separator') return 34 + CHAT_VIRTUAL_ROW_GAP_PX;
  const message = item.message;
  const contentLength = message.content?.length ?? 0;
  const attachmentCount = (message.attachments?.length ?? 0) + (message.commentAttachments?.length ?? 0);
  const eventCount = message.events?.length ?? 0;
  const fileCount = message.producedFiles?.length ?? 0;
  const base = message.role === 'user' ? 82 : 118;
  const contentRows = Math.min(18, Math.ceil(contentLength / 120));
  return (
    base
    + contentRows * 18
    + attachmentCount * 34
    + eventCount * 28
    + fileCount * 32
    + CHAT_VIRTUAL_ROW_GAP_PX
  );
}

function useMeasuredVirtualWindow<T extends { key: string }>(
  items: T[],
  {
    enabled,
    containerRef,
    estimateSize,
    overscanPx,
    resetKey,
    initialTailRows,
  }: {
    enabled: boolean;
    containerRef: MutableRefObject<HTMLDivElement | null>;
    estimateSize: (item: T) => number;
    overscanPx: number;
    resetKey: string;
    initialTailRows: number;
  },
) {
  const measuredHeightsRef = useRef<Map<string, number>>(new Map());
  const [measureVersion, setMeasureVersion] = useState(0);
  const [viewport, setViewport] = useState({ scrollTop: 0, height: 0 });

  useEffect(() => {
    measuredHeightsRef.current.clear();
    setMeasureVersion((version) => version + 1);
    setViewport({ scrollTop: 0, height: 0 });
  }, [resetKey]);

  useEffect(() => {
    if (!enabled) return undefined;
    const el = containerRef.current;
    if (!el) return undefined;
    let frame: number | null = null;
    const readViewport = () => {
      frame = null;
      setViewport((current) => {
        const next = {
          scrollTop: el.scrollTop,
          height: el.clientHeight || CHAT_VIRTUAL_DEFAULT_VIEWPORT_PX,
        };
        return current.scrollTop === next.scrollTop && current.height === next.height
          ? current
          : next;
      });
    };
    const scheduleRead = () => {
      if (frame !== null) return;
      frame = requestAnimationFrame(readViewport);
    };
    scheduleRead();
    el.addEventListener('scroll', scheduleRead, { passive: true });
    const observer =
      typeof ResizeObserver !== 'undefined'
        ? new ResizeObserver(scheduleRead)
        : null;
    observer?.observe(el);
    return () => {
      if (frame !== null) cancelAnimationFrame(frame);
      el.removeEventListener('scroll', scheduleRead);
      observer?.disconnect();
    };
  }, [containerRef, enabled]);

  const layout = useMemo(() => {
    const offsets: number[] = [];
    const sizes: number[] = [];
    let cursor = 0;
    for (const item of items) {
      offsets.push(cursor);
      const measured = measuredHeightsRef.current.get(item.key);
      const size = Math.max(
        CHAT_VIRTUAL_MIN_ROW_HEIGHT,
        measured ?? estimateSize(item),
      );
      sizes.push(size);
      cursor += size;
    }
    return { offsets, sizes, totalHeight: cursor };
  }, [estimateSize, items, measureVersion]);

  const rows = useMemo(() => {
    if (!enabled || items.length === 0) return [];
    const height = viewport.height || CHAT_VIRTUAL_DEFAULT_VIEWPORT_PX;
    if (viewport.scrollTop === 0 && viewport.height === 0) {
      const start = Math.max(0, items.length - initialTailRows);
      return items.slice(start).map((item, offset) => {
        const index = start + offset;
        return { item, index, top: layout.offsets[index] ?? 0 };
      });
    }
    const startTarget = Math.max(0, viewport.scrollTop - overscanPx);
    const endTarget = viewport.scrollTop + height + overscanPx;
    let start = 0;
    while (
      start < items.length - 1
      && (layout.offsets[start] ?? 0) + (layout.sizes[start] ?? 0) < startTarget
    ) {
      start += 1;
    }
    let end = start;
    while (end < items.length && (layout.offsets[end] ?? 0) <= endTarget) {
      end += 1;
    }
    return items.slice(start, end).map((item, offset) => {
      const index = start + offset;
      return { item, index, top: layout.offsets[index] ?? 0 };
    });
  }, [
    enabled,
    initialTailRows,
    items,
    layout.offsets,
    layout.sizes,
    overscanPx,
    viewport.height,
    viewport.scrollTop,
  ]);

  const onMeasure = useCallback((key: string, height: number) => {
    if (!Number.isFinite(height) || height <= 0) return;
    const next = Math.max(CHAT_VIRTUAL_MIN_ROW_HEIGHT, Math.ceil(height));
    const previous = measuredHeightsRef.current.get(key);
    if (previous !== undefined && Math.abs(previous - next) < 2) return;
    measuredHeightsRef.current.set(key, next);
    setMeasureVersion((version) => version + 1);
  }, []);

  return {
    rows,
    totalHeight: layout.totalHeight,
    onMeasure,
  };
}

// Pinned task list above the chat composer. The latest TodoWrite snapshot
// across the entire conversation is the canonical state; AssistantMessage
// no longer renders these inline so there is exactly one TodoCard on
// screen. When every task is complete the user can dismiss the card; the
// dismissal sticks to the current snapshot only, so a fresh TodoWrite
// from the agent re-shows it.
function PinnedTodoSlot({
  messages,
  streaming,
  dismissedKey,
  onDismiss,
  containerRef,
}: {
  messages: ChatMessage[];
  streaming: boolean;
  dismissedKey: string | null;
  onDismiss: (key: string | null) => void;
  containerRef?: MutableRefObject<HTMLDivElement | null>;
}) {
  // `exiting` lets the dismiss click play a slide-down transition before
  // the slot tears down. Without it React would unmount immediately and
  // the card would pop out without animation.
  const [exiting, setExiting] = useState(false);
  const input = latestTodoWriteInputForPinnedCard(messages);
  if (input == null) return null;
  let snapshotKey: string;
  try {
    snapshotKey = JSON.stringify(input);
  } catch {
    snapshotKey = String(input);
  }
  if (snapshotKey === dismissedKey) return null;
  return (
    <div className={`chat-pinned-todo${exiting ? ' chat-pinned-todo-exit' : ''}`} ref={containerRef}>
      <TodoCard
        input={input}
        runStreaming={streaming}
        runSucceeded={!streaming}
        onDismiss={() => {
          if (exiting) return;
          setExiting(true);
          // Match the slide-out duration in CSS (220ms) — once the
          // transition completes the snapshot key is recorded as
          // dismissed and the slot is unmounted by the early return.
          window.setTimeout(() => onDismiss(snapshotKey), 220);
        }}
      />
    </div>
  );
}

function QueuedSendStrip({
  containerRef,
  editingId,
  items,
  onEdit,
  onRemove,
  onReorder,
  onSendNow,
}: {
  containerRef?: MutableRefObject<HTMLDivElement | null>;
  editingId?: string | null;
  items: QueuedSendItem[];
  onEdit?: (item: QueuedSendItem) => void;
  onRemove?: (id: string) => void;
  onReorder?: (orderedIds: string[]) => void;
  onSendNow?: (id: string) => void;
}) {
  const t = useT();
  const [dragState, setDragState] = useState<QueuedSendDragState | null>(null);
  if (items.length === 0) return null;
  const canReorder = Boolean(onReorder && items.length > 1);
  const overflowCount = Math.max(0, items.length - QUEUED_SEND_VISIBLE_ROW_COUNT);

  const handleDragStart = (
    event: ReactDragEvent<HTMLButtonElement>,
    item: QueuedSendItem,
  ) => {
    if (!canReorder) return;
    event.dataTransfer.effectAllowed = 'move';
    event.dataTransfer.setData(QUEUED_SEND_DRAG_MIME, item.id);
    event.dataTransfer.setData('text/plain', item.id);
    setDragState({ draggingId: item.id, overId: item.id, edge: null });
  };

  const handleDragOver = (
    event: ReactDragEvent<HTMLDivElement>,
    targetId: string,
  ) => {
    if (!canReorder) return;
    const draggingId = dragState?.draggingId || event.dataTransfer.getData(QUEUED_SEND_DRAG_MIME);
    if (!draggingId) return;
    event.preventDefault();
    event.dataTransfer.dropEffect = 'move';
    if (draggingId === targetId) {
      if (dragState?.overId !== targetId || dragState.edge !== null) {
        setDragState({ draggingId, overId: targetId, edge: null });
      }
      return;
    }
    const edge = queuedDropEdgeForEvent(event);
    if (
      dragState?.draggingId !== draggingId
      || dragState.overId !== targetId
      || dragState.edge !== edge
    ) {
      setDragState({ draggingId, overId: targetId, edge });
    }
  };

  const handleDrop = (
    event: ReactDragEvent<HTMLDivElement>,
    targetId: string,
  ) => {
    if (!canReorder) return;
    event.preventDefault();
    const draggingId =
      dragState?.draggingId
      || event.dataTransfer.getData(QUEUED_SEND_DRAG_MIME)
      || event.dataTransfer.getData('text/plain');
    if (!draggingId || draggingId === targetId) {
      setDragState(null);
      return;
    }
    const edge = dragState?.overId === targetId && dragState.edge
      ? dragState.edge
      : queuedDropEdgeForEvent(event);
    const nextIds = reorderQueuedSendIds(items, draggingId, targetId, edge);
    if (nextIds.join('\0') !== items.map((item) => item.id).join('\0')) {
      onReorder?.(nextIds);
    }
    setDragState(null);
  };

  return (
    <div
      ref={containerRef}
      className="chat-queued-send-strip"
      data-testid="chat-queued-send-strip"
      onDragLeave={(event) => {
        const related = event.relatedTarget;
        if (related instanceof Node && event.currentTarget.contains(related)) return;
        setDragState(null);
      }}
    >
      <div className="chat-queued-send-header">
        <div className="chat-queued-send-heading">
          <strong>
            {items.length} {t('chat.queuedHeader')}
          </strong>
          <span aria-hidden>↩</span>
          <span>{t('chat.queuedToSend')}</span>
        </div>
      </div>
      <div className={`chat-queued-send-list${overflowCount > 0 ? ' is-scrollable' : ''}`}>
        {items.map((item, index) => {
          const isDragging = dragState?.draggingId === item.id;
          const dropClass = dragState?.overId === item.id
            && dragState.draggingId !== item.id
            && dragState.edge
            ? ` chat-queued-send-row-drop-${dragState.edge}`
            : '';
          return (
            <div
              className={`chat-queued-send-row${index === 0 ? ' chat-queued-send-row-active' : ''}${
                editingId === item.id ? ' chat-queued-send-row-editing' : ''
              }${isDragging ? ' chat-queued-send-row-dragging' : ''}${dropClass}`}
              key={item.id}
              onDragOver={(event) => handleDragOver(event, item.id)}
              onDrop={(event) => handleDrop(event, item.id)}
            >
              <button
                type="button"
                className="chat-queued-send-drag-handle chat-queued-send-tooltip od-tooltip"
                title={t('chat.queuedReorder')}
                data-tooltip={t('chat.queuedReorder')}
                data-tooltip-placement="right"
                aria-label={t('chat.queuedReorder')}
                draggable={canReorder}
                disabled={!canReorder}
                onDragStart={(event) => handleDragStart(event, item)}
                onDragEnd={() => setDragState(null)}
              >
                <Icon name="grip-vertical" size={14} />
              </button>
              <div className="chat-queued-send-main">
                <span className="chat-queued-send-title">{summarizeQueuedPrompt(item, t)}</span>
                <QueuedSendMetaChips item={item} />
              </div>
              <div className="chat-queued-send-actions">
                {onEdit ? (
                  <button
                    type="button"
                    className="chat-queued-send-action chat-queued-send-tooltip od-tooltip"
                    title={t('chat.queuedEdit')}
                    data-tooltip={t('chat.queuedEdit')}
                    data-tooltip-placement="top"
                    aria-label={t('chat.queuedEdit')}
                    onClick={() => onEdit(item)}
                  >
                    <Icon name="pencil" size={13} />
                  </button>
                ) : null}
                <button
                  type="button"
                  className="chat-queued-send-action chat-queued-send-tooltip od-tooltip"
                  title={t('chat.send')}
                  data-tooltip={t('chat.send')}
                  data-tooltip-placement="top"
                  aria-label={t('chat.send')}
                  onClick={() => onSendNow?.(item.id)}
                  disabled={!onSendNow}
                >
                  <Icon name="arrow-up" size={13} />
                </button>
                {onRemove ? (
                  <button
                    type="button"
                    className="chat-queued-send-action chat-queued-send-tooltip od-tooltip"
                    onClick={() => onRemove(item.id)}
                    title={t('chat.comments.remove')}
                    data-tooltip={t('chat.comments.remove')}
                    data-tooltip-placement="top"
                    aria-label={t('chat.comments.remove')}
                  >
                    <Icon name="trash" size={13} />
                  </button>
                ) : null}
              </div>
            </div>
          );
        })}
      </div>
      {overflowCount > 0 ? (
        <div className="chat-queued-send-overflow">
          +{overflowCount} {t('chat.queuedMore')}
        </div>
      ) : null}
    </div>
  );
}

const QUEUED_SEND_DRAG_MIME = 'application/x-open-design-queued-send';
const QUEUED_SEND_VISIBLE_ROW_COUNT = 4;

type QueuedSendDropEdge = 'before' | 'after';

interface QueuedSendDragState {
  draggingId: string;
  overId: string | null;
  edge: QueuedSendDropEdge | null;
}

function queuedDropEdgeForEvent(event: ReactDragEvent<HTMLElement>): QueuedSendDropEdge {
  const rect = event.currentTarget.getBoundingClientRect();
  return event.clientY < rect.top + rect.height / 2 ? 'before' : 'after';
}

function reorderQueuedSendIds(
  items: QueuedSendItem[],
  draggingId: string,
  targetId: string,
  edge: QueuedSendDropEdge,
): string[] {
  const ids = items.map((item) => item.id);
  const from = ids.indexOf(draggingId);
  if (from < 0) return ids;
  const [draggedId] = ids.splice(from, 1);
  const targetIndex = ids.indexOf(targetId);
  if (targetIndex < 0 || !draggedId) return items.map((item) => item.id);
  ids.splice(edge === 'after' ? targetIndex + 1 : targetIndex, 0, draggedId);
  return ids;
}

function summarizeQueuedPrompt(item: QueuedSendItem, t: TranslateFn): string {
  const normalized = item.prompt.replace(/\s+/g, ' ').trim();
  const text = normalized || t('chat.queuedFollowUpFallback');
  return text.length > 58 ? `${text.slice(0, 57)}...` : text;
}

// Surfaces what a queued turn carries — attachments, visual marks, and the
// staged plugin / skill / MCP / connector context from its meta — as compact
// chips so the user can see (and trust) what will be sent without expanding it.
// Counts use the same plain-English style as the rest of this strip.
function QueuedSendMetaChips({ item }: { item: QueuedSendItem }) {
  const ctx = item.meta?.context;
  const files = item.attachments?.length ?? 0;
  const marks = item.commentAttachments?.length ?? 0;
  const plugins = item.meta?.appliedPluginSnapshot ? 1 : ctx?.pluginIds?.length ?? 0;
  const skills = ctx?.skillIds?.length ?? 0;
  const mcp = ctx?.mcpServerIds?.length ?? 0;
  const connectors = ctx?.connectorIds?.length ?? 0;
  const workspace = ctx?.workspaceItems?.length ?? 0;
  const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`;
  const chips: Array<{ key: string; label: string }> = [];
  if (files > 0) chips.push({ key: 'files', label: plural(files, 'file') });
  if (marks > 0) chips.push({ key: 'marks', label: plural(marks, 'mark') });
  if (plugins > 0) chips.push({ key: 'plugins', label: plural(plugins, 'plugin') });
  if (skills > 0) chips.push({ key: 'skills', label: plural(skills, 'skill') });
  if (mcp > 0) chips.push({ key: 'mcp', label: `${mcp} MCP` });
  if (connectors > 0) chips.push({ key: 'connectors', label: plural(connectors, 'connector') });
  if (workspace > 0) chips.push({ key: 'workspace', label: plural(workspace, 'workspace context') });
  if (chips.length === 0) return null;
  return (
    <div className="chat-queued-send-chips">
      {chips.map((chip) => (
        <span key={chip.key} className="chat-queued-send-chip">
          {chip.label}
        </span>
      ))}
    </div>
  );
}

function CommentsPanel({
  comments,
  attachedComments,
  onAttach,
  onDetach,
  onDelete,
  t,
}: {
  comments: PreviewComment[];
  attachedComments: PreviewComment[];
  onAttach?: (comment: PreviewComment) => void;
  onDetach?: (commentId: string) => void;
  onDelete?: (commentId: string) => void;
  t: TranslateFn;
}) {
  const attachedIds = new Set(attachedComments.map((comment) => comment.id));
  const saved = comments.filter((comment) => !attachedIds.has(comment.id));
  return (
    <div className="comments-panel" data-testid="comments-panel">
      <CommentSection
        title={t('chat.comments.attached')}
        empty={t('chat.comments.emptyAttached')}
        comments={attachedComments}
        actionLabel={t('chat.comments.remove')}
        onAction={(comment) => onDetach?.(comment.id)}
        attached
      />
      <CommentSection
        title={t('chat.comments.saved')}
        empty={t('chat.comments.emptySaved')}
        comments={saved}
        actionLabel={t('chat.comments.add')}
        onAction={(comment) => onAttach?.(comment)}
        secondaryActionLabel={t('chat.comments.remove')}
        onSecondaryAction={(comment) => onDelete?.(comment.id)}
      />
      {saved.length > 0 ? (
        <div className="comments-footer">
          <button
            type="button"
            className="primary"
            onClick={() => saved.forEach((comment) => onAttach?.(comment))}
          >
            {t('chat.comments.addAll')}
          </button>
        </div>
      ) : null}
    </div>
  );
}

function CommentSection({
  title,
  empty,
  comments,
  actionLabel,
  onAction,
  secondaryActionLabel,
  onSecondaryAction,
  attached,
}: {
  title: string;
  empty: string;
  comments: PreviewComment[];
  actionLabel: string;
  onAction: (comment: PreviewComment) => void;
  secondaryActionLabel?: string;
  onSecondaryAction?: (comment: PreviewComment) => void;
  attached?: boolean;
}) {
  return (
    <section className="comments-section">
      <h3>{title}</h3>
      {comments.length === 0 ? (
        <p className="comments-empty">{empty}</p>
      ) : (
        comments.map((comment) => (
          <article
            key={comment.id}
            className={`comment-card${attached ? ' attached' : ''}`}
            data-testid={`comment-card-${comment.elementId}`}
          >
            <div className="comment-card-top">
              <strong>{commentTargetDisplayName(comment)}</strong>
              <div className="comment-card-actions">
                {secondaryActionLabel && onSecondaryAction ? (
                  <button
                    type="button"
                    className="comment-card-action danger"
                    onClick={() => onSecondaryAction(comment)}
                  >
                    {secondaryActionLabel}
                  </button>
                ) : null}
                <button type="button" className="comment-card-action" onClick={() => onAction(comment)}>
                  {actionLabel}
                </button>
              </div>
            </div>
            <p>{comment.note}</p>
            <div className="comment-card-meta">
              <span>{comment.id}</span>
              <span>{comment.filePath}</span>
              <span>{commentTargetDisplayName(comment)}</span>
              <span>{simplePositionLabel(comment.position)}</span>
            </div>
          </article>
        ))
      )}
    </section>
  );
}

function isActiveRunStatus(status: ChatMessage['runStatus']): boolean {
  return status === 'queued' || status === 'running';
}

function isTerminalRunStatus(status: ChatMessage['runStatus']): boolean {
  return status === 'succeeded' || status === 'failed' || status === 'canceled';
}

export function retryableAssistantMessage(
  messages: ChatMessage[],
  lastAssistantId: string | null | undefined,
  paneStreaming: boolean,
): ChatMessage | null {
  if (paneStreaming) return null;
  const last = messages[messages.length - 1];
  if (!last || last.role !== 'assistant') return null;
  if (last.id !== lastAssistantId) return null;
  return last.runStatus === 'failed' ? last : null;
}

export function isAssistantMessageStreaming(
  message: ChatMessage,
  paneStreaming: boolean,
  lastAssistantId: string | null | undefined,
  forceStreamingMessageIds?: Set<string>,
): boolean {
  if (message.role !== 'assistant') return false;
  if (forceStreamingMessageIds?.has(message.id)) return true;
  if (isActiveRunStatus(message.runStatus)) return true;
  if (message.id !== lastAssistantId) return false;
  if (!paneStreaming) return false;
  if (message.endedAt !== undefined) return false;
  if (isTerminalRunStatus(message.runStatus)) return false;
  return true;
}

function filterConversations(
  conversations: Conversation[],
  query: string,
  t: TranslateFn,
): Conversation[] {
  const normalized = query.trim().toLocaleLowerCase();
  if (!normalized) return conversations;
  return conversations.filter((conversation) => {
    const title = conversation.title || t('chat.untitledConversation');
    const meta = conversationMetaLabel(conversation, t);
    return `${title} ${conversation.id} ${meta}`.toLocaleLowerCase().includes(normalized);
  });
}

function conversationMessageCount(
  conversation: Conversation,
  activeConversationId: string | null,
  messagesConversationId: string | null,
  activeMessageCount: number,
): number | null {
  // The live `messages` array is authoritative for the active conversation —
  // it stays fresh as a run streams new turns in — but ONLY once it has
  // actually loaded for that conversation. While a switch is mid-flight (or a
  // load failed) `messages` is reset to [] and `messagesConversationId` no
  // longer matches the active id; trusting `messages.length` there renders a
  // phantom "0 msg". Fall back to the persisted server count until the live
  // array catches up.
  if (
    conversation.id === activeConversationId &&
    messagesConversationId === activeConversationId
  ) {
    return activeMessageCount;
  }
  return typeof conversation.messageCount === 'number' ? conversation.messageCount : null;
}

function compactCount(value: number): string {
  if (value < 1000) return String(value);
  const compact = Math.floor(value / 100) / 10;
  return `${compact}k`;
}

function ConversationRow({
  conversation,
  active,
  messageCount,
  onSelect,
  onDelete,
  t,
}: {
  conversation: Conversation;
  active: boolean;
  messageCount: number | null;
  onSelect: () => void;
  onDelete: () => void;
  t: TranslateFn;
}) {
  const displayTitle =
    conversation.title || t('chat.untitledConversation');

  return (
    <div
      className={`chat-conv-item${active ? ' active' : ''}`}
      data-testid={`conversation-item-${conversation.id}`}
    >
      <button
        type="button"
        className="chat-conv-item-name"
        data-testid={`conversation-select-${conversation.id}`}
        style={{ background: 'transparent', border: 'none', padding: 0, textAlign: 'left' }}
        onClick={onSelect}
      >
        {displayTitle}
      </button>
      <span className="chat-conv-item-meta">
        {messageCount !== null ? `${compactCount(messageCount)} msg · ` : ''}
        {conversationMetaLabel(conversation, t)}
      </span>
      <button
        type="button"
        className="chat-conv-item-del"
        data-testid={`conversation-delete-${conversation.id}`}
        title={t('chat.deleteConversation')}
        onClick={(e) => {
          e.stopPropagation();
          if (
            confirm(t('chat.deleteConversationConfirm', { title: displayTitle }))
          ) {
            onDelete();
          }
        }}
      >
        <Icon name="close" size={12} />
      </button>
    </div>
  );
}

// Memoized (hoisted impl referenced below): a static user message has stable
// props, so it skips re-render while a later turn streams.
const UserMessage = memo(UserMessageImpl);

function UserMessageImpl({
  message,
  projectId,
  projectFileNames,
  onRequestOpenFile,
  onRequestPluginDetails,
  onRequestDesignSystemDetails,
  t,
  activePluginSnapshot,
  activeDesignSystem,
}: {
  message: ChatMessage;
  projectId: string | null;
  projectFileNames?: Set<string>;
  onRequestOpenFile?: (name: string) => void;
  onRequestPluginDetails?: (pluginId: string) => void;
  onRequestDesignSystemDetails?: (system: DesignSystemSummary) => void;
  t: TranslateFn;
  activePluginSnapshot?: AppliedPluginSnapshot | null;
  activeDesignSystem?: DesignSystemSummary | null;
}) {
  const attachments = sortChatAttachmentsForDisplay(message.attachments ?? []);
  const commentAttachments = message.commentAttachments ?? [];
  const workspaceItems = message.runContext?.workspaceItems ?? [];
  const messagePluginSnapshot = message.appliedPluginSnapshot ?? activePluginSnapshot ?? null;
  const hasRunContext = Boolean(
    message.sessionMode ||
      workspaceItems.length > 0 ||
      messagePluginSnapshot ||
      activeDesignSystem,
  );
  const [copied, setCopied] = useState(false);
  const copyTimerRef = useRef<ReturnType<typeof setTimeout>>();

  useEffect(() => {
    return () => {
      if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
    };
  }, []);

  async function handleCopy() {
    if (!message.content) return;
    if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
    const ok = await copyToClipboard(message.content);
    if (!ok) return;
    setCopied(true);
    copyTimerRef.current = setTimeout(() => {
      setCopied(false);
      copyTimerRef.current = undefined;
    }, 2000);
  }

  const isDesignSystemWorkspaceRequest = isDesignSystemWorkspacePrompt(message.content);
  const ts = messageTime(message);

  return (
    <div className="msg user">
      <div className="role">
        <span>{t('chat.you')}</span>
        <MessageTimestamp message={message} t={t} />
      </div>
      {hasRunContext ? (
        <div className="msg-run-context-row" data-testid="msg-run-context-row">
          {message.sessionMode ? (
            <MessageSessionModeChip mode={message.sessionMode} t={t} />
          ) : null}
          {workspaceItems.map((item) => (
            <ActiveWorkspaceContextChip
              key={`${item.kind}:${item.id}`}
              item={item}
              onOpen={onRequestOpenFile}
            />
          ))}
          {messagePluginSnapshot ? (
            <ActivePluginChip
              snapshot={messagePluginSnapshot}
              t={t}
              onOpenDetails={onRequestPluginDetails}
            />
          ) : null}
          {activeDesignSystem ? (
            <ActiveDesignSystemChip
              system={activeDesignSystem}
              onOpenDetails={onRequestDesignSystemDetails}
            />
          ) : null}
        </div>
      ) : null}
      {attachments.length > 0 ? (
        <div className="user-attachments">
          {attachments.map((a, index) => {
            const baseName = a.path.split('/').pop() || a.path;
            const openable =
              !!onRequestOpenFile &&
              (projectFileNames ? projectFileNames.has(baseName) : true);
            const handleOpen = openable
              ? () => onRequestOpenFile?.(baseName)
              : undefined;
            return (
              <button
                type="button"
                key={a.path}
                className={`user-attachment staged-${a.kind}${openable ? ' openable' : ''}`}
                onClick={handleOpen}
                disabled={!openable}
                title={openable ? t('chat.openFile', { name: baseName }) : a.path}
              >
                <span className="staged-order" aria-label={`Attachment ${index + 1}`}>
                  {index + 1}
                </span>
                {a.kind === 'image' && projectId ? (
                  <img src={projectRawUrl(projectId, a.path)} alt={a.name} />
                ) : (
                  <Icon name="file" size={14} />
                )}
                <span className="staged-name">{a.name}</span>
              </button>
            );
          })}
        </div>
      ) : null}
      {commentAttachments.some((attachment) => attachment.selectionKind !== 'visual') ? (
        <div className="user-attachments comment-history-attachments">
          {commentAttachments.filter((attachment) => attachment.selectionKind !== 'visual').map((a) => (
            <span key={a.id} className="user-attachment staged-comment">
              <span className="staged-name" title={a.comment ? `${commentTargetDisplayName(a)}: ${a.comment}` : commentTargetDisplayName(a)}>
                <strong>{commentTargetDisplayName(a)}</strong>
                {a.comment ? <span>{a.comment}</span> : null}
              </span>
            </span>
          ))}
        </div>
      ) : null}
      {message.content && isDesignSystemWorkspaceRequest ? (
        <div className="user-text-wrap user-status-wrap">
          <div className="user-status-card design-system-generation-status">
            <span className="user-status-card__icon">
              <Icon name="blocks" size={15} />
            </span>
            <span className="user-status-card__copy">
              <strong>{DESIGN_SYSTEM_WORKSPACE_DISPLAY_TITLE}</strong>
              <span>{DESIGN_SYSTEM_WORKSPACE_DISPLAY_DESCRIPTION}</span>
            </span>
          </div>
        </div>
      ) : message.content ? (
        <div className="user-text-wrap">
          <div className="user-text user-bubble">{message.content}</div>
          <div className="user-actions">
            {ts ? (
              <time
                className="user-actions-time"
                dateTime={new Date(ts).toISOString()}
                title={exactDateTime(ts)}
              >
                {shortTime(ts)}
              </time>
            ) : null}
            <button
              type="button"
              className="ghost user-copy-btn"
              onClick={handleCopy}
              aria-label={copied ? t('chat.copyDone') : t('chat.copyPrompt')}
              title={copied ? t('chat.copyDone') : t('chat.copyPrompt')}
            >
              <Icon name={copied ? 'check' : 'copy'} size={13} />
            </button>
          </div>
        </div>
      ) : null}
    </div>
  );
}

// Context chip rendered above a user message when the project pinned a
// plugin at create time (PluginLoopHome on Home). Replaces the noisy
// in-composer plugin rail so the user is not re-prompted to pick
// something they already chose; instead the active plugin lives inside
// the run message it kicked off.
function ActivePluginChip({
  snapshot,
  t: _t,
  onOpenDetails,
}: {
  snapshot: AppliedPluginSnapshot;
  t: TranslateFn;
  onOpenDetails?: (pluginId: string) => void;
}) {
  const title = snapshot.pluginTitle ?? snapshot.pluginId;
  const version = snapshot.pluginVersion;
  const taskKind = snapshot.taskKind;
  const content = (
    <>
      <span className="msg-plugin-chip__dot" aria-hidden />
      <span className="msg-plugin-chip__label">
        <span className="msg-plugin-chip__kind">Plugin</span>
        <span className="msg-plugin-chip__title">{title}</span>
        {version ? (
          <span className="msg-plugin-chip__version">@{version}</span>
        ) : null}
      </span>
      {taskKind ? (
        <span className="msg-plugin-chip__task">{taskKind}</span>
      ) : null}
    </>
  );
  // One clean chip per message — the plugin's full resolved context still
  // rides the run via the persisted snapshot; we no longer fan it out into
  // per-category (design-system / asset / skill) chips here.
  return (
    <div className="msg-plugin-context" data-testid="msg-plugin-context">
      {onOpenDetails ? (
        <button
          type="button"
          className="msg-plugin-chip msg-plugin-chip--action"
          data-testid="msg-plugin-chip"
          title={title}
          onClick={() => onOpenDetails(snapshot.pluginId)}
        >
          {content}
        </button>
      ) : (
        <div className="msg-plugin-chip" data-testid="msg-plugin-chip">
          {content}
        </div>
      )}
    </div>
  );
}

function MessageSessionModeChip({
  mode,
  t,
}: {
  mode: ChatSessionMode;
  t: TranslateFn;
}) {
  const label = mode === 'chat'
    ? t('chat.mode.chat.label')
    : t('chat.mode.design.label');
  return (
    <div
      className={`msg-mode-chip msg-mode-chip--${mode}`}
      data-testid="msg-session-mode-chip"
      title={label}
    >
      <Icon name={mode === 'chat' ? 'comment' : 'sparkles'} size={12} />
      <span>{label}</span>
    </div>
  );
}

function ActiveDesignSystemChip({
  system,
  onOpenDetails,
}: {
  system: DesignSystemSummary;
  onOpenDetails?: (system: DesignSystemSummary) => void;
}) {
  const content = (
    <>
      <span className="msg-plugin-chip__dot" aria-hidden />
      <span className="msg-plugin-chip__label">
        <span className="msg-plugin-chip__kind">Design System</span>
        <span className="msg-plugin-chip__title">{system.title}</span>
      </span>
      {system.category ? (
        <span className="msg-plugin-chip__task">{system.category}</span>
      ) : null}
    </>
  );
  if (!onOpenDetails) {
    return (
      <div className="msg-plugin-chip msg-plugin-chip--design-system" data-testid="msg-design-system-chip">
        {content}
      </div>
    );
  }
  return (
    <button
      type="button"
      className="msg-plugin-chip msg-plugin-chip--design-system msg-plugin-chip--action"
      data-testid="msg-design-system-chip"
      title={system.title}
      onClick={() => onOpenDetails(system)}
    >
      {content}
    </button>
  );
}

function DaySeparator({ ts }: { ts: number | undefined }) {
  if (!ts) return null;
  return (
    <div className="chat-day-separator" role="separator">
      <time dateTime={new Date(ts).toISOString()}>{dayLabel(ts)}</time>
    </div>
  );
}

function MessageTimestamp({ message, t }: { message: ChatMessage; t: TranslateFn }) {
  const ts = messageTime(message);
  if (!ts) return null;
  return (
    <time className="msg-time" dateTime={new Date(ts).toISOString()} title={exactDateTime(ts)}>
      {relativeTimeLong(ts, t)}
    </time>
  );
}

function shouldShowDaySeparator(prev: ChatMessage | undefined, curr: ChatMessage): boolean {
  const currTime = messageTime(curr);
  if (!currTime) return false;
  const prevTime = prev ? messageTime(prev) : undefined;
  if (!prevTime) return true;
  return dayKey(prevTime) !== dayKey(currTime);
}

const WORKSPACE_DESIGN_FILES_TAB = '__design_files__';
const WORKSPACE_DESIGN_SYSTEM_TAB = '__design_system__';

function ActiveWorkspaceContextChip({
  item,
  onOpen,
}: {
  item: WorkspaceContextItem;
  onOpen?: (name: string) => void;
}) {
  const target = workspaceContextOpenTarget(item);
  const content = (
    <>
      <span className="msg-plugin-chip__icon" aria-hidden>
        <Icon name={workspaceContextIcon(item)} size={12} />
      </span>
      <span className="msg-plugin-chip__label">
        <span className="msg-plugin-chip__kind">Current</span>
        <span className="msg-plugin-chip__title">{item.label}</span>
      </span>
    </>
  );
  if (!target || !onOpen) {
    return (
      <div
        className={`msg-plugin-chip msg-plugin-chip--workspace msg-plugin-chip--workspace-${item.kind}`}
        data-testid="msg-workspace-context-chip"
        title={workspaceContextTitle(item)}
      >
        {content}
      </div>
    );
  }
  return (
    <button
      type="button"
      className={`msg-plugin-chip msg-plugin-chip--workspace msg-plugin-chip--workspace-${item.kind} msg-plugin-chip--action`}
      data-testid="msg-workspace-context-chip"
      title={workspaceContextTitle(item)}
      onClick={() => onOpen(target)}
    >
      {content}
    </button>
  );
}

function workspaceContextOpenTarget(item: WorkspaceContextItem): string | null {
  if (item.tabId) return item.tabId;
  if (item.kind === 'design-files') return WORKSPACE_DESIGN_FILES_TAB;
  if (item.kind === 'design-system') return WORKSPACE_DESIGN_SYSTEM_TAB;
  if (item.kind === 'file' || item.kind === 'live-artifact') {
    return item.path ?? item.label;
  }
  return null;
}

function workspaceContextIcon(item: WorkspaceContextItem): IconName {
  if (item.kind === 'browser') return 'globe';
  if (item.kind === 'folder' || item.kind === 'design-files') return 'folder';
  if (item.kind === 'terminal') return 'terminal';
  if (item.kind === 'side-chat') return 'comment';
  if (item.kind === 'design-system') return 'blocks';
  return 'file';
}

function workspaceContextTitle(item: WorkspaceContextItem): string {
  return [
    workspaceContextKindLabel(item.kind),
    item.path ? `path: ${item.path}` : null,
    item.absolutePath ? `absolute: ${item.absolutePath}` : null,
    item.url ? `url: ${item.url}` : null,
    item.title ? `title: ${item.title}` : null,
  ].filter(Boolean).join(' | ');
}

function workspaceContextKindLabel(kind: WorkspaceContextItem['kind']): string {
  switch (kind) {
    case 'browser':
      return 'Browser';
    case 'design-files':
      return 'Design files';
    case 'design-system':
      return 'Design system';
    case 'folder':
      return 'Folder';
    case 'terminal':
      return 'Terminal';
    case 'side-chat':
      return 'Side chat';
    case 'live-artifact':
      return 'Live artifact';
    case 'file':
    default:
      return 'File';
  }
}

function sortChatAttachmentsForDisplay(attachments: ChatAttachment[]): ChatAttachment[] {
  return attachments
    .map((attachment, index) => ({ attachment, index }))
    .sort((a, b) => {
      const aOrder = typeof a.attachment.order === 'number' && Number.isFinite(a.attachment.order)
        ? a.attachment.order
        : a.index;
      const bOrder = typeof b.attachment.order === 'number' && Number.isFinite(b.attachment.order)
        ? b.attachment.order
        : b.index;
      if (aOrder !== bOrder) return aOrder - bOrder;
      return a.index - b.index;
    })
    .map((entry) => entry.attachment);
}

function relTime(ts: number, t: TranslateFn): string {
  const diff = Date.now() - ts;
  const min = 60_000;
  const hr = 60 * min;
  const day = 24 * hr;
  if (diff < min) return t('common.now');
  if (diff < hr) return t('common.minutesShort', { n: Math.floor(diff / min) });
  if (diff < day) return t('common.hoursShort', { n: Math.floor(diff / hr) });
  if (diff < 7 * day) return t('common.daysShort', { n: Math.floor(diff / day) });
  return new Date(ts).toLocaleDateString();
}

export function conversationMetaLabel(
  conversation: Conversation,
  t: TranslateFn,
): string {
  const latestRun = conversation.latestRun;
  if (
    latestRun &&
    (latestRun.status === 'succeeded' ||
      latestRun.status === 'failed' ||
      latestRun.status === 'canceled') &&
    typeof conversation.totalDurationMs === 'number' &&
    Number.isFinite(conversation.totalDurationMs)
  ) {
    return formatDurationShort(conversation.totalDurationMs);
  }
  if (
    latestRun &&
    (latestRun.status === 'succeeded' ||
      latestRun.status === 'failed' ||
      latestRun.status === 'canceled') &&
    typeof latestRun.durationMs === 'number' &&
    Number.isFinite(latestRun.durationMs)
  ) {
    return formatDurationShort(latestRun.durationMs);
  }
  return relTime(conversation.updatedAt, t);
}

function formatDurationShort(ms: number): string {
  const s = Math.max(0, ms) / 1000;
  if (s < 60) return `${s.toFixed(s < 10 ? 1 : 0)}s`;
  const m = Math.floor(s / 60);
  const rem = Math.floor(s - m * 60);
  return `${m}m ${rem.toString().padStart(2, '0')}s`;
}
