Vectrum
Sign In
807 lines · 31.3 KB · TypeScript
"use client";

import Link from "next/link";
import { useRouter } from "next/navigation";
import { Fragment, useEffect, useOptimistic, useState, useTransition, type ReactNode } from "react";
import {
  Field,
  IconButton,
  Led,
  Button as MuButton,
  Popover,
  Select,
  Switcher,
  Toggle,
  Tooltip,
  Well,
} from "@unlocalhosted/metalui";
import { updateIssueAction } from "@/app/actions/issues";
import {
  BoardIcon,
  CalendarIcon,
  ChevronRightIcon,
  CommentIcon,
  IssueOpenedIcon,
  ListIcon,
  PersonIcon,
  PlusIcon,
  SearchIcon,
  SlidersIcon,
  TagIcon,
  XIcon,
} from "@/components/icons";
import { ActorAvatar } from "@/components/agent";
import { Avatar, Blankslate, Box, Button, ButtonLink, Kbd, buttonClass } from "@/components/ui";
import {
  LIST_STATUS_ORDER,
  PRIORITIES,
  STATUSES,
  applyPatch,
  formatDay,
  priorityRank,
  type IssuePatch,
  type IssueSummary,
  type Member,
} from "@/lib/issue-meta";
import { CreateIssueDialog, type IssueDefaults } from "./issue-composer";
import { SCOPE_STATUSES, viewToQuery, type Grouping, type Ordering, type Scope, type TrackerView } from "./view";
import { LabelChip, LabelDot, PriorityIcon, StatusIcon } from "./issue-icons";
import { AssigneePicker, EstimateIcon, LabelsPicker, PriorityPicker, StatusPicker } from "./properties";
import { Picker } from "./picker";
import { useTracker } from "./tracker-context";

type Group = {
  key: string;
  label: string;
  icon: ReactNode;
  /** What dropping an issue into (or creating one in) this group sets; null if it can't be set. */
  patch: IssuePatch | null;
  issues: IssueSummary[];
};

const COMPARATORS: Record<Ordering, (a: IssueSummary, b: IssueSummary) => number> = {
  manual: (a, b) => a.sortOrder - b.sortOrder || b.number - a.number,
  priority: (a, b) => priorityRank(a.priority) - priorityRank(b.priority) || a.sortOrder - b.sortOrder,
  updated: (a, b) => b.updatedAt - a.updatedAt,
  created: (a, b) => b.createdAt - a.createdAt,
};

type PickerKind = "status" | "priority" | "assignee" | "labels";
const SHORTCUT_PICKERS: Record<string, PickerKind> = { s: "status", p: "priority", a: "assignee", l: "labels" };

