"use client";

import {
 useEffect,
 useId,
 useMemo,
 useRef,
 useState,
 useCallback,
 type CSSProperties,
} from "react";
import { useNotifications } from "@/hooks/useNotifications";
import { useFocusTrap } from "@/hooks/useFocusTrap";
import { useRouter } from "next/navigation";
import { truncateAtWordBoundary } from "@/lib/utils";
import { MAX_MESSAGE_LENGTH } from "./composer/composerConstants";
import { track, lengthBucket } from "@/lib/analytics";
import { getSubdomainSlug, getPortalUrl } from "@/lib/subdomain";
import {
 getSession,
 getPortalContext,
 getPortalSpecialists,
 avatarSrc,
 ApiError,
 type Session,
 type ConversationMessageMetadata,
 MessageAttachment,
 type NativeTranscriptMessage,
 uploadChatAttachment,
 PortalContext,
 PortalSpecialist,
 markNotificationRead,
 getToken,
 rewriteDraft,
 ClarifyingCardAnswer,
 type RewriteTone,
} from "@/lib/api";
import { ClarifyingCardView } from "./ClarifyingCardView";
import {
 ArrowRight,
 ChevronDown,
 Check,
 Copy,
 Menu,
 ChevronLeft,
 ThumbsUp,
 ThumbsDown,
 Wrench,
} from "lucide-react";
import { toast } from "@/components/shared/Toast";
import { useConfirmDialog } from "@/components/ui/confirm-dialog";
import { ExpertChatOverlay } from "@/components/internal/ExpertChatOverlay";
import { useAuth } from "@/hooks/useAuth";
import { useIsMobile, LANDSCAPE_PHONE_MAX_HEIGHT } from "@/hooks/useIsMobile";
import { useNetworkState, isNetworkError } from "@/hooks/useNetworkState";
import { OfflineBanner } from "./OfflineBanner";
import { MobileThreadSheet } from "./MobileThreadSheet";
import { MobileSpecialistSheet } from "./MobileSpecialistSheet";
import { MobileChatLayout } from "./layout/MobileChatLayout";
import { SidebarCollapseToggle } from "./layout/SidebarCollapseToggle";
import { portalSidebarColumnStyle } from "./layout/portalSidebarStyle";
import { useThreads } from "./hooks/useThreads";
import {
 isOptimisticMessage,
 messageClientKey,
 newestServerMessageAt,
 useMessages,
 type MessageWithState,
 type OptimisticMessage,
 type StreamingReply,
} from "./hooks/useMessages";
import { useSpecialistActions } from "./hooks/useSpecialistActions";
export { reconcileFetchedMessages } from "./hooks/useMessages";
import { RelTime, relTimeFull } from "@/lib/relTime";
import { ChatAttachments } from "@/components/shared/ChatAttachments";
import { Sidebar } from "./Sidebar";
import { WorkerAvatar } from "./WorkerAvatar";
import {
 activeSpecialist,
 specialistRoleLabel,
} from "./specialist-selection";
import { BlockMarkdown } from "@/lib/blockMarkdown";
import { TrialBanner } from "./TrialBanner";
import { OnboardingCallView } from "@/components/client/OnboardingCallView";
import { VideoCallView } from "@/components/client/VideoCallView";
import Avatar from "@/components/ui/avatar";
import Badge from "@/components/ui/badge";
import { skillTagTokens } from "@/lib/skillTagColor";
import {
 specialistDisplayName,
 specialistTags,
} from "./briefing/specialist-fields";
import { MessageList } from "./MessageList";
import { ComposerArea } from "./ComposerArea";
import { PORTAL_HEADER_HEIGHT } from "./layout/portalHeaderLayout";
import {
 MOBILE_THREAD_HEADER_LEFT_INSET,
 THREAD_HORIZONTAL_INSET,
} from "./layout/threadLayout";
import { BriefingPanel as ExtractedBriefingPanel } from "./BriefingPanel";
import { getSendErrorMessage } from "./sendErrorMessage";

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

function isClosedClientThread(status: string | null | undefined): boolean {
 // `archived` was collapsed into `resolved` (#2002) — `resolved` is the only
 // closed state the backend ever returns.
 return status === "resolved";
}

// ── Specialist activity / waiting status ───────────────────────────────────────
// #3503: the chat used to render an animated "typing…" dots bubble on the
// specialist side whenever the last client-visible message was from the user,
// and kept it up until a reply landed — sometimes for minutes or hours. That is
// a false signal: no one is actually typing. The platform is HITL-by-default,
// so a reply is queued for an Expert rather than composed live, and there is no
// presence/generation signal saying a specialist is typing right now. Showing
// fake activity erodes trust in every other status indicator in the product.
//
// The fix separates the two states the old bubble conflated:
//   • genuine in-flight activity → `TypingIndicator` (animated dots), rendered
//     ONLY when a real, transient "specialist is composing now" signal is
//     present. No such backend presence signal exists today, so it stays off in
//     production; the `specialistTyping` prop keeps the path wired for when one
//     lands (and lets the regression test exercise it).
//   • idle waiting → `WaitingForSpecialist`: a truthful status line with a
//     subtle pulse and no typing dots.

// How long the opening "message received" confirmation shows before it decays
// to the steady "waiting for specialist" line.
const WAITING_CONFIRMATION_MS = 4000;

// ── Waiting-for-specialist status ───────────────────────────────────────────────
// Truthful status shown while the client awaits a reply and nobody is actively
// composing. Opens with a brief "message received" confirmation that decays to
// a steady "waiting" line, giving instant acknowledgement without faking a
// typing animation.
// The caller remounts this via a `key` on the last client message id, so the
// confirmation→waiting decay restarts cleanly each time the client sends again.
function WaitingForSpecialist({
 specialistName,
 avatarSrc,
}: {
 specialistName: string;
 avatarSrc?: string | null;
}) {
 const [confirming, setConfirming] = useState(true);

 useEffect(() => {
  const timer = setTimeout(
   () => setConfirming(false),
   WAITING_CONFIRMATION_MS,
  );
  return () => clearTimeout(timer);
 }, []);

 const named = specialistName && specialistName !== "Your specialist";
 const text = confirming
  ? named
   ? `Message received — ${specialistName} will reply shortly.`
   : "Message received — your specialist will reply shortly."
  : named
   ? `Waiting for ${specialistName}…`
   : "Waiting for your specialist…";

 return (
  <>
   <div
    style={{
     display: "flex",
     gap: 8,
     alignItems: "center",
     // #4583: mirror to the right so the pending pill sits on the same
     // side as the Specialist reply bubble (#4159). row-reverse hugs the
     // avatar to the right edge like the Specialist message.
     alignSelf: "flex-end",
     flexDirection: "row-reverse",
    }}
   >
    <WorkerAvatar
     size={26}
     initial={specialistName.charAt(0)}
     color="var(--brand)"
     src={avatarSrc}
     alt={specialistName}
    />
    <div
     style={{
      background: "var(--bg-surface)",
      border: "1px solid var(--border)",
      // #4583: tail corner on the right to match the Specialist reply bubble.
      borderRadius: "18px 4px 18px 18px",
      padding: "8px 14px",
      display: "flex",
      gap: 8,
      alignItems: "center",
     }}
     role="status"
     aria-label={text}
    >
     <span
      aria-hidden="true"
      style={{
       width: 7,
       height: 7,
       borderRadius: "50%",
       background: "var(--text-muted)",
       display: "inline-block",
       animation: "hw-waiting-pulse 1.8s ease-in-out infinite",
      }}
     />
     <span style={{ fontSize: 14, color: "var(--text-muted)" }}>
      {text}
     </span>
    </div>
   </div>
   <style>{`
        @keyframes hw-waiting-pulse {
          0%, 100% { opacity: 0.3; transform: scale(0.85); }
          50% { opacity: 1; transform: scale(1); }
        }
      `}</style>
  </>
 );
}
// ── Specialist activity indicator ───────────────────────────────────────────────
// Picks the right specialist-side affordance while the client awaits a reply:
// the animated typing bubble ONLY when a specialist is genuinely composing,
// otherwise the truthful waiting status.
export function SpecialistActivityIndicator({
 specialistName,
 avatarSrc,
 lastUserMessageId,
 specialistTyping = false,
}: {
 specialistName: string;
 avatarSrc?: string | null;
 lastUserMessageId?: string;
 /** True only while a real, transient "specialist is composing now" signal is
  *  active. Defaults false — see #3503: we never fabricate typing activity. */
 specialistTyping?: boolean;
}) {
 if (specialistTyping) {
  return (
   <TypingIndicator specialistName={specialistName} avatarSrc={avatarSrc} />
  );
 }
 // `key` remounts WaitingForSpecialist on each new client message so its
 // "message received" → "waiting" decay restarts.
 return (
  <WaitingForSpecialist
   key={lastUserMessageId}
   specialistName={specialistName}
   avatarSrc={avatarSrc}
  />
 );
}

// ── Typing indicator ───────────────────────────────────────────────────────────
// Animated three-dots bubble. Render this ONLY while a specialist is genuinely
// composing a reply — never to represent "message sent, awaiting a reply"
// (that is `WaitingForSpecialist`'s job). See #3503.

function TypingIndicator({
 specialistName,
 avatarSrc,
}: {
 specialistName: string;
 avatarSrc?: string | null;
}) {
 return (
  <>
   <div
    style={{
     display: "flex",
     gap: 8,
     alignItems: "flex-start",
     // #4583: mirror to the right so the typing indicator sits on the same
     // side as the Specialist reply bubble (#4159), matching the pending
     // pill above.
     alignSelf: "flex-end",
     flexDirection: "row-reverse",
    }}
   >
    <WorkerAvatar
     size={26}
     initial={specialistName.charAt(0)}
     color="var(--brand)"
     src={avatarSrc}
     alt={specialistName}
    />
    <div
     style={{
      background: "var(--bg-surface)",
      border: "1px solid var(--border)",
      // #4583: tail corner on the right to match the Specialist reply bubble.
      borderRadius: "18px 4px 18px 18px",
      padding: "10px 14px",
      display: "flex",
      gap: 5,
      alignItems: "center",
     }}
     aria-label={`${specialistName} is typing`}
     role="status"
    >
     {[0, 180, 360].map((delay) => (
      <span
       key={delay}
       aria-hidden="true"
       style={{
        width: 6,
        height: 6,
        borderRadius: "50%",
        background: "var(--text-muted)",
        display: "inline-block",
        animation: `hw-typing-bounce 1.4s ease-in-out ${delay}ms infinite`,
       }}
      />
     ))}
    </div>
   </div>
   <style>{`
        @keyframes hw-typing-bounce {
          0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
          30% { transform: translateY(-6px); opacity: 1; }
        }
      `}</style>
  </>
 );
}

// ── Message bubble ─────────────────────────────────────────────────────────────

const EXPERT_ROLES = ["expert", "account_manager", "superadmin"];

function channelViaLabel(channel: string | null | undefined): string | null {
 if (!channel) return null;
 const c = channel.toLowerCase();
 if (c === "web" || c === "webchat") return null;
 const names: Record<string, string> = {
  whatsapp: "WhatsApp",
  email: "Email",
  slack: "Slack",
  telegram: "Telegram",
  teams: "Teams",
  sms: "SMS",
  line: "LINE",
 };
 return `via ${names[c] ?? channel}`;
}

// Hover-revealed "copy message" affordance sitting just outside the bubble.
// The affordance is positioned in the gap beside the bubble (left/right anchored
// to -hitWidth), which is OUTSIDE the hover container's box. What keeps it
// reliably clickable:
//   1. It stays MOUNTED and only fades via opacity/pointer-events, so the
//      click target always exists while the pointer is over it.
//   2. Its hit box spans the FULL HEIGHT of the bubble column (top:0/bottom:0),
//      not a 22px sliver at the top. This is the load-bearing part: the button
//      is a DOM descendant of the hover container, so as long as the strip it
//      occupies is contiguous with the bubble, moving the pointer outward from
//      ANY point on the bubble lands directly on the button (a descendant) and
//      never crosses container-external empty space — so the container's
//      onMouseLeave never fires and `hovered` stays true. A short (22px) hit
//      box only bridged the top edge: approaching the chip from the body of a
//      normal multi-line bubble left the container first, flipped `hovered`
//      false, and the button went pointer-events:none before the click landed
//      (the #5038 residual — the chip "still" vanished on approach). The
//      visible 22px chip is centred within the full-height hit box.
//   3. The inner edge is always flush at the bubble edge (offset === -width), so
//      the box never overlaps the message body. This matters because on mobile
//      the box is always interactive — a wider target that reached over the
//      text would steal taps (and link taps) from the content. The mobile 44px
//      touch target therefore grows OUTWARD into the gutter, not inward.
//   4. Touch devices have no hover, so `hovered` is never true there. The chip
//      is shown unconditionally on mobile — otherwise it was permanently
//      invisible and untappable despite its 44px touch target.
function CopyMessageButton({
 side,
 copied,
 visible,
 isMobile,
 onCopy,
}: {
 side: "left" | "right";
 copied: boolean;
 visible: boolean;
 isMobile?: boolean;
 onCopy: () => void;
}) {
 const isLeft = side === "left";
 const shown = visible || !!isMobile;
 const hitWidth = isMobile ? 44 : 28;
 return (
  <button
   type="button"
   onClick={onCopy}
   title="Copy message"
   aria-label="Copy message to clipboard"
   aria-hidden={!shown}
   tabIndex={shown ? 0 : -1}
   style={{
    position: "absolute",
    top: 0,
    bottom: 0,
    [isLeft ? "left" : "right"]: -hitWidth,
    width: hitWidth,
    display: "flex",
    alignItems: "center",
    justifyContent: isLeft ? "flex-start" : "flex-end",
    background: "transparent",
    border: "none",
    padding: 0,
    cursor: "pointer",
    opacity: shown ? 1 : 0,
    pointerEvents: shown ? "auto" : "none",
    transition: "opacity 0.15s",
   }}
  >
   <span
    style={{
     width: 22,
     height: 22,
     display: "flex",
     alignItems: "center",
     justifyContent: "center",
     background: "var(--bg-elevated)",
     border: "1px solid var(--border)",
     borderRadius: 6,
     color: copied ? "var(--success, #16a34a)" : "var(--text-muted)",
     transition: "color 0.15s",
    }}
   >
    {copied ? <Check size={12} /> : <Copy size={12} />}
   </span>
  </button>
 );
}

/**
 * Hermes flattens inbound attachment blocks into annotation lines inside the
 * SessionDB user record ("[Attached image: x.png]", "URI: file:///…",
 * "[screenshot]"). The transcript is read back byte-for-byte by design, so
 * the client view strips those runtime annotations at render time — the
 * attachment itself is shown as a chip from the receipt metadata.
 */
function stripAttachmentAnnotations(content: string | null | undefined): string {
 if (!content) return "";
 return content
  .split("\n")
  .filter(
   (line) =>
    !/^\[Attached (?:image|file):[^\]]*\]\s*$/.test(line) &&
    !/^URI: file:\/\/\/\S+\s*$/.test(line) &&
    !/^\[(?:screenshot|image|document|attachment)\]\s*$/.test(line),
  )
  .join("\n")
  .trim();
}

/**
 * A single tool step, rendered as its own collapsible activity card
 * (Codex/Claude pattern) rather than merged into an assistant bubble. The
 * header names the tool and shows its exit signal; expanding reveals the
 * raw tool output. Tool `content` is a JSON envelope
 * (`{output, exit_code, error}` for terminal, tool-specific otherwise); we
 * surface it verbatim in a monospace block, never parsed into prose.
 *
 * #6097: a client-scoped session read never carries the raw payload — the
 * API strips it and maps `toolName` to a plain-English label before the
 * response leaves the server (SessionsService.projectTranscriptForClient) —
 * so `content` arrives empty here. With nothing to expand into, this
 * renders a small centered, non-interactive chip instead of a debug-style
 * collapsible "tool" row. Centered (not flush to either side) so it never
 * reads as something either party said — this portal renders the
 * Specialist's replies on the right and the client's own messages on the
 * left, and a plain text label on either side risked being misread as part
 * of that party's message. The collapsible raw view below still serves the
 * internal ops surfaces that reuse this component (SideChatPanel, agent
 * workspace preview), which read the untouched row and still need it for
 * debugging.
 */
