/**
 * The Piggy workspace: history, the conversation, and the evidence beside it.
 *
 * Three columns on a wide screen, and the interesting decisions are all about
 * what happens when there are not three columns' worth of room. In order of
 * what gives way first:
 *
 *   ≥ 1536  history rail, conversation, activity rail. The activity rail is the
 *           last thing added because it is the least urgent of the three: it
 *           says what has already happened.
 *   ≥ 1280  history and conversation. Activity moves into a sheet, on a button
 *           in the header, because a third column here leaves the middle one at
 *           about 420px — narrower than the phone layout, on the pane the whole
 *           screen exists to show.
 *   ≥ 1024  the history rail collapses to initials by default, which buys the
 *           conversation 230px and keeps the threads reachable in one click.
 *   < 1024  one column. Both rails become sheets on header buttons, the
 *           composer keeps the floor of the box, and nothing is stacked above
 *           the transcript except the header — two rows on a phone held
 *           upright, one on any screen under 560px tall, because there the
 *           second row is a fifth of everything the reader came for.
 *
 * The transcript, the composer and the approval cards are `PiggyChatPanel` —
 * the same component the dock and the phone drawer use — so this file is a
 * layout and a set of decisions about conversations, not a second chat client.
 */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
import { toast } from 'sonner';
import {
  AlertTriangle,
  History,
  PanelLeftClose,
  PanelLeftOpen,
  PanelRight,
  PanelRightClose,
  SquarePen,
} from 'lucide-react';
import type { PiggyChatContext } from '@pig/core';
import { get, post } from '@/lib/api';
import { useHasVerticalRoom, useIsMobile, useMediaQuery } from '@/hooks/use-media-query';
import { piggyCopy, piggyLine } from '@/lib/piggy-copy';
import { usePiggyContext } from '@/lib/piggy-context';
import type { PiggyConversation, TranscriptMessage } from '@/lib/piggy-chat';
import { PiggyChatPanel, PiggyUnavailable, usePiggyStatus } from '@/components/PiggyChat';
import { PiggyConversationList } from '@/components/piggy/conversation-list';
import { Button, EmptyState, Skeleton, cn } from '@/components/ui';
import {
  Sheet,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
} from '@/components/ui/sheet';
import { PiggyControls, usePiggyChatSession, type PiggyControlsState } from './controls';
import { PiggyWorkspaceRail } from './evidence';
import { PiggyWorkspaceStarters } from './starters';
import { toTranscript, type StoredPiggyConversation } from './stored-transcript';

/**
 * The context every turn from this page carries.
 *
 * A constant, not an inline object: `usePiggyContext` re-publishes whenever the
 * value changes identity, and the page tool this resolves to
 * (`pig_get_workspace_summary`) is fixed for the whole workspace.
 */
const WORKSPACE_CONTEXT: PiggyChatContext = { type: 'page', route: '/piggy' };

/** Where the activity rail earns a column of its own rather than a sheet. */
const ACTIVITY_COLUMN_BREAKPOINT = 1536;

/** Where the history rail is worth showing expanded by default. */
const WIDE_HISTORY_BREAKPOINT = 1280;

/**
 * Tailwind `sm`. Above it the title row has room for the mode and model
 * controls beside the thread's name; below it, it has not.
 */
const SHARED_TITLE_ROW_BREAKPOINT = 640;

const HISTORY_STORAGE_KEY = 'pig.piggy.workspace.history';
const ACTIVITY_STORAGE_KEY = 'pig.piggy.workspace.activity';

/** The conversation being read, as a URL — so a run in the ledger can link to it. */
const CONVERSATION_PARAM = 'conversation';

