/**
 * Piggy's history rail: every conversation this person has had, newest first.
 *
 * Four decisions here are worth stating, because each replaces something more
 * obvious that would have been wrong.
 *
 * **A thread with nothing in it is not shown.** See `conversations` below: a
 * row exists from the moment New is pressed, so the rail was mostly abandoned
 * drafts sharing one derived title.
 *
 * **Recency buckets, not a flat list.** History is scanned, not read — the
 * question is "where was that thing I asked on Tuesday", and a wall of relative
 * timestamps answers it one row at a time. Today / Yesterday / This week /
 * Earlier is how people already hold the week in their heads, and the headers
 * stick so the answer stays on screen while the list scrolls under it.
 *
 * **`running` is a prop, never a field this component fetches.** The server
 * does not persist "a turn is in flight" and should not: it is live state
 * belonging to the open stream, and a flag in Postgres would survive a crashed
 * relay and mark a thread busy forever. `PiggyConversationSummary.running` is
 * honoured if a future endpoint ever sets it, but the workspace's own
 * `runningId` is the source of truth.
 *
 * **No `window.confirm` for the delete.** It blocks the event loop, so an
 * answer still streaming into another conversation stalls behind a modal the
 * browser drew, and it cannot name the thread being destroyed in a way anyone
 * would read. `AlertDialog` does both, and refuses to be dismissed by a click
 * landing somewhere else.
 */
import { useCallback, useEffect, useMemo, useRef, useState, type JSX } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
  AlertTriangle,
  MessageSquarePlus,
  MoreHorizontal,
  Pencil,
  Plus,
  RefreshCw,
  Trash2,
} from 'lucide-react';
import { toast } from 'sonner';
import type { PiggyConversationSummary } from '@pig/core';
import { api, get, patch, post, shortDate } from '@/lib/api';
import { useIsMobile } from '@/hooks/use-media-query';
import { Button, EmptyState, Input, Label, Skeleton, cn } from '@/components/ui';
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from '@/components/ui/tooltip';

/** One key for the whole history, so every mutation invalidates the same list. */
const CONVERSATIONS_KEY = ['piggy', 'conversations'] as const;

/**
 * Mirrors `PIGGY_TITLE_MAX` on the server, which truncates silently rather than
 * refusing. Enforcing it in the input means the title the user reads back is the
 * title that was stored, instead of one that lost its last few words on save.
 */
const TITLE_MAX = 120;

/** A stable empty array, so `conversations` does not change identity per render. */
const NO_CONVERSATIONS: PiggyConversationSummary[] = [];

const TIME_OF_DAY = new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit' });
const WEEKDAY = new Intl.DateTimeFormat('en-US', { weekday: 'short' });

// ------------------------------------------------------------------ the data

function useConversationsQuery() {
  return useQuery({
    queryKey: CONVERSATIONS_KEY,
    queryFn: () => get<PiggyConversationSummary[]>('/api/piggy/conversations'),
  });
}

/**
 * The list plus the three writes that change it.
 *
 * Rename and delete are optimistic. Not for the milliseconds — the endpoint is
 * fast — but because both are direct manipulations of a row the user is looking
 * at: a title that stays wrong until a refetch lands reads as the rename having
 * failed, and people press it again.
 */