export function IssueTracker({ issues, initialView }: { issues: IssueSummary[]; initialView: TrackerView }) {
  const tracker = useTracker();
  const { owner, name, base, members, labels, viewerId, canTriage, today, notify } = tracker;
  const router = useRouter();
  const [view, setView] = useState(initialView);
  const [optimistic, applyOptimistic] = useOptimistic(issues, (list, { number, patch }: { number: number; patch: IssuePatch }) =>
    list.map((i) => (i.number === number ? applyPatch(i, patch, { members, labels }) : i)),
  );
  const [, startTransition] = useTransition();
  const [creating, setCreating] = useState<IssueDefaults | null>(null);
  const [focused, setFocused] = useState<number | null>(null);
  const [openPicker, setOpenPicker] = useState<{ number: number; kind: PickerKind } | null>(null);
  const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set(["done", "canceled"]));
  const [drag, setDrag] = useState<{ number: number; group: string | null; index: number } | null>(null);

  const update = (issue: IssueSummary, patch: IssuePatch) =>
    startTransition(async () => {
      applyOptimistic({ number: issue.number, patch });
      const result = await updateIssueAction(owner, name, issue.number, patch);
      if (result.error) notify(result.error, { tone: "error" });
    });

  // Keep the URL in sync so views can be bookmarked and shared.
  useEffect(() => {
    window.history.replaceState(null, "", `${window.location.pathname}${viewToQuery(view)}`);
  }, [view]);

  const set = <K extends keyof TrackerView>(key: K, value: TrackerView[K]) => setView((v) => ({ ...v, [key]: value }));

  // ---- Filtering, ordering, grouping --------------------------------------
  const scoped = SCOPE_STATUSES[view.scope];
  const q = view.q.trim().toLowerCase();
  const visible = optimistic
    .filter(
      (i) =>
        (!scoped || scoped.includes(i.status)) &&
        (!q || i.title.toLowerCase().includes(q) || `#${i.number}` === q || String(i.number) === q) &&
        (view.assignee.length === 0 || view.assignee.includes(i.assigneeId === null ? "none" : String(i.assigneeId))) &&
        (view.priority.length === 0 || view.priority.includes(String(i.priority))) &&
        (view.label.length === 0 || i.labels.some((l) => view.label.includes(l.id))),
    )
    .sort(COMPARATORS[view.order]);

  const groups = groupIssues(visible, view, members, optimistic);
  const listGroups = groups.filter((g) => g.issues.length > 0);
  const navigable = (view.view === "board" ? groups : listGroups.filter((g) => !collapsed.has(g.key))).flatMap((g) => g.issues);
  const scopeCounts = {
    all: optimistic.length,
    active: optimistic.filter((i) => SCOPE_STATUSES.active!.includes(i.status)).length,
    backlog: optimistic.filter((i) => i.status === "backlog").length,
  };
  const filtered = view.assignee.length + view.priority.length + view.label.length > 0 || !!q;

  function startCreate(defaults: IssueDefaults = {}) {
    if (viewerId === null) {
      router.push(`/login?next=${encodeURIComponent(`${base}/issues`)}`);
      return;
    }
    setCreating(view.scope === "backlog" && !defaults.status ? { ...defaults, status: "backlog" } : defaults);
  }

  // ---- Keyboard shortcuts ---------------------------------------------------
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      const target = e.target as HTMLElement;
      if (e.metaKey || e.ctrlKey || e.altKey || target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return;
      // A MetalUI control already handled it (a switcher's arrows), or a dialog or popover is open.
      if (e.defaultPrevented || document.querySelector('dialog[open], [role="dialog"]')) return;
      const key = e.key.toLowerCase();
      if (key.startsWith("arrow") && target.closest('[role="radiogroup"]')) return;
      const index = navigable.findIndex((i) => i.number === focused);
      const current = navigable[index];
      if (key === "c") {
        e.preventDefault();
        startCreate();
      } else if (key === "j" || key === "arrowdown" || key === "k" || key === "arrowup") {
        e.preventDefault();
        const down = key === "j" || key === "arrowdown";
        const next =
          navigable[
            index === -1 ? (down ? 0 : navigable.length - 1) : Math.min(Math.max(index + (down ? 1 : -1), 0), navigable.length - 1)
          ];
        if (!next) return;
        setFocused(next.number);
        document.getElementById(`issue-${next.number}`)?.scrollIntoView({ block: "nearest" });
      } else if ((key === "enter" || key === "o") && current) {
        e.preventDefault();
        router.push(`${base}/issues/${current.number}`);
      } else if (key === "escape") {
        setFocused(null);
      } else if (current && SHORTCUT_PICKERS[key]) {
        const kind = SHORTCUT_PICKERS[key];
        if (!canTriage) return;
        e.preventDefault();
        setOpenPicker({ number: current.number, kind });
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  });

  const pickerProps = (issue: IssueSummary, kind: PickerKind) => ({
    open: openPicker?.number === issue.number && openPicker.kind === kind ? true : undefined,
    onOpenChange: (open: boolean) => !open && setOpenPicker(null),
  });

  // ---- Board drag and drop ----------------------------------------------------
  function dragOver(e: React.DragEvent<HTMLElement>, group: Group) {
    if (!drag || !group.patch) return;
    e.preventDefault();
    e.dataTransfer.dropEffect = "move";
    const cards = [...e.currentTarget.querySelectorAll<HTMLElement>("[data-card]")].filter(
      (el) => el.dataset.number !== String(drag.number),
    );
    let index = cards.findIndex((el) => {
      const r = el.getBoundingClientRect();
      return e.clientY < r.top + r.height / 2;
    });
    if (index === -1) index = cards.length;
    if (drag.group !== group.key || drag.index !== index) setDrag({ ...drag, group: group.key, index });
  }

  function drop(e: React.DragEvent, group: Group) {
    e.preventDefault();
    const issue = optimistic.find((i) => i.number === drag?.number);
    setDrag(null);
    if (!issue || !drag || !group.patch) return;
    const patch: IssuePatch = {};
    const [field, value] = Object.entries(group.patch)[0] ?? [];
    if (field && issue[field as keyof IssueSummary] !== value) Object.assign(patch, group.patch);
    if (view.order === "manual") {
      const others = group.issues.filter((i) => i.number !== issue.number);
      const before = others[drag.index - 1]?.sortOrder;
      const after = others[drag.index]?.sortOrder;
      const sortOrder =
        before !== undefined && after !== undefined
          ? (before + after) / 2
          : before !== undefined
            ? before + 1
            : after !== undefined
              ? after - 1
              : issue.sortOrder;
      if (sortOrder !== issue.sortOrder) patch.sortOrder = sortOrder;
    }
    if (Object.keys(patch).length > 0) update(issue, patch);
  }

  // ---- Rendering ----------------------------------------------------------------
  const pill = "inline-flex h-5 items-center gap-1 rounded-pill px-2 type-meta font-medium text-ink2 recipe-chip-tag";

  const properties = (issue: IssueSummary, compact = false) => (
    <>
      {issue.labels.length > 0 && (
        <span className={`flex items-center gap-1 ${compact ? "flex-wrap" : "hidden lg:flex"}`}>
          {issue.labels.slice(0, compact ? 4 : 3).map((l) => (
            <LabelChip key={l.id} name={l.name} color={l.color} />
          ))}
          {issue.labels.length > (compact ? 4 : 3) && (
            <span className="type-meta text-ink3">+{issue.labels.length - (compact ? 4 : 3)}</span>
          )}
        </span>
      )}
      {issue.dueDate &&
        (() => {
          const overdue = issue.dueDate < today && !["done", "canceled"].includes(issue.status);
          return (
            <span className={pill} title={overdue ? "Overdue" : undefined}>
              {overdue ? <Led kind="failed" size="small" /> : <CalendarIcon width={11} height={11} />}
              {formatDay(issue.dueDate)}
              {overdue && <span className="sr-only">(overdue)</span>}
            </span>
          );
        })()}
      {issue.estimate !== null && (
        <span className={pill} title={`${issue.estimate} points`}>
          <EstimateIcon size={11} /> {issue.estimate}
        </span>
      )}
      {issue.commentCount > 0 && (
        <span className={`items-center gap-1 type-meta text-ink3 tabular-nums ${compact ? "inline-flex" : "hidden sm:inline-flex"}`}>
          <CommentIcon width={12} height={12} /> {issue.commentCount}
        </span>
      )}
    </>
  );

  // A list row on the plate: it lifts (MetalUI's list-row hover) while pointed at or reached with J/K.
  const row = (issue: IssueSummary) => (
    <div
      key={issue.number}
      id={`issue-${issue.number}`}
      onMouseEnter={() => setFocused(issue.number)}
      className={`group relative flex h-10 items-center gap-2 rounded-plate pr-2.5 pl-1.5 transition-[background,box-shadow] ${focused === issue.number ? "recipe-row-list-hover" : ""}`}
    >
      <PriorityPicker
        variant="icon"
        value={issue.priority}
        onChange={(priority) => update(issue, { priority })}
        disabled={!canTriage}
        {...pickerProps(issue, "priority")}
      />
      <span className="hidden w-11 shrink-0 font-mono type-meta text-ink3 sm:inline">#{issue.number}</span>
      <StatusPicker
        variant="icon"
        value={issue.status}
        onChange={(status) => update(issue, { status })}
        disabled={!canTriage}
        {...pickerProps(issue, "status")}
      />
      <Link
        href={`${base}/issues/${issue.number}`}
        className="min-w-0 truncate text-[13px] leading-5 font-medium text-ink outline-none after:absolute after:inset-0 after:rounded-plate focus-visible:after:focus-ring-flush"
      >
        {issue.title}
      </Link>
      <span className="ml-auto flex shrink-0 items-center gap-2">
        {properties(issue)}
        {canTriage && openPicker?.number === issue.number && openPicker.kind === "labels" && (
          <LabelsPicker
            variant="icon"
            value={issue.labels.map((l) => l.id)}
            onChange={(labelIds) => update(issue, { labelIds })}
            {...pickerProps(issue, "labels")}
          />
        )}
        <AssigneePicker
          variant="icon"
          value={issue.assigneeId}
          onChange={(assigneeId) => update(issue, { assigneeId })}
          disabled={!canTriage}
          {...pickerProps(issue, "assignee")}
        />
      </span>
    </div>
  );

  // A board card: a raised plate in the column's sunk well; it rises a step while pointed at.
  const card = (issue: IssueSummary) => (
    <div
      key={issue.number}
      id={`issue-${issue.number}`}
      data-card
      data-number={issue.number}
      draggable={canTriage}
      onDragStart={(e) => {
        e.dataTransfer.effectAllowed = "move";
        e.dataTransfer.setData("text/plain", String(issue.number));
        setDrag({ number: issue.number, group: null, index: -1 });
      }}
      onDragEnd={() => setDrag(null)}
      onMouseEnter={() => setFocused(issue.number)}
      className={`group relative rounded-plate p-3 transition-[box-shadow,opacity] ${canTriage ? "cursor-grab active:cursor-grabbing" : ""} ${drag?.number === issue.number ? "opacity-40" : ""} ${focused === issue.number ? "recipe-surface-raise" : "recipe-surface-raise-lite"}`}
    >
      <div className="flex items-center gap-2">
        <span className="font-mono type-meta text-ink3">#{issue.number}</span>
        <span className="ml-auto">
          <AssigneePicker
            variant="icon"
            value={issue.assigneeId}
            onChange={(assigneeId) => update(issue, { assigneeId })}
            disabled={!canTriage}
            {...pickerProps(issue, "assignee")}
          />
        </span>
      </div>
      <div className="mt-1 flex items-start gap-1.5">
        {view.group !== "status" && (
          <span className="-ml-1">
            <StatusPicker
              variant="icon"
              value={issue.status}
              onChange={(status) => update(issue, { status })}
              disabled={!canTriage}
              {...pickerProps(issue, "status")}
            />
          </span>
        )}
        <Link
          href={`${base}/issues/${issue.number}`}
          draggable={false}
          className="line-clamp-2 pt-0.5 text-[13px] leading-5 font-medium text-ink outline-none after:absolute after:inset-0 after:rounded-plate focus-visible:after:focus-ring"
        >
          {issue.title}
        </Link>
      </div>
      <div className="mt-2.5 flex flex-wrap items-center gap-1.5">
        <span className="-ml-1">
          <PriorityPicker
            variant="icon"
            value={issue.priority}
            onChange={(priority) => update(issue, { priority })}
            disabled={!canTriage}
            {...pickerProps(issue, "priority")}
          />
        </span>
        {properties(issue, true)}
        {canTriage && openPicker?.number === issue.number && openPicker.kind === "labels" && (
          <LabelsPicker
            variant="icon"
            value={issue.labels.map((l) => l.id)}
            onChange={(labelIds) => update(issue, { labelIds })}
            {...pickerProps(issue, "labels")}
          />
        )}
      </div>
    </div>
  );

  // Where a dragged card will land: a green rule (intent), between plates.
  const dropLine = <div className="-my-1 h-0.5 shrink-0 rounded-full bg-green-deep" aria-hidden />;

  const newInGroup = (group: Group) =>
    group.patch &&
    canTriage && (
      <Tooltip label={`New issue in ${group.label}`}>
        <IconButton
          variant="ghost"
          label={`New issue in ${group.label}`}
          icon={<PlusIcon />}
          onClick={() => startCreate(group.patch as IssueDefaults)}
          className="ml-auto"
        />
      </Tooltip>
    );

  const mine = view.assignee.length === 1 && view.assignee[0] === viewerId;

  return (
    <div>
      {/* Header: scopes, search and view controls */}
      <div className="mb-4 flex flex-wrap items-center gap-3">
        <h1 className="mr-1 text-[22px] leading-7 font-semibold tracking-[-0.025em] text-ink">Issues</h1>
        <Switcher<Scope>
          aria-label="Scope"
          value={view.scope}
          onValueChange={(scope) => set("scope", scope)}
          options={(["all", "active", "backlog"] as Scope[]).map((scope) => ({
            value: scope,
            label: (
              <>
                {scope === "all" ? "All issues" : scope === "active" ? "Active" : "Backlog"}
                <span className="type-meta text-ink3 tabular-nums">{scopeCounts[scope]}</span>
              </>
            ),
          }))}
        />
        <div className="ml-auto flex flex-wrap items-center gap-2">
          <Field size="regular" className="w-52">
            <Field.Icon>
              <SearchIcon />
            </Field.Icon>
            <Field.Input
              value={view.q}
              onChange={(e) => set("q", e.target.value)}
              onKeyDown={(e) => e.key === "Escape" && (e.currentTarget.blur(), set("q", ""))}
              placeholder="Filter by title or #"
              aria-label="Filter issues"
            />
          </Field>
          <Switcher<TrackerView["view"]>
            aria-label="Layout"
            value={view.view}
            onValueChange={(layout) => set("view", layout)}
            options={[
              { value: "list", label: "List", icon: <ListIcon width={14} height={14} /> },
              { value: "board", label: "Board", icon: <BoardIcon width={14} height={14} /> },
            ]}
          />
          <Popover.Root>
            <Popover.Trigger>
              <MuButton cap="standard" icon={<SlidersIcon />} aria-label="Display options">
                Display
              </MuButton>
            </Popover.Trigger>
            <Popover.Content align="end">
              <Popover.Title>Display</Popover.Title>
              <Popover.Body className="space-y-2.5">
                <DisplaySelect
                  label="Grouping"
                  value={view.group}
                  onChange={(v) => set("group", v as Grouping)}
                  options={[
                    ["status", "Status"],
                    ["priority", "Priority"],
                    ["assignee", "Assignee"],
                    ["none", "No grouping"],
                  ]}
                />
                <DisplaySelect
                  label="Ordering"
                  value={view.order}
                  onChange={(v) => set("order", v as Ordering)}
                  options={[
                    ["manual", "Manual"],
                    ["priority", "Priority"],
                    ["updated", "Last updated"],
                    ["created", "Last created"],
                  ]}
                />
              </Popover.Body>
            </Popover.Content>
          </Popover.Root>
          {viewerId === null ? (
            <ButtonLink href={`/login?next=${encodeURIComponent(`${base}/issues`)}`} variant="primary" icon={<PlusIcon />}>
              New issue
            </ButtonLink>
          ) : (
            <Tooltip label="Create issue" shortcut="C">
              <MuButton cap="primary" icon={<PlusIcon />} onClick={() => startCreate()}>
                New issue
              </MuButton>
            </Tooltip>
          )}
        </div>
      </div>

      {/* Filters: latching keys whose lamp is lit while they narrow the list */}
      <div className="mb-4 flex flex-wrap items-center gap-2">
        <FilterPicker
          label="Assignee"
          icon={<PersonIcon width={14} height={14} />}
          value={view.assignee}
          onChange={(v) => set("assignee", v)}
          options={[
            { value: "none", label: "No assignee", icon: <PersonIcon width={14} height={14} /> },
            ...members.map((m) => ({
              value: m.id,
              label: m.id === viewerId ? `${m.username} (you)` : m.agentOf ? `${m.username} (agent of ${m.agentOf})` : m.username,
              icon: <ActorAvatar name={m.username} agent={m.agentOf ? m.username : null} size={16} />,
            })),
          ]}
        />
        <FilterPicker
          label="Priority"
          icon={<PriorityIcon priority={2} size={13} />}
          value={view.priority}
          onChange={(v) => set("priority", v)}
          options={PRIORITIES.map((p) => ({ value: String(p.id), label: p.label, icon: <PriorityIcon priority={p.id} /> }))}
        />
        <FilterPicker
          label="Label"
          icon={<TagIcon width={14} height={14} />}
          value={view.label}
          onChange={(v) => set("label", v)}
          options={labels.map((l) => ({ value: l.id, label: l.name, icon: <LabelDot color={l.color} /> }))}
        />
        {viewerId !== null && members.some((m) => m.id === viewerId) && (
          <Toggle pressed={mine} onPressedChange={() => set("assignee", mine ? [] : [viewerId])}>
            My issues
          </Toggle>
        )}
        {filtered && (
          <Button
            type="button"
            variant="invisible"
            icon={<XIcon />}
            onClick={() => setView((v) => ({ ...v, q: "", assignee: [], priority: [], label: [] }))}
          >
            Clear
          </Button>
        )}
        <span className="ml-auto type-label engraved tabular-nums">
          {visible.length} issue{visible.length === 1 ? "" : "s"}
        </span>
      </div>

      {optimistic.length === 0 ? (
        <Box>
          <Blankslate
            icon={<IssueOpenedIcon />}
            title="Plan your work"
            action={
              <Button type="button" variant="primary" icon={<PlusIcon />} onClick={() => startCreate()}>
                Create the first issue
              </Button>
            }
          >
            Track todos, bugs and features with statuses, priorities, assignees and labels — as a list or on a board.
          </Blankslate>
        </Box>
      ) : view.view === "list" ? (
        listGroups.length === 0 ? (
          <Box>
            <Blankslate icon={<SearchIcon />} title="No matching issues">
              Try a different scope or clear the filters.
            </Blankslate>
          </Box>
        ) : (
          <Box className="p-1.5">
            {listGroups.map((group, gi) => {
              const isCollapsed = collapsed.has(group.key);
              return (
                <section key={group.key} aria-label={view.group !== "none" ? group.label : undefined}>
                  {view.group !== "none" && (
                    <div className={`flex h-9 items-center gap-2 pr-1 pl-0.5 ${gi > 0 ? "rule-t mt-1.5 h-[43px] pt-1.5" : ""}`}>
                      <button
                        type="button"
                        onClick={() =>
                          setCollapsed((c) => {
                            const next = new Set(c);
                            if (next.has(group.key)) next.delete(group.key);
                            else next.add(group.key);
                            return next;
                          })
                        }
                        className="mu-icon-trigger flex h-7 min-w-0 items-center gap-2 rounded-pill px-2 type-ui text-ink transition-colors hover:recipe-icon-button-ghost-hover focus-visible:focus-ring-flush"
                        aria-expanded={!isCollapsed}
                      >
                        <ChevronRightIcon
                          width={12}
                          height={12}
                          className={`text-ink3 transition-transform ${isCollapsed ? "" : "rotate-90"}`}
                        />
                        {group.icon}
                        <span className="truncate font-semibold">{group.label}</span>
                        <span className="type-label engraved tabular-nums">{group.issues.length}</span>
                      </button>
                      {newInGroup(group)}
                    </div>
                  )}
                  {!isCollapsed && group.issues.map(row)}
                </section>
              );
            })}
          </Box>
        )
      ) : (
        <div className="-mx-4 overflow-x-auto px-4 pb-2 md:-mx-5 md:px-5">
          <div className="flex min-h-[60vh] items-start gap-3">
            {groups.map((group) => {
              // Where the drop line goes: before others[lineAt], or at the end.
              const others = group.issues.filter((i) => i.number !== drag?.number);
              const lineAt = drag?.group === group.key && view.order === "manual" ? drag.index : -1;
              return (
                <Well
                  key={group.key}
                  variant="region"
                  radius="region"
                  over={drag?.group === group.key}
                  role="group"
                  aria-label={group.label}
                  onDragOver={(e) => dragOver(e, group)}
                  onDragLeave={(e) => {
                    if (drag && !e.currentTarget.contains(e.relatedTarget as Node)) setDrag({ ...drag, group: null, index: -1 });
                  }}
                  onDrop={(e) => drop(e, group)}
                  className="flex max-h-[calc(100vh-220px)] w-[300px] shrink-0 flex-col"
                >
                  <div className="flex h-11 shrink-0 items-center gap-2 pr-2 pl-3.5">
                    {group.icon}
                    <span className="truncate type-ui font-semibold text-ink">{group.label}</span>
                    <span className="type-label engraved tabular-nums">{group.issues.length}</span>
                    {newInGroup(group)}
                  </div>
                  <div className="flex min-h-16 flex-col gap-2 overflow-y-auto px-2 pt-0.5 pb-2">
                    {group.issues.map((issue) => (
                      <Fragment key={issue.number}>
                        {lineAt !== -1 && others[lineAt] === issue && dropLine}
                        {card(issue)}
                      </Fragment>
                    ))}
                    {lineAt !== -1 && lineAt >= others.length && dropLine}
                  </div>
                </Well>
              );
            })}
          </div>
        </div>
      )}

      <p className="mt-4 hidden flex-wrap items-center gap-x-4 gap-y-1 type-meta text-ink3 md:flex">
        <span className="flex items-center gap-1.5">
          <Kbd>C</Kbd> new issue
        </span>
        <span className="flex items-center gap-1.5">
          <Kbd>J</Kbd>
          <Kbd>K</Kbd> move
        </span>
        <span className="flex items-center gap-1.5">
          <Kbd>↵</Kbd> open
        </span>
        {canTriage && (
          <>
            <span className="flex items-center gap-1.5">
              <Kbd>S</Kbd> status
            </span>
            <span className="flex items-center gap-1.5">
              <Kbd>P</Kbd> priority
            </span>
            <span className="flex items-center gap-1.5">
              <Kbd>A</Kbd> assignee
            </span>
            <span className="flex items-center gap-1.5">
              <Kbd>L</Kbd> labels
            </span>
            {view.view === "board" && <span>Drag cards between columns</span>}
          </>
        )}
      </p>

      <CreateIssueDialog open={creating !== null} defaults={creating ?? undefined} onClose={() => setCreating(null)} />
    </div>
  );
}