export function PiggyWorkspace() {
  const status = usePiggyStatus();
  usePiggyContext(WORKSPACE_CONTEXT);

  const isMobile = useIsMobile();
  const hasActivityColumn = useMediaQuery(`(min-width: ${ACTIVITY_COLUMN_BREAKPOINT}px)`);
  const wideHistory = useMediaQuery(`(min-width: ${WIDE_HISTORY_BREAKPOINT}px)`);

  const [historyExpanded, setHistoryExpanded] = useStoredFlag(HISTORY_STORAGE_KEY, wideHistory);
  const [activityOpen, setActivityOpen] = useStoredFlag(ACTIVITY_STORAGE_KEY, hasActivityColumn);
  const [historySheet, setHistorySheet] = useState(false);

  const [params, setParams] = useSearchParams();
  const activeId = params.get(CONVERSATION_PARAM);
  /**
   * Bumped by "New conversation" so the thread below is rebuilt even when the
   * URL does not change — pressing New twice must give you two fresh threads,
   * not one thread and a control that appears to be broken.
   */
  const [newThread, setNewThread] = useState(0);
  const [pendingAsk, setPendingAsk] = useState<{ id: string; message: string } | null>(null);
  const [running, setRunning] = useState(false);
  const queryClient = useQueryClient();

  const select = useCallback(
    (id: string) => {
      // Replaced rather than pushed: reading four threads should not put four
      // entries in the history stack for Back to walk out through.
      setParams({ [CONVERSATION_PARAM]: id }, { replace: true });
      setHistorySheet(false);
    },
    [setParams],
  );

  const startNew = useCallback(() => {
    setParams({}, { replace: true });
    setNewThread((count) => count + 1);
    setHistorySheet(false);
  }, [setParams]);

  /**
   * A conversation is created by asking the first question, not by pressing New.
   *
   * The row's title is derived server-side from that first message, so creating
   * eagerly would fill the sidebar with rows called "New conversation" every
   * time somebody opened the page and thought better of it.
   */
  const created = useCallback(
    (conversation: StoredPiggyConversation, message: string) => {
      // Seeded so the detail query below answers from cache: without it, the
      // thread would remount into a loading skeleton for the length of a round
      // trip, immediately after the user pressed send.
      queryClient.setQueryData(conversationKey(conversation.id), conversation);
      void queryClient.invalidateQueries({ queryKey: ['piggy', 'conversations'] });
      setPendingAsk({ id: conversation.id, message });
      setParams({ [CONVERSATION_PARAM]: conversation.id }, { replace: true });
    },
    [queryClient, setParams],
  );

  const detail = useQuery({
    queryKey: conversationKey(activeId ?? ''),
    queryFn: () => get<StoredPiggyConversation>(`/api/piggy/conversations/${activeId}`),
    enabled: Boolean(activeId),
    /*
     * Always read fresh. The relay appends every turn to this conversation, so
     * a cached copy taken before the last three questions is a transcript with
     * three turns missing — which is what "come back to a thread you were just
     * in" looks like. Refetching under a live conversation is safe because the
     * hook stops adopting the seed the moment this session sends anything (see
     * `touched` in usePiggyConversation).
     */
    staleTime: 0,
    retry: false,
  });

  // Stable while the fetch's payload is, so the thread's seed effect is not
  // handed a new array on every parent render.
  const storedTranscript = useMemo(
    () => (detail.data ? toTranscript(detail.data.messages) : undefined),
    [detail.data],
  );

  if (status.isLoading) {
    return (
      <div className="flex h-full min-h-0 flex-col gap-3 p-4">
        <Skeleton className="h-12 rounded-xl" />
        <Skeleton className="min-h-0 flex-1 rounded-xl" />
      </div>
    );
  }
  if (!status.data?.canUse) {
    return (
      <div className="flex h-full min-h-0 items-center justify-center p-6">
        <PiggyUnavailable status={status.data} />
      </div>
    );
  }

  const list = (
    <PiggyConversationList
      activeId={activeId}
      onSelect={select}
      onNew={startNew}
      collapsed={!historyExpanded}
      runningId={running ? activeId : null}
    />
  );

  return (
    /*
     * The thread is FIRST in the DOM and second on screen.
     *
     * Measured from a cold load of /piggy: reaching the composer cost 72 Tab
     * presses, 38 of them the conversation rail — nineteen rows, each with its
     * own action button — and "Skip to content" landed immediately BEFORE the
     * rail rather than after it, so the one affordance built to fix this walked
     * straight into the thing that caused it. It grows with use: 123
     * conversations exist in this workspace already.
     *
     * Reordering rather than removing tab stops, because every one of those
     * stops is a control somebody needs: the rename and delete actions are
     * reachable by keyboard only through the per-row button, and taking it out
     * of tab order would trade a long walk for a dead end. `order` is the same
     * instrument the delete dialog already uses to put the destructive action
     * first in the DOM and second on screen — declared reading order and
     * declared visual order are allowed to differ, and this is what for.
     *
     * It also puts the transcript before the navigation for a screen reader,
     * which is the arrangement skip links exist to approximate.
     */
    <div className="flex h-full min-h-0 w-full overflow-hidden">
      <PiggyWorkspaceThread
        key={activeId ?? `new-${newThread}`}
        conversationId={activeId}
        title={detail.data?.title ?? null}
        initialMessages={storedTranscript}
        loading={Boolean(activeId) && detail.isLoading}
        loadError={detail.isError ? detail.error : null}
        autoSend={pendingAsk?.id === activeId ? pendingAsk.message : undefined}
        onAutoSent={() => setPendingAsk(null)}
        onCreated={created}
        onRunningChange={setRunning}
        onNew={startNew}
        onOpenHistory={() => setHistorySheet(true)}
        historyExpanded={historyExpanded}
        onToggleHistory={() => setHistoryExpanded(!historyExpanded)}
        showHistoryToggle={!isMobile}
        activityOpen={hasActivityColumn && activityOpen}
        onToggleActivity={() => setActivityOpen(!activityOpen)}
        activityInColumn={hasActivityColumn}
      />

      {isMobile ? null : (
        <aside
          className={cn(
            // `-order-1` puts it back on the left. See the note above.
            '-order-1 flex h-full min-h-0 shrink-0 border-r border-border bg-surface',
            // The rail sets its own 3.75rem when collapsed; only the expanded
            // width is the parent's to decide.
            historyExpanded && 'w-[17rem]',
          )}
          aria-label="Piggy conversations"
        >
          {list}
        </aside>
      )}

      {/* Both workspace sheets are 20rem — the same width as the activity
          column beside the transcript. Two overlays on one screen at 19 and
          21rem is a difference nobody can name and everybody can see. */}
      <Sheet open={historySheet} onOpenChange={setHistorySheet}>
        <SheetContent side="left" className="flex w-[20rem] flex-col p-0 sm:max-w-none">
          {/* Visible, not `sr-only`. A sheet that slides in over the
              transcript with no name on it asks the reader to work out what
              they opened from the contents. `pr-14` keeps the sentence clear
              of the dismiss control the primitive pins to the corner. */}
          <SheetHeader band className="pr-14">
            <SheetTitle>Conversations</SheetTitle>
            <SheetDescription>Pick one up from where it stopped.</SheetDescription>
          </SheetHeader>
          <div className="min-h-0 flex-1">{isMobile ? list : null}</div>
        </SheetContent>
      </Sheet>

    </div>
  );
}

// ------------------------------------------------------------------- thread

function PiggyWorkspaceThread({
  conversationId,
  title,
  initialMessages,
  loading,
  loadError,
  autoSend,
  onAutoSent,
  onCreated,
  onRunningChange,
  onNew,
  onOpenHistory,
  historyExpanded,
  onToggleHistory,
  showHistoryToggle,
  activityOpen,
  onToggleActivity,
  activityInColumn,
}: {
  conversationId: string | null;
  title: string | null;
  initialMessages?: TranscriptMessage[];
  loading: boolean;
  loadError: unknown;
  autoSend?: string;
  onAutoSent: () => void;
  onCreated: (conversation: StoredPiggyConversation, message: string) => void;
  onRunningChange: (running: boolean) => void;
  onNew: () => void;
  onOpenHistory: () => void;
  historyExpanded: boolean;
  onToggleHistory: () => void;
  showHistoryToggle: boolean;
  activityOpen: boolean;
  onToggleActivity: () => void;
  activityInColumn: boolean;
}) {
  const isMobile = useIsMobile();
  /*
   * Whether this viewport can afford a second header row.
   *
   * `useIsMobile` is width-only, so a phone in landscape and a phone with the
   * keyboard up both got the two-row header built for a 393x852 portrait
   * screen — and were left with a measured 40px of transcript on the pane the
   * whole page exists to show. Below 560px tall the controls fold back onto
   * the title row, where they still fit because a landscape phone is wide.
   */
  const hasHeaderRoom = useHasVerticalRoom();
  /**
   * Whether the title row is wide enough to carry the controls as well.
   *
   * Folding them up is only an improvement where there is width to fold into:
   * a landscape phone has 852px and loses nothing, a portrait phone with the
   * keyboard up has 393px and would crush the thread's name to four
   * characters to save one row. That screen keeps the second row.
   *
   * Measured, and left alone deliberately: the controls `flex-wrap`, so forcing
   * them onto a 393px title row beside three 44px icon buttons does not fold
   * anything — it wraps them and rebuilds the same two rows with a crushed
   * title as well. The transcript budget on that screen is reclaimed from the
   * composer and this header's own padding instead; see below and `PiggyChat`.
   */
  const canShareTitleRow = useMediaQuery(`(min-width: ${SHARED_TITLE_ROW_BREAKPOINT}px)`);
  const { conversation, controls } = usePiggyChatSession({
    context: WORKSPACE_CONTEXT,
    initialMessages,
    initialConversationId: conversationId ?? undefined,
  });
  const [creating, setCreating] = useState(false);
  /*
   * The rail as an overlay, below the width where it earns a column. It lives
   * here rather than beside the history sheet in the parent because its first
   * tab describes THIS conversation, and the parent has no transcript to
   * describe — a sheet opened from up there would tell a phone user in the
   * middle of a conversation that nothing had been asked yet.
   */
  const [activitySheet, setActivitySheet] = useState(false);
  /** A write opener waiting for the mode it needs. See `askWithChange`. */
  const [escalating, setEscalating] = useState<string | null>(null);
  const autoSent = useRef(false);

  const { running, send: sendTurn, messages } = conversation;

  useEffect(() => {
    onRunningChange(running);
    // The rail's running dot belongs to whichever thread is on screen, so the
    // flag has to be lowered when this one is replaced as well as when its turn
    // ends — otherwise switching conversations mid-answer leaves a dot spinning
    // on a thread nothing is running in.
    return () => onRunningChange(false);
  }, [running, onRunningChange]);

  /**
   * Send, creating the stored conversation first when this is the first thing
   * said in it.
   *
   * The order is forced by the server: the row's title comes from the opening
   * message, and the id has to exist before the turn is streamed so that the
   * relay continues the same conversation the sidebar is listing.
   */
  const ask = useCallback(
    (text?: string, from?: TranscriptMessage[]) => {
      const message = (text ?? conversation.draft).trim();
      if (!message || running || creating) return;
      // A thread that already has messages but no stored id is one whose
      // creation failed. Creating now would strand everything above on a page
      // that is about to remount, so it stays unsaved for the rest of its life.
      if (conversationId || messages.length) {
        sendTurn(text, from);
        return;
      }
      setCreating(true);
      post<StoredPiggyConversation>('/api/piggy/conversations', { firstMessage: message })
        .then((created) => onCreated(created, message))
        .catch(() => {
          // The question is worth more than the filing. Piggy answers, the
          // relay mints its own conversation id, and only the history entry is
          // lost — which is what the toast says rather than implying the turn
          // failed.
          toast.error('Piggy could not save this to your history. The answer below is not filed.');
          sendTurn(text, from);
        })
        .finally(() => setCreating(false));
    },
    [conversation.draft, conversationId, creating, messages.length, onCreated, running, sendTurn],
  );

  /**
   * The opening question of a conversation created a moment ago.
   *
   * Scheduled rather than sent inline, and cancelled by this effect's own
   * cleanup. A send started from an effect body outlives the mount that started
   * it: React's StrictMode mounts, runs effects, tears them down and mounts
   * again, and `usePiggyConversation` aborts its stream on unmount — so the
   * first thing anyone saw after asking the very first question of a new
   * conversation was their own question with "Stopped" under it, and a real
   * turn spent to get there. Deferring by a tick means the throwaway pass
   * cancels a timer instead of a request.
   */
  useEffect(() => {
    if (!autoSend || autoSent.current) return;
    const timer = setTimeout(() => {
      autoSent.current = true;
      sendTurn(autoSend);
      onAutoSent();
    }, 0);
    return () => clearTimeout(timer);
  }, [autoSend, onAutoSent, sendTurn]);

  /**
   * A write opener pressed while Piggy is in Read only.
   *
   * The mode has to be committed before the turn leaves, because `send` reads
   * it off the conversation — so the text is parked here and sent by the effect
   * below once the conversation is actually holding the new mode. Sending in
   * the same tick would ask Piggy to change something with the write tools
   * still withheld, and the answer would be a polite refusal.
   */
  const askWithChange = useCallback(
    (text: string) => {
      if (!controls.canWrite) return;
      if (conversation.mode === 'read_only') {
        controls.setMode('confirm');
        setEscalating(text);
        return;
      }
      ask(text);
    },
    [ask, controls, conversation.mode],
  );

  useEffect(() => {
    if (escalating === null) return;
    if (conversation.mode === 'read_only') return;
    setEscalating(null);
    ask(escalating);
  }, [ask, escalating, conversation.mode]);

  const busy = running || creating;

  const controlsRow = (
    <PiggyControls controls={controls} compact disabled={busy} />
  );
  /** The controls take a row of their own only on a tall, narrow screen. */
  const controlsOnSecondRow = isMobile && (hasHeaderRoom || !canShareTitleRow);

  return (
    <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
      {/* `lg:px-4` puts the first glyph about 30px from the pane edge, which is
          the CRM's own `lg:px-8` content inset once the icon button's optical
          padding is counted. The workspace is allowed a toolbar; it is not
          allowed to start 8px from an edge every other page starts 32px from. */}
      <header
        className={cn(
          'shrink-0 border-b border-border bg-surface px-2 sm:px-3 lg:px-4',
          // Tighter where the transcript is counted in tens of pixels. The
          // controls are still 44px; only the air around them gives. `py-0.5`
          // rather than `py-1` because a 393x390 keyboard-up screen has 390px
          // to divide between a 56px app bar, this header, the composer and the
          // thread — and four of those pixels are 1% of the transcript.
          hasHeaderRoom ? 'py-2' : 'py-0',
        )}
      >
        <div className="flex min-w-0 items-center gap-2">
          {showHistoryToggle ? (
            <IconButton
              label={historyExpanded ? 'Collapse the conversation list' : 'Expand the conversation list'}
              onClick={onToggleHistory}
            >
              {historyExpanded ? <PanelLeftClose aria-hidden /> : <PanelLeftOpen aria-hidden />}
            </IconButton>
          ) : (
            <IconButton label="Your Piggy conversations" onClick={onOpenHistory}>
              <History aria-hidden />
            </IconButton>
          )}

          <div className="min-w-0 flex-1">
            {/* An unsaved thread has no name yet, and calling it "New
                conversation" would put the sidebar's button's own words in the
                title bar. The workspace is called Piggy until the first
                question names the thread. */}
            <h1 className="truncate text-base font-semibold leading-tight tracking-tight">
              {title ?? 'Piggy'}
            </h1>
            <p className="hidden truncate text-xs leading-5 text-muted sm:block">
              {piggyLine(conversationId ? piggyCopy.threadSaved : piggyCopy.threadNew, isMobile)}
            </p>
          </div>

          {controlsOnSecondRow ? null : controlsRow}

          {showHistoryToggle ? null : (
            <IconButton label="Start a new conversation" onClick={onNew}>
              <SquarePen aria-hidden />
            </IconButton>
          )}
          <IconButton
            label={
              activityInColumn
                ? activityOpen
                  ? 'Hide the activity panel'
                  : 'Show the activity panel'
                : 'Show activity'
            }
            pressed={activityInColumn ? activityOpen : undefined}
            onClick={() => (activityInColumn ? onToggleActivity() : setActivitySheet(true))}
          >
            {activityOpen ? <PanelRightClose aria-hidden /> : <PanelRight aria-hidden />}
          </IconButton>
        </div>

        {/* On a phone the controls take the second row rather than shrinking:
            "Ask first" and a model name cannot share 393px with a title. On a
            short viewport they do share it, because 52px of chrome is a fifth
            of the transcript there. */}
        {controlsOnSecondRow ? (
          <div className={hasHeaderRoom ? 'mt-2' : 'mt-0.5'}>{controlsRow}</div>
        ) : null}
      </header>

      <div className="flex min-h-0 flex-1">
        {/* No background of its own. The transcript well IS the canvas: the
            shell's `.app-canvas` wash sits behind everything, and painting a
            flat `bg-bg` over it made the one full-bleed surface in the product
            the only place the canvas could not be seen — three planes
            (rail, canvas, card) collapsing into two. */}
        <div className="flex min-h-0 min-w-0 flex-1 flex-col">
          {loading ? (
            <ThreadSkeleton />
          ) : loadError ? (
            <div className="flex min-h-0 flex-1 items-center justify-center p-6">
              <EmptyState
                icon={<AlertTriangle />}
                title="That conversation could not be opened"
                description={
                  loadError instanceof Error
                    ? loadError.message
                    : 'It may have been deleted, or it belongs to someone else.'
                }
                action={
                  <Button type="button" variant="outline" onClick={onNew}>
                    Start a new conversation
                  </Button>
                }
              />
            </div>
          ) : (
            <PiggyChatPanel
              conversation={askingConversation(conversation, ask)}
              /* No `context`: the panel would draw a badge saying the
                 conversation is working from the page you are looking at, which
                 on this page is a badge reading "piggy". The turn still carries
                 the context — the conversation was created with it. */
              className="min-h-0 flex-1"
              /* Focus follows the thread across the remount that filing it
                 causes. The first question of a new conversation creates the
                 stored row, which puts an id in the URL, which rebuilds this
                 subtree under a new key — and the caret the user had just
                 typed into landed on `<body>`, so the obvious next thing to do
                 was to reach for the mouse. */
              autoFocusComposer={Boolean(autoSend)}
              emptyState={
                <PiggyWorkspaceStarters
                  context={WORKSPACE_CONTEXT}
                  mode={controls.mode}
                  canWrite={controls.canWrite}
                  onAsk={ask}
                  onAskWithChange={askWithChange}
                  /* The two columns of openers fit whenever the pane does:
                     even with both rails out at 1280 the middle keeps ~700px,
                     which is two 340px cards. Only the phone stacks them. */
                  narrow={isMobile}
                />
              }
            />
          )}
        </div>

        {activityOpen ? (
          <aside
            className="hidden w-[20rem] shrink-0 overflow-hidden border-l border-border bg-surface 2xl:flex"
            aria-label="Piggy activity"
          >
            <PiggyWorkspaceRail messages={messages} className="min-h-0 w-full flex-1" />
          </aside>
        ) : null}
      </div>

      <Sheet open={activitySheet} onOpenChange={setActivitySheet}>
        <SheetContent side="right" className="flex w-[20rem] flex-col p-0 sm:max-w-none">
          <SheetHeader band className="pr-14">
            <SheetTitle>Activity</SheetTitle>
            <SheetDescription>
              What this conversation has touched, and what the workspace has run.
            </SheetDescription>
          </SheetHeader>
          {/* Mounted only while open: the ledger polls, and a hidden copy would
              poll alongside the one in the column. */}
          {activitySheet ? (
            <PiggyWorkspaceRail messages={messages} className="min-h-0 flex-1" />
          ) : null}
        </SheetContent>
      </Sheet>
    </div>
  );
}

/**
 * The conversation as the panel should see it: identical, except that sending
 * goes through the workspace's own `ask`, which may have a conversation to
 * create first. The panel is deliberately unaware of that — it has three other
 * callers with nothing to file.
 */
function askingConversation(
  conversation: PiggyConversation,
  ask: (text?: string, from?: TranscriptMessage[]) => void,
): PiggyConversation {
  return { ...conversation, send: ask };
}

function ThreadSkeleton() {
  return (
    <div className="flex min-h-0 flex-1 flex-col gap-3 p-4" aria-hidden>
      <Skeleton className="h-16 w-2/3 rounded-xl" />
      <Skeleton className="ml-auto h-12 w-1/2 rounded-xl" />
      <Skeleton className="h-24 w-3/4 rounded-xl" />
    </div>
  );
}

function IconButton({
  label,
  onClick,
  pressed,
  children,
}: {
  label: string;
  onClick: () => void;
  pressed?: boolean;
  children: React.ReactNode;
}) {
  return (
    <Button
      type="button"
      variant="ghost"
      size="icon"
      className={cn('size-11 shrink-0 text-muted', pressed && 'text-fg')}
      aria-label={label}
      aria-pressed={pressed}
      title={label}
      onClick={onClick}
    >
      {children}
    </Button>
  );
}

// -------------------------------------------------------------------- state

function conversationKey(id: string) {
  return ['piggy', 'conversation', id] as const;
}

/**
 * A panel's open/closed state, remembered.
 *
 * Not keyed by user, unlike the mode: which rails somebody likes open is a
 * preference about a window, not a permission, and the worst a shared laptop
 * can do with it is show the second person a column they can close.
 */
function useStoredFlag(key: string, fallback: boolean): [boolean, (value: boolean) => void] {
  const [value, setValue] = useState<boolean>(() => {
    try {
      const raw = localStorage.getItem(key);
      return raw === null ? fallback : raw === 'true';
    } catch {
      // Private browsing throws on access; the layout default is fine.
      return fallback;
    }
  });

  const update = useCallback(
    (next: boolean) => {
      setValue(next);
      try {
        localStorage.setItem(key, String(next));
      } catch {
        // Nothing to do: the panel still opens, it just forgets by tomorrow.
      }
    },
    [key],
  );

  return [value, update];
}