function useConversationMutations() {
  const queryClient = useQueryClient();

  const settle = () => {
    void queryClient.invalidateQueries({ queryKey: CONVERSATIONS_KEY });
  };

  const create = useMutation({
    mutationFn: () => post<{ id: string }>('/api/piggy/conversations', {}),
    onSuccess: settle,
  });

  const rename = useMutation({
    mutationFn: ({ id, title }: { id: string; title: string }) =>
      patch<{ id: string }>(`/api/piggy/conversations/${id}`, { title }),
    onMutate: async ({ id, title }) => {
      // Without the cancel, a refetch already in flight can land after the
      // optimistic write and paint the old title back over the new one.
      await queryClient.cancelQueries({ queryKey: CONVERSATIONS_KEY });
      const previous = queryClient.getQueryData<PiggyConversationSummary[]>(CONVERSATIONS_KEY);
      queryClient.setQueryData<PiggyConversationSummary[]>(CONVERSATIONS_KEY, (current) =>
        current?.map((entry) => (entry.id === id ? { ...entry, title } : entry)),
      );
      return { previous };
    },
    onError: (error, _variables, context) => {
      if (context?.previous) queryClient.setQueryData(CONVERSATIONS_KEY, context.previous);
      toast.error(error instanceof Error ? error.message : 'The rename did not save.');
    },
    onSettled: settle,
  });

  const remove = useMutation({
    mutationFn: (id: string) =>
      api<{ id: string; deleted: boolean }>(`/api/piggy/conversations/${id}`, {
        method: 'DELETE',
      }),
    onMutate: async (id: string) => {
      await queryClient.cancelQueries({ queryKey: CONVERSATIONS_KEY });
      const previous = queryClient.getQueryData<PiggyConversationSummary[]>(CONVERSATIONS_KEY);
      queryClient.setQueryData<PiggyConversationSummary[]>(CONVERSATIONS_KEY, (current) =>
        current?.filter((entry) => entry.id !== id),
      );
      return { previous };
    },
    onError: (error, _id, context) => {
      if (context?.previous) queryClient.setQueryData(CONVERSATIONS_KEY, context.previous);
      toast.error(error instanceof Error ? error.message : 'The conversation was not deleted.');
    },
    onSettled: settle,
  });

  return { create, rename, remove };
}

export function usePiggyConversations(): {
  conversations: PiggyConversationSummary[];
  isLoading: boolean;
  create: () => Promise<string>;
  rename: (id: string, title: string) => Promise<void>;
  remove: (id: string) => Promise<void>;
} {
  const query = useConversationsQuery();
  const { create, rename, remove } = useConversationMutations();

  const createConversation = useCallback(async () => {
    const created = await create.mutateAsync();
    return created.id;
  }, [create]);

  const renameConversation = useCallback(
    async (id: string, title: string) => {
      await rename.mutateAsync({ id, title });
    },
    [rename],
  );

  const removeConversation = useCallback(
    async (id: string) => {
      await remove.mutateAsync(id);
    },
    [remove],
  );

  return {
    conversations: query.data ?? NO_CONVERSATIONS,
    // `isPending` rather than `isFetching`: this is "there is nothing to draw
    // yet", so a background refresh does not flash the skeletons back in.
    isLoading: query.isPending,
    create: createConversation,
    rename: renameConversation,
    remove: removeConversation,
  };
}

// -------------------------------------------------------------- the grouping

type Bucket = 'today' | 'yesterday' | 'week' | 'earlier';

const BUCKET_LABELS: Record<Bucket, string> = {
  today: 'Today',
  yesterday: 'Yesterday',
  week: 'This week',
  earlier: 'Earlier',
};

const BUCKET_ORDER: readonly Bucket[] = ['today', 'yesterday', 'week', 'earlier'];

interface ConversationGroup {
  bucket: Bucket;
  label: string;
  items: PiggyConversationSummary[];
}

/**
 * Buckets are computed from local midnights stepped with `setDate`, not from
 * subtracting 86,400,000 milliseconds: on the two days a year the clocks move,
 * a fixed-millisecond day puts 23:30 yesterday into "Today".
 */
function groupConversations(
  conversations: readonly PiggyConversationSummary[],
  now: number,
): ConversationGroup[] {
  const today = new Date(now);
  today.setHours(0, 0, 0, 0);
  const yesterday = new Date(today);
  yesterday.setDate(yesterday.getDate() - 1);
  const weekStart = new Date(today);
  weekStart.setDate(weekStart.getDate() - 6);

  const buckets: Record<Bucket, PiggyConversationSummary[]> = {
    today: [],
    yesterday: [],
    week: [],
    earlier: [],
  };

  // Sorted here as well as by the endpoint. The order is the product promise —
  // "your last thread is the top row" — and it should not depend on a query
  // plan in another process staying the way it is today.
  const sorted = [...conversations].sort(
    (left, right) => timestamp(right.updatedAt) - timestamp(left.updatedAt),
  );

  for (const entry of sorted) {
    const at = timestamp(entry.updatedAt);
    if (at >= today.getTime()) buckets.today.push(entry);
    else if (at >= yesterday.getTime()) buckets.yesterday.push(entry);
    else if (at >= weekStart.getTime()) buckets.week.push(entry);
    else buckets.earlier.push(entry);
  }

  return BUCKET_ORDER.filter((bucket) => buckets[bucket].length > 0).map((bucket) => ({
    bucket,
    label: BUCKET_LABELS[bucket],
    items: buckets[bucket],
  }));
}

/** An unparseable date sorts to the bottom rather than throwing the whole list away. */
function timestamp(value: string): number {
  const parsed = Date.parse(value);
  return Number.isNaN(parsed) ? 0 : parsed;
}

/**
 * The time a row shows, chosen so it never repeats the header above it.
 *
 * A relative stamp would: under "Yesterday", every single row says "yesterday".
 * Within a day the useful detail is the hour; within a week, which day; beyond
 * that, the date.
 */
function formatWhen(bucket: Bucket, value: string): string {
  const at = timestamp(value);
  if (!at) return '';
  const date = new Date(at);
  if (bucket === 'today' || bucket === 'yesterday') return TIME_OF_DAY.format(date);
  if (bucket === 'week') return WEEKDAY.format(date);
  return shortDate(date);
}

/**
 * A thread's name, short enough to be read as a question.
 *
 * Titles are derived from the opening prompt and run to the server's
 * 120-character cap, so a confirmation headed with one in full is three lines
 * of somebody's own question with "?" stuck on the end — a heading nobody
 * finishes before they decide. Cut at a word boundary; the row behind the
 * dialog still carries the whole of it.
 */
function shortTitle(title: string, max = 64): string {
  const trimmed = title.trim();
  if (trimmed.length <= max) return trimmed;
  const cut = trimmed.slice(0, max);
  const space = cut.lastIndexOf(' ');
  const kept = space > max * 0.6 ? cut.slice(0, space) : cut;
  return `${kept.replace(/[\s.,;:—–-]+$/, '')}…`;
}

/** The letter the collapsed rail shows. Punctuation and emoji are skipped. */
function railInitial(title: string): string {
  const letter = title.match(/[\p{L}\p{N}]/u);
  return letter ? letter[0].toUpperCase() : '·';
}

// ------------------------------------------------------------- the component

export interface PiggyConversationListProps {
  activeId: string | null;
  onSelect: (id: string) => void;
  onNew: () => void;
  /** Icon rail for narrow desktop. Ignored on a phone — see the component. */
  collapsed?: boolean;
  /**
   * The conversation with a turn in flight, if any.
   *
   * Live state the workspace owns; nothing here fetches it. Pass
   * `running ? conversationId ?? null : null` from `usePiggyConversation`.
   */
  runningId?: string | null;
}

export function PiggyConversationList({
  activeId,
  onSelect,
  onNew,
  collapsed = false,
  runningId = null,
}: PiggyConversationListProps): JSX.Element {
  const query = useConversationsQuery();
  const { rename, remove } = useConversationMutations();
  const isMobile = useIsMobile();

  const [renamingId, setRenamingId] = useState<string | null>(null);
  const [pendingDelete, setPendingDelete] = useState<PiggyConversationSummary | null>(null);

  /**
   * The dialog animates out over 240ms, and it is still on screen for all of
   * them. Reading `pendingDelete` directly meant the heading became `Delete
   * “”?` the instant either button was pressed — the confirmation forgetting
   * what it had just asked about, in front of the person who answered it.
   */
  const lastPendingDelete = useRef<PiggyConversationSummary | null>(null);
  if (pendingDelete) lastPendingDelete.current = pendingDelete;
  const deleting = pendingDelete ?? lastPendingDelete.current;

  /**
   * The ⋯ button the confirmation was opened from.
   *
   * The overlay primitive restores focus to whatever held it when the dialog
   * mounted, which here is the dropdown menu — a node that has been removed
   * from the document by the time anyone answers. So the row hands over its own
   * button and the dialog is told explicitly where to go back to; without it,
   * Escape dropped a keyboard user on `<body>`, a hundred-odd tab stops from
   * the row they were working on.
   */
  const deleteOpener = useRef<HTMLButtonElement | null>(null);

  /** Where focus goes when the row it came from no longer exists. */
  const newConversationRef = useRef<HTMLButtonElement>(null);

  /**
   * Threads with nothing said in them are not history.
   *
   * A conversation row is created the moment somebody presses New, and again
   * whenever a turn is refused before a word is stored — so the demo book holds
   * 58 empty threads against 14 real ones, and the rail people scan to find
   * Tuesday's question is four-fifths abandoned drafts with the same derived
   * title. Filtered here rather than on the server because the rows are real
   * and something else may legitimately want them; this is a reading decision.
   *
   * The open thread is always kept. A conversation created a second ago has no
   * messages yet, and it must not disappear from under the person typing in it.
   */
  const conversations = useMemo(() => {
    const all = query.data ?? NO_CONVERSATIONS;
    const said = all.filter((entry) => entry.messageCount > 0 || entry.id === activeId);
    return said.length === all.length ? all : said;
  }, [activeId, query.data]);

  /**
   * Recomputed when the list changes rather than on a timer. The boundary only
   * matters at midnight, and a component that re-rendered every minute to catch
   * it would cost more than the one row that would briefly sit under the wrong
   * header until the next fetch.
   */
  const groups = useMemo(() => groupConversations(conversations, Date.now()), [conversations]);

  /**
   * A rail is a compromise for a screen that has width to spare but not enough.
   * A phone has neither, and 60px of initials taken off a 393px column would
   * leave the chat unusable — so on a phone this ignores `collapsed` entirely
   * and renders in full, expecting to be inside the Sheet the workspace opens.
   */
  const rail = collapsed && !isMobile;

  const handleSelect = useCallback(
    (id: string) => {
      setRenamingId(null);
      onSelect(id);
    },
    [onSelect],
  );

  const confirmDelete = useCallback(async () => {
    const target = pendingDelete;
    if (!target) return;
    setPendingDelete(null);
    try {
      await remove.mutateAsync(target.id);
      toast.success('Conversation deleted');
      // Deleting the thread you are reading has to leave you somewhere. A fresh
      // conversation is the only destination that is certainly still there.
      if (target.id === activeId) onNew();
    } catch {
      /* Reported by the mutation's onError, which also rolls the row back. */
    }
  }, [activeId, onNew, pendingDelete, remove]);

  const body = query.isPending ? (
    <ListSkeleton rail={rail} />
  ) : query.isError ? (
    <ListError rail={rail} message={query.error.message} onRetry={() => void query.refetch()} />
  ) : conversations.length === 0 ? (
    rail ? null : (
      // No button here. There is already one directly above it, highlighted
      // because nothing is selected, and two identical calls to action a
      // centimetre apart read as a mistake rather than an invitation.
      <EmptyState
        icon={<MessageSquarePlus className="size-7" aria-hidden />}
        title="Ask Piggy your first question"
        description="Piggy reads the book — accounts, deals, contracts, utilisation — and can draft the follow-up. Start one above and it will be kept here."
      />
    )
  ) : (
    <ul className="flex flex-col gap-px">
      {groups.map((group) => (
        <li key={group.bucket}>
          {rail ? (
            // The header has nowhere to go at 60px, so the grouping survives as
            // a rule between runs of conversations. First group gets none.
            group.bucket === groups[0]?.bucket ? null : (
              <div className="mx-auto my-1.5 h-px w-6 bg-border" aria-hidden />
            )
          ) : (
            /* h4, not h3: these name a run of rows inside the rail, and the
               workspace's own headings sit above them. */
            <Label as="h4" className="sticky top-0 z-10 bg-surface px-2.5 pb-1 pt-3">
              {group.label}
            </Label>
          )}
          <ul className={cn('flex flex-col', rail ? 'items-center gap-1' : 'gap-px')}>
            {group.items.map((conversation) =>
              rail ? (
                <RailRow
                  key={conversation.id}
                  conversation={conversation}
                  bucket={group.bucket}
                  active={conversation.id === activeId}
                  running={Boolean(conversation.running) || conversation.id === runningId}
                  onSelect={handleSelect}
                />
              ) : (
                <ConversationRow
                  key={conversation.id}
                  conversation={conversation}
                  bucket={group.bucket}
                  active={conversation.id === activeId}
                  running={Boolean(conversation.running) || conversation.id === runningId}
                  renaming={conversation.id === renamingId}
                  onSelect={handleSelect}
                  onStartRename={() => setRenamingId(conversation.id)}
                  onCancelRename={() => setRenamingId(null)}
                  onCommitRename={(title) => {
                    setRenamingId(null);
                    if (title && title !== conversation.title) {
                      rename.mutate({ id: conversation.id, title });
                    }
                  }}
                  onRequestDelete={(opener) => {
                    deleteOpener.current = opener;
                    setPendingDelete(conversation);
                  }}
                />
              ),
            )}
          </ul>
        </li>
      ))}
    </ul>
  );

  return (
    <TooltipProvider delayDuration={300}>
      <nav
        aria-label="Piggy conversations"
        className={cn(
          // `min-h-0` is what lets the list below scroll instead of pushing the
          // whole column past the bottom of the viewport in a flex parent.
          'flex h-full min-h-0 flex-col bg-surface',
          rail ? 'w-[3.75rem] shrink-0' : 'w-full',
        )}
      >
        <div
          className={cn(
            'border-b border-border',
            rail ? 'flex justify-center p-2' : 'p-2',
            // On a phone this list lives inside the workspace's Sheet, whose own
            // dismiss control is pinned to the top-right corner — directly over
            // a full-width button. The corner is reserved rather than fought
            // over.
            !rail && isMobile && 'pr-14',
          )}
        >
          {rail ? (
            <Tooltip>
              <TooltipTrigger asChild>
                <Button
                  ref={newConversationRef}
                  type="button"
                  variant={activeId === null ? 'secondary' : 'ghost'}
                  size="icon"
                  aria-label="New conversation"
                  onClick={onNew}
                >
                  <Plus className="size-5" aria-hidden />
                </Button>
              </TooltipTrigger>
              <TooltipContent side="right">New conversation</TooltipContent>
            </Tooltip>
          ) : (
            <Button
              ref={newConversationRef}
              type="button"
              variant="outline"
              className={cn(
                'w-full justify-start gap-2',
                // No thread selected means the composer is already on a blank
                // one; showing that state stops the button reading as dead.
                activeId === null && 'border-brand/40 bg-accent-subtle text-accent-fg',
              )}
              onClick={onNew}
            >
              <MessageSquarePlus className="size-4" aria-hidden />
              New conversation
            </Button>
          )}
        </div>

        <div
          className={cn(
            // `overscroll-contain` stops a flick at the end of the history from
            // scrolling the page behind it, which on a phone drags the sheet.
            // The `calc` form, not `max(...)`: Tailwind's arbitrary-value parser
            // drops the latter and the utility is silently never generated,
            // which on a notched phone means the last row sits under the home
            // indicator with nothing to say it is there.
            'min-h-0 flex-1 overflow-y-auto overscroll-contain pb-[calc(0.5rem+var(--safe-bottom))]',
            rail ? 'px-1.5 pt-1.5' : 'px-1.5',
          )}
        >
          {body}
        </div>
      </nav>

      {/*
        The destructive choice comes FIRST in the DOM and last on the screen.
        A screen reader reads a footer in source order, so the consequence has
        to arrive before the way out of it; `AlertDialogAction`'s own `order`
        classes put "Keep it" back on the left where the platform puts it. The
        title names the thread, because "this conversation" is not something
        anyone can check before agreeing to destroy it.
      */}
      <AlertDialog
        open={pendingDelete !== null}
        onOpenChange={(open) => {
          if (!open) setPendingDelete(null);
        }}
      >
        <AlertDialogContent
          onCloseAutoFocus={(event) => {
            // After a delete the row's button is gone, and focusing a detached
            // node silently lands on <body>. The list's own New conversation
            // button is the nearest thing that certainly still exists — and is
            // where `confirmDelete` has just sent the reader anyway.
            const back = deleteOpener.current?.isConnected
              ? deleteOpener.current
              : newConversationRef.current;
            if (!back) return;
            event.preventDefault();
            back.focus();
          }}
        >
          <AlertDialogHeader>
            <AlertDialogTitle className="break-words">
              Delete “{deleting ? shortTitle(deleting.title) : ''}”?
            </AlertDialogTitle>
            <AlertDialogDescription>
              The thread and everything said in it will be removed. This cannot be undone.
            </AlertDialogDescription>
          </AlertDialogHeader>
          <AlertDialogFooter>
            <AlertDialogAction disabled={remove.isPending} onClick={() => void confirmDelete()}>
              <Trash2 className="size-4" aria-hidden />
              Delete conversation
            </AlertDialogAction>
            <AlertDialogCancel>Keep it</AlertDialogCancel>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>
    </TooltipProvider>
  );
}

// -------------------------------------------------------------------- a row

function ConversationRow({
  conversation,
  bucket,
  active,
  running,
  renaming,
  onSelect,
  onStartRename,
  onCancelRename,
  onCommitRename,
  onRequestDelete,
}: {
  conversation: PiggyConversationSummary;
  bucket: Bucket;
  active: boolean;
  running: boolean;
  renaming: boolean;
  onSelect: (id: string) => void;
  onStartRename: () => void;
  onCancelRename: () => void;
  onCommitRename: (title: string) => void;
  onRequestDelete: (opener: HTMLButtonElement | null) => void;
}) {
  /* Handed to the confirmation so it knows where to send focus back to. */
  const actionsRef = useRef<HTMLButtonElement>(null);

  if (renaming) {
    return (
      <li className="px-1 py-1">
        <RenameField
          initial={conversation.title}
          onCancel={onCancelRename}
          onCommit={onCommitRename}
        />
      </li>
    );
  }

  const when = formatWhen(bucket, conversation.updatedAt);

  return (
    <li className="group/row relative">
      {/*
       * The selected row needs a marker that does not depend on the accent.
       * `accent-subtle` is 96% lightness under the default monochrome palette
       * and 97% under rose — against a white surface that is a tint you have to
       * look for, and in a list you are scanning it disappears. The bar is the
       * brand at full strength, so selection is legible whatever the user's
       * colour and whichever theme they are in.
       */}
      {active ? (
        <span
          className="pointer-events-none absolute inset-y-1.5 left-0 w-0.5 rounded-full bg-brand"
          aria-hidden
        />
      ) : null}
      <button
        type="button"
        onClick={() => onSelect(conversation.id)}
        aria-current={active ? 'true' : undefined}
        className={cn(
          'flex w-full min-w-0 flex-col gap-0.5 rounded-lg py-2 pl-2.5 pr-12 text-left',
          'transition-colors duration-1 ease-enter',
          active ? 'bg-accent-subtle text-accent-fg' : 'hover:bg-surface-2',
        )}
      >
        {/*
         * Two lines, then an ellipsis. Titles are derived from the opening
         * question and routinely run to a full sentence; one line loses the
         * distinguishing half of "Draft a follow-up to …" and three turns the
         * rail into a wall. `break-words` only splits a word that could not fit
         * on a line of its own, so an ordinary title still breaks at a space.
         */}
        <span
          className={cn('line-clamp-2 break-words text-sm leading-5', active && 'font-medium')}
          title={conversation.title}
        >
          {conversation.title}
        </span>
        <span className="flex min-w-0 items-center gap-1.5 text-xs leading-4 text-muted">
          {running ? (
            <>
              <RunningDot />
              <span>Working…</span>
            </>
          ) : (
            <>
              {when ? <span className="tabular-nums">{when}</span> : null}
              {when && conversation.messageCount > 0 ? <span aria-hidden>·</span> : null}
              {conversation.messageCount > 0 ? (
                <span className="truncate">
                  {conversation.messageCount} message{conversation.messageCount === 1 ? '' : 's'}
                </span>
              ) : null}
            </>
          )}
        </span>
      </button>

      {/*
       * Outside the row button rather than inside it: a button inside a button
       * is invalid markup, and browsers resolve it by firing both handlers, so
       * opening the menu would also switch conversations.
       */}
      <DropdownMenu>
        <DropdownMenuTrigger asChild>
          <button
            ref={actionsRef}
            type="button"
            // Clipped, because a conversation's title is its opening question
            // and these run to 120 characters — "Actions for List the three
            // commitments closest to expiry as a markdown table with columns
            // Provider, GPU, Ends, Idle hours, Margin…" is a label nobody
            // listens to the end of.
            aria-label={`Actions for ${shortTitle(conversation.title)}`}
            className={cn(
              'absolute right-0.5 top-0.5 flex size-11 items-center justify-center rounded-lg',
              'text-muted transition duration-1 ease-enter hover:bg-border hover:text-fg',
              // Focus reveals it as well as ringing it: the button is invisible
              // until hover, and hover is not how a keyboard reaches it.
              'focus-visible:opacity-100 data-[state=open]:opacity-100',
              // Hidden until hovered only where hovering is possible. On a touch
              // screen there is no hover, so the same rule would hide rename and
              // delete for good.
              '[@media(hover:hover)]:opacity-0',
              '[@media(hover:hover)]:group-hover/row:opacity-100',
            )}
          >
            <MoreHorizontal className="size-4" aria-hidden />
          </button>
        </DropdownMenuTrigger>
        <DropdownMenuContent align="end" className="w-44">
          <DropdownMenuItem className="min-h-11" onSelect={() => onStartRename()}>
            <Pencil aria-hidden />
            Rename
          </DropdownMenuItem>
          <DropdownMenuItem
            className="min-h-11 text-danger focus:text-danger"
            onSelect={() => onRequestDelete(actionsRef.current)}
          >
            <Trash2 aria-hidden />
            Delete
          </DropdownMenuItem>
        </DropdownMenuContent>
      </DropdownMenu>
    </li>
  );
}

/**
 * The inline rename editor.
 *
 * Deliberately not a `<form>`: this list is dropped into whatever the workspace
 * is, and a form nested inside the composer's form would be invalid markup with
 * a submit that fires the wrong one. Enter and Escape are handled directly.
 */
function RenameField({
  initial,
  onCancel,
  onCommit,
}: {
  initial: string;
  onCancel: () => void;
  onCommit: (title: string) => void;
}) {
  const [value, setValue] = useState(initial);
  const inputRef = useRef<HTMLInputElement>(null);
  /**
   * Escape blurs the field, and blur commits — so without this the cancel key
   * would save. Set synchronously in the key handler, read in the blur that
   * follows it.
   */
  const cancelledRef = useRef(false);

  useEffect(() => {
    // Selected backwards on purpose. `select()` leaves the caret at the end,
    // which scrolls a 120-character title so that only its last few words are
    // visible — the half the user is least likely to be editing. A backward
    // selection puts the caret at the start and shows the beginning.
    inputRef.current?.setSelectionRange(0, inputRef.current.value.length, 'backward');
  }, []);

  const commit = () => {
    if (cancelledRef.current) return;
    onCommit(value.trim());
  };

  return (
    <div className="flex flex-col gap-1">
      <Input
        ref={inputRef}
        value={value}
        maxLength={TITLE_MAX}
        autoFocus
        aria-label="Conversation title"
        // No `text-sm` here, however well it would match the rows: the base
        // stylesheet floors every input at 16px so that focusing one does not
        // make mobile Safari zoom the viewport and never zoom back out.
        className="h-11"
        onChange={(event) => setValue(event.target.value)}
        onBlur={commit}
        onKeyDown={(event) => {
          if (event.key === 'Enter') {
            event.preventDefault();
            commit();
          } else if (event.key === 'Escape') {
            event.preventDefault();
            cancelledRef.current = true;
            onCancel();
          }
        }}
      />
      <p className="px-1 text-xs leading-4 text-muted">Enter to save · Escape to cancel</p>
    </div>
  );
}

function RailRow({
  conversation,
  bucket,
  active,
  running,
  onSelect,
}: {
  conversation: PiggyConversationSummary;
  bucket: Bucket;
  active: boolean;
  running: boolean;
  onSelect: (id: string) => void;
}) {
  const when = formatWhen(bucket, conversation.updatedAt);
  return (
    <li>
      <Tooltip>
        <TooltipTrigger asChild>
          <button
            type="button"
            onClick={() => onSelect(conversation.id)}
            aria-current={active ? 'true' : undefined}
            aria-label={conversation.title}
            className={cn(
              'relative flex size-11 items-center justify-center rounded-lg text-sm font-semibold',
              'transition-colors duration-1 ease-enter',
              // A solid fill, not the subtle tint the wide list uses. At 44px
              // there is no title to carry the selection, so the square itself
              // has to be unmistakable — and `accent-subtle` against
              // `surface-2` is a one-percent difference in lightness.
              active
                ? 'bg-brand text-accent-on'
                : 'text-muted hover:bg-surface-2 hover:text-fg',
            )}
          >
            <span aria-hidden>{railInitial(conversation.title)}</span>
            {running ? (
              // The dot sits on its own patch of the rail's background, because
              // the selected square is painted in the same brand colour and the
              // marker would otherwise vanish on exactly the conversation most
              // likely to be running.
              <span className="absolute -right-1 -top-1 rounded-full bg-surface p-0.5">
                <RunningDot />
              </span>
            ) : null}
          </button>
        </TooltipTrigger>
        {/* The rail shows one letter, so the tooltip is the only place the
            thread is actually named. It carries the timestamp too, because the
            headers that would have grouped it are gone at this width. */}
        <TooltipContent side="right" className="max-w-[16rem]">
          <p className="line-clamp-3 break-words">{conversation.title}</p>
          <p className="mt-0.5 text-muted-foreground">
            {running ? 'Working…' : when}
          </p>
        </TooltipContent>
      </Tooltip>
    </li>
  );
}

/** A turn in flight. `motion-reduce` because a pulse in a list is decoration. */
function RunningDot() {
  return (
    <span className="relative flex size-1.5 shrink-0" aria-hidden>
      <span className="absolute inline-flex size-full animate-ping rounded-full bg-brand opacity-75 motion-reduce:animate-none" />
      <span className="relative inline-flex size-1.5 rounded-full bg-brand" />
    </span>
  );
}

// ------------------------------------------------------- loading and failure

function ListSkeleton({ rail }: { rail: boolean }) {
  if (rail) {
    return (
      <div className="flex flex-col items-center gap-1" aria-busy>
        <span className="sr-only">Loading conversations…</span>
        {[0, 1, 2, 3].map((row) => (
          <Skeleton key={row} className="size-11 rounded-lg" />
        ))}
      </div>
    );
  }
  return (
    <div className="flex flex-col gap-1 pt-3" aria-busy>
      <span className="sr-only">Loading conversations…</span>
      {/* Uneven widths, because a column of identical bars reads as a loaded
          table rather than as something still arriving. */}
      {['w-3/4', 'w-full', 'w-2/3', 'w-5/6', 'w-1/2'].map((width, index) => (
        <div key={width} className="flex flex-col gap-1.5 px-1.5 py-2">
          <Skeleton className={cn('h-4', width)} />
          <Skeleton className={cn('h-3', index % 2 === 0 ? 'w-1/3' : 'w-1/4')} />
        </div>
      ))}
    </div>
  );
}

function ListError({
  rail,
  message,
  onRetry,
}: {
  rail: boolean;
  message: string;
  onRetry: () => void;
}) {
  if (rail) {
    return (
      <div className="flex justify-center">
        <Tooltip>
          <TooltipTrigger asChild>
            <Button
              type="button"
              variant="ghost"
              size="icon"
              aria-label="History unavailable. Try again."
              onClick={onRetry}
            >
              <AlertTriangle className="size-5 text-warning" aria-hidden />
            </Button>
          </TooltipTrigger>
          <TooltipContent side="right" className="max-w-[16rem]">
            History unavailable. Press to try again.
          </TooltipContent>
        </Tooltip>
      </div>
    );
  }
  return (
    <EmptyState
      icon={<AlertTriangle className="size-7" aria-hidden />}
      title="History unavailable"
      description={message}
      action={
        <Button type="button" variant="outline" size="sm" onClick={onRetry}>
          <RefreshCw className="size-4" aria-hidden />
          Try again
        </Button>
      }
    />
  );
}