function groupIssues(visible: IssueSummary[], view: TrackerView, members: Member[], all: IssueSummary[]): Group[] {
  const by = <K,>(key: (i: IssueSummary) => K, value: K) => visible.filter((i) => key(i) === value);
  if (view.group === "status") {
    const scoped = SCOPE_STATUSES[view.scope];
    const order = view.view === "board" ? STATUSES.map((s) => s.id) : LIST_STATUS_ORDER;
    return order
      .filter((s) => !scoped || scoped.includes(s))
      .map((s) => ({
        key: s,
        label: STATUSES.find((x) => x.id === s)!.label,
        icon: <StatusIcon status={s} />,
        patch: { status: s },
        issues: by((i) => i.status, s),
      }));
  }
  if (view.group === "priority") {
    return PRIORITIES.map((p) => ({
      key: `priority-${p.id}`,
      label: p.label,
      icon: <PriorityIcon priority={p.id} />,
      patch: { priority: p.id },
      issues: by((i) => i.priority, p.id as number),
    }));
  }
  if (view.group === "assignee") {
    const groups: Group[] = [
      ...members.map((m) => ({
        key: `user-${m.id}`,
        label: m.agentOf ? `${m.username} (agent of ${m.agentOf})` : m.username,
        icon: <ActorAvatar name={m.username} agent={m.agentOf ? m.username : null} size={16} />,
        patch: { assigneeId: m.id },
        issues: by((i) => i.assigneeId, m.id as string | null),
      })),
    ];
    // People who were assigned before losing access still get a (read-only) column.
    const former = new Map(
      all
        .filter((i) => i.assigneeId !== null && !members.some((m) => m.id === i.assigneeId))
        .map((i) => [i.assigneeId!, i.assignee ?? "?"]),
    );
    for (const [id, username] of former) {
      groups.push({
        key: `user-${id}`,
        label: username,
        icon: <Avatar name={username} size={16} />,
        patch: null,
        issues: by((i) => i.assigneeId, id as string | null),
      });
    }
    groups.push({
      key: "user-none",
      label: "No assignee",
      icon: (
        <span className="well flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-ink3">
          <PersonIcon width={10} height={10} />
        </span>
      ),
      patch: { assigneeId: null },
      issues: by((i) => i.assigneeId, null as string | null),
    });
    return groups;
  }
  return [{ key: "all", label: "All issues", icon: null, patch: {}, issues: visible }];
}