export function ToolActivityCard({
 toolName,
 content,
}: {
 toolName: string | null;
 content: string;
}) {
 const [expanded, setExpanded] = useState(false);
 const label = toolName || "Tool activity";
 if (!content) {
  return (
   <div
    style={{
     alignSelf: "center",
     maxWidth: "90%",
     width: "fit-content",
     margin: "2px 0",
    }}
   >
    <span
     style={{
      display: "inline-flex",
      alignItems: "center",
      gap: 6,
      padding: "4px 10px",
      borderRadius: 999,
      border: "1px solid var(--info)",
      background: "var(--info-subtle)",
      color: "var(--info)",
      fontSize: 12,
     }}
    >
     <Wrench size={11} aria-hidden />
     {label}
    </span>
   </div>
  );
 }
 return (
  <div
   style={{
    alignSelf: "flex-start",
    maxWidth: "76%",
    margin: "2px 0",
    fontSize: 12,
   }}
  >
   <button
    type="button"
    onClick={() => setExpanded((v) => !v)}
    style={{
     display: "flex",
     alignItems: "center",
     gap: 6,
     background: "var(--surface-muted, #f4f4f5)",
     border: "1px solid var(--border, #e4e4e7)",
     borderRadius: 8,
     padding: "4px 10px",
     cursor: "pointer",
     color: "var(--text-muted, #71717a)",
     fontFamily: "var(--font-mono, ui-monospace, monospace)",
     width: "100%",
     textAlign: "left",
    }}
    aria-expanded={expanded}
   >
    <span aria-hidden>{expanded ? "▾" : "▸"}</span>
    <span style={{ fontWeight: 600 }}>{label}</span>
    <span style={{ opacity: 0.6 }}>tool</span>
   </button>
   {expanded ? (
    <pre
     style={{
      margin: "4px 0 0",
      padding: 8,
      background: "var(--surface-muted, #f4f4f5)",
      border: "1px solid var(--border, #e4e4e7)",
      borderRadius: 8,
      maxHeight: 240,
      overflow: "auto",
      whiteSpace: "pre-wrap",
      wordBreak: "break-word",
      fontSize: 12,
      fontFamily: "var(--font-mono, ui-monospace, monospace)",
     }}
    >
     {content}
    </pre>
   ) : null}
  </div>
 );
}

export function MessageBubble({
 msg,
 productMetadata,
 agentName = "Your specialist",
 agentAvatarUrl,
 platformRole,
 convChannel,
 conversationId,
 onRetry,
 onFeedback,
 onClarifyingCardSubmit,
 clarifyingCardAnswered,
 onAvatarClick,
 isMobile,
}: {
 msg: MessageWithState;
 productMetadata?: ConversationMessageMetadata;
 agentName?: string;
 agentAvatarUrl?: string | null;
 platformRole?: string | null;
 convChannel?: string | null;
 conversationId?: string;
 onRetry?: (msg: OptimisticMessage) => void;
 onFeedback?: (
  msg: NativeTranscriptMessage,
  sentiment: "up" | "down",
 ) => void;
 onClarifyingCardSubmit?: (
  answer: ClarifyingCardAnswer,
  summaryText: string,
 ) => void | boolean | Promise<void | boolean>;
 clarifyingCardAnswered?: boolean;
 onAvatarClick?: () => void;
 isMobile?: boolean;
}) {
 const optimistic = isOptimisticMessage(msg);
 const isAgent = !optimistic && msg.role === "assistant";
 const isWorker = isAgent;
 const isUser = msg.role === "user";
 const isInternal = !optimistic && productMetadata?.isInternal === true;
 const attachments = optimistic
  ? msg.attachments
  : productMetadata?.attachments;
 const messageChannel = optimistic ? undefined : productMetadata?.channel;
 const clarifyingCard = optimistic
  ? undefined
  : (productMetadata?.clarifyingCard ??
   ("clarifyingCard" in msg ? msg.clarifyingCard : undefined));
 const feedback = optimistic ? undefined : productMetadata?.feedback;
 const senderName = optimistic ? undefined : productMetadata?.senderName;
 const isExpertViewer = EXPERT_ROLES.includes(platformRole ?? "");
 const [hovered, setHovered] = useState(false);
 const [copied, setCopied] = useState(false);
 const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

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

 function handleCopy() {
  if (!navigator.clipboard) {
   toast.error("Failed to copy");
   return;
  }
  navigator.clipboard
   .writeText(msg.content)
   .then(() => {
    setCopied(true);
    toast.success("Copied to clipboard");
    if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
    copyTimerRef.current = setTimeout(() => setCopied(false), 2000);
   })
   .catch(() => {
    toast.error("Failed to copy");
   });
 }

 // Hide internal notes from non-experts
 if (isInternal && !isExpertViewer) return null;

 // Tool call rows are a debugging aid, not something a client needs to see —
 // that's what logs are for. The client portal renders nothing for them.
 if (!optimistic && msg.role === "tool") {
  return null;
 }
 if (isWorker) {
  // Internal note — amber dashed style
  if (isInternal) {
   return (
    <div
     style={{
      display: "flex",
      gap: 8,
      alignItems: "flex-start",
      maxWidth: "76%",
      alignSelf: "flex-start",
     }}
    >
     {/* Lock SVG badge instead of emoji */}
     <div
      style={{
       width: 26,
       height: 26,
       borderRadius: "50%",
       background: "var(--warning-text, #92400e)",
       display: "flex",
       alignItems: "center",
       justifyContent: "center",
       flexShrink: 0,
      }}
     >
      <svg
       width="13"
       height="13"
       viewBox="0 0 24 24"
       fill="none"
       stroke="#fff"
       strokeWidth="2.2"
       strokeLinecap="round"
       strokeLinejoin="round"
       aria-hidden="true"
      >
       <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
       <path d="M7 11V7a5 5 0 0 1 10 0v4" />
      </svg>
     </div>
     <div>
      <div
       style={{
        fontSize: 12,
        color: "var(--warning-text, #92400e)",
        marginBottom: 3,
        display: "flex",
        alignItems: "center",
        gap: 5,
       }}
      >
       <span style={{ fontWeight: 600 }}>Internal note</span>
       <RelTime
        ts={msg.createdAt}
        formatter={relTimeFull}
        style={{ color: "var(--text-secondary)" }}
       />
      </div>
      <div
       style={{
        background: "var(--warning-subtle, rgba(251,191,36,0.08))",
        border: "1.5px dashed var(--warning, #fbbf24)",
        borderRadius: "2px 12px 12px 12px",
        padding: "8px 12px",
        fontSize: 14,
        color: "var(--text-primary)",
        lineHeight: 1.55,
        whiteSpace: "pre-wrap",
        overflowWrap: "break-word",
        unicodeBidi: "plaintext",
       }}
      >
       {/* Internal notes are authored by Experts — same block-markdown
               * renderer as worker replies so notes with headings/lists
               * render consistently. */}
       <BlockMarkdown>{msg.content}</BlockMarkdown>
      </div>
     </div>
    </div>
   );
  }

  // Experts see "Expert" label + purple; clients see specialist persona + handling badge
  const senderColor = "var(--brand)";
  const senderLabel = senderName || agentName;
  const showHandlingBadge = !isExpertViewer;

  // #4340 — Specialist avatar on the bubble, clickable → opens the profile.
  // Ghost-write safety: a client (and any AI message) always sees the
  // Specialist persona avatar; only an Expert viewing an Expert-authored
  // message sees the purple "E" identity. The Expert's own avatar is never
  // surfaced to a client. The "E" badge is not a Specialist, so it does not
  // link to a profile.
  const isExpertIdentity = false;
  const messageAvatarSrc = isExpertIdentity ? null : (agentAvatarUrl ?? null);
  const avatarInitial = isExpertIdentity ? "E" : senderLabel.charAt(0) || "S";
  const avatarClickable = !!onAvatarClick && !isExpertIdentity;

  return (
   <div
    style={{
     display: "flex",
     gap: 8,
     alignItems: "flex-start",
     maxWidth: "76%",
     alignSelf: "flex-end",
     flexDirection: "row-reverse",
    }}
    onMouseEnter={() => setHovered(true)}
    onMouseLeave={() => setHovered(false)}
   >
    {/* #4340 — Specialist avatar beside the bubble. row-reverse puts this
            first child on the far right, next to the message. When a profile
            handler is wired (and this isn't the Expert identity badge) it's a
            real button: keyboard-focusable, Enter/Space activates, screen
            readers announce the label, 44px touch target on mobile. */}
    {avatarClickable ? (
     <button
      type="button"
      onClick={onAvatarClick}
      aria-label={`View ${senderLabel} profile`}
      title={`View ${senderLabel} profile`}
      style={{
       background: "transparent",
       border: "none",
       padding: 0,
       margin: 0,
       cursor: "pointer",
       borderRadius: "50%",
       flexShrink: 0,
       display: "flex",
       alignItems: "center",
       justifyContent: "center",
       minWidth: isMobile ? 44 : undefined,
       minHeight: isMobile ? 44 : undefined,
      }}
     >
      <WorkerAvatar
       size={26}
       initial={avatarInitial}
       color={senderColor}
       src={messageAvatarSrc}
       alt={senderLabel}
      />
     </button>
    ) : (
     <WorkerAvatar
      size={26}
      initial={avatarInitial}
      color={senderColor}
      src={messageAvatarSrc}
      alt={senderLabel}
     />
    )}
    <div style={{ position: "relative" }}>
     <div
      style={{
       fontSize: 12,
       color: "var(--text-muted)",
       marginBottom: 3,
       display: "flex",
       alignItems: "center",
       gap: 5,
       justifyContent: "flex-end",
      }}
     >
      {(() => {
       const vl = channelViaLabel(messageChannel ?? convChannel);
       return vl ? (
        <>
         <span>{vl}</span>
         <span>·</span>
        </>
       ) : null;
      })()}
      <RelTime
       ts={msg.createdAt}
       formatter={relTimeFull}
       style={{ color: "var(--text-secondary)" }}
      />
      {showHandlingBadge && isExpertViewer && (
       <span
        style={{
         fontSize: 12,
         fontWeight: 500,
         padding: "1px 5px",
         borderRadius: 4,
         background: "rgba(37,99,235,0.08)",
         color: "var(--primary)",
         textTransform: "uppercase",
         letterSpacing: "0.04em",
        }}
       >
        Specialist reply
       </span>
      )}
      <span style={{ fontWeight: 600, color: senderColor }}>
       {senderLabel}
      </span>
     </div>
     {msg.content && (
      <div
       style={{
        background: "var(--bg-surface)",
        border: "1px solid var(--border)",
        borderRadius: "18px 4px 18px 18px",
        padding: "9px 13px",
        fontSize: 14,
        color: "var(--text-primary)",
        lineHeight: 1.55,
        whiteSpace: "pre-wrap",
        overflowWrap: "break-word",
        unicodeBidi: "plaintext",
       }}
      >
       {/* Render assistant/expert replies through the same safe inline
               * markdown subset used by the Expert draft preview (#1758). */}
       <BlockMarkdown>{msg.content}</BlockMarkdown>
      </div>
     )}
     {/* #2686 — incoming (Specialist/Expert) replies can carry image/file
              attachments too. Previously only the client's OWN (isUser) bubble
              rendered ChatAttachments, so an Expert reply with an image showed
              as text-only to the client. Render via the same auth-gated proxy. */}
     {Array.isArray(attachments) && attachments.length > 0 && (
      <ChatAttachments
       attachments={attachments}
       conversationId={conversationId}
      />
     )}
     {/* Clarifying card (HW_QUESTION): tappable options the client answers
              inline. Interactive for the client; read-only for an Expert viewer
              reviewing the thread. */}
     {clarifyingCard && (
      <ClarifyingCardView
       card={clarifyingCard}
       readOnly={isExpertViewer || !onClarifyingCardSubmit}
       answered={clarifyingCardAnswered}
       onSubmit={onClarifyingCardSubmit}
      />
     )}
     {msg.content && (
      <CopyMessageButton
       side="left"
       copied={copied}
       visible={hovered}
       isMobile={isMobile}
       onCopy={handleCopy}
      />
     )}
     {/* P4.4 (R4.7): client 👍/👎 on a Specialist reply. Only the client
           *  sees it (not the Expert viewer), never on internal notes. Copy
           *  neutral — never invites rating "the AI" (Q4/D5: persona-safe). */}
     {onFeedback && !isExpertViewer && !isInternal && msg.content && (
      <div
       style={{
        display: "flex",
        gap: 4,
        marginTop: 6,
        alignItems: "center",
       }}
      >
       {(["up", "down"] as const).map((s) => {
        const active = feedback === s;
        const Icon = s === "up" ? ThumbsUp : ThumbsDown;
        return (
         <button
          key={s}
          onClick={() => onFeedback(msg, s)}
          title={
           s === "up"
            ? "This reply was helpful"
            : "This reply was not helpful"
          }
          aria-label={
           s === "up"
            ? "This reply was helpful"
            : "This reply was not helpful"
          }
          aria-pressed={active}
          style={{
           display: "flex",
           alignItems: "center",
           justifyContent: "center",
           width: 24,
           height: 24,
           minWidth: isMobile ? 44 : undefined,
           minHeight: isMobile ? 44 : undefined,
           background: active
            ? "var(--brand-subtle, rgba(37,99,235,0.1))"
            : "transparent",
           border: "1px solid var(--border)",
           borderRadius: 6,
           cursor: "pointer",
           color: active ? "var(--brand)" : "var(--text-muted)",
           padding: 0,
           transition: "color 0.15s, background 0.15s",
          }}
         >
          <Icon size={13} />
         </button>
        );
       })}
      </div>
     )}
    </div>
   </div>
  );
 }

 if (isUser) {
  const viaLabel = channelViaLabel(messageChannel ?? convChannel);
  // #4159 — client member identity: show avatar + name on the left
  const memberName = senderName ?? null;
  const memberInitial = memberName
   ? memberName.charAt(0).toUpperCase()
   : null;
  return (
   <div
    style={{
     display: "flex",
     gap: 8,
     alignItems: "flex-start",
     maxWidth: "76%",
     alignSelf: "flex-start",
    }}
    onMouseEnter={() => setHovered(true)}
    onMouseLeave={() => setHovered(false)}
   >
    {/* Member avatar — human client identity: generic person glyph, not
            the seeded avatar asset (kept consistent with the sidebar footer). */}
    {memberName && (
     <WorkerAvatar
      size={26}
      initial={memberInitial ?? "?"}
      color="var(--brand)"
      personIcon
      alt={memberName ?? "Client"}
     />
    )}
    <div style={{ position: "relative", minWidth: 0, flex: 1 }}>
     {/* Name + timestamp header */}
     <div
      style={{
       fontSize: 12,
       color: "var(--text-muted)",
       marginBottom: 3,
       display: "flex",
       alignItems: "center",
       gap: 5,
      }}
     >
      {memberName && (
       <span style={{ fontWeight: 600, color: "var(--brand)" }}>
        {memberName}
       </span>
      )}
      {viaLabel && (
       <span style={{ color: "var(--text-secondary)" }}>
        {memberName ? "·" : ""} {viaLabel}
       </span>
      )}
      <RelTime
       ts={msg.createdAt}
       formatter={relTimeFull}
       style={{ color: "var(--text-secondary)" }}
      />
      {/* W-2: client-side send status for the user's own message. */}
      {optimistic && msg.sendState === "sending" && (
       <span style={{ color: "var(--text-secondary)" }}>· Sending…</span>
      )}
      {optimistic && msg.sendState === "sent" && (
       <span
        style={{ color: "var(--text-secondary)" }}
        aria-label="Sent"
        title="Sent"
       >
        · ✓
       </span>
      )}
      {optimistic && msg.sendState === "failed" && (
       <button
        type="button"
        onClick={() => onRetry?.(msg)}
        style={{
         fontSize: 12,
         color: "var(--danger)",
         background: "none",
         border: "none",
         padding: 0,
         cursor: "pointer",
         fontFamily: "inherit",
         minWidth: isMobile ? 44 : undefined,
         minHeight: isMobile ? 44 : undefined,
        }}
       >
        · Failed — Retry
       </button>
      )}
     </div>
     {stripAttachmentAnnotations(msg.content) && (
      <div
       style={{
        background: "var(--brand)",
        color: "var(--brand-foreground)",
        borderRadius: "4px 18px 18px 18px",
        padding: "9px 13px",
        fontSize: 14,
        lineHeight: 1.55,
        whiteSpace: "pre-wrap",
        overflowWrap: "break-word",
        unicodeBidi: "plaintext",
       }}
      >
       {stripAttachmentAnnotations(msg.content)}
      </div>
     )}
     {Array.isArray(attachments) && attachments.length > 0 && (
      <ChatAttachments
       attachments={attachments}
       conversationId={conversationId ?? (optimistic ? msg.conversationId : undefined)}
      />
     )}
     {msg.content && (
      <CopyMessageButton
       side="right"
       copied={copied}
       visible={hovered}
       isMobile={isMobile}
       onCopy={handleCopy}
      />
     )}
    </div>
   </div>
  );
 }

 return null;
}

// ── Briefing panel (no thread selected) ────────────────────────────────────────

// Generic fallback prompts. Only shown when the assigned specialist has no
// catalog profile (legacy rows). For catalog-backed specialists we derive
// role-relevant prompts from their capabilities — see specialistSuggestionPrompts.
const BRIEFING_PROMPTS: string[] = [
 "Draft a polite payment chase to a late supplier",
 "Pull last month's expense breakdown by vendor",
 "Summarize this week's compliance escalations",
 "Find duplicate invoices over $1,000 from Q1",
];

/**
 * #3496: Build role-relevant starter prompts for the empty-state briefing.
 *
 * The hardcoded finance prompts (BRIEFING_PROMPTS) were shown to every client
 * regardless of their specialist's role — e.g. "payment chase" / "duplicate
 * invoices" on a Cold Chain / Shipment Coordinator. Derive the suggestions from
 * the assigned specialist's catalog capabilities so what's offered matches what
 * the specialist actually does. Capabilities are already imperative phrases
 * ("Coordinate outbound and inbound load logistics…"), which read naturally as
 * a client's opening ask. Falls back to the generic list for legacy specialists
 * with no catalog profile.
 */
interface SuggestionPrompt {
 /** What renders on the button. */
 label: string;
 /** What lands in the composer draft on click — always the full sentence. */
 text: string;
}

function specialistSuggestionPrompts(
 specialist?: PortalSpecialist | null,
): SuggestionPrompt[] {
 const caps = (specialist?.coreCapabilities ?? [])
  .map((c) => (typeof c === "string" ? c.trim() : ""))
  .filter((c): c is string => c.length > 0);

 if (caps.length === 0)
  return BRIEFING_PROMPTS.map((prompt) => ({ label: prompt, text: prompt }));

 return caps.slice(0, 4).map((cap) => {
  // Collapse whitespace and drop trailing punctuation. These buttons are
  // full-width and wrap, so the label needs no length cap — it shows the
  // same clean, complete sentence that gets sent as the draft. Still
  // clamped to the API's message limit as a backstop: catalog capabilities
  // are admin-authored and normally well under this, but nothing enforces
  // that server-side, and an oversized one would otherwise reach the send
  // path unchanged and get rejected there instead.
  const clean = cap.replace(/\s+/g, " ").replace(/[.;:,]+$/, "");
  const bounded = truncateAtWordBoundary(clean, MAX_MESSAGE_LENGTH);
  return { label: bounded, text: bounded };
 });
}

function statusGroupOf(
 status: string | null | undefined,
): "awaiting" | "open" | "resolved" | "other" {
 if (status === "awaiting_client") return "awaiting";
 if (status === "pending") return "open";
 if (status === "resolved") return "resolved";
 return "other";
}

function StatusPill({ status }: { status: string | null | undefined }) {
 const group = statusGroupOf(status);
 if (group === "other") return null;
 const tokens: Record<
  "awaiting" | "open" | "resolved",
  { bg: string; fg: string; label: string }
 > = {
  awaiting: {
   bg: "var(--warning-subtle)",
   fg: "var(--warning)",
   label: "Needs input",
  },
  open: { bg: "var(--info-subtle)", fg: "var(--info)", label: "In progress" },
  resolved: {
   bg: "var(--bg-elevated)",
   fg: "var(--text-muted)",
   label: "Resolved",
  },
 };
 const t = tokens[group];
 return (
  <span
   style={{
    display: "inline-flex",
    alignItems: "center",
    fontSize: 12,
    fontWeight: 600,
    textTransform: "uppercase",
    letterSpacing: "0.05em",
    padding: "2px 7px",
    borderRadius: 999,
    background: t.bg,
    color: t.fg,
    whiteSpace: "nowrap",
   }}
  >
   {t.label}
  </span>
 );
}

function chipStyle(tag: string): CSSProperties {
 const { bg, text } = skillTagTokens(tag);
 return {
  display: "inline-flex",
  alignItems: "center",
  padding: "3px 9px",
  fontSize: 12,
  borderRadius: 999,
  border: "1px solid transparent",
  color: text,
  background: bg,
  whiteSpace: "nowrap",
  maxWidth: "100%",
 };
}

// #4581: capabilities are full sentences, so nowrap text must clip inside the
// pill. `inline-block` (not `inline-flex`) — text-overflow doesn't ellipsize a
// flex container's anonymous text child. Pair with a `title` attr for the full
// text when the content can be long.
const plainChipStyle: CSSProperties = {
 display: "inline-block",
 padding: "3px 9px",
 fontSize: 12,
 borderRadius: 999,
 border: "1px solid var(--border)",
 color: "var(--text-secondary)",
 background: "var(--bg-elevated)",
 whiteSpace: "nowrap",
 maxWidth: "100%",
 overflow: "hidden",
 textOverflow: "ellipsis",
};

const specialistSectionLabelStyle: CSSProperties = {
 margin: "0 0 8px",
 fontSize: 12,
 fontWeight: 600,
 color: "var(--text-muted)",
 textTransform: "uppercase",
 letterSpacing: "0.07em",
};

export function SpecialistDashboardProfile({
 specialist,
 allSpecialists,
}: {
 specialist: PortalSpecialist;
 /** Full assigned list, for the Primary/Secondary badge. Omitted → treat `s`
  *  as the whole team (lone specialist → no badge). #3174 */
 allSpecialists?: PortalSpecialist[];
}) {
 return (
  <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
   <SpecialistIdentity
    specialist={specialist}
    allSpecialists={allSpecialists}
   />
   <div style={{ height: 1, background: "var(--border)" }} />
   <SpecialistDetail specialist={specialist} />
  </div>
 );
}

/**
 * Identity half of the Specialist profile — avatar, online dot, name, the
 * Primary/Secondary badge, title and the per-OSA email alias.
 *
 * Split out of SpecialistDashboardProfile (#4949) so the briefing rail can put
 * the Specialist's two actions directly under the identity block and push the
 * dossier (SpecialistDetail, via SpecialistAbout) below them. The composed
 * SpecialistDashboardProfile renders exactly as before.
 */
export function SpecialistIdentity({
 specialist: s,
 allSpecialists,
}: {
 specialist: PortalSpecialist;
 allSpecialists?: PortalSpecialist[];
}) {
 const displayName = specialistDisplayName(s) || s.firstName;
 // #3174: no Primary/Secondary badge for a lone specialist.
 const roleLabel = specialistRoleLabel(s, allSpecialists ?? [s]);

 return (
  <div style={{ textAlign: "center" }}>
   {/* The sheet's largest step. This was `lg` forced to 72px with `!size-[]`
       and a hand-drawn dot at #22c55e, which is on no ramp (#5885). */}
   <div style={{ display: "inline-flex", marginBottom: 12 }}>
    <Avatar name={displayName} size="3xl" src={avatarSrc(s.avatarUrl)} online />
   </div>

   <div
    style={{
     display: "flex",
     alignItems: "center",
     justifyContent: "center",
     gap: 8,
     flexWrap: "wrap",
    }}
   >
    <h2
     style={{
      margin: 0,
      fontSize: 16,
      fontWeight: 600,
      color: "var(--text-primary)",
      lineHeight: 1.2,
     }}
    >
     {displayName}
    </h2>
    {roleLabel && (
     <Badge variant="outline" size="sm">
      {roleLabel}
     </Badge>
    )}
   </div>

   {s.title && (
    <p
     style={{
      margin: "5px 0 0",
      fontSize: 12,
      color: "var(--text-secondary)",
      lineHeight: 1.35,
     }}
    >
     {s.title}
    </p>
   )}

   {/* #2617: surface the Specialist's contact email in-app so clients
            don't have to dig through their onboarding email to find it. */}
   {s.emailAlias && (
    <a
     href={`mailto:${s.emailAlias}`}
     className="chat-inline-link"
     style={{
      margin: "6px 0 0",
      display: "inline-block",
      fontSize: 12,
      color: "var(--link, var(--primary))",
      textDecoration: "none",
      wordBreak: "break-all",
     }}
    >
     {s.emailAlias}
    </a>
   )}
   {/* #2785: the online status is already shown as a green dot on the
            avatar (above); a second inline "● online" indicator here collided
            with the email line, so it was removed to avoid the duplicate. */}
  </div>
 );
}

/**
 * Dossier half of the Specialist profile — bio, tags, capabilities and the
 * integrations they need. Rendered as a fragment so the sections stay direct
 * children of SpecialistDashboardProfile's flex column and keep its gap.
 */
export function SpecialistDetail({
 specialist: s,
}: {
 specialist: PortalSpecialist;
}) {
 const tags = specialistTags(s);

 return (
  <>
   {/* #4856: prefer the `bio` column (edited via /ops/specialists) and fall
          back to `description` — mirrors the ops view modal. Reading only
          `description` silently dropped saved bios and showed the empty state. */}
   {s.bio || s.description ? (
    <p
     style={{
      margin: 0,
      fontSize: 14,
      lineHeight: 1.6,
      color: "var(--text-secondary)",
     }}
    >
     {s.bio || s.description}
    </p>
   ) : (
    <p
     style={{
      margin: 0,
      fontSize: 12,
      fontStyle: "italic",
      lineHeight: 1.5,
      color: "var(--text-muted)",
     }}
    >
     No description on file for this Specialist yet.
    </p>
   )}

   {tags.length > 0 && (
    <section>
     <p style={specialistSectionLabelStyle}>Tags</p>
     <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
      {tags.map((tag) => (
       <span key={tag} style={chipStyle(tag)}>
        {tag}
       </span>
      ))}
     </div>
    </section>
   )}

   {s.coreCapabilities.length > 0 && (
    <section>
     <p style={specialistSectionLabelStyle}>What they do</p>
     <ul
      style={{
       margin: 0,
       // Flush-left (#3115): no left indent / bullet gutter, matching the
       // "What they need access to" list below.
       padding: 0,
       listStyle: "none",
       fontSize: 14,
       lineHeight: 1.55,
       color: "var(--text-secondary)",
      }}
     >
      {s.coreCapabilities.map((cap) => (
       <li key={cap}>{cap}</li>
      ))}
     </ul>
    </section>
   )}

   {s.requiredIntegrations.length > 0 && (
    <section>
     <p style={specialistSectionLabelStyle}>What they need access to</p>
     <ul
      style={{
       display: "flex",
       flexWrap: "wrap",
       gap: 6,
       listStyle: "none",
       margin: 0,
       padding: 0,
      }}
     >
      {s.requiredIntegrations.map((integration) => (
       <li key={integration} style={plainChipStyle}>
        {integration}
       </li>
      ))}
     </ul>
    </section>
   )}
  </>
 );
}

export function DashboardSpecialistCarousel({
 specialists,
 activeSpecialistId,
 onActiveChange,
 profile = "full",
}: {
 specialists: PortalSpecialist[];
 /** #5444: the Specialist to display, when the parent owns the selection for a
     wider surface. It wins over any local pick — the briefing rail's composer
     moves the selection too, and a carousel holding its own would show one
     Specialist while the rail's actions targeted another. Omit it (or pass
     null) to let the carousel own the selection. */
 activeSpecialistId?: string | null;
 /** #4584: notifies the parent which Specialist profile is currently
    displayed, so context-specific actions (the panel's "+ New thread" CTA)
    can target them instead of re-asking via the W-1 picker. */
 onActiveChange?: (specialistId: string) => void;
 /** #4949 — "identity" renders only the identity block, so the briefing rail
  *  can slot the Specialist's actions between it and the dossier (which the
  *  rail then renders itself via SpecialistAbout). "full" keeps the original
  *  identity + dossier profile. */
 profile?: "full" | "identity";
}) {
 // #2796: null = no manual pick yet, so the default tracks the Primary
 // Specialist (resolved below) even if the list loads/reorders after mount.
 // A pick via the dropdown pins the choice.
 const [pickedSpecialistId, setPickedSpecialistId] = useState<string | null>(
  null,
 );
 const [open, setOpen] = useState(false);
 const menuRef = useRef<HTMLDivElement | null>(null);
 const triggerRef = useRef<HTMLButtonElement | null>(null);
 const firstOptionRef = useRef<HTMLButtonElement | null>(null);
 const hasPicker = specialists.length > 1;
 const menuId = useId();

 // Resolve who is on display: the parent's selection when it owns one,
 // otherwise the local pick, otherwise the Primary (#2796). Resolving by id
 // rather than index also means a selection that outlives a reordered or
 // shrunken list falls back to the Primary instead of pointing at whoever
 // now holds that slot.
 const displayedSpecialist = activeSpecialist(
  specialists,
  activeSpecialistId ?? pickedSpecialistId,
 );

 // Close the dropdown on outside click or Escape, returning focus to the
 // trigger so keyboard users aren't stranded.
 useEffect(() => {
  if (!open) return;
  const onPointerDown = (event: MouseEvent) => {
   if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
    setOpen(false);
   }
  };
  const onKeyDown = (event: KeyboardEvent) => {
   if (event.key === "Escape") {
    setOpen(false);
    triggerRef.current?.focus();
   }
  };
  document.addEventListener("mousedown", onPointerDown);
  document.addEventListener("keydown", onKeyDown);
  return () => {
   document.removeEventListener("mousedown", onPointerDown);
   document.removeEventListener("keydown", onKeyDown);
  };
 }, [open]);

 // When the menu opens, move focus into it so keyboard users land on the
 // currently-selected option.
 useEffect(() => {
  if (open) firstOptionRef.current?.focus();
 }, [open]);

 if (!displayedSpecialist) {
  return (
   <section aria-label="Your Specialists">
    <div
     style={{
      border: "1px dashed var(--border)",
      borderRadius: 10,
      padding: "18px 14px",
      textAlign: "center",
      fontSize: 14,
      lineHeight: 1.5,
      color: "var(--text-muted)",
     }}
    >
     No Specialists are assigned to your team yet.
    </div>
   </section>
  );
 }

 const activeName =
  specialistDisplayName(displayedSpecialist) || displayedSpecialist.firstName;
 // #3174: Primary/Secondary by effective-primary rule (no all-Secondary when
 // none is flagged); null only for a lone specialist (picker is hidden then).
 const activeRole = specialistRoleLabel(displayedSpecialist, specialists);

 // #4584: the parent hears every pick, so a surface that owns the selection
 // sends it straight back down as `activeSpecialistId`.
 const selectSpecialist = (specialistId: string) => {
  setPickedSpecialistId(specialistId);
  onActiveChange?.(specialistId);
  setOpen(false);
 };

 return (
  <section aria-label="Your Specialists">
   <p style={{ ...specialistSectionLabelStyle, margin: "0 0 8px" }}>
    Your Specialists
   </p>

   {hasPicker && (
    <div ref={menuRef} style={{ position: "relative", marginBottom: 16 }}>
     {/* Picker trigger — shows the currently selected Specialist */}
     <button
      ref={triggerRef}
      type="button"
      onClick={() => setOpen((v) => !v)}
      aria-expanded={open}
      aria-controls={menuId}
      aria-label={`Selected specialist: ${activeName}. Choose a different specialist`}
      style={{
       width: "100%",
       display: "flex",
       alignItems: "center",
       gap: 10,
       padding: "8px 10px",
       border: "1px solid var(--border)",
       borderRadius: 9,
       background: "var(--bg-elevated)",
       color: "var(--text-primary)",
       cursor: "pointer",
       textAlign: "left",
       fontFamily: "inherit",
       transition: "border-color 0.12s",
      }}
      onMouseEnter={(e) => {
       (e.currentTarget as HTMLButtonElement).style.borderColor =
        "var(--primary)";
      }}
      onMouseLeave={(e) => {
       (e.currentTarget as HTMLButtonElement).style.borderColor =
        "var(--border)";
      }}
     >
      <Avatar
       name={activeName}
       size="sm"
       src={avatarSrc(displayedSpecialist.avatarUrl)}
       className="!size-7 text-xs"
      />
      <span
       style={{
        flex: 1,
        minWidth: 0,
        display: "flex",
        flexDirection: "column",
        lineHeight: 1.2,
       }}
      >
       <span
        style={{
         fontSize: 14,
         fontWeight: 600,
         color: "var(--text-primary)",
         overflowWrap: "break-word",
        }}
       >
        {activeName}
       </span>
       {activeRole && (
        <span style={{ fontSize: 12, color: "var(--text-muted)" }}>
         {activeRole}
        </span>
       )}
      </span>
      <ChevronDown
       size={16}
       style={{
        flexShrink: 0,
        color: "var(--text-muted)",
        transform: open ? "rotate(180deg)" : "none",
        transition: "transform 160ms ease",
       }}
       aria-hidden="true"
      />
     </button>

     {/* Dropdown — every assigned Specialist, immediately accessible.
              Rendered as a radiogroup of native buttons (role=radio). Unlike
              role=menu/listbox, the radiogroup pattern tolerates Tab-based
              navigation, so keyboard semantics match the actual behaviour:
              Tab to traverse, Enter/Space to pick, Escape to close. */}
     {open && (
      <div
       id={menuId}
       role="radiogroup"
       aria-label="Choose specialist"
       style={{
        position: "absolute",
        top: "calc(100% + 4px)",
        left: 0,
        right: 0,
        zIndex: 20,
        padding: 4,
        border: "1px solid var(--border)",
        borderRadius: 10,
        background: "var(--bg-surface)",
        boxShadow: "0 8px 24px rgba(0,0,0,0.18)",
        maxHeight: 280,
        overflowY: "auto",
       }}
      >
       {specialists.map((specialist) => {
        const name =
         specialistDisplayName(specialist) || specialist.firstName;
        const selected = specialist.id === displayedSpecialist.id;
        const role = specialistRoleLabel(specialist, specialists);
        return (
         <button
          key={specialist.id}
          ref={selected ? firstOptionRef : undefined}
          type="button"
          role="radio"
          aria-checked={selected}
          aria-label={`Show ${name}`}
          onClick={() => selectSpecialist(specialist.id)}
          style={{
           width: "100%",
           display: "flex",
           alignItems: "center",
           gap: 10,
           padding: "8px 10px",
           border: "none",
           borderRadius: 7,
           background: selected
            ? "var(--bg-hover, var(--bg-secondary))"
            : "transparent",
           color: "var(--text-primary)",
           cursor: "pointer",
           textAlign: "left",
           fontFamily: "inherit",
          }}
          onMouseEnter={(e) => {
           (e.currentTarget as HTMLButtonElement).style.background =
            "var(--bg-hover, var(--bg-secondary))";
          }}
          onMouseLeave={(e) => {
           (e.currentTarget as HTMLButtonElement).style.background =
            selected
             ? "var(--bg-hover, var(--bg-secondary))"
             : "transparent";
          }}
         >
          <Avatar
           name={name}
           size="sm"
           src={avatarSrc(specialist.avatarUrl)}
           className="!size-7 text-xs"
          />
          <span
           style={{
            flex: 1,
            minWidth: 0,
            display: "flex",
            flexDirection: "column",
            lineHeight: 1.2,
           }}
          >
           <span
            style={{
             fontSize: 14,
             fontWeight: selected ? 600 : 500,
             color: "var(--text-primary)",
             overflowWrap: "break-word",
            }}
           >
            {name}
           </span>
           {role && (
            <span
             style={{ fontSize: 12, color: "var(--text-muted)" }}
            >
             {role}
            </span>
           )}
          </span>
          {selected && (
           <Check
            size={15}
            style={{ flexShrink: 0, color: "var(--brand)" }}
            aria-hidden="true"
           />
          )}
         </button>
        );
       })}
      </div>
     )}
    </div>
   )}

   <article
    id={`specialist-panel-${displayedSpecialist.id}`}
    aria-label={`Specialist profile: ${activeName}`}
   >
    {profile === "identity" ? (
     <SpecialistIdentity
      specialist={displayedSpecialist}
      allSpecialists={specialists}
     />
    ) : (
     <SpecialistDashboardProfile
      specialist={displayedSpecialist}
      allSpecialists={specialists}
     />
    )}
   </article>
  </section>
 );
}

function BriefingEmpty({
 portalContext,
 specialists,
 onNew,
 onSuggestion,
 createError,
 userFirstName,
}: {
 portalContext?: PortalContext | null;
 specialists: PortalSpecialist[];
 onNew: () => void;
 onSuggestion: (text: string) => void;
 createError?: string | null;
 userFirstName?: string | null;
}) {
 // #5444: the carousel selection drives the introduction and the starter
 // prompts beside it, the way it drives the populated rail. Resolving the
 // Primary here independently let the copy introduce one Specialist while the
 // carousel showed another.
 const [selectedSpecialistId, setSelectedSpecialistId] = useState<
  string | null
 >(null);
 const displayedSpecialist = activeSpecialist(
  specialists,
  selectedSpecialistId,
 );
 const specialistName =
  displayedSpecialist?.firstName ?? "your specialist";
 const orgName = portalContext?.orgName ?? "your team";
 const greetingName = userFirstName?.trim();
 // #3496: prompts tailored to the assigned specialist's role, not hardcoded finance.
 const suggestionPrompts = specialistSuggestionPrompts(displayedSpecialist);

 if (specialists.length === 0) {
  return (
   <div
    style={{
     flex: 1,
     overflowY: "auto",
     padding: "56px 24px 64px",
     background: "var(--bg-canvas)",
    }}
   >
    <div style={{ maxWidth: 540, margin: "0 auto", textAlign: "center" }}>
     <p
      style={{
       margin: "0 auto 16px",
       maxWidth: 420,
       fontSize: 14,
       color: "var(--text-secondary)",
       lineHeight: 1.6,
      }}
     >
      {greetingName
       ? `Hi ${greetingName} — no Specialists are assigned to your team yet.`
       : "No Specialists are assigned to your team yet."}
     </p>
     <p
      style={{
       margin: 0,
       fontSize: 14,
       color: "var(--text-muted)",
       lineHeight: 1.5,
      }}
     >
      Please contact your Account Manager to get started.
     </p>
    </div>
   </div>
  );
 }

 return (
  <div
   style={{
    flex: 1,
    overflowY: "auto",
    padding: "56px 24px 64px",
    background: "var(--bg-canvas)",
   }}
  >
   <div style={{ maxWidth: 540, margin: "0 auto" }}>
    {/* Specialist carousel - shows all assigned specialists */}
    <div style={{ marginBottom: 32 }}>
     <DashboardSpecialistCarousel
      specialists={specialists}
      activeSpecialistId={displayedSpecialist?.id ?? null}
      onActiveChange={setSelectedSpecialistId}
     />
    </div>

    <div style={{ textAlign: "center" }}>
     <p
      style={{
       margin: "0 auto 32px",
       maxWidth: 420,
       fontSize: 14,
       color: "var(--text-secondary)",
       lineHeight: 1.6,
      }}
     >
      {greetingName
       ? `Hi ${greetingName} — I'm ${specialistName}, your dedicated specialist for ${orgName}. I'm here to handle your ops work so you don't have to.`
       : `I'm ${specialistName}, your dedicated specialist for ${orgName}. I'm here to handle your ops work so you don't have to.`}
     </p>

     <p
      style={{
       margin: "0 0 12px",
       fontSize: 12,
       fontWeight: 600,
       color: "var(--text-muted)",
       textTransform: "uppercase",
       letterSpacing: "0.08em",
      }}
     >
      What would you like to work on today?
     </p>
     <div
      style={{
       display: "flex",
       flexDirection: "column",
       gap: 8,
       marginBottom: 24,
      }}
     >
      {suggestionPrompts.map((prompt, i) => (
       <button
        key={`${i}-${prompt.text}`}
        type="button"
        onClick={() => onSuggestion(prompt.text)}
        style={{
         display: "block",
         width: "100%",
         textAlign: "left",
         padding: "11px 14px",
         fontSize: 14,
         color: "var(--text-primary)",
         background: "var(--bg-surface)",
         border: "1px solid var(--border)",
         borderRadius: 8,
         cursor: "pointer",
         fontFamily: "inherit",
         opacity: 1,
         transition: "border-color 0.15s, background 0.15s",
        }}
        onMouseEnter={(e) => {
         (e.currentTarget as HTMLButtonElement).style.borderColor =
          "var(--primary)";
         (e.currentTarget as HTMLButtonElement).style.background =
          "var(--accent-subtle)";
        }}
        onMouseLeave={(e) => {
         (e.currentTarget as HTMLButtonElement).style.borderColor =
          "var(--border)";
         (e.currentTarget as HTMLButtonElement).style.background =
          "var(--bg-surface)";
        }}
       >
        {prompt.label}
       </button>
      ))}
     </div>

     <button
      type="button"
      onClick={onNew}
      style={{
       background: "var(--brand)",
       color: "var(--brand-foreground)",
       border: "none",
       borderRadius: 8,
       padding: "10px 20px",
       fontSize: 14,
       fontWeight: 600,
       cursor: "pointer",
       opacity: 1,
       fontFamily: "inherit",
      }}
     >
      <span style={{ display: "flex", alignItems: "center", gap: 5 }}>
       Start a conversation <ArrowRight size={13} />
      </span>
     </button>

     {createError && (
      <p
       style={{
        margin: "16px 0 0",
        fontSize: 12,
        color: "var(--danger)",
       }}
      >
       {createError}
      </p>
     )}
    </div>
   </div>
  </div>
 );
}

// ── Chat panel ─────────────────────────────────────────────────────────────────

/**
 * Placeholder shown while an existing thread's message history is loading.
 * Renders a few shimmering bubble outlines so the user sees the chat is
 * populating rather than a blank panel or the misleading "ready" empty-state
 * (#1737). Alternating alignment mimics user/specialist turns.
 */
function MessageHistorySkeleton() {
 // Widths chosen to look like a natural back-and-forth conversation.
 const rows: Array<{ align: "flex-start" | "flex-end"; width: number }> = [
  { align: "flex-start", width: 62 },
  { align: "flex-end", width: 48 },
  { align: "flex-start", width: 74 },
  { align: "flex-end", width: 40 },
 ];
 return (
  <div
   role="status"
   aria-label="Loading conversation"
   style={{
    display: "flex",
    flexDirection: "column",
    gap: 12,
    padding: "8px 0",
   }}
  >
   {rows.map((row, i) => (
    <div key={i} style={{ display: "flex", justifyContent: row.align }}>
     <div
      style={{
       width: `${row.width}%`,
       height: 44,
       borderRadius: 12,
       background:
        "linear-gradient(90deg, var(--surface-2, rgba(0,0,0,0.05)) 25%, var(--surface-3, rgba(0,0,0,0.09)) 37%, var(--surface-2, rgba(0,0,0,0.05)) 63%)",
       backgroundSize: "400% 100%",
       animation: "hwSkeletonShimmer 1.4s ease infinite",
      }}
     />
    </div>
   ))}
   <span
    style={{
     position: "absolute",
     width: 1,
     height: 1,
     overflow: "hidden",
     clip: "rect(0 0 0 0)",
    }}
   >
    Loading conversation…
   </span>
   <style>{`
        @keyframes hwSkeletonShimmer {
          0% { background-position: 100% 50%; }
          100% { background-position: 0 50%; }
        }
        @media (prefers-reduced-motion: reduce) {
          [aria-label="Loading conversation"] > div > div { animation: none; }
        }
      `}</style>
  </div>
 );
}

export function ChatPanel({
 conversation,
 messages,
 messageMetadata,
 loadingMessages,
 messagesError,
 onRetryLoadMessages,
 onSend,
 onSendNote,
 sending,
 streamingReply,
 onBack,
 portalContext,
 platformRole,
 userId,
 onStatusChange,
 onArchive,
 onAssignToMe,
 onUnarchive,
 resolvingId,
 onRetry,
 onFeedback,
 onClarifyingCardSubmit,
 answeredCardIds,
 onOpenThreadSheet,
 onOpenSpecialistSheet,
 onRequestHuman,
 humanRequested,
 requestingHuman,
}: {
 conversation: Session;
 messages: MessageWithState[];
 messageMetadata: ConversationMessageMetadata[];
 /** True while the message history for this thread is still being fetched.
    Distinguishes a genuinely-empty new thread (show the "ready" prompt) from
    an existing thread whose messages haven't arrived yet (show a skeleton).
    Without this, an existing thread flashes the empty-state — the #1737 bug. */
 loadingMessages: boolean;
 /** Set when the message fetch for this thread failed (#5442). Passed through
    to MessageList so a failed load renders an error state with Try again
    instead of the "{Specialist} is ready" empty-thread prompt. */
 messagesError?: string | null;
 /** Re-runs the failed fetch for this thread. */
 onRetryLoadMessages?: () => void;
 onSend: (text: string, attachments?: MessageAttachment[]) => void;
 onSendNote: (text: string) => void;
 sending: boolean;
 /** Live agent reply streaming for this thread (ordered text/tool segments), or null. */
 streamingReply?: StreamingReply | null;
 /** Returns to the briefing view — used as a mobile-friendly back affordance. */
 onBack: () => void;
 portalContext?: PortalContext | null;
 platformRole?: string | null;
 userId?: string;
 onStatusChange?: (convId: string, status: string) => void;
 /** Client-safe resolve handler — PATCH /conversations/:id/resolve. Unlike
    onStatusChange (staff-only /status), clients are authorized to call this. */
 onArchive?: (convId: string) => void;
 onAssignToMe?: (convId: string) => void;
 onUnarchive?: (id: string) => void;
 /** Conversation ID currently being resolved - disables resolve button to prevent double-clicks */
 resolvingId?: string | null;
 /** W-2: retry a failed optimistic send (client WebChat only). */
 onRetry?: (msg: OptimisticMessage) => void;
 /** P4.4 (R4.7): client 👍/👎 on a Specialist reply. */
 onFeedback?: (
  msg: NativeTranscriptMessage,
  sentiment: "up" | "down",
 ) => void;
 /** Client submitted a clarifying card — the parent sends the readable
  *  summary as a normal message, carrying the structured answer. Resolves to
  *  `false` when the send failed so the card can offer a retry. */
 onClarifyingCardSubmit?: (
  answer: ClarifyingCardAnswer,
  summaryText: string,
 ) => void | boolean | Promise<void | boolean>;
 /** Card ids the client has successfully answered (locks that exact card). */
 answeredCardIds?: Set<string>;
 /** Opens the mobile thread switcher sheet. */
 onOpenThreadSheet?: () => void;
 /** Opens the mobile specialist info sheet. */
 onOpenSpecialistSheet?: () => void;
 /** #4907: "Talk to a human" — sends this thread straight to an Expert. */
 onRequestHuman?: () => void;
 /** #4907: an ask is already on the record for this thread. */
 humanRequested?: boolean;
 /** #4907: the request is in flight. */
 requestingHuman?: boolean;
}) {
 const isExpert = EXPERT_ROLES.includes(platformRole ?? "");
 const isMobile = useIsMobile();

 const subject =
  conversation.customTitle ||
  conversation.subject ||
  truncateAtWordBoundary(messages[0]?.content || "", 80) ||
  "New thread";
 const threadSpecialist = conversation.specialistId
  ? (portalContext?.specialists?.find(
   (s) => s.id === conversation.specialistId,
  ) ?? null)
  : null;
 // Fallback hierarchy: thread-specific specialist → primary specialist →
 // conversation.agentName (ground truth stored at thread creation time) →
 // generic. Using conversation.agentName before the generic default ensures
 // the composer and send button always reflect the actual specialist for this
 // thread even when portalContext hasn't loaded yet or the specialistId no
 // longer matches a currently-assigned specialist.
 const fallbackSpecialist =
  portalContext?.specialists?.find((s) => s.isPrimary) ??
  portalContext?.specialists?.[0] ??
  null;
 const activeSpecialist = threadSpecialist ?? fallbackSpecialist;
 const specialistName =
  activeSpecialist?.firstName ??
  conversation.agentName ??
  "Your specialist";
 const specialistAvatarUrl = activeSpecialist?.avatarUrl ?? null;
 // Capture orgId at render time so it's narrowed to `string` inside the
 // onUploadFile callback — TypeScript can't narrow through the closure.
 const convOrgId = conversation.orgId;

 return (
  <div
   style={{
    flex: 1,
    display: "flex",
    flexDirection: "column",
    height: "100%",
    minWidth: 0,
   }}
  >
   {/* Thread header — subject + status + channel */}
   <div
    style={{
     padding: isMobile
      ? `0 ${THREAD_HORIZONTAL_INSET}px 0 ${MOBILE_THREAD_HEADER_LEFT_INSET}px`
      : `0 ${THREAD_HORIZONTAL_INSET}px`,
     borderBottom: "1px solid var(--border)",
     display: "flex",
     alignItems: "center",
     gap: 10,
     flexShrink: 0,
     background: "var(--bg-surface)",
     height: PORTAL_HEADER_HEIGHT,
    }}
   >
    <button
     type="button"
     onClick={onBack}
     aria-label="Back to briefing"
     title="Back to briefing"
     className="hw-chat-back"
     style={{
      background: "transparent",
      border: "none",
      padding: "4px 6px",
      cursor: "pointer",
      color: "var(--text-muted)",
      display: "none",
      alignItems: "center",
      flexShrink: 0,
      borderRadius: 6,
      fontFamily: "inherit",
     }}
    >
     <svg
      width="18"
      height="18"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
     >
      <line x1="19" y1="12" x2="5" y2="12" />
      <polyline points="12 19 5 12 12 5" />
     </svg>
    </button>

    {/* Thread switcher — mobile only */}
    {onOpenThreadSheet && (
     <button
      type="button"
      onClick={onOpenThreadSheet}
      aria-label="Switch threads"
      title="Switch threads"
      className="hw-thread-switcher"
      style={{
       background: "transparent",
       border: "none",
       padding: "4px 6px",
       cursor: "pointer",
       color: "var(--text-muted)",
       display: "none",
       alignItems: "center",
       flexShrink: 0,
       borderRadius: 6,
       fontFamily: "inherit",
      }}
     >
      <Menu size={18} aria-hidden="true" />
     </button>
    )}

    <div style={{ flex: 1, minWidth: 0 }}>
     <div
      style={{
       fontSize: 14,
       fontWeight: 600,
       color: "var(--text-primary)",
       overflow: "hidden",
       textOverflow: "ellipsis",
       whiteSpace: "nowrap",
       letterSpacing: "-0.1px",
      }}
     >
      {subject}
     </div>
    </div>

    <div
     style={{
      display: "flex",
      alignItems: "center",
      gap: 8,
      flexShrink: 0,
     }}
    >
     <StatusPill status={conversation.status} />

     {/* Specialist avatar - mobile only, opens specialist info sheet */}
     {onOpenSpecialistSheet && (
      <button
       type="button"
       onClick={onOpenSpecialistSheet}
       aria-label={`View ${specialistName} profile`}
       title={`View ${specialistName} profile`}
       className="hw-specialist-avatar-btn"
       style={{
        background: "transparent",
        border: "none",
        padding: 0,
        cursor: "pointer",
        display: "none",
        alignItems: "center",
        justifyContent: "center",
        borderRadius: "50%",
       }}
      >
       <WorkerAvatar
        size={32}
        initial={specialistName.charAt(0)}
        src={specialistAvatarUrl}
        alt={specialistName}
       />
      </button>
     )}

     {!isExpert && !isClosedClientThread(conversation.status) && (
      <button
       type="button"
       disabled={resolvingId === conversation.id}
       onClick={() => onArchive?.(conversation.id)}
       title={`Mark as done — ${specialistName} will stop working on this thread.`}
       aria-label={`Resolve thread — ${specialistName} will stop working on this thread.`}
       style={{
        fontSize: 12,
        fontWeight: 500,
        padding: "4px 10px",
        borderRadius: 6,
        border: "1px solid var(--border)",
        background: "var(--bg-elevated)",
        color: "var(--text-secondary)",
        cursor:
         resolvingId === conversation.id ? "not-allowed" : "pointer",
        fontFamily: "inherit",
        transition: "color 0.15s, border-color 0.15s, background 0.15s",
        opacity: resolvingId === conversation.id ? 0.6 : 1,
       }}
       onMouseEnter={(e) => {
        if (resolvingId === conversation.id) return;
        (e.currentTarget as HTMLButtonElement).style.borderColor =
         "var(--primary)";
        (e.currentTarget as HTMLButtonElement).style.color =
         "var(--primary)";
        (e.currentTarget as HTMLButtonElement).style.background =
         "var(--bg-hover, var(--bg-secondary))";
       }}
       onMouseLeave={(e) => {
        (e.currentTarget as HTMLButtonElement).style.borderColor =
         "var(--border)";
        (e.currentTarget as HTMLButtonElement).style.color =
         "var(--text-secondary)";
        (e.currentTarget as HTMLButtonElement).style.background =
         "var(--bg-elevated)";
       }}
      >
       {resolvingId === conversation.id
        ? "Resolving..."
        : "Resolve thread"}
      </button>
     )}
     {isExpert && (
      <ExpertChatOverlay
       conversation={conversation}
       userId={userId}
       onStatusChange={onStatusChange}
       onAssign={onAssignToMe}
      />
     )}
    </div>
   </div>

   <MessageList
    messages={messages}
    messageMetadata={messageMetadata}
    conversationId={conversation.id}
    loading={loadingMessages}
    error={messagesError}
    onRetry={onRetryLoadMessages}
    specialistName={specialistName}
    specialistAvatarUrl={specialistAvatarUrl}
    isExpert={isExpert}
    isClosed={isClosedClientThread(conversation.status)}
    loadingFallback={<MessageHistorySkeleton />}
    renderMessage={(msg) => {
     const metadata = isOptimisticMessage(msg)
      ? undefined
      : messageMetadata.find(
       (entry) =>
        entry.locator.sessionId === msg.locator.sessionId &&
        entry.locator.messageId === msg.locator.messageId,
      );
     const clarifyingCard = metadata?.clarifyingCard;
     return (
      <MessageBubble
       msg={msg}
       productMetadata={metadata}
       agentName={specialistName}
       agentAvatarUrl={specialistAvatarUrl}
       platformRole={platformRole}
       convChannel={conversation.channel}
       conversationId={conversation.id}
       onRetry={onRetry}
       onFeedback={onFeedback}
       onClarifyingCardSubmit={onClarifyingCardSubmit}
       clarifyingCardAnswered={
        !!clarifyingCard &&
        !!answeredCardIds?.has(clarifyingCard.id)
       }
       onAvatarClick={onOpenSpecialistSheet}
       isMobile={isMobile}
      />
     );
    }}
    renderActivity={(lastVisibleMessage) =>
     streamingReply &&
      streamingReply.conversationId === conversation.id &&
      // A tool-only stream (no text segment yet, or a text segment that's
      // still empty/whitespace — the first token or two before real content
      // lands) has nothing client-visible to draw — tool activity is a
      // debugging aid, logs cover that — so falling into the text-segment
      // branch below would render an empty fragment and leave the client
      // with no sign the Specialist is still working. Keep the activity
      // indicator up until there's actual text to show in its place.
      streamingReply.segments.some(
       (segment) => segment.type === "text" && segment.content.trim().length > 0,
      ) ? (
      <>
       {streamingReply.segments.map((segment, index) =>
        segment.type === "tool" ? null : (
         <MessageBubble
          key={`__streaming-text-${index}`}
          msg={{
           id: `__streaming__${index}`,
           role: "assistant",
           content: segment.content,
           createdAt: streamingReply.startedAt,
           toolCallId: null,
           toolCalls: null,
           toolName: null,
           finishReason: null,
           platformMessageId: null,
           locator: {
            sessionId: "__streaming__",
            messageId: `__streaming__${index}`,
           },
          }}
          agentName={specialistName}
          agentAvatarUrl={specialistAvatarUrl}
          platformRole={platformRole}
          convChannel={conversation.channel}
          conversationId={conversation.id}
          isMobile={isMobile}
         />
        ),
       )}
      </>
     ) : (
      <SpecialistActivityIndicator
       specialistName={specialistName}
       avatarSrc={specialistAvatarUrl}
       lastUserMessageId={messageClientKey(lastVisibleMessage)}
      />
     )
    }
   />

   {isClosedClientThread(conversation.status) ? (
    <div
     style={{
      borderTop: "1px solid var(--border)",
      padding: "12px 16px",
      display: "flex",
      alignItems: "center",
      justifyContent: "space-between",
      gap: 12,
      background: "var(--bg-elevated)",
      flexShrink: 0,
     }}
    >
     <span style={{ fontSize: 14, color: "var(--text-muted)" }}>
      This thread is closed.
     </span>
     <button
      type="button"
      onClick={() => onUnarchive?.(conversation.id)}
      style={{
       fontSize: 14,
       fontFamily: "inherit",
       padding: "5px 12px",
       borderRadius: 6,
       border: "1px solid var(--border)",
       background: "var(--bg-canvas)",
       color: "var(--text-primary)",
       cursor: "pointer",
       display: "flex",
       alignItems: "center",
       gap: 6,
       whiteSpace: "nowrap",
      }}
     >
      <svg
       width="13"
       height="13"
       viewBox="0 0 24 24"
       fill="none"
       stroke="currentColor"
       strokeWidth="2"
       strokeLinecap="round"
       strokeLinejoin="round"
       aria-hidden="true"
      >
       <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
       <path d="M3 3v5h5" />
      </svg>
      Restore thread
     </button>
    </div>
   ) : (
    <ComposerArea
     isExpert={isExpert}
     onRequestHuman={onRequestHuman}
     humanRequested={humanRequested}
     requestingHuman={requestingHuman}
     onSend={onSend}
     onSendNote={onSendNote}
     sending={sending}
     specialistName={specialistName}
     conversationId={conversation.id}
     onUploadFile={
      convOrgId && conversation.specialistId
       ? (file) =>
        uploadChatAttachment(file, {
         orgId: convOrgId,
         // Specialist-keyed (shared inbound AgentFS namespace): works both
         // for existing threads and for the compose view, where no session
         // exists until the first message mints one.
         specialistId: conversation.specialistId,
        })
       : undefined
     }
     onRewrite={
      !isExpert
       ? (text, tone) => rewriteDraft(text, tone, convOrgId ?? undefined)
       : undefined
     }
    />
   )}
  </div>
 );
}

// ── Main PortalChat component ──────────────────────────────────────────────────

function getUserFirstName(): string | null {
 if (typeof window === "undefined") return null;
 const token = getToken();
 if (!token) return null;
 try {
  const payload = JSON.parse(atob(token.split(".")[1]));
  const display =
   (typeof payload?.displayName === "string" &&
    payload.displayName.trim()) ||
   (typeof payload?.name === "string" && payload.name.trim()) ||
   "";
  if (!display) return null;
  return display.split(/[\s._-]+/)[0] || null;
 } catch {
  return null;
 }
}

// W-1: modal that asks which assigned Specialist a new thread should go to.
// Only shown for orgs with >1 Specialist; clicking a Specialist creates the
// thread pinned to them (the API still validates the choice against the org).
function NewThreadSpecialistPicker({
 specialists,
 onPick,
 onClose,
}: {
 specialists: PortalSpecialist[];
 onPick: (specialistId: string) => void;
 onClose: () => void;
}) {
 // #4644: restore Escape-to-dismiss (and focus-on-open) for the picker. The
 // component only mounts while the dialog is open, so the trap is always
 // active here. Matches the pattern in KeyboardShortcutsModal.
 const trapRef = useFocusTrap(true);

 useEffect(() => {
  function onKeyDown(e: KeyboardEvent) {
   if (e.key === "Escape") {
    e.stopPropagation();
    onClose();
   }
  }
  window.addEventListener("keydown", onKeyDown, { capture: true });
  return () =>
   window.removeEventListener("keydown", onKeyDown, { capture: true });
 }, [onClose]);

 return (
  <div
   role="dialog"
   aria-modal="true"
   aria-label="Choose a Specialist for the new thread"
   onClick={onClose}
   style={{
    position: "fixed",
    inset: 0,
    background: "rgba(0,0,0,0.4)",
    display: "flex",
    alignItems: "center",
    justifyContent: "center",
    zIndex: 1000,
   }}
  >
   <div
    ref={trapRef}
    onClick={(e) => e.stopPropagation()}
    style={{
     background: "var(--bg-surface)",
     border: "1px solid var(--border)",
     borderRadius: 12,
     padding: 20,
     width: 360,
     maxWidth: "90vw",
     maxHeight: "80vh",
     overflowY: "auto",
    }}
   >
    <div
     style={{
      fontSize: 16,
      fontWeight: 600,
      color: "var(--text-primary)",
      marginBottom: 4,
     }}
    >
     Start a thread with…
    </div>
    <div
     style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 14 }}
    >
     Choose which Specialist should handle this conversation.
    </div>
    <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
     {specialists.map((s) => {
      const name =
       [s.firstName, s.lastName].filter(Boolean).join(" ") ||
       s.firstName;
      // #4550: surface the description + capabilities the client needs
      // to tell overlapping-domain Specialists apart. Cap the visible
      // capabilities so a long list can't blow out the modal row.
      const MAX_VISIBLE_CAPS = 3;
      const visibleCaps = s.coreCapabilities.slice(0, MAX_VISIBLE_CAPS);
      const hiddenCapCount =
       s.coreCapabilities.length - visibleCaps.length;
      return (
       <button
        key={s.id}
        type="button"
        onClick={() => onPick(s.id)}
        style={{
         display: "flex",
         alignItems: "flex-start",
         gap: 10,
         padding: "10px 12px",
         border: "1px solid var(--border)",
         borderRadius: 10,
         background: "var(--bg-elevated)",
         cursor: "pointer",
         textAlign: "left",
         fontFamily: "inherit",
         width: "100%",
        }}
       >
        <Avatar name={name} size="sm" src={avatarSrc(s.avatarUrl)} />
        <span
         style={{
          minWidth: 0,
          flex: 1,
          display: "flex",
          flexDirection: "column",
          gap: 4,
         }}
        >
         <span
          style={{ display: "flex", alignItems: "center", gap: 6 }}
         >
          <span
           style={{
            fontSize: 14,
            fontWeight: 600,
            color: "var(--text-primary)",
           }}
          >
           {name}
          </span>
          {s.isPrimary && (
           <Badge variant="outline" size="sm">
            Primary
           </Badge>
          )}
         </span>
         {s.title && (
          <span style={{ fontSize: 12, color: "var(--text-muted)" }}>
           {s.title}
          </span>
         )}
         {(s.bio || s.description) && (
          <span
           style={{
            fontSize: 12,
            lineHeight: 1.45,
            color: "var(--text-secondary)",
           }}
          >
           {s.bio || s.description}
          </span>
         )}
         {visibleCaps.length > 0 && (
          <span
           style={{
            display: "flex",
            flexWrap: "wrap",
            gap: 4,
            marginTop: 2,
           }}
          >
           {visibleCaps.map((cap) => (
            <span key={cap} style={plainChipStyle} title={cap}>
             {cap}
            </span>
           ))}
           {hiddenCapCount > 0 && (
            <span
             style={{
              ...plainChipStyle,
              color: "var(--text-muted)",
             }}
            >
             +{hiddenCapCount} more
            </span>
           )}
          </span>
         )}
        </span>
       </button>
      );
     })}
    </div>
    <button
     type="button"
     onClick={onClose}
     style={{
      marginTop: 14,
      background: "none",
      border: "none",
      color: "var(--text-muted)",
      fontSize: 12,
      cursor: "pointer",
      fontFamily: "inherit",
      padding: 0,
     }}
    >
     Cancel
    </button>
   </div>
  </div>
 );
}

/**
 * #5811 (D1 v3 Option A) — a thumbs-down opens an Expert re-review item
 * either way; this is just the client's chance to say what was wrong before
 * that happens. Free text, optional — the client describes the problem, an
 * Expert authors the fix (this is not a client-authored-correction surface,
 * that's a separate, larger decision). Matches NewThreadSpecialistPicker's
 * dialog shell (focus trap, Escape-to-dismiss, overlay+card).
 */
export function FlagReplyModal({
 onSubmit,
}: {
 onSubmit: (comment: string) => void;
}) {
 const trapRef = useFocusTrap(true);
 const [comment, setComment] = useState("");

 useEffect(() => {
  function onKeyDown(e: KeyboardEvent) {
   if (e.key === "Escape") {
    e.stopPropagation();
    onSubmit(comment);
   }
  }
  window.addEventListener("keydown", onKeyDown, { capture: true });
  return () =>
   window.removeEventListener("keydown", onKeyDown, { capture: true });
  // eslint-disable-next-line react-hooks/exhaustive-deps
 }, [comment]);

 return (
  <div
   role="dialog"
   aria-modal="true"
   aria-label="What was wrong with this reply?"
   // Backdrop click still records the flag (the button is already showing
   // active) — it just skips the description, same as Escape/Skip.
   onClick={() => onSubmit(comment)}
   style={{
    position: "fixed",
    inset: 0,
    background: "rgba(0,0,0,0.4)",
    display: "flex",
    alignItems: "center",
    justifyContent: "center",
    zIndex: 1000,
   }}
  >
   <div
    ref={trapRef}
    onClick={(e) => e.stopPropagation()}
    style={{
     background: "var(--bg-surface)",
     border: "1px solid var(--border)",
     borderRadius: 12,
     padding: 20,
     width: 400,
     maxWidth: "90vw",
    }}
   >
    <div
     style={{
      fontSize: 16,
      fontWeight: 600,
      color: "var(--text-primary)",
      marginBottom: 4,
     }}
    >
     What was wrong?
    </div>
    <div
     style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 12 }}
    >
     Optional — an Expert will review this reply either way. Telling us what
     was wrong helps them fix it faster.
    </div>
    <textarea
     autoFocus
     value={comment}
     onChange={(e) => setComment(e.target.value)}
     placeholder="e.g. this cited the wrong refund window"
     maxLength={1000}
     rows={3}
     style={{
      width: "100%",
      resize: "vertical",
      background: "var(--bg-canvas)",
      border: "1px solid var(--border)",
      borderRadius: 8,
      padding: "8px 10px",
      fontSize: 14,
      color: "var(--text-primary)",
      fontFamily: "inherit",
      boxSizing: "border-box",
     }}
    />
    <div
     style={{
      display: "flex",
      justifyContent: "flex-end",
      gap: 8,
      marginTop: 14,
     }}
    >
     <button
      type="button"
      onClick={() => onSubmit("")}
      style={{
       background: "none",
       border: "none",
       color: "var(--text-muted)",
       fontSize: 14,
       cursor: "pointer",
       fontFamily: "inherit",
       padding: "6px 10px",
      }}
     >
      Skip
     </button>
     <button
      type="button"
      onClick={() => onSubmit(comment)}
      style={{
       background: "var(--brand)",
       border: "none",
       color: "var(--brand-contrast, #fff)",
       fontSize: 14,
       fontWeight: 600,
       cursor: "pointer",
       fontFamily: "inherit",
       padding: "6px 14px",
       borderRadius: 8,
      }}
     >
      Submit
     </button>
    </div>
   </div>
  </div>
 );
}

export function PortalChat({
 initialId,
 initialCallParam,
 initialAssignmentParam,
 initialMarkReadId,
 startNewThread = false,
}: {
 initialId?: string;
 initialCallParam?: string;
 initialAssignmentParam?: string;
 initialMarkReadId?: string;
 /** Deeplink from the rail's New thread control (`?new=1`), which lives
  *  outside this surface and so cannot call `requestNewThread` directly. */
 startNewThread?: boolean;
}) {
 const router = useRouter();
 const { platformRole, userId, orgId } = useAuth();
 const isExpertViewer = EXPERT_ROLES.includes(platformRole ?? "");
 const { confirm, ConfirmDialogComponent } = useConfirmDialog();
 // Guards against a second resolve prompt clobbering an in-flight one.
 const archiveConfirmPendingRef = useRef(false);
 // Thread-list domain (fetch/cache, selection, deep-link reconcile) lives
 // in useThreads (#5135). The message/compose flows below still apply their
 // optimistic list updates through the setters this returns.
 const {
  conversations,
  setConversations,
  conversationsRef,
  selectedId,
  setSelectedId,
  selectedIdRef,
  convsLoaded,
  threadsUnavailable,
  resolvingId,
  setResolvingId,
  refetch: refetchThreads,
 } = useThreads(orgId, initialId);
 // "New thread" is PURE composer state — no session, no id (real or
 // placeholder) exists until the first message's terminal frame returns the
 // Hermes-minted id (mint and first turn are one request). `specialistId`
 // null = server assigns the org's primary specialist at mint.
 const [composeDraft, setComposeDraft] = useState<{
  specialistId: string | null;
 } | null>(null);
 const composeDraftRef = useRef(composeDraft);
 composeDraftRef.current = composeDraft;
 const composeOpen = composeDraft !== null;
 const {
  renameThread,
  resolveThread,
  reopenThread,
  dismissThread,
  updateStatus,
  assignToMe,
 } = useSpecialistActions({
  conversations,
  setConversations,
  userId,
 });
 const {
  messages,
  messageMetadata,
  loading: loadingMsgs,
  sending,
  humanRequestedIds,
  requestingHumanIds,
  error: messageError,
  loadMessages,
  refreshMessages,
  sendMessage,
  sendFirstMessage,
  retryMessage,
  requestHuman,
  submitFeedback,
  clearMessages,
  sendInternalNote,
  streamingReply,
 } = useMessages();
 const [portalContext, setPortalContext] = useState<PortalContext | null>(
  null,
 );
 const [portalSpecialists, setPortalSpecialists] = useState<
  PortalSpecialist[]
 >([]);
 const [contextLoading, setContextLoading] = useState(true);
 const [threadSheetOpen, setThreadSheetOpen] = useState(false);
 const [specialistSheetOpen, setSpecialistSheetOpen] = useState(false);
 // #5636: desktop-only sidebar collapse. Session-only on purpose — it resets
 // to "open" on reload rather than persisting, keeping the first paint of the
 // portal predictable (a client landing on their briefing should see the full
 // nav, not a surprise-collapsed shell). If we later want it remembered, this
 // is the single spot to swap in a persisted store. Mobile ignores it: there
 // the sidebar is already a full-screen overlay driven by selectedId.
 const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
 const isMobile = useIsMobile();
 const network = useNetworkState();
 const [createError, setCreateError] = useState<string | null>(null);
 const [userFirstName, setUserFirstName] = useState<string | null>(null);
 const [onboardingCall, setOnboardingCall] = useState<{
  assignmentId: string;
  specialistFirstName: string;
  specialistAvatarUrl?: string;
 } | null>(() => {
  if (initialCallParam === "onboarding" && initialAssignmentParam) {
   // specialistFirstName backfilled once portalContext loads (see below)
   return { assignmentId: initialAssignmentParam, specialistFirstName: "" };
  }
  return null;
 });
 // Set once the backend confirms onboarding is truly complete (OnboardingCallView
 // saw a 409 `onboarding_already_completed`). Suppresses the OnboardCallCard
 // "Rejoin" CTA immediately, without racing the async `__conversation_ended__`
 // → KB-ingestion → `onboarding_call_status` pipeline that otherwise leaves a
 // window where a stale "Rejoin" flashes and 409s on click. NOT set on a plain
 // call end — the conversation may still be live and "Rejoin" must remain.
 const [onboardingDone, setOnboardingDone] = useState(false);

 // #4949 — guards the compose-from-home path against a double submit (Send
 // click racing Cmd+Enter), which would otherwise create two threads.
 const composingRef = useRef(false);

 /** State for on-demand video call UI (issue #2058). */
 const [videoCall, setVideoCall] = useState<{
  specialistFirstName: string;
  specialistAvatarUrl?: string;
 } | null>(null);
 // Clarifying-card ids the client has SUCCESSFULLY answered — keyed by the
 // card's own id, so only the exact card that was answered locks (an unrelated
 // later message never locks a different, unanswered card). Persisted to
 // localStorage so the lock survives reloads and new tabs on this device; set
 // only after the answer send succeeds (a failed send stays retryable).
 const CLARIFYING_ANSWERED_KEY = "answeredClarifyingCardIds";
 const [answeredCardIds, setAnsweredCardIds] = useState<Set<string>>(
  new Set(),
 );
 useEffect(() => {
  if (typeof window === "undefined") return;
  try {
   const raw = window.localStorage.getItem(CLARIFYING_ANSWERED_KEY);
   if (raw) setAnsweredCardIds(new Set(JSON.parse(raw) as string[]));
  } catch {
   // localStorage unavailable / malformed — start empty (cards stay usable).
  }
 }, []);
 const markCardAnswered = useCallback((cardId: string) => {
  setAnsweredCardIds((prev) => {
   if (prev.has(cardId)) return prev;
   const next = new Set(prev).add(cardId);
   try {
    // Cap the stored list so it can't grow unbounded on a shared device.
    const trimmed = [...next].slice(-500);
    window.localStorage.setItem(
     CLARIFYING_ANSWERED_KEY,
     JSON.stringify(trimmed),
    );
   } catch {
    // best-effort persistence; the in-memory set still blocks re-submit.
   }
   return next;
  });
 }, []);
 const { unreadThreads, clearThreadUnread } = useNotifications({
  selectedConversationId: selectedId,
  // A pushed message for an inactive thread identifies the one conversation
  // whose list metadata changed. The active thread uses the canonical
  // transcript read below instead, so one socket message never triggers both
  // conversation-detail endpoints.
  onConversationChanged: useCallback(
   ({ conversationId }: { conversationId: string }) => {
    void getSession(conversationId)
     .then((fresh) => {
      setConversations((previous) => {
       const index = previous.findIndex(
        (conversation) => conversation.id === fresh.id,
       );
       if (index < 0) return [fresh, ...previous];
       const next = [...previous];
       next[index] = fresh;
       return next;
      });
     })
     .catch(() => { });
   },
   [setConversations],
  ),
  // #2565 — Expert Resolve (and other server-side status flips) push
  // conversation_status_changed to conversation:<id>. Flip the thread's
  // status in local state so the pill / Active-vs-Resolved filter / resolved
  // group update live, without a hard refresh.
  onConversationStatusChanged: useCallback(
   (data: { conversationId: string; newStatus: string }) => {
    setConversations((cs) =>
     cs.map((c) =>
      c.id === data.conversationId
       ? { ...c, status: data.newStatus as Session["status"] }
       : c,
     ),
    );
   },
   [setConversations],
  ),
  onCurrentConversationMessage: useCallback(() => {
   const id = selectedIdRef.current;
   if (!id) return;
   // One canonical active-transcript read. The hook drops a late response
   // if the user has already switched threads.
   void refreshMessages(id);
  }, [refreshMessages, selectedIdRef]),
 });

 // Issue #3219: Mark notification as read when opened from push notification
 useEffect(() => {
  if (initialMarkReadId) {
   markNotificationRead(initialMarkReadId).catch(() => {
    // Silent fail — notification read state is not critical
   });
  }
 }, [initialMarkReadId]);

 useEffect(() => {
  setUserFirstName(getUserFirstName());

  // Fetch portal context (specialist name/avatar, org name)
  getPortalContext()
   .then((ctx) => {
    if (ctx) {
     setPortalContext(ctx);

     // Backfill specialist name if the onboarding call was opened via deeplink
     // (URL param path sets specialistFirstName: "" because portalContext isn't
     // loaded yet at useState init time).
     setOnboardingCall((prev) => {
      if (!prev || prev.specialistFirstName) return prev;
      const specialist =
       ctx?.specialists?.find((s) => s.isPrimary) ??
       ctx?.specialists?.[0];
      const name = specialist?.firstName ?? "";
      const avatarUrl = specialist?.avatarUrl ?? undefined;
      return name
       ? {
        ...prev,
        specialistFirstName: name,
        specialistAvatarUrl: avatarUrl,
       }
       : prev;
     });

     // Subdomain mismatch protection: redirect users authenticated for a
     // different org than the subdomain they're currently on.
     const subdomain = getSubdomainSlug();
     if (subdomain && ctx.orgSlug && subdomain !== ctx.orgSlug) {
      const correctUrl = getPortalUrl(ctx.orgSlug);
      window.location.href = correctUrl;
     }
    }
   })
   .catch(() => { })
   .finally(() => {
    setTimeout(() => setContextLoading(false), 150);
   });

  getPortalSpecialists()
   .then((specialists) => setPortalSpecialists(specialists))
   .catch((err) => {
    console.error("Failed to load specialist profiles", err);
    toast.error(
     "Couldn't load your Specialists. The dashboard will show limited info until this recovers.",
    );
   });
  // The conversation list itself is fetched by useThreads (#5135).
 }, []);

 // #3220: Auto-retry failed messages when coming back online
 const messagesRef = useRef(messages);
 messagesRef.current = messages;
 // #4294: Track the timers we've scheduled, keyed by message id. This does
 // double duty: (1) de-dup — a message already awaiting a retry is never
 // scheduled a second time, so reconnect flapping (online→offline→online
 // within the stagger window) can't double-send it; (2) cancellation — we can
 // clear every pending timer on unmount or conversation switch so a stale
 // retry never fires against a thread the user has navigated away from.
 const retryTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
  new Map(),
 );
 useEffect(() => {
  if (network.isOnline && !network.hasNetworkError) {
   // Find all failed messages using ref to avoid dependency cycle, skipping
   // any that already have a retry scheduled (guards against a duplicate
   // batch when the effect re-fires under flaky connectivity).
   const failedMessages = messagesRef.current.filter(
    (message): message is OptimisticMessage =>
     isOptimisticMessage(message) &&
     message.sendState === "failed" &&
     !retryTimersRef.current.has(message.localId),
   );
   // Retry each newly-failed message with a small delay to avoid flooding.
   const retryConversationId = selectedIdRef.current;
   failedMessages.forEach((msg, index) => {
    const timer = setTimeout(() => {
     retryTimersRef.current.delete(msg.localId);
     if (selectedIdRef.current === retryConversationId) {
      void retryMessage(msg.localId);
     }
    }, index * 500); // 500ms stagger between retries
    retryTimersRef.current.set(msg.localId, timer);
   });
  }
  // eslint-disable-next-line react-hooks/exhaustive-deps
 }, [network.isOnline, network.hasNetworkError]);

 // #4294: Cancel any pending auto-retries when the component unmounts or the
 // user switches conversations. Without this a staggered retry can fire after
 // navigation and re-send a message into the wrong (now-current) thread, since
 // handleSend targets whatever selectedId is live when the timer runs.
 useEffect(() => {
  const timers = retryTimersRef.current;
  return () => {
   timers.forEach((timer) => clearTimeout(timer));
   timers.clear();
  };
 }, [selectedId]);

 // Deep-link reconciliation (confirm a URL-selected thread exists, splice it
 // in, or fall back to the briefing on a definitive 404/403) now lives in
 // useThreads (#5135).

 useEffect(() => {
  clearMessages();
  if (selectedId) void loadMessages(selectedId);
 }, [selectedId, clearMessages, loadMessages]);

 // #5658: a history-load error that clears itself moments later — a fast
 // retry, or the socket catch-up in refreshMessages (#5442) — must never
 // have put a toast on screen in the first place. The inline "Try again"
 // banner in MessageList already tracks messageError live and disappears
 // the instant it clears, but this toast used to fire the moment the error
 // appeared, with no way to take it back once the fetch behind it recovered.
 // That's how a client could see a fully rendered transcript with a stale
 // "Couldn't load this conversation" toast still sitting on top of it. Hold
 // the toast until the error has stood for a beat, and drop it if the error
 // clears before then.
 useEffect(() => {
  if (!messageError) return;
  const timer = setTimeout(() => {
   toast.error("Couldn't load this conversation. Please try again.");
  }, 600);
  return () => clearTimeout(timer);
 }, [messageError]);

 useEffect(() => {
  if (!selectedId || messages.length === 0) return;
  const conversation = conversationsRef.current.find(
   (item) => item.id === selectedId,
  );
  if (conversation?.subject) return;
  const firstUserMessage =
   messages.find((message) => message.role === "user") ?? messages[0];
  const content = firstUserMessage?.content?.trim() ?? "";
  if (!content) return;
  const subject = truncateAtWordBoundary(content, 40);
  setConversations((previous) =>
   previous.map((item) =>
    item.id === selectedId && !item.subject ? { ...item, subject } : item,
   ),
  );
 }, [selectedId, messages, setConversations, conversationsRef]);

 // Keep the open thread's list row stamped with its own transcript, so the
 // conversation being had is the one at the top of the list (#6081). The
 // list read happens once per mount; sending and receiving here would
 // otherwise leave this thread ranked by whatever activity it had when the
 // page loaded.
 //
 // Optimistic echoes are excluded on purpose: their `createdAt` is a browser
 // clock, and ordering it against server stamps from every other row is
 // unsound under skew. The server's own stamp arrives with the reconciled
 // transcript a moment later.
 useEffect(() => {
  if (!selectedId) return;
  const newest = newestServerMessageAt(messages);
  if (!newest) return;
  setConversations((previous) =>
   previous.map((item) =>
    item.id === selectedId &&
     (!item.lastMessageAt || item.lastMessageAt < newest)
     ? { ...item, lastMessageAt: newest }
     : item,
   ),
  );
 }, [selectedId, messages, setConversations]);

 // #804 (P0-2 PR-C): polling fallback removed. Socket.io reconnect path
 // in useNotifications.ts now triggers a single catch-up fetch on
 // 'connect' (after the initial). UI dedup by message id keeps things
 // safe if a push event later delivers the same message.

 const selectedConv =
  conversations.find((c) => c.id === selectedId) ?? null;
 // Compose mode renders ChatPanel against a VIEW-model, not a thread: the
 // empty `id` is the absence of a session (nothing is minted yet), never an
 // identity — it is not routable, not persisted, and never sent on the wire
 // (compose sends go through sendFirstMessage, which has no id parameter).
 const composeView: Session | null = composeOpen
  ? {
   id: "",
   orgId,
   specialistId: composeDraft?.specialistId ?? "",
   status: "active",
   createdAt: new Date().toISOString(),
  }
  : null;
 const requestingHuman = selectedId
  ? requestingHumanIds.has(selectedId)
  : false;
 // BriefingPanel needs the full set — it computes its own "active" vs
 // "resolved this week" groups internally. (Pre-#2002 this excluded the
 // separate `archived` bucket, which no longer exists.)
 const briefingConversations = conversations;

 const handleSelect = useCallback(
  (id: string) => {
   clearMessages();
   setSelectedId(id);
   clearThreadUnread(id);
   // Use query param instead of path segment — keeps the component mounted
   // (same page, no remount) while still updating the URL for shareability.
   router.replace(`/client/chat?id=${encodeURIComponent(id)}`);
  },
  [router, clearThreadUnread, clearMessages],
 );

 const handleGoHome = useCallback(() => {
  // Leaving compose mode abandons nothing: no session exists until the
  // first message is sent, and the typed draft persists in localStorage
  // (the composer's own per-key draft), restored on the next compose.
  setComposeDraft(null);
  setSelectedId(null);
  clearMessages();
  router.replace("/client/chat");
 }, [router, clearMessages]);

 /**
  * "New thread" — pure local state. No API call, no id of any kind: the
  * session is minted by the FIRST message (one request mints and runs the
  * turn), and the real id is adopted from its terminal frame in handleSend.
  * In a 1-specialist org the specialist is resolved here so uploads (which
  * are specialist-keyed) work before the mint; multi-specialist orgs always
  * arrive with an explicit pick (the W-1 picker gates this call).
  */
 const handleNew = useCallback(
  (specialistId?: string): void => {
   setCreateError(null);
   const resolved =
    specialistId ??
    (portalSpecialists.length === 1 ? portalSpecialists[0].id : null);
   setComposeDraft({ specialistId: resolved });
   setSelectedId(null);
   clearMessages();
   router.replace("/client/chat");
  },
  [router, clearMessages, portalSpecialists],
 );

 // Onboarding suggestion clicked: create a thread and seed the composer draft
 // so the user can hit send (or edit) without retyping.
 const handleSuggestion = useCallback(
  (text: string) => {
   try {
    // The compose surface's draft key — the composer is keyed by the empty
    // compose scope until the real session id is adopted.
    localStorage.setItem("hw_draft_", text);
   } catch {
    // ignore
   }
   handleNew();
  },
  [handleNew],
 );

 // W-1: when the org has more than one assigned Specialist, the "New thread"
 // action first asks which Specialist to talk to; with 0–1 Specialist we skip
 // the prompt and let the API assign the (only / primary) one.
 const [specialistPickerOpen, setSpecialistPickerOpen] = useState(false);
 const noSpecialists = portalSpecialists.length === 0;
 // #5811 — the reply a "what was wrong?" prompt is currently open for, plus
 // the conversation it was flagged in. Captured at open time (not read from
 // `selectedId` on submit) because the client can switch threads while the
 // prompt is still open — submitting against the newly selected conversation
 // would pair conversation B's id with conversation A's message locator and
 // 404. null when no prompt is showing.
 const [flagPrompt, setFlagPrompt] = useState<{
  msg: NativeTranscriptMessage;
  conversationId: string;
 } | null>(null);
 const requestNewThread = useCallback(() => {
  if (noSpecialists) {
   toast.error(
    "No Specialists are assigned to your team yet. Please contact your Account Manager.",
   );
   return;
  }
  if (portalSpecialists.length > 1) {
   setSpecialistPickerOpen(true);
  } else {
   handleNew();
  }
 }, [portalSpecialists, noSpecialists, handleNew, setSpecialistPickerOpen]);

 // Fire the rail's New thread request once, then drop the param so a reload
 // or a back-navigation does not open a second thread. Held until the context
 // resolves: acting on the initial empty specialist list would report the org
 // as having none, and the latch would stop it ever retrying.
 const newThreadRequestedRef = useRef(false);
 useEffect(() => {
  if (!startNewThread || contextLoading || newThreadRequestedRef.current) return;
  newThreadRequestedRef.current = true;
  router.replace("/client/chat");
  requestNewThread();
 }, [startNewThread, contextLoading, router, requestNewThread]);

 const handleRenameThread = useCallback(
  (id: string, name: string) => {
   void renameThread(id, name);
  },
  [renameThread],
 );

 const handleArchiveThread = useCallback(
  async (id: string): Promise<boolean> => {
   // Serialize confirmations — useConfirmDialog keeps only one outstanding
   // resolver, so a second prompt opened while the first is still pending
   // would orphan the first (its promise never settles, its thread never
   // resolves). Ignore re-entrant calls until the current prompt is answered.
   if (archiveConfirmPendingRef.current) return false;
   archiveConfirmPendingRef.current = true;
   try {
    // Resolving stops the specialist working on the thread and can only be
    // undone from the Resolved filter, so guard against accidental clicks /
    // swipes with an explicit confirmation before we flip anything.
    const conv = conversations.find((c) => c.id === id);
    const specialistDisplay =
     (conv?.specialistId
      ? portalContext?.specialists?.find(
       (s) => s.id === conv.specialistId,
      )?.firstName
      : null) ??
     conv?.agentName ??
     "Your specialist";
    const confirmed = await confirm({
     title: "Mark this thread as done?",
     description: `${specialistDisplay} will stop working on this conversation. You can reopen it anytime from the Resolved filter.`,
     confirmText: "Resolve thread",
     cancelText: "Cancel",
     variant: "default",
    });
    if (!confirmed) return false;
    // #4662/#6 — client-judged task success, captured at loop close. A single
    // ✓/✗ (per the product decision); it feeds the Scoreboard-B success rate.
    const wasSuccessful = await confirm({
     title: "Was this task successful?",
     description:
      "A quick answer helps us measure how well your Specialist is doing.",
     confirmText: "Yes, it worked",
     cancelText: "No",
     variant: "default",
    });
    // #4662/#8 — northstar CSAT gate: willingness-to-pay. This is the exit
    // metric ("would have paid" ≥60%), captured as a simple Yes/No at close.
    // The optional 1–10 rating is a deliberate follow-up UI — not asked here,
    // to keep the close flow to a couple of quick taps.
    const wouldHavePaid = await confirm({
     title: "Would you have paid for this?",
     description:
      "Whether the outcome was worth paying for is the single most useful signal for us.",
     confirmText: "Yes, worth paying",
     cancelText: "No",
     variant: "default",
    });
    // If the resolved thread was selected, go back to briefing.
    if (selectedId === id) {
     setSelectedId(null);
     clearMessages();
     router.replace("/client/chat");
    }
    await resolveThread(id, wasSuccessful, wouldHavePaid);
    return true;
   } finally {
    archiveConfirmPendingRef.current = false;
   }
  },
  [
   selectedId,
   router,
   conversations,
   portalContext,
   confirm,
   clearMessages,
   resolveThread,
  ],
 );

 const handleUnarchiveThread = useCallback(
  async (id: string) => {
   await reopenThread(id);
  },
  [reopenThread],
 );

 const handleDismissFromBriefing = useCallback(
  async (id: string) => {
   try {
    await dismissThread(id);
   } catch {
    toast.error("Failed to dismiss conversation. Please try again.");
   }
  },
  [dismissThread],
 );

 const handleSend = async (
  text: string,
  attachments: MessageAttachment[] = [],
  _retryMessage?: OptimisticMessage,
  targetConversationId: string | null = selectedIdRef.current,
  target?: Session,
  clarifyingCardAnswer?: ClarifyingCardAnswer,
 ): Promise<boolean> => {
  const conversationId = target?.id ?? targetConversationId;

  // Compose mode: no session exists yet. The FIRST message mints it — one
  // request runs session/new + the turn — and the real id arrives on the
  // terminal frame. Adopt it (sidebar, selection, URL); on a failure after
  // the mint the thread is still adopted so the retry goes through the
  // normal per-session path; on a failure before it, the compose surface
  // keeps everything.
  if (!conversationId && composeDraftRef.current) {
   const draftSpecialistId = composeDraftRef.current.specialistId;
   try {
    const first = await sendFirstMessage({
     specialistId: draftSpecialistId,
     content: text,
     attachments,
    });
    if (first.sessionId) {
     const adopted: Session = {
      id: first.sessionId,
      orgId,
      specialistId: draftSpecialistId ?? "",
      status: "active",
      createdAt: new Date().toISOString(),
      ...(text.trim()
       ? { subject: truncateAtWordBoundary(text.trim(), 40) }
       : {}),
     };
     setConversations((previous) =>
      previous.some((conversation) => conversation.id === adopted.id)
       ? previous
       : [adopted, ...previous],
     );
     setComposeDraft(null);
     setSelectedId(first.sessionId);
     router.replace(`/client/chat?id=${encodeURIComponent(first.sessionId)}`);
    }
    if (first.sent && first.sessionId) {
     track("message_sent", {
      conversation_id: first.sessionId,
      channel: "web",
      attachment_count: attachments.length,
      is_first_message: true,
      length_bucket: lengthBucket(text.length),
     });
    } else if (!first.sessionId) {
     toast.error(getSendErrorMessage(new Error("send failed")));
    } else {
     toast.error(
      "Your thread was created but the Specialist couldn't respond — retry from the thread.",
     );
    }
    return first.sent;
   } catch (error) {
    if (isNetworkError(error)) network.reportNetworkError();
    if (error instanceof ApiError && error.status === 402) {
     toast.error("Your trial has ended. Redirecting to billing…");
     router.push("/client/settings/billing");
    } else {
     toast.error(getSendErrorMessage(error));
    }
    return false;
   }
  }

  if (
   !conversationId ||
   (!target && selectedIdRef.current !== conversationId)
  ) {
   return false;
  }

  const lastUserMessage = target
   ? undefined
   : [...messagesRef.current]
    .reverse()
    .find((message) => message.role === "user");
  const lastUserMetadata =
   lastUserMessage && !isOptimisticMessage(lastUserMessage)
    ? messageMetadata.find(
     (entry) =>
      entry.locator.sessionId === lastUserMessage.locator.sessionId &&
      entry.locator.messageId === lastUserMessage.locator.messageId,
    )
    : undefined;
  const replyChannel =
   lastUserMetadata?.channel ?? (target ?? selectedConv)?.channel ?? "web";
  const trimmed = text.trim();
  const optimisticSubject =
   trimmed && !(target ?? selectedConv)?.subject
    ? truncateAtWordBoundary(trimmed, 40)
    : null;
  if (optimisticSubject) {
   setConversations((previous) =>
    previous.map((conversation) =>
     conversation.id === conversationId && !conversation.subject
      ? { ...conversation, subject: optimisticSubject }
      : conversation,
    ),
   );
  }

  try {
   const succeeded = await sendMessage({
    conversationId,
    content: text,
    replyChannel,
    attachments,
    clarifyingCardAnswer,
   });
   if (!succeeded) return false;

   track("message_sent", {
    conversation_id: conversationId,
    channel: replyChannel,
    attachment_count: attachments.length,
    is_first_message: !!optimisticSubject,
    length_bucket: lengthBucket(text.length),
   });
   return true;
  } catch (error) {
   if (isNetworkError(error)) network.reportNetworkError();
   if (optimisticSubject) {
    setConversations((previous) =>
     previous.map((conversation) =>
      conversation.id === conversationId &&
       conversation.subject === optimisticSubject
       ? { ...conversation, subject: null }
       : conversation,
     ),
    );
   }
   if (error instanceof ApiError && error.status === 402) {
    toast.error("Your trial has ended. Redirecting to billing…");
    router.push("/client/settings/billing");
   } else {
    toast.error(getSendErrorMessage(error));
   }
   return false;
  }
 };

 const handleRetrySend = (
  msg: OptimisticMessage,
  conversationId: string | null = selectedIdRef.current,
 ) => {
  // Compose-scope echo (conversationId "" — mint never happened): the retry
  // re-runs the first-message flow; the adoption is handled inside the hook.
  if (msg.conversationId === "" && composeDraftRef.current) {
   void retryMessage(msg.localId);
   return;
  }
  if (!conversationId || selectedIdRef.current !== conversationId) return;
  void retryMessage(msg.localId);
 };

 // #4949 — send straight from the briefing. Creates the thread pinned to the
 // Specialist the composer is addressed to, uploads any staged files against
 // the new conversation (uploadChatAttachment needs an {orgId, convId} pair
 // that cannot exist before the thread does), then posts the message.
 //
 // Navigation is deferred until the message actually lands. Moving the user
 // into the thread first (the obvious ordering) unmounts the composer, and the
 // staged File objects live nowhere else — so a failed upload would cost them
 // their attachments and strand them in an empty thread. The thread stays out
 // of the sidebar until a message lands (#2694), so an abandoned attempt
 // leaves nothing behind either way.
 const [composing, setComposing] = useState(false);
 // The briefing composer has no thread to scope the rewrite to, so the org
 // comes from the session — `/composer/rewrite` needs it to meter the LLM call
 // against the right membership.
 const handleComposeRewrite = useMemo(
  () =>
   isExpertViewer
    ? undefined
    : (text: string, tone: RewriteTone) =>
     rewriteDraft(text, tone, orgId ?? undefined),
  [isExpertViewer, orgId],
 );
 const handleComposeSend = useCallback(
  async (
   text: string,
   files: File[],
   specialistId: string | null,
  ): Promise<boolean> => {
   // Returning false means "the composer keeps what it has". Anything short
   // of a delivered message returns false, and because we have not navigated
   // yet the composer is still mounted to receive that answer — still
   // holding the text AND the staged files. Nothing is created up front:
   // mint and first turn are ONE request (sendFirstMessage), so a failed
   // attempt leaves no thread anywhere — not on the server, not in the
   // sidebar.
   if (composingRef.current) return false;
   composingRef.current = true;
   setComposing(true);
   try {
    // Uploads are specialist-keyed (shared inbound AgentFS namespace) and
    // precede the mint. Resolve the specialist the same way the server
    // will at mint time: explicit pick, else the org's only specialist.
    const uploadSpecialistId =
     specialistId ??
     (portalSpecialists.length === 1 ? portalSpecialists[0].id : null);
    let uploaded: MessageAttachment[] = [];
    if (files.length > 0) {
     if (!orgId) {
      throw new Error("This portal session is missing its organization");
     }
     if (!uploadSpecialistId) {
      throw new Error("Choose a Specialist before attaching files");
     }
     uploaded = await Promise.all(
      files.map((file) =>
       uploadChatAttachment(file, {
        orgId,
        specialistId: uploadSpecialistId,
       }),
      ),
     );
    }

    // Clear the previously-viewed thread's messages so the first-message
    // optimistic echo lands on an empty list.
    clearMessages();
    const first = await sendFirstMessage({
     specialistId: uploadSpecialistId,
     content: text,
     attachments: uploaded,
    });
    if (first.sessionId) {
     const adopted: Session = {
      id: first.sessionId,
      orgId,
      specialistId: uploadSpecialistId ?? "",
      status: "active",
      createdAt: new Date().toISOString(),
      ...(text.trim()
       ? { subject: truncateAtWordBoundary(text.trim(), 40) }
       : {}),
     };
     setConversations((previous) =>
      previous.some((conversation) => conversation.id === adopted.id)
       ? previous
       : [adopted, ...previous],
     );
     // Delivered (or minted-then-failed, which is still a real thread) —
     // move the user into it.
     setComposeDraft(null);
     setSelectedId(first.sessionId);
     router.replace(`/client/chat?id=${encodeURIComponent(first.sessionId)}`);
     return first.sent;
    }
    if (!first.sent) {
     // Nothing minted — drop the compose-scope echo so a later compose view
     // doesn't show a stray failed bubble; the briefing composer still holds
     // the text and files for the retry.
     clearMessages();
     toast.error(
      "Couldn't send that. Your message and attachments are still here — try again.",
     );
    }
    return first.sent;
   } catch (err) {
    toast.error(
     err instanceof Error &&
      err.message.includes("exceeds the 25 MB limit")
      ? err.message
      : "Couldn't send that. Your message and attachments are still here — try again.",
    );
    return false;
   } finally {
    composingRef.current = false;
    setComposing(false);
   }
  },
  // eslint-disable-next-line react-hooks/exhaustive-deps
  [router, clearMessages, sendFirstMessage, portalSpecialists, orgId],
 );

 // P4.4 (R4.7): optimistic 👍/👎 on a Specialist reply. Flip locally first,
 // POST, roll back + toast on failure. Re-clicking the active vote toggles it
 // off (DELETE not implemented server-side yet, so an "off" is best-effort
 // local — a subsequent vote overwrites via upsert).
 //
 // #5811 (D1 v3 Option A): turning a "down" vote ON opens a lightweight
 // prompt for what was wrong before submitting — the backend opens an Expert
 // re-review item on every down-vote regardless, so this is the client's one
 // chance to leave the Expert context. "up" and toggling a vote back off
 // submit immediately, unchanged.
 const handleFeedback = (
  msg: NativeTranscriptMessage,
  sentiment: "up" | "down",
 ) => {
  if (!selectedId) return;
  const prevValue =
   messageMetadata.find(
    (entry) =>
     entry.locator.sessionId === msg.locator.sessionId &&
     entry.locator.messageId === msg.locator.messageId,
   )?.feedback ?? null;
  const nextValue = prevValue === sentiment ? null : sentiment;

  if (sentiment === "down" && nextValue === "down") {
   setFlagPrompt({ msg, conversationId: selectedId });
   return;
  }

  const nextFeedback =
   nextValue === null ? null : nextValue === "up" ? "positive" : "negative";
  void submitFeedback(selectedId, msg, nextFeedback).catch(() => {
   toast.error("Couldn't save your feedback. Please try again.");
  });
 };

 const handleFlagReplySubmit = (comment: string) => {
  const prompt = flagPrompt;
  setFlagPrompt(null);
  if (!prompt) return;
  const { msg, conversationId } = prompt;
  void submitFeedback(
   conversationId,
   msg,
   "negative",
   comment.trim() || undefined,
  ).catch(() => {
   toast.error("Couldn't save your feedback. Please try again.");
  });
 };

 /**
  * #4907 — "Talk to a human".
  *
  * The reply the client sees is the Specialist's real message, which arrives
  * over the `agent_message_sent` socket like any other, so nothing is
  * optimistically appended here — doing so would duplicate the bubble a
  * moment later. Only the button state flips locally.
  *
  * `already_requested` is treated as success: to the client, asking twice is
  * one ask, and the server has deliberately not messaged them a second time.
  */
 const handleRequestHuman = async () => {
  if (!selectedId || requestingHumanIds.has(selectedId)) return;
  try {
   const result = await requestHuman(selectedId);
   if (result.outcome === "disabled") {
    toast.error("Expert escalation isn't available on this account.");
    return;
   }
   if (result.outcome === "escalated") {
    toast.success("Sent to a human expert — they'll reply here.");
   }
  } catch {
   toast.error("Couldn't reach an expert just now. Please try again.");
  }
 };

 const handleSendNote = async (text: string) => {
  if (!selectedId) return;
  try {
   await sendInternalNote(selectedId, text);
  } catch {
   toast.error("Failed to add internal note.");
  }
 };

 const handleStatusChange = useCallback(
  async (convId: string, status: string) => {
   setResolvingId(convId);
   try {
    await updateStatus(
     convId,
     status as "pending" | "awaiting_client" | "resolved" | "snoozed",
    );
   } catch {
    toast.error("Failed to update status.");
   } finally {
    setResolvingId(null);
   }
  },
  [setResolvingId, updateStatus],
 );

 const handleAssignToMe = useCallback(
  async (convId: string) => {
   try {
    await assignToMe(convId);
   } catch {
    toast.error("Failed to assign conversation.");
   }
  },
  [assignToMe],
 );

 // Hold the conversation view behind a skeleton until context is loaded, so no
 // default name (Amy, H, h.work) is ever painted. It draws the thread column,
 // which is why it is scoped to the surfaces that have one: desktop Home has
 // none, its greeting comes off the token rather than the wire, and everything
 // else it shows is already held by `isLoaded` below — so gating it here only
 // meant three seconds of somebody else's chrome.
 if (contextLoading && (isMobile || selectedId)) {
  return (
   <div
    style={{
     display: "flex",
     height: "100dvh", // Use dvh for mobile browser chrome support
     background: "var(--bg-canvas)",
     overflow: "hidden",
    }}
   >
    <div
     style={{
      width: 280,
      borderRight: "1px solid var(--border)",
      background: "var(--bg-sidebar)",
      display: "flex",
      flexDirection: "column",
      gap: 12,
      padding: 16,
     }}
    >
     <div
      style={{
       height: 22,
       width: "70%",
       background: "var(--border)",
       borderRadius: 4,
       animation: "pulse 1.5s ease-in-out infinite",
      }}
     />
     <div
      style={{
       height: 12,
       width: "40%",
       background: "var(--border)",
       borderRadius: 4,
       opacity: 0.6,
      }}
     />
     {[1, 2, 3].map((i) => (
      <div
       key={i}
       style={{
        display: "flex",
        gap: 10,
        alignItems: "center",
        padding: "8px 0",
       }}
      >
       <div
        style={{
         width: 36,
         height: 36,
         borderRadius: "50%",
         background: "var(--border)",
         flexShrink: 0,
        }}
       />
       <div style={{ flex: 1 }}>
        <div
         style={{
          height: 12,
          width: "60%",
          background: "var(--border)",
          borderRadius: 4,
          marginBottom: 6,
         }}
        />
        <div
         style={{
          height: 10,
          width: "80%",
          background: "var(--border)",
          borderRadius: 4,
          opacity: 0.6,
         }}
        />
       </div>
      </div>
     ))}
    </div>
    <div style={{ flex: 1, background: "var(--bg-canvas)" }} />
   </div>
  );
 }

 return (
  <>
   <ConfirmDialogComponent />
   <OfflineBanner
    isOnline={network.isOnline}
    hasNetworkError={network.hasNetworkError}
   />
   <div
    className="hw-portal-shell"
    style={{
     display: "flex",
     height: "100dvh", // Use dvh for mobile browser chrome support
     background: "var(--bg-canvas)",
     overflow: "hidden",
    }}
   >
    <MobileChatLayout
     isMobile={isMobile}
     selectedId={selectedId}
     composeOpen={composeOpen}
     sidebar={
      // Home has no thread column in the design — the board carries its
      // own thread table — so the column belongs to the conversation view.
      // Mobile still needs it either way: it is the list half of the
      // list/detail pattern, and hiding it would strand the client.
      !isMobile && !selectedId && !composeOpen ? null : !isMobile && sidebarCollapsed ? (
       /* #5636: collapsed state. The full sidebar is fully hidden; a slim
          rail keeps the reopen control on screen so main content never
          overlaps it and the sidebar can be brought back from anywhere in
          the shell. */
       <div
        className="hw-portal-sidebar-rail"
        style={{
         width: 44,
         minWidth: 44,
         flexShrink: 0,
         borderRight: "1px solid var(--border)",
         background: "var(--bg-sidebar)",
         display: "flex",
         flexDirection: "column",
         alignItems: "center",
         paddingTop: (PORTAL_HEADER_HEIGHT - 30) / 2,
        }}
       >
        <SidebarCollapseToggle
         collapsed={true}
         onToggle={() => setSidebarCollapsed(false)}
        />
       </div>
      ) : (
       <div
        className="hw-portal-sidebar"
        style={portalSidebarColumnStyle(isMobile)}
       >
        <Sidebar
         conversations={conversations}
         selectedId={selectedId}
         onSelect={handleSelect}
         onNew={requestNewThread}
         onGoHome={handleGoHome}
         noSpecialists={noSpecialists}
         onRenameThread={handleRenameThread}
         onArchiveThread={handleArchiveThread}
         onUnarchiveThread={handleUnarchiveThread}
         onMarkReadThread={clearThreadUnread}
         portalContext={portalContext}
         createError={createError}
         unreadThreads={unreadThreads}
         threadsUnavailable={threadsUnavailable}
         onOpenSpecialistSheet={() => setSpecialistSheetOpen(true)}
         onRefresh={refetchThreads}
         onCollapse={() => setSidebarCollapsed(true)}
        />
       </div>
      )
     }
     main={
      <div
       className="hw-portal-main"
       style={{
        flex: 1,
        display: "flex",
        flexDirection: "column",
        minWidth: 0,
       }}
      >
       <TrialBanner />
       {onboardingCall ? (
        <OnboardingCallView
         assignmentId={onboardingCall.assignmentId}
         specialistFirstName={onboardingCall.specialistFirstName}
         onClose={() => setOnboardingCall(null)}
         onCompleted={() => setOnboardingDone(true)}
        />
       ) : videoCall ? (
        /* userId is always defined by this point — VideoCallView is only
   mounted after onStartVideoCall fires, which guards specialist ≠ null.
   The setVideoCall call site also checks userId via the guard below. */
        <VideoCallView
         clientId={userId!}
         specialistFirstName={videoCall.specialistFirstName}
         specialistAvatarUrl={videoCall.specialistAvatarUrl}
         onClose={() => setVideoCall(null)}
        />
       ) : (selectedConv ?? composeView) ? (
        <ChatPanel
         conversation={(selectedConv ?? composeView) as Session}
         messages={messages}
         messageMetadata={messageMetadata}
         loadingMessages={loadingMsgs}
         messagesError={messageError}
         onRetryLoadMessages={() => {
          const active = selectedConv ?? composeView;
          if (active?.id) void loadMessages(active.id);
         }}
         onSend={handleSend}
         onSendNote={handleSendNote}
         sending={sending}
         streamingReply={streamingReply}
         onBack={handleGoHome}
         portalContext={portalContext}
         platformRole={platformRole}
         userId={userId}
         onStatusChange={handleStatusChange}
         onArchive={handleArchiveThread}
         onAssignToMe={handleAssignToMe}
         onUnarchive={handleUnarchiveThread}
         resolvingId={resolvingId}
         onRetry={handleRetrySend}
         onFeedback={handleFeedback}
         onClarifyingCardSubmit={async (answer, summaryText) => {
          if (!selectedConv) return false;
          const ok = await handleSend(
           summaryText,
           [],
           undefined,
           selectedConv.id,
           undefined,
           answer,
          );
          // Lock THIS card (by id) only once its answer actually sent; a failed
          // send stays retryable (the failed message shows a retry too).
          if (ok !== false) markCardAnswered(answer.cardId);
          return ok;
         }}
         answeredCardIds={answeredCardIds}
         onOpenThreadSheet={() => setThreadSheetOpen(true)}
         onOpenSpecialistSheet={() => setSpecialistSheetOpen(true)}
         onRequestHuman={handleRequestHuman}
         humanRequested={selectedConv ? humanRequestedIds.has(selectedConv.id) : false}
         requestingHuman={requestingHuman}
        />
       ) : selectedId && (!convsLoaded || loadingMsgs) ? (
        <div
         style={{
          flex: 1,
          display: "flex",
          flexDirection: "column",
          height: "100%",
          minWidth: 0,
         }}
        >
         {/* Mobile back header — mirrors ChatPanel header */}
         {isMobile && (
          <div
           style={{
            padding: `0 ${THREAD_HORIZONTAL_INSET}px 0 ${MOBILE_THREAD_HEADER_LEFT_INSET}px`,
            borderBottom: "1px solid var(--border)",
            display: "flex",
            alignItems: "center",
            gap: 10,
            flexShrink: 0,
            background: "var(--bg-surface)",
            height: PORTAL_HEADER_HEIGHT,
           }}
          >
           <button
            type="button"
            onClick={handleGoHome}
            aria-label="Back to thread list"
            title="Back to thread list"
            style={{
             background: "transparent",
             border: "none",
             padding: "4px 6px",
             cursor: "pointer",
             color: "var(--text-muted)",
             display: "flex",
             alignItems: "center",
             flexShrink: 0,
             borderRadius: 6,
             fontFamily: "inherit",
            }}
           >
            <ChevronLeft size={20} />
           </button>
           <span
            style={{
             fontSize: 14,
             fontWeight: 600,
             color: "var(--text)",
             overflow: "hidden",
             textOverflow: "ellipsis",
             whiteSpace: "nowrap",
            }}
           >
            Loading...
           </span>
          </div>
         )}
         <div
          style={{
           flex: 1,
           overflowY: "auto",
           padding: "16px 18px",
           display: "flex",
           flexDirection: "column",
           gap: 10,
          }}
         >
          <MessageHistorySkeleton />
         </div>
        </div>
       ) : (
        <ExtractedBriefingPanel
         conversations={briefingConversations}
         isLoaded={convsLoaded && !contextLoading}
         threadsUnavailable={threadsUnavailable}
         portalContext={portalContext}
         onSelect={handleSelect}
         onDismiss={handleDismissFromBriefing}
         onNew={requestNewThread}
         onNewWithSpecialist={(specialistId) =>
          handleNew(specialistId)
         }
         onSuggestion={handleSuggestion}
         onComposeSend={handleComposeSend}
         onComposeRewrite={handleComposeRewrite}
         composing={composing}
         createError={createError}
         userFirstName={userFirstName}
         portalSpecialists={portalSpecialists}
         hideOnboardCard={onboardingDone}
         onStartCall={(assignmentId, name) =>
          setOnboardingCall({
           assignmentId,
           specialistFirstName: name,
          })
         }
         onStartVideoCall={(specialistId) => {
          // Guard: session must be fully resolved (userId set) before we
          // can call POST /clients/:clientId/video-calls.
          if (!userId) return;
          // The rail decides who the call is for and says so; the
          // canonical Specialist response supplies that identity.
          const specialist = portalSpecialists.find(
           (s) => s.id === specialistId,
          );
          if (!specialist) return;
          // AC §12 — analytics event on video call start
          try {
           (
            window as Window & {
             analytics?: {
              track: (
               event: string,
               props?: Record<string, unknown>,
              ) => void;
             };
            }
           ).analytics?.track("video_call_started", {
            specialistId: specialist.id,
            specialistName: specialist.firstName,
            clientId: userId,
           });
          } catch {
           // analytics is best-effort — never block
          }
          setVideoCall({
           specialistFirstName: specialist.firstName,
           specialistAvatarUrl: specialist.avatarUrl ?? undefined,
          });
         }}
         BriefingEmpty={BriefingEmpty}
         SpecialistCarousel={DashboardSpecialistCarousel}
        />
       )}
      </div>
     }
    />

    {/* #804 / #4356: the client-facing reconnect pill was removed per Eusden
          — the websocket reconnects transparently and the badge was noise for
          clients. Experts keep their own indicator on the workspace surface
          (unaffected — that surface doesn't use PortalChat). */}

    {/* W-1: Specialist picker for new threads (multi-Specialist orgs only). */}
    {specialistPickerOpen && (
     <NewThreadSpecialistPicker
      specialists={portalSpecialists}
      onPick={(id) => {
       setSpecialistPickerOpen(false);
       handleNew(id);
      }}
      onClose={() => setSpecialistPickerOpen(false)}
     />
    )}

    {/* #5811: "what was wrong?" prompt on a fresh thumbs-down. */}
    {flagPrompt && (
     <FlagReplyModal onSubmit={handleFlagReplySubmit} />
    )}

    {/* Mobile thread switcher bottom sheet */}
    {isMobile && (
     <MobileThreadSheet
      open={threadSheetOpen}
      onClose={() => setThreadSheetOpen(false)}
      threadsUnavailable={threadsUnavailable}
      conversations={conversations}
      selectedId={selectedId}
      onSelect={handleSelect}
      onNew={requestNewThread}
      noSpecialists={noSpecialists}
      onRenameThread={handleRenameThread}
      onArchiveThread={handleArchiveThread}
      onUnarchiveThread={handleUnarchiveThread}
      onMarkReadThread={clearThreadUnread}
      portalContext={portalContext}
      unreadThreads={unreadThreads}
     />
    )}

    {/* Specialist profile sheet. Opened from the chat-header avatar (mobile
          only) OR any Specialist message-bubble avatar (#4340). Rendered on
          both mobile and desktop — the bubble-avatar entry point is the desktop
          affordance, since the header avatar button is mobile-only CSS. */}
    <MobileSpecialistSheet
     open={specialistSheetOpen}
     onClose={() => setSpecialistSheetOpen(false)}
     specialist={
      selectedConv?.specialistId
       ? (portalContext?.specialists?.find(
        (s) => s.id === selectedConv.specialistId,
       ) ?? null)
       : (portalContext?.specialists?.find((s) => s.isPrimary) ??
        portalContext?.specialists?.[0] ??
        null)
     }
     onStartVideoCall={() => {
      // Guard: session must be fully resolved (userId set) before we
      // can call POST /clients/:clientId/video-calls.
      if (!userId) return;
      const specialist = selectedConv?.specialistId
       ? portalContext?.specialists?.find(
        (s) => s.id === selectedConv.specialistId,
       )
       : (portalContext?.specialists?.find((s) => s.isPrimary) ??
        portalContext?.specialists?.[0]);
      if (!specialist) return;
      // AC §12 — analytics event on video call start
      try {
       (
        window as Window & {
         analytics?: {
          track: (
           event: string,
           props?: Record<string, unknown>,
          ) => void;
         };
        }
       ).analytics?.track("video_call_started", {
        specialistId: specialist.id,
        specialistName: specialist.firstName,
        clientId: userId,
       });
      } catch {
       // analytics is best-effort — never block
      }
      setVideoCall({
       specialistFirstName: specialist.firstName,
       specialistAvatarUrl: specialist.avatarUrl ?? undefined,
      });
     }}
    />

    {/* Mobile-only: show thread-switcher button and specialist avatar in chat header */}
    <style>{`
        @media (max-width: 767px) {
          .hw-thread-switcher {
            display: inline-flex !important;
          }
          .hw-specialist-avatar-btn {
            display: inline-flex !important;
          }
          .hw-chat-back {
            display: inline-flex !important;
          }
        }

        /* Landscape phone: hide sidebar and show full-width chat */
        @media (orientation: landscape) and (max-height: ${LANDSCAPE_PHONE_MAX_HEIGHT}px) {
          /* Hide only the desktop sidebar (direct child of shell), not mobile-wrapped sidebar */
          .hw-portal-shell > .hw-portal-sidebar,
          .hw-portal-shell > .hw-portal-sidebar-rail {
            display: none !important;
          }
          .hw-portal-main {
            width: 100% !important;
            flex: 1 1 100% !important;
            min-width: 0 !important;
          }
          /* Show mobile UI elements */
          .hw-thread-switcher {
            display: inline-flex !important;
          }
          .hw-specialist-avatar-btn {
            display: inline-flex !important;
          }
          [data-mobile-back="true"] {
            display: flex !important;
          }
        }
      `}</style>
   </div>
  </>
 );
}