/** One display setting: its name, and MetalUI's compact select. */
function DisplaySelect({
  label,
  value,
  onChange,
  options,
}: {
  label: string;
  value: string;
  onChange: (value: string) => void;
  options: [string, string][];
}) {
  return (
    <div className="flex items-center justify-between gap-3">
      <span className="type-ui text-ink2">{label}</span>
      <Select
        size="compact"
        aria-label={label}
        value={value}
        onValueChange={onChange}
        options={options.map(([v, l]) => ({ value: v, label: l }))}
        className="w-36"
      />
    </div>
  );
}

/** A filter as a latching key: its lamp is lit, and the cap pressed in, while it narrows the list. */
function FilterPicker({
  label,
  icon,
  value,
  onChange,
  options,
}: {
  label: string;
  icon: ReactNode;
  value: string[];
  onChange: (value: string[]) => void;
  options: { value: string; label: string; icon?: ReactNode }[];
}) {
  const active = value.length > 0;
  const summary = active
    ? value.length === 1
      ? (options.find((o) => o.value === value[0])?.label ?? "1 selected")
      : `${value.length} selected`
    : null;
  return (
    <Picker
      multiple
      label={`Filter by ${label.toLowerCase()}`}
      placeholder={`Filter by ${label.toLowerCase()}…`}
      options={options}
      value={value}
      onSelect={onChange}
      triggerClassName={buttonClass("default", `max-w-64 ${active ? "recipe-button-pressed" : ""}`)}
    >
      <Led kind={active ? "live" : "off"} size="small" />
      {icon}
      <span className="truncate">
        {label}
        {summary && <span className="text-ink2"> is {summary}</span>}
      </span>
    </Picker>
  );
}