# Perplexity

> A full recreation of a Perplexity thread with search steps, citations, sources sheet, composer and a replayable playback, built on shadcn Base UI.

A shadcn/ui block from the [@notra](https://ui.usenotra.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/notra/perplexity).

- Page, with a live preview: https://shadcn.exchange/r/notra/perplexity
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/notra/perplexity-light-c58bc3dd558a0189.png
- Registry item: https://ui.usenotra.com/r/perplexity.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://ui.usenotra.com/r/perplexity.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `cn`, `lucide-react`
- Registry items: `avatar`, `badge`, `button`, `card`, `collapsible`, `https://ui.usenotra.com/r/notra-dropdown-menu.json`, `empty`, `hover-card`, `input-group`, `item`, `popover`, `scroll-area`, `separator`, `toggle-group`, `tooltip`, `https://ui.usenotra.com/r/shimmer.json`

## Files

### `registry/notra/perplexity/components/perplexity-message.tsx`

```tsx
import { cn } from "cn";

import type { PerplexityMessageProps } from "../types/perplexity";

export const PerplexityMessage = ({
  actions,
  children,
  className,
  from,
  search,
  ...props
}: PerplexityMessageProps) => {
  if (from === "user") {
    return (
      <div
        className={cn(
          "group/message flex items-center justify-end gap-3",
          className
        )}
        data-from="user"
        data-slot="perplexity-message"
        {...props}
      >
        {actions ? (
          <div className="opacity-0 transition-opacity duration-150 group-focus-within/message:opacity-100 group-hover/message:opacity-100 motion-reduce:transition-none [@media(hover:none)]:opacity-100">
            {actions}
          </div>
        ) : null}
        <div className="bg-pplx-bubble font-pplx text-pplx-fg max-w-[min(36rem,82%)] rounded-2xl px-4 py-2.5 text-base leading-6">
          {children}
        </div>
      </div>
    );
  }

  return (
    <div
      className={cn("flex w-full flex-col items-start gap-3.5", className)}
      data-from="assistant"
      data-slot="perplexity-message"
      {...props}
    >
      {search}
      {children ? (
        <div className="font-pplx-serif text-pplx-fg max-w-2xl text-base leading-6.5 font-normal">
          {children}
        </div>
      ) : null}
      {actions ? (
        <div className="-ms-1.5 w-full max-w-2xl">{actions}</div>
      ) : null}
    </div>
  );
};
```

### `registry/notra/perplexity/components/perplexity-search.tsx`

```tsx
"use client";

import { cn } from "cn";
import { ChevronDown, Search } from "lucide-react";
import { useCallback, useState } from "react";
import type { CSSProperties, ReactNode } from "react";

import { Button } from "@/components/ui/button";
import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Empty, EmptyDescription } from "@/components/ui/empty";
import {
  Item,
  ItemContent,
  ItemGroup,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item";
import { Separator } from "@/components/ui/separator";

import { Shimmer } from "../../shimmer/components/shimmer";
import {
  PERPLEXITY_SEARCH_PREVIEW_COUNT,
  PERPLEXITY_SEARCH_STAGGER_MS,
} from "../constants/perplexity";
import { usePerplexitySearchSequence } from "../hooks/use-perplexity-search-sequence";
import type {
  PerplexitySearchProps,
  PerplexitySource,
} from "../types/perplexity";
import { PerplexityFavicon } from "./perplexity-favicon";
import {
  PerplexityShieldIcon,
  PerplexityWebSearchIcon,
} from "./perplexity-icons";

const WWW_PREFIX = /^www\./;
const TLD_SUFFIX = /\.[^.]+$/;

const EASE = "ease-[cubic-bezier(0.22,1,0.36,1)]";

const ENTER_CLASS = `translate-y-0 opacity-100 transition-[opacity,translate] duration-200 ${EASE} motion-reduce:transition-none starting:translate-y-1.5 starting:opacity-0 motion-reduce:starting:translate-y-0 motion-reduce:starting:opacity-100`;

const PANEL_CLASS = `grid overflow-hidden transition-[grid-template-rows,opacity] duration-300 ${EASE} outline-none data-closed:grid-rows-[0fr] data-ending-style:grid-rows-[0fr] data-ending-style:opacity-0 data-open:grid-rows-[1fr] data-starting-style:grid-rows-[0fr] data-starting-style:opacity-0 motion-reduce:transition-none`;

const TRIGGER_CLASS =
  "focus-visible:ring-pplx-ring h-auto w-fit max-w-full justify-start gap-1.5 rounded-md border-0 p-0 text-left text-sm leading-5 font-normal whitespace-normal hover:bg-transparent focus-visible:border-transparent focus-visible:ring-2 active:not-aria-[haspopup]:translate-y-0 aria-expanded:bg-transparent dark:hover:bg-transparent dark:aria-expanded:bg-transparent";

const CHEVRON_CLASS = `text-pplx-muted size-3.5 shrink-0 transition-transform duration-300 ${EASE} motion-reduce:transition-none`;

const ROW_CLASS =
  "flex-nowrap items-center gap-2 rounded-sm border-0 p-0 text-sm leading-5";

const ROW_LINK_CLASS =
  "focus-visible:ring-pplx-ring [a]:hover:bg-transparent focus-visible:ring-2 [&:hover_[data-slot=item-title]]:text-pplx-fg";

const sourceHref = (url: string | undefined) => {
  if (!url) {
    return null;
  }

  try {
    const parsed = new URL(url);
    if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
      return null;
    }
    return parsed.href;
  } catch {
    return null;
  }
};

export const perplexityShortDomain = (domain: string) => {
  const host = domain.replace(WWW_PREFIX, "");
  const short = host.replace(TLD_SUFFIX, "");
  return short || host;
};

const SourceRow = ({
  className,
  source,
  style,
}: {
  className?: string;
  source: PerplexitySource;
  style?: CSSProperties;
}) => {
  const href = sourceHref(source.url);

  return (
    <Item
      className={cn(ROW_CLASS, href && ROW_LINK_CLASS, className)}
      render={
        href
          ? (linkProps) => (
              <a
                {...linkProps}
                href={href}
                rel="noopener noreferrer"
                target="_blank"
              >
                {linkProps.children}
              </a>
            )
          : undefined
      }
      role="listitem"
      style={style}
    >
      <ItemMedia>
        <PerplexityFavicon domain={source.domain} />
      </ItemMedia>
      <ItemContent className="min-w-0 flex-row items-center gap-2">
        <ItemTitle className="text-pplx-muted line-clamp-none block w-auto min-w-0 truncate text-sm leading-5 font-normal transition-colors">
          {source.title}
        </ItemTitle>
        <span className="text-pplx-subtle flex shrink-0 items-center gap-1 text-sm leading-5">
          <span className="max-w-36 truncate">
            {perplexityShortDomain(source.domain)}
          </span>
          {source.verified === false ? null : (
            <PerplexityShieldIcon className="size-3" />
          )}
        </span>
      </ItemContent>
    </Item>
  );
};

const useControllableOpen = (
  open: boolean | undefined,
  defaultOpen: boolean,
  onChange: ((next: boolean) => void) | undefined
) => {
  const [uncontrolled, setUncontrolled] = useState(defaultOpen);
  const value = open ?? uncontrolled;
  const setValue = (next: boolean) => {
    if (open === undefined) {
      setUncontrolled(next);
    }
    onChange?.(next);
  };
  return [value, setValue, setUncontrolled] as const;
};

interface SearchFooterProps {
  extraSources: readonly PerplexitySource[];
  hiddenCount: number;
  labels: PerplexitySearchProps["labels"];
  sequential: boolean;
}

const SearchFooter = ({
  extraSources,
  hiddenCount,
  labels,
  sequential,
}: SearchFooterProps) => {
  const [extrasOpen, setExtrasOpen] = useState(false);
  const moreLabel = labels?.more(hiddenCount) ?? `+${hiddenCount} more`;

  if (hiddenCount === 0) {
    return null;
  }

  if (extraSources.length === 0) {
    return (
      <p
        className={cn(
          "text-pplx-subtle mt-1.5 text-sm leading-5",
          sequential && ENTER_CLASS
        )}
      >
        {moreLabel}
      </p>
    );
  }

  return (
    <Collapsible onOpenChange={setExtrasOpen} open={extrasOpen}>
      <CollapsibleContent className={PANEL_CLASS} keepMounted>
        <ItemGroup className="min-h-0 gap-1.5 overflow-hidden pb-1.5">
          {extraSources.map((source) => (
            <SourceRow
              key={`${source.domain}-${source.title}`}
              source={source}
            />
          ))}
        </ItemGroup>
      </CollapsibleContent>
      <CollapsibleTrigger
        render={
          <Button
            className={cn(
              "text-pplx-subtle hover:text-pplx-secondary focus-visible:ring-pplx-ring dark:hover:text-pplx-secondary mt-1.5 h-auto rounded-sm border-0 p-0 text-sm leading-5 font-normal no-underline transition-colors hover:no-underline focus-visible:border-transparent focus-visible:ring-2 active:not-aria-[haspopup]:translate-y-0",
              sequential && ENTER_CLASS
            )}
            variant="link"
          />
        }
      >
        {extrasOpen ? (labels?.showLess ?? "Show less") : moreLabel}
      </CollapsibleTrigger>
    </Collapsible>
  );
};

interface SourcesBodyProps extends SearchFooterProps {
  emptyDescription: ReactNode;
  previewSources: readonly PerplexitySource[];
  show: boolean;
  totalSources: number;
}

const SourcesBody = ({
  emptyDescription,
  previewSources,
  show,
  totalSources,
  ...footerProps
}: SourcesBodyProps) => {
  if (show && totalSources > 0) {
    return (
      <div>
        <ItemGroup className="gap-1.5">
          {previewSources.map((source, index) => (
            <SourceRow
              className={
                footerProps.sequential
                  ? cn(ENTER_CLASS, "delay-(--pplx-delay)")
                  : undefined
              }
              key={`${source.domain}-${source.title}`}
              source={source}
              style={
                {
                  "--pplx-delay": `${index * PERPLEXITY_SEARCH_STAGGER_MS}ms`,
                } as CSSProperties
              }
            />
          ))}
        </ItemGroup>
        <SearchFooter {...footerProps} />
      </div>
    );
  }

  if (!footerProps.sequential && emptyDescription) {
    return (
      <Empty className="items-start rounded-none border-0 p-0 text-left md:p-0">
        <EmptyDescription className="text-pplx-subtle text-sm leading-5">
          {emptyDescription}
        </EmptyDescription>
      </Empty>
    );
  }

  return null;
};

interface SearchStepProps {
  onOpenChange: (next: boolean) => void;
  open: boolean;
  queries: readonly string[];
  sequential: boolean;
  sourcesBody: ReactNode;
  title: ReactNode;
  visibleQueryCount: number;
}

const SearchStep = ({
  onOpenChange,
  open,
  queries,
  sequential,
  sourcesBody,
  title,
  visibleQueryCount,
}: SearchStepProps) => (
  <Collapsible onOpenChange={onOpenChange} open={open}>
    <CollapsibleTrigger
      render={
        <Button className={cn(TRIGGER_CLASS, "group/step")} variant="ghost" />
      }
    >
      <SearchStepHeader title={title} />
      <ChevronDown
        className={cn(
          CHEVRON_CLASS,
          "-rotate-90 group-data-panel-open/step:rotate-0"
        )}
        strokeWidth={1.75}
      />
    </CollapsibleTrigger>
    <CollapsibleContent className={PANEL_CLASS} keepMounted>
      <div className="min-h-0 overflow-hidden">
        <div className="flex flex-col gap-1.5 ps-7.5 pt-2">
          <ItemGroup className="gap-1.5">
            {queries.slice(0, visibleQueryCount).map((query) => (
              <Item
                className={cn(
                  ROW_CLASS,
                  "text-pplx-muted items-start",
                  sequential && ENTER_CLASS
                )}
                key={query}
                role="listitem"
              >
                <ItemMedia className="h-5">
                  <Search
                    className="text-pplx-subtle size-4"
                    strokeWidth={1.75}
                  />
                </ItemMedia>
                <ItemContent className="min-w-0 gap-0">
                  <ItemTitle className="line-clamp-none w-auto text-sm leading-5 font-normal">
                    {query}
                  </ItemTitle>
                </ItemContent>
              </Item>
            ))}
          </ItemGroup>
          {sourcesBody}
        </div>
      </div>
    </CollapsibleContent>
  </Collapsible>
);

const SearchStepHeader = ({ title }: { title: ReactNode }) => (
  <>
    <span className="flex size-6 shrink-0 items-center justify-center">
      <PerplexityWebSearchIcon className="text-pplx-muted size-4" />
    </span>
    <span className="text-pplx-muted min-w-0">{title}</span>
  </>
);

export const PerplexitySearch = ({
  className,
  defaultOpen = false,
  duration,
  emptyDescription,
  extraCount,
  label = "Researched",
  labels,
  onOpenChange,
  onStepOpenChange,
  open: openProp,
  previewCount = PERPLEXITY_SEARCH_PREVIEW_COUNT,
  queries,
  reducedMotion = false,
  runningLabel = "Researching…",
  sequential = false,
  sources,
  stepDefaultOpen = false,
  stepOpen: stepOpenProp,
  title = "Searching the web",
  ...props
}: PerplexitySearchProps) => {
  const shouldSequence = sequential && !reducedMotion;
  const previewSources = sources.slice(0, Math.max(previewCount, 0));
  const extraSources = sources.slice(previewSources.length);
  const hiddenCount = extraSources.length || extraCount || 0;
  const [uncontrolledOpen, setOpenState] = useState(defaultOpen);
  const open = openProp ?? uncontrolledOpen;
  const [stepOpen, setStepOpen, setStepOpenState] = useControllableOpen(
    stepOpenProp,
    stepDefaultOpen,
    onStepOpenChange
  );

  // Stable identities: the sequence effect restarts when these change.
  const handleStart = useCallback(() => {
    setOpenState(true);
    setStepOpenState(true);
  }, [setStepOpenState]);
  const handleDone = useCallback(() => {
    setOpenState(false);
    setStepOpenState(false);
  }, [setStepOpenState]);

  const progress = usePerplexitySearchSequence({
    enabled: shouldSequence,
    onDone: handleDone,
    onStart: handleStart,
    previewCount: previewSources.length,
    queryCount: queries.length,
  });

  const running = shouldSequence && !progress.done;
  const hasBody =
    queries.length > 0 || sources.length > 0 || Boolean(emptyDescription);

  const sourcesBody = (
    <SourcesBody
      emptyDescription={emptyDescription}
      extraSources={extraSources}
      hiddenCount={hiddenCount}
      labels={labels}
      previewSources={previewSources}
      sequential={shouldSequence}
      show={shouldSequence ? progress.sources : sources.length > 0}
      totalSources={sources.length}
    />
  );

  return (
    <Collapsible
      className={cn("font-pplx w-full max-w-2xl", className)}
      data-slot="perplexity-search"
      onOpenChange={(next, details) => {
        if (openProp === undefined) {
          setOpenState(next);
        }
        onOpenChange?.(next, details);
      }}
      open={open}
      {...props}
    >
      <CollapsibleTrigger
        render={
          <Button
            className={cn(TRIGGER_CLASS, "group/search text-pplx-fg")}
            variant="ghost"
          />
        }
      >
        {running ? (
          <Shimmer className="[--shimmer-highlight:var(--color-pplx-fg)]">
            {runningLabel}
          </Shimmer>
        ) : (
          <span>
            {label}
            {duration ? (
              <span className="text-pplx-subtle"> {duration}</span>
            ) : null}
          </span>
        )}
        <ChevronDown
          className={cn(
            CHEVRON_CLASS,
            "-rotate-90 group-data-panel-open/search:rotate-0"
          )}
          strokeWidth={1.75}
        />
      </CollapsibleTrigger>
      <CollapsibleContent className={PANEL_CLASS} keepMounted>
        <div className="min-h-0 overflow-hidden">
          <div className="flex gap-4 pt-2 pb-1">
            <Separator className="bg-pplx-border" orientation="vertical" />
            <div className="min-w-0 flex-1">
              {hasBody ? (
                <SearchStep
                  onOpenChange={setStepOpen}
                  open={stepOpen}
                  queries={queries}
                  sequential={shouldSequence}
                  sourcesBody={sourcesBody}
                  title={title}
                  visibleQueryCount={
                    shouldSequence ? progress.queries : queries.length
                  }
                />
              ) : (
                <div className="flex items-center gap-1.5 text-sm leading-5">
                  <SearchStepHeader title={title} />
                </div>
              )}
            </div>
          </div>
        </div>
      </CollapsibleContent>
    </Collapsible>
  );
};
```

### `registry/notra/perplexity/components/perplexity-citation.tsx`

```tsx
"use client";

import { cn } from "cn";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useState } from "react";

import { badgeVariants } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from "@/components/ui/hover-card";
import { Separator } from "@/components/ui/separator";

import {
  PERPLEXITY_CITATION_CLOSE_DELAY_MS,
  PERPLEXITY_CITATION_OPEN_DELAY_MS,
} from "../constants/perplexity";
import type {
  PerplexityCitationProps,
  PerplexityCitationSource,
} from "../types/perplexity";
import { PerplexityFavicon } from "./perplexity-favicon";
import { PerplexityShieldIcon } from "./perplexity-icons";

const STACK_LIMIT = 3;

const siteName = (domain: string) => {
  const parts = domain.replace(/^www\./, "").split(".");
  return parts.length > 1 ? parts.slice(0, -1).join(".") : domain;
};

interface CitationCardProps {
  sources: readonly PerplexityCitationSource[];
}

const CitationCard = ({ sources }: CitationCardProps) => {
  const [index, setIndex] = useState(0);
  const source = sources[index] as PerplexityCitationSource;
  const step = (delta: number) =>
    setIndex((current) => (current + delta + sources.length) % sources.length);

  return (
    <>
      <div className="flex items-center justify-between gap-3 px-2 py-1.5">
        <div className="text-pplx-muted flex items-center gap-0.5 text-xs tabular-nums">
          <Button
            aria-label="Previous source"
            className="text-pplx-muted hover:bg-pplx-hover hover:text-pplx-fg dark:hover:bg-pplx-hover size-6 rounded-full"
            disabled={sources.length < 2}
            onClick={() => step(-1)}
            size="icon"
            variant="ghost"
          >
            <ChevronLeft className="size-3.5" strokeWidth={1.75} />
          </Button>
          <span aria-live="polite" className="px-1">
            {index + 1}/{sources.length}
          </span>
          <Button
            aria-label="Next source"
            className="text-pplx-muted hover:bg-pplx-hover hover:text-pplx-fg dark:hover:bg-pplx-hover size-6 rounded-full"
            disabled={sources.length < 2}
            onClick={() => step(1)}
            size="icon"
            variant="ghost"
          >
            <ChevronRight className="size-3.5" strokeWidth={1.75} />
          </Button>
        </div>
        <div className="text-pplx-muted flex items-center gap-1.5 text-xs">
          <span aria-hidden="true" className="flex items-center -space-x-1">
            {sources.slice(0, STACK_LIMIT).map((item) => (
              <PerplexityFavicon
                className="ring-pplx-popover size-4 ring-2"
                domain={item.domain}
                key={item.domain}
              />
            ))}
          </span>
          {sources.length} {sources.length === 1 ? "source" : "sources"}
        </div>
      </div>
      <Separator className="bg-pplx-border" />
      <div className="flex flex-col gap-1.5 p-3">
        <div className="text-pplx-muted flex items-center gap-1.5 text-xs">
          <PerplexityFavicon className="size-4" domain={source.domain} />
          {siteName(source.domain)}
        </div>
        <p className="text-pplx-fg text-[0.9375rem] leading-5 font-medium text-balance">
          {source.url ? (
            <a
              className="hover:underline"
              href={source.url}
              rel="noopener noreferrer"
              target="_blank"
            >
              {source.title}
            </a>
          ) : (
            source.title
          )}
        </p>
        {source.description ? (
          <p className="text-pplx-muted text-[0.8125rem] leading-5">
            {source.description}
          </p>
        ) : null}
      </div>
    </>
  );
};

export const PerplexityCitation = ({
  className,
  extra,
  label,
  sources,
  ...props
}: PerplexityCitationProps) => {
  const chipClassName = cn(
    badgeVariants({ variant: "secondary" }),
    "bg-pplx-fg/7 text-pplx-fg hover:bg-pplx-fg/12 relative mx-1 h-5 max-w-full translate-y-[-0.1em] gap-1 rounded-md border-0 px-[0.3rem] py-0.75 align-middle font-mono text-[10px] leading-[13.75px] font-normal",
    className
  );
  const chip = (
    <>
      <PerplexityShieldIcon className="text-pplx-muted size-3" />
      <span className="max-w-36 truncate">{label}</span>
      {extra ? <span className="text-pplx-subtle">+{extra}</span> : null}
    </>
  );

  if (!sources || sources.length === 0) {
    return (
      <span
        className={chipClassName}
        data-slot="perplexity-citation"
        {...props}
      >
        {chip}
      </span>
    );
  }

  const [first] = sources;

  return (
    <HoverCard>
      <HoverCardTrigger
        aria-label={extra ? `${label} (+${extra})` : label}
        className={chipClassName}
        closeDelay={PERPLEXITY_CITATION_CLOSE_DELAY_MS}
        data-slot="perplexity-citation"
        delay={PERPLEXITY_CITATION_OPEN_DELAY_MS}
        href={first?.url}
        rel="noopener noreferrer"
        target="_blank"
      >
        {chip}
      </HoverCardTrigger>
      <HoverCardContent
        align="start"
        className="bg-pplx-popover font-pplx text-pplx-fg ring-pplx-border w-[min(20rem,calc(100vw-2rem))] gap-0 rounded-xl p-0 shadow-[0_8px_28px_rgb(0_0_0/0.12)] dark:shadow-[0_8px_28px_rgb(0_0_0/0.45)]"
        sideOffset={8}
      >
        <CitationCard sources={sources} />
      </HoverCardContent>
    </HoverCard>
  );
};
```

### `registry/notra/perplexity/components/perplexity-favicon.tsx`

```tsx
import { cn } from "cn";
import { Globe } from "lucide-react";

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";

import type { PerplexityFaviconProps } from "../types/perplexity";

export const perplexityFaviconSrc = (domain: string) =>
  `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=64`;

export const PerplexityFavicon = ({
  className,
  domain,
  ...props
}: PerplexityFaviconProps) => (
  <Avatar
    className={cn("size-4 after:hidden", className)}
    data-slot="perplexity-favicon"
    {...props}
  >
    <AvatarImage alt="" src={perplexityFaviconSrc(domain)} />
    <AvatarFallback className="text-pplx-subtle bg-transparent">
      <Globe className="size-full" strokeWidth={1.75} />
    </AvatarFallback>
  </Avatar>
);
```

### `registry/notra/perplexity/components/perplexity-actions.tsx`

```tsx
"use client";

import { cn } from "cn";
import {
  Check,
  Copy,
  Download,
  Ellipsis,
  RefreshCw,
  Share,
  ThumbsDown,
  ThumbsUp,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { ComponentProps, ReactElement, ReactNode } from "react";

import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip";

import {
  PERPLEXITY_COPIED_RESET_MS,
  PERPLEXITY_TOOLTIP_DELAY_MS,
} from "../constants/perplexity";
import type { PerplexityActionsProps } from "../types/perplexity";
import { PerplexityRewritePanel } from "./perplexity-rewrite-panel";
import { PerplexitySourcesSummary } from "./perplexity-sources-summary";

const ICON_STROKE = 1.75;

interface ActionButtonProps extends ComponentProps<typeof Button> {
  children: ReactNode;
  label: string;
  /** Wraps the button in another trigger, like a popover or menu trigger. */
  trigger?: (button: ReactElement) => ReactElement;
}

const ActionButton = ({
  children,
  label,
  trigger,
  ...props
}: ActionButtonProps) => {
  const button = (
    <Button
      aria-label={label}
      className="text-pplx-muted hover:bg-pplx-hover hover:text-pplx-fg focus-visible:ring-pplx-ring aria-expanded:bg-pplx-hover aria-expanded:text-pplx-fg dark:hover:bg-pplx-hover size-8 rounded-full transition-[color,background-color,transform] duration-150 focus-visible:border-transparent focus-visible:ring-2 active:scale-[0.96] active:not-aria-[haspopup]:translate-y-0 motion-reduce:transition-none [&_svg]:size-4"
      size="icon"
      variant="ghost"
      {...props}
    />
  );

  return (
    <Tooltip>
      <TooltipTrigger render={trigger ? trigger(button) : button}>
        {children}
      </TooltipTrigger>
      <TooltipContent
        className="bg-pplx-fg font-pplx text-pplx-bg [&>div]:bg-pplx-fg [&>div]:fill-pplx-fg rounded-md border-0 bg-none px-3 py-1.5 shadow-none [corner-shape:round]"
        side="bottom"
        sideOffset={6}
      >
        {label}
      </TooltipContent>
    </Tooltip>
  );
};

const MENU_SURFACE_CLASS =
  "bg-pplx-popover font-pplx text-pplx-fg rounded-2xl p-1.5 shadow-[0_8px_28px_rgb(0_0_0/0.12)] ring-0 dark:shadow-[0_8px_28px_rgb(0_0_0/0.45)]";

const MENU_ITEM_CLASS =
  "focus:bg-pplx-hover focus:text-pplx-fg data-highlighted:bg-pplx-hover data-open:bg-pplx-hover data-popup-open:bg-pplx-hover h-10 cursor-pointer gap-3 rounded-xl px-3 text-[0.9375rem] focus:**:text-inherit [&_svg:not([class*='size-'])]:size-4";

export const PerplexityActions = ({
  className,
  onBadResponse,
  onDownload,
  onGoodResponse,
  models,
  onMore,
  onRewrite,
  onShare,
  sources,
  text,
  ...props
}: PerplexityActionsProps) => {
  const [copied, setCopied] = useState(false);
  const [rewriteOpen, setRewriteOpen] = useState(false);
  const [moreOpen, setMoreOpen] = useState(false);
  const resetRef = useRef<number | undefined>(undefined);

  useEffect(() => () => window.clearTimeout(resetRef.current), []);

  const copy = async () => {
    try {
      await navigator.clipboard.writeText(text);
    } catch {
      return;
    }
    setCopied(true);
    window.clearTimeout(resetRef.current);
    resetRef.current = window.setTimeout(
      () => setCopied(false),
      PERPLEXITY_COPIED_RESET_MS
    );
  };

  return (
    <TooltipProvider delay={PERPLEXITY_TOOLTIP_DELAY_MS}>
      <div
        className={cn(
          "flex w-full min-w-0 flex-wrap items-center gap-y-1",
          className
        )}
        data-slot="perplexity-actions"
        {...props}
      >
        <div className="flex items-center gap-1">
          <ActionButton label="Share" onClick={onShare}>
            <Share strokeWidth={ICON_STROKE} />
          </ActionButton>
          <ActionButton label="Download" onClick={onDownload}>
            <Download strokeWidth={ICON_STROKE} />
          </ActionButton>
          <ActionButton label={copied ? "Copied" : "Copy"} onClick={copy}>
            {copied ? (
              <Check strokeWidth={ICON_STROKE} />
            ) : (
              <Copy strokeWidth={ICON_STROKE} />
            )}
          </ActionButton>
          <Popover onOpenChange={setRewriteOpen} open={rewriteOpen}>
            <ActionButton
              label="Rewrite"
              trigger={(button) => <PopoverTrigger render={button} />}
            >
              <RefreshCw strokeWidth={ICON_STROKE} />
            </ActionButton>
            <PopoverContent
              align="start"
              className={cn(MENU_SURFACE_CLASS, "w-auto gap-0")}
              side="top"
              sideOffset={8}
            >
              <PerplexityRewritePanel
                models={models}
                onClose={() => setRewriteOpen(false)}
                onRewrite={onRewrite}
              />
            </PopoverContent>
          </Popover>
        </div>

        {sources && sources.length > 0 ? (
          <PerplexitySourcesSummary sources={sources} />
        ) : null}

        <div className="ms-auto flex items-center gap-1">
          <ActionButton label="Good response" onClick={onGoodResponse}>
            <ThumbsUp strokeWidth={ICON_STROKE} />
          </ActionButton>
          <ActionButton label="Bad response" onClick={onBadResponse}>
            <ThumbsDown strokeWidth={ICON_STROKE} />
          </ActionButton>
          <DropdownMenu onOpenChange={setMoreOpen} open={moreOpen}>
            <ActionButton
              label="More"
              onClick={onMore}
              trigger={(button) => <DropdownMenuTrigger render={button} />}
            >
              <Ellipsis strokeWidth={ICON_STROKE} />
            </ActionButton>
            <DropdownMenuContent
              align="end"
              className={cn(MENU_SURFACE_CLASS, "min-w-44")}
              side="bottom"
              sideOffset={8}
            >
              <DropdownMenuItem
                className={MENU_ITEM_CLASS}
                onClick={onDownload}
              >
                <Download strokeWidth={ICON_STROKE} />
                Download
              </DropdownMenuItem>
              <DropdownMenuSub>
                <DropdownMenuSubTrigger className={MENU_ITEM_CLASS}>
                  <RefreshCw strokeWidth={ICON_STROKE} />
                  Rewrite
                </DropdownMenuSubTrigger>
                <DropdownMenuSubContent
                  className={cn(MENU_SURFACE_CLASS, "w-auto")}
                  sideOffset={8}
                >
                  <PerplexityRewritePanel
                    models={models}
                    onClose={() => setMoreOpen(false)}
                    onRewrite={onRewrite}
                  />
                </DropdownMenuSubContent>
              </DropdownMenuSub>
            </DropdownMenuContent>
          </DropdownMenu>
        </div>
      </div>
    </TooltipProvider>
  );
};
```

### `registry/notra/perplexity/components/perplexity-sources-summary.tsx`

```tsx
import { cn } from "cn";

import { AvatarGroup } from "@/components/ui/avatar";

import { PERPLEXITY_SOURCES_PREVIEW_COUNT } from "../constants/perplexity";
import type {
  PerplexitySource,
  PerplexitySourcesSummaryProps,
} from "../types/perplexity";
import { PerplexityFavicon } from "./perplexity-favicon";

const previewSources = (sources: readonly PerplexitySource[]) => {
  const seen = new Set<string>();
  const preview: PerplexitySource[] = [];

  for (const source of sources) {
    if (seen.has(source.domain)) {
      continue;
    }
    seen.add(source.domain);
    preview.push(source);
    if (preview.length === PERPLEXITY_SOURCES_PREVIEW_COUNT) {
      break;
    }
  }

  return preview;
};

export const PerplexitySourcesSummary = ({
  className,
  sources,
  ...props
}: PerplexitySourcesSummaryProps) => {
  if (sources.length === 0) {
    return null;
  }

  const preview = previewSources(sources);

  return (
    <div
      className={cn(
        "font-pplx text-pplx-muted ms-1.5 flex h-8 items-center gap-2 px-2 text-sm leading-5",
        className
      )}
      data-slot="perplexity-sources-summary"
      {...props}
    >
      <AvatarGroup aria-hidden="true" className="-space-x-1.5">
        {preview.map((source) => (
          <PerplexityFavicon
            className="bg-pplx-bg ring-pplx-bg size-4 ring-[1.5px]"
            domain={source.domain}
            key={source.domain}
          />
        ))}
      </AvatarGroup>
      <span>
        {sources.length} {sources.length === 1 ? "source" : "sources"}
      </span>
    </div>
  );
};
```

### `registry/notra/perplexity/components/perplexity-composer.tsx`

```tsx
"use client";

import { cn } from "cn";
import { ArrowUp, Mic, Plus, Search, Square } from "lucide-react";
import { useState } from "react";
import type { FormEvent, KeyboardEvent, MouseEvent } from "react";

import { Badge } from "@/components/ui/badge";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupTextarea,
} from "@/components/ui/input-group";

import {
  PERPLEXITY_DEFAULT_FOCUS,
  PERPLEXITY_FOCUS_OPTIONS,
  PERPLEXITY_MODELS,
  PERPLEXITY_SEARCH_FOCUS,
} from "../constants/perplexity";
import type { PerplexityComposerProps } from "../types/perplexity";
import { PerplexityModelSelector } from "./perplexity-model-selector";

const ICON_STROKE = 1.75;

const ICON_BUTTON_CLASS =
  "text-pplx-muted hover:bg-pplx-hover hover:text-pplx-fg focus-visible:ring-pplx-ring aria-expanded:bg-pplx-hover aria-expanded:text-pplx-fg dark:hover:bg-pplx-hover dark:aria-expanded:bg-pplx-hover size-8 shrink-0 rounded-full border-0 transition-[background-color,transform] duration-150 focus-visible:border-transparent focus-visible:ring-2 active:not-aria-[haspopup]:translate-y-0 active:scale-[0.96] motion-reduce:transition-none";

export const PerplexityComposer = ({
  busy = false,
  className,
  defaultModel,
  focus = PERPLEXITY_DEFAULT_FOCUS,
  focusOptions = PERPLEXITY_FOCUS_OPTIONS,
  model: modelProp,
  models = PERPLEXITY_MODELS,
  onModelChange,
  onSend,
  onStop,
  placeholder = "Ask a follow-up",
  ...props
}: PerplexityComposerProps) => {
  const [value, setValue] = useState("");
  const [uncontrolledModel, setUncontrolledModel] = useState(defaultModel);
  const model = modelProp ?? uncontrolledModel;
  const selectedFocus =
    focusOptions.find((option) => option.id === focus) ??
    PERPLEXITY_SEARCH_FOCUS;
  const canSend = value.trim().length > 0;
  const enabled = busy || canSend;

  const submit = () => {
    const text = value.trim();
    if (!text || busy) {
      return;
    }
    onSend?.(text);
    if (onSend) {
      setValue("");
    }
  };

  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    submit();
  };

  const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
    if (event.key === "Enter" && !event.shiftKey) {
      event.preventDefault();
      submit();
    }
  };

  const handleStop = (event: MouseEvent<HTMLButtonElement>) => {
    event.preventDefault();
    onStop?.();
  };

  const setModel = (next: string) => {
    if (modelProp === undefined) {
      setUncontrolledModel(next);
    }
    onModelChange?.(next);
  };

  return (
    <form
      className={cn("font-pplx w-full", className)}
      data-slot="perplexity-composer"
      onSubmit={handleSubmit}
      {...props}
    >
      <InputGroup className="border-pplx-border bg-pplx-surface has-disabled:bg-pplx-surface dark:has-disabled:bg-pplx-surface has-[[data-slot=input-group-control]:focus-visible]:border-pplx-border dark:bg-pplx-surface h-auto flex-col rounded-2xl px-3 pt-3 pb-3 shadow-[0_1px_2px_rgb(0_0_0/0.04),0_8px_24px_rgb(0_0_0/0.04)] has-disabled:opacity-100 has-[[data-slot=input-group-control]:focus-visible]:ring-0 dark:shadow-none">
        <InputGroupTextarea
          aria-label="Follow-up"
          className="text-pplx-fg placeholder:text-pplx-subtle max-h-80 min-h-11 w-full overflow-y-auto px-2 pt-1 pb-2 text-base leading-6 md:text-base"
          onChange={(event) => setValue(event.target.value)}
          onKeyDown={handleKeyDown}
          placeholder={placeholder}
          rows={1}
          value={value}
        />
        <InputGroupAddon
          align="block-end"
          className="text-pplx-muted cursor-default flex-wrap justify-between gap-2 p-0 font-normal"
        >
          <div className="flex min-w-0 items-center gap-0.5">
            <InputGroupButton
              aria-label="Add"
              className={ICON_BUTTON_CLASS}
              size="icon-sm"
            >
              <Plus className="size-4" strokeWidth={ICON_STROKE} />
            </InputGroupButton>
            <Badge
              className="border-pplx-border text-pplx-fg h-8 gap-1 rounded-full bg-transparent px-3 text-sm leading-5 font-normal"
              data-slot="perplexity-composer-focus"
              variant="outline"
            >
              <Search className="size-3.5" strokeWidth={ICON_STROKE} />
              {selectedFocus.label}
            </Badge>
          </div>
          <div className="ms-auto flex shrink-0 items-center gap-0.5">
            <PerplexityModelSelector
              model={model}
              models={models}
              onModelChange={setModel}
            />
            <InputGroupButton
              aria-label="Voice input"
              className={ICON_BUTTON_CLASS}
              size="icon-sm"
            >
              <Mic className="size-4" strokeWidth={ICON_STROKE} />
            </InputGroupButton>
            <InputGroupButton
              aria-label={busy ? "Stop" : "Send"}
              className={cn(
                "focus-visible:ring-pplx-ring size-8 shrink-0 rounded-full border-0 transition-[background-color,color,opacity,transform] duration-150 focus-visible:border-transparent focus-visible:ring-2 active:scale-[0.96] active:not-aria-[haspopup]:translate-y-0 disabled:opacity-100 motion-reduce:transition-none",
                enabled
                  ? "bg-pplx-send text-pplx-send-fg hover:bg-pplx-send-hover"
                  : "bg-pplx-send-disabled text-pplx-send-disabled-fg"
              )}
              disabled={!enabled}
              onClick={busy ? handleStop : undefined}
              size="icon-sm"
              type={canSend && !busy ? "submit" : "button"}
              variant="ghost"
            >
              {busy ? (
                <Square className="size-3 fill-current" strokeWidth={1.75} />
              ) : (
                <ArrowUp className="size-4" strokeWidth={1.75} />
              )}
            </InputGroupButton>
          </div>
        </InputGroupAddon>
      </InputGroup>
    </form>
  );
};
```

### `registry/notra/perplexity/components/perplexity-model-selector.tsx`

```tsx
"use client";

import { cn } from "cn";
import { ChevronDown, ChevronRight, LockKeyhole } from "lucide-react";
import { useState } from "react";

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";

import { PERPLEXITY_MODELS } from "../constants/perplexity";
import type {
  PerplexityModelBadge,
  PerplexityModelSelectorProps,
} from "../types/perplexity";
import { PerplexityModelIcon } from "./perplexity-model-icon";

const MODEL_ROW_CLASS =
  "text-pplx-subtle focus:bg-pplx-hover focus:text-pplx-fg data-highlighted:bg-pplx-hover [&_[data-slot=dropdown-menu-radio-item-indicator]]:text-pplx-fg h-9 cursor-pointer gap-2.5 rounded-[0.7rem] px-2.5 text-sm focus:**:text-inherit data-checked:pe-8";

const ModelBadge = ({ badge }: { badge: PerplexityModelBadge }) => (
  <Badge
    className={cn(
      "h-auto rounded-full border-0 px-1.5 py-px text-[9px] leading-none font-medium",
      badge === "max"
        ? "bg-pplx-badge text-pplx-badge-fg"
        : "bg-pplx-badge-new text-pplx-badge-new-fg"
    )}
  >
    {badge === "max" ? "Max" : "New"}
  </Badge>
);

export const PerplexityModelSelector = ({
  className,
  label = "Model",
  model,
  models = PERPLEXITY_MODELS,
  onModelChange,
  promoLabel = "Access the top AI models",
  ...props
}: PerplexityModelSelectorProps) => {
  const [open, setOpen] = useState(false);

  return (
    <DropdownMenu modal={false} onOpenChange={setOpen} open={open}>
      <DropdownMenuTrigger
        render={
          <Button
            aria-label="Model"
            className={cn(
              "font-pplx text-pplx-muted hover:bg-pplx-hover hover:text-pplx-fg focus-visible:ring-pplx-ring aria-expanded:bg-pplx-hover aria-expanded:text-pplx-fg dark:hover:bg-pplx-hover h-8 shrink-0 gap-1 rounded-full border-0 bg-transparent px-3 text-sm leading-5 font-medium transition-[background-color,transform] duration-150 focus-visible:border-transparent focus-visible:ring-2 active:scale-[0.96] motion-reduce:transition-none",
              className
            )}
            data-slot="perplexity-model-selector"
            variant="ghost"
            {...props}
          />
        }
      >
        <span>{label}</span>
        <ChevronDown
          className={cn(
            "size-4 transition-transform duration-150 motion-reduce:transition-none",
            open && "rotate-180"
          )}
          strokeWidth={1.75}
        />
      </DropdownMenuTrigger>
      <DropdownMenuContent
        align="end"
        className="bg-pplx-popover font-pplx text-pplx-fg w-80 min-w-80 rounded-[1.15rem] p-1.5 shadow-[0_8px_28px_rgb(0_0_0/0.12)] ring-0 dark:shadow-[0_8px_28px_rgb(0_0_0/0.45)]"
        side="top"
        sideOffset={10}
      >
        <DropdownMenuItem className="bg-pplx-promo text-pplx-fg focus:bg-pplx-promo-hover focus:text-pplx-fg data-highlighted:bg-pplx-promo-hover mb-0.5 h-9 justify-between gap-3 rounded-[0.85rem] px-3 text-sm font-medium">
          <span>{promoLabel}</span>
          <ChevronRight className="text-pplx-fg size-3.5" strokeWidth={1.75} />
        </DropdownMenuItem>
        <DropdownMenuRadioGroup
          onValueChange={(value: string) => onModelChange?.(value)}
          value={model ?? ""}
        >
          {models.map((item) => {
            const content = (
              <>
                <PerplexityModelIcon provider={item.provider} />
                <span className="flex min-w-0 flex-1 items-center gap-1.5">
                  <span className="truncate">{item.label}</span>
                  {item.badge ? <ModelBadge badge={item.badge} /> : null}
                </span>
                {item.locked ? (
                  <LockKeyhole
                    aria-label="Locked"
                    className="text-pplx-lock size-3.5"
                    strokeWidth={1.75}
                  />
                ) : null}
              </>
            );

            // Locked models still highlight on hover like the real menu, they just can't be picked.
            return item.locked ? (
              <DropdownMenuItem
                aria-disabled="true"
                className={MODEL_ROW_CLASS}
                closeOnClick={false}
                data-locked=""
                key={item.id}
              >
                {content}
              </DropdownMenuItem>
            ) : (
              <DropdownMenuRadioItem
                className={MODEL_ROW_CLASS}
                key={item.id}
                value={item.id}
              >
                {content}
              </DropdownMenuRadioItem>
            );
          })}
        </DropdownMenuRadioGroup>
      </DropdownMenuContent>
    </DropdownMenu>
  );
};
```

### `registry/notra/perplexity/components/perplexity-model-icon.tsx`

```tsx
import { cn } from "cn";
import type { ComponentType, SVGProps } from "react";

import type {
  PerplexityModelIconProps,
  PerplexityModelProvider,
} from "../types/perplexity";
import {
  PerplexityClaudeIcon,
  PerplexityGeminiIcon,
  PerplexityGrokIcon,
  PerplexityKimiIcon,
  PerplexityLogoIcon,
  PerplexityNvidiaIcon,
  PerplexityOpenAIIcon,
  PerplexityZaiIcon,
} from "./perplexity-icons";

const PROVIDER_ICONS: Record<
  PerplexityModelProvider,
  ComponentType<SVGProps<SVGSVGElement>>
> = {
  anthropic: PerplexityClaudeIcon,
  google: PerplexityGeminiIcon,
  kimi: PerplexityKimiIcon,
  nvidia: PerplexityNvidiaIcon,
  openai: PerplexityOpenAIIcon,
  perplexity: PerplexityLogoIcon,
  xai: PerplexityGrokIcon,
  zhipu: PerplexityZaiIcon,
};

export const PerplexityModelIcon = ({
  className,
  provider,
  ...props
}: PerplexityModelIconProps) => {
  const Icon = PROVIDER_ICONS[provider];

  return (
    <span
      className={cn(
        "text-pplx-subtle flex size-4 shrink-0 items-center justify-center grayscale",
        className
      )}
      data-slot="perplexity-model-icon"
      {...props}
    >
      <Icon className="size-4" />
    </span>
  );
};
```

### `registry/notra/perplexity/components/perplexity-thinking.tsx`

```tsx
import { cn } from "cn";

import { Shimmer } from "../../shimmer/components/shimmer";
import type { PerplexityThinkingProps } from "../types/perplexity";

export const PerplexityThinking = ({
  className,
  label = "Thinking...",
  reducedMotion = false,
  ...props
}: PerplexityThinkingProps) => (
  <div
    aria-live="polite"
    className={cn(
      "font-pplx text-pplx-secondary text-sm leading-5 opacity-100 transition-opacity duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none starting:opacity-0",
      className
    )}
    data-slot="perplexity-thinking"
    {...props}
  >
    {reducedMotion ? (
      label
    ) : (
      <Shimmer className="font-medium [--shimmer-highlight:var(--color-pplx-fg)]">
        {label}
      </Shimmer>
    )}
  </div>
);
```

### `registry/notra/perplexity/components/perplexity-answer.tsx`

```tsx
import { cn } from "cn";

import type {
  PerplexityAnswerProps,
  PerplexityThreadCitation,
} from "../types/perplexity";
import { PerplexityCitation } from "./perplexity-citation";

const CITE_PATTERN = /(\{\{\w+(?:-\w+)*\}\})/;
const CITE_TOKEN_PATTERN = /^\{\{(\w+(?:-\w+)*)\}\}$/;
const BOLD_PATTERN = /(\*\*[^*]+\*\*)/;
const BOLD_MARK = "**";
const LIST_MARK = "- ";

interface Segment {
  offset: number;
  text: string;
}

const splitWithOffsets = (
  text: string,
  separator: string | RegExp,
  separatorLength: number
) => {
  const segments: Segment[] = [];
  let offset = 0;
  for (const part of text.split(separator)) {
    if (part.length > 0) {
      segments.push({ offset, text: part });
    }
    offset += part.length + separatorLength;
  }
  return segments;
};

const isBold = (text: string) =>
  text.startsWith(BOLD_MARK) && text.endsWith(BOLD_MARK);

interface InlineTextProps {
  citations: ReadonlyMap<string, PerplexityThreadCitation>;
  text: string;
}

const CitedText = ({ citations, text }: InlineTextProps) =>
  splitWithOffsets(text, CITE_PATTERN, 0).map((piece) => {
    const citeMatch = piece.text.match(CITE_TOKEN_PATTERN);
    if (!citeMatch) {
      return <span key={piece.offset}>{piece.text}</span>;
    }
    const citation = citations.get(citeMatch[1] ?? "");
    return citation ? (
      <PerplexityCitation
        extra={citation.extra}
        key={piece.offset}
        label={citation.label}
        sources={citation.sources}
      />
    ) : null;
  });

/** Bold is parsed first so a citation can sit inside a bold span. */
const InlineText = ({ citations, text }: InlineTextProps) => (
  <span className="min-w-0">
    {splitWithOffsets(text, BOLD_PATTERN, 0).map((part) =>
      isBold(part.text) ? (
        <strong className="font-bold" key={part.offset}>
          <CitedText citations={citations} text={part.text.slice(2, -2)} />
        </strong>
      ) : (
        <CitedText citations={citations} key={part.offset} text={part.text} />
      )
    )}
  </span>
);

export const PerplexityAnswer = ({
  citations = [],
  className,
  text,
  ...props
}: PerplexityAnswerProps) => {
  const lookup = new Map(citations.map((item) => [item.id, item]));
  const blocks = splitWithOffsets(text, "\n\n", 2);

  return (
    <div
      className={cn("flex flex-col gap-4", className)}
      data-slot="perplexity-answer"
      {...props}
    >
      {blocks.map((block) => {
        const lines = splitWithOffsets(block.text, "\n", 1);
        const isList = lines.every((line) => line.text.startsWith(LIST_MARK));
        const isHeading =
          lines.length === 1 &&
          isBold(block.text) &&
          !block.text.slice(2, -2).includes(BOLD_MARK);

        if (isHeading) {
          return (
            <h3
              className="font-pplx-serif mt-1 text-lg leading-7 font-semibold"
              key={block.offset}
            >
              <InlineText citations={lookup} text={block.text.slice(2, -2)} />
            </h3>
          );
        }

        if (isList) {
          return (
            <ul
              className="flex list-disc flex-col gap-2 ps-8.5"
              key={block.offset}
            >
              {lines.map((line) => (
                <li className="ps-1.5" key={line.offset}>
                  <InlineText
                    citations={lookup}
                    text={line.text.slice(LIST_MARK.length)}
                  />
                </li>
              ))}
            </ul>
          );
        }

        return (
          <p key={block.offset}>
            <InlineText citations={lookup} text={block.text} />
          </p>
        );
      })}
    </div>
  );
};
```

### `registry/notra/perplexity/components/perplexity-icons.tsx`

```tsx
"use client";

import { useId } from "react";
import type { ComponentProps } from "react";

export const PerplexityShieldIcon = (props: ComponentProps<"svg">) => (
  <svg aria-hidden="true" fill="none" viewBox="0 0 12 12" {...props}>
    <path
      d="M6 1.15 10.2 2.7v3.05c0 2.28-1.72 3.86-4.2 4.95-2.48-1.09-4.2-2.67-4.2-4.95V2.7L6 1.15Z"
      stroke="currentColor"
      strokeLinejoin="round"
      strokeWidth="0.9"
    />
    <path
      d="M4.15 6.05 5.35 7.2 7.9 4.55"
      stroke="currentColor"
      strokeLinecap="round"
      strokeLinejoin="round"
      strokeWidth="0.9"
    />
  </svg>
);

export const PerplexityWebSearchIcon = (props: ComponentProps<"svg">) => (
  <svg
    aria-hidden="true"
    fill="none"
    stroke="currentColor"
    strokeLinecap="round"
    strokeLinejoin="round"
    strokeWidth="1.75"
    viewBox="0 0 24 24"
    {...props}
  >
    <path d="M20.9 10.6A9 9 0 1 0 10.6 20.9" />
    <path d="M3.6 9h16.8M3.6 15h7.6" />
    <path d="M11.6 3a14.5 14.5 0 0 0 0 18" />
    <path d="M12.4 3a14.5 14.5 0 0 1 3.1 7.6" />
    <circle cx="17.2" cy="17.2" r="3" />
    <path d="m19.4 19.4 2.1 2.1" />
  </svg>
);

export const PerplexityLogoIcon = (props: ComponentProps<"svg">) => (
  <svg
    aria-hidden="true"
    fill="none"
    stroke="#20808d"
    strokeLinecap="round"
    strokeLinejoin="round"
    viewBox="0 0 48 48"
    {...props}
  >
    <path d="M24 4.5v39M13.73 16.573v-9.99L24 16.573m0 14.5L13.73 41.417V27.01L24 16.573m0 0l10.27-9.99v9.99" />
    <path d="M13.73 31.396H9.44V16.573h29.12v14.823h-4.29" />
    <path d="M24 16.573L34.27 27.01v14.407L24 31.073" />
  </svg>
);

export const PerplexityOpenAIIcon = (props: ComponentProps<"svg">) => (
  <svg
    aria-hidden="true"
    fill="currentColor"
    preserveAspectRatio="xMidYMid"
    viewBox="0 0 256 260"
    {...props}
  >
    <path d="M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z" />
  </svg>
);

export const PerplexityGeminiIcon = (props: ComponentProps<"svg">) => {
  const gradientId = `pplx-gemini-${useId().replaceAll(":", "")}`;

  return (
    <svg
      aria-hidden="true"
      fill="none"
      overflow="visible"
      viewBox="0 0 296 298"
      {...props}
    >
      <defs>
        <linearGradient
          gradientUnits="userSpaceOnUse"
          id={gradientId}
          x1="28"
          x2="268"
          y1="18"
          y2="280"
        >
          <stop stopColor="#439DDF" />
          <stop offset=".524" stopColor="#4F87ED" />
          <stop offset=".781" stopColor="#9476C5" />
          <stop offset=".888" stopColor="#BC688E" />
          <stop offset="1" stopColor="#D6645D" />
        </linearGradient>
      </defs>
      <path
        d="M141.201 4.886c2.282-6.17 11.042-6.071 13.184.148l5.985 17.37a184.004 184.004 0 0 0 111.257 113.049l19.304 6.997c6.143 2.227 6.156 10.91.02 13.155l-19.35 7.082a184.001 184.001 0 0 0-109.495 109.385l-7.573 20.629c-2.241 6.105-10.869 6.121-13.133.025l-7.908-21.296a184 184 0 0 0-109.02-108.658l-19.698-7.239c-6.102-2.243-6.118-10.867-.025-13.132l20.083-7.467A183.998 183.998 0 0 0 133.291 26.28l7.91-21.394Z"
        fill={`url(#${gradientId})`}
      />
    </svg>
  );
};

export const PerplexityClaudeIcon = (props: ComponentProps<"svg">) => (
  <svg
    aria-hidden="true"
    preserveAspectRatio="xMidYMid"
    viewBox="0 0 256 257"
    {...props}
  >
    <path
      d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z"
      fill="#D97757"
    />
  </svg>
);

export const PerplexityKimiIcon = (props: ComponentProps<"svg">) => (
  <svg aria-hidden="true" fill="currentColor" viewBox="0 0 24 24" {...props}>
    <path d="M21.765.351C22.998.351 24 1.353 24 2.586S22.998 4.82 21.765 4.82h-1.974c-.15 0-.26-.12-.26-.26V2.586A2.237 2.237 0 0 1 21.765.35M9.41 13.388l8.447-8.377c.16-.16.07-.471-.14-.471h-4.55s-.1.02-.14.06l-9.099 9.029c-.14.14-.35.02-.35-.21V4.81c0-.15-.1-.27-.221-.27H.22c-.12 0-.22.12-.22.27v18.57c0 .15.1.27.22.27h3.137c.12 0 .22-.12.22-.27v-3.79c0-.08.03-.16.08-.21l2.826-2.796c.07-.07.16-.08.241-.03l7.546 5.551a8.9 8.9 0 0 0 4.018 1.493c.12.01.23-.11.23-.27V19.76c0-.14-.08-.25-.19-.26a5.8 5.8 0 0 1-2.355-.942l-6.533-4.73c-.14-.09-.15-.32-.03-.441" />
  </svg>
);

export const PerplexityGrokIcon = (props: ComponentProps<"svg">) => (
  <svg
    aria-hidden="true"
    fill="currentColor"
    viewBox="0 0 1024 1024"
    {...props}
  >
    <path d="M395.479 633.828L735.91 381.105C752.599 368.715 776.454 373.548 784.406 392.792C826.26 494.285 807.561 616.253 724.288 699.996C641.016 783.739 525.151 802.104 419.247 760.277L303.556 814.143C469.49 928.202 670.987 899.995 796.901 773.282C896.776 672.843 927.708 535.937 898.785 412.476L899.047 412.739C857.105 231.37 909.358 158.874 1016.4 10.6326C1018.93 7.11771 1021.47 3.60279 1024 0L883.144 141.651V141.212L395.392 633.916" />
    <path d="M325.226 695.251C206.128 580.84 226.662 403.776 328.285 301.668C403.431 226.097 526.549 195.254 634.026 240.596L749.454 186.994C728.657 171.88 702.007 155.623 671.424 144.2C533.19 86.9942 367.693 115.465 255.323 228.382C147.234 337.081 113.244 504.215 171.613 646.833C215.216 753.423 143.739 828.818 71.7385 904.916C46.2237 931.893 20.6216 958.87 0 987.429L325.139 695.339" />
  </svg>
);

export const PerplexityZaiIcon = (props: ComponentProps<"svg">) => (
  <svg
    aria-hidden="true"
    fill="currentColor"
    fillRule="evenodd"
    viewBox="0 0 24 24"
    {...props}
  >
    <path d="M12.105 2 9.927 4.953H.653L2.83 2h9.275ZM23.254 19.048 21.078 22h-9.242l2.174-2.952h9.244ZM24 2 9.264 22H0L14.736 2H24Z" />
  </svg>
);

export const PerplexityNvidiaIcon = (props: ComponentProps<"svg">) => (
  <svg aria-hidden="true" fill="currentColor" viewBox="0 0 24 24" {...props}>
    <path d="M8.948 8.798v-1.43a6.7 6.7 0 0 1 .424-.018c3.922-.124 6.493 3.374 6.493 3.374s-2.774 3.851-5.75 3.851c-.398 0-.787-.062-1.158-.185v-4.346c1.528.185 1.837.857 2.747 2.385l2.04-1.714s-1.492-1.952-4-1.952a6.016 6.016 0 0 0-.796.035m0-4.735v2.138l.424-.027c5.45-.185 9.01 4.47 9.01 4.47s-4.08 4.964-8.33 4.964c-.37 0-.733-.035-1.095-.097v1.325c.3.035.61.062.91.062 3.957 0 6.82-2.023 9.593-4.408.459.371 2.34 1.263 2.73 1.652-2.633 2.208-8.772 3.984-12.253 3.984-.335 0-.653-.018-.971-.053v1.864H24V4.063zm0 10.326v1.131c-3.657-.654-4.673-4.46-4.673-4.46s1.758-1.944 4.673-2.262v1.237H8.94c-1.528-.186-2.73 1.245-2.73 1.245s.68 2.412 2.739 3.11M2.456 10.9s2.164-3.197 6.5-3.533V6.201C4.153 6.59 0 10.653 0 10.653s2.35 6.802 8.948 7.42v-1.237c-4.84-.6-6.492-5.936-6.492-5.936z" />
  </svg>
);
```

### `registry/notra/perplexity/types/perplexity.ts`

```ts
import type { ComponentProps, ReactNode } from "react";

import type { Avatar } from "@/components/ui/avatar";
import type { Collapsible } from "@/components/ui/collapsible";

export type PerplexityMessageRole = "user" | "assistant";

export type PerplexityModelProvider =
  | "perplexity"
  | "openai"
  | "google"
  | "anthropic"
  | "kimi"
  | "zhipu"
  | "xai"
  | "nvidia";

export type PerplexityModelBadge = "max" | "new";

export interface PerplexityModel {
  badge?: PerplexityModelBadge;
  id: string;
  label: string;
  locked?: boolean;
  provider: PerplexityModelProvider;
}

export type PerplexityFocusId = "search" | "research";

export type PerplexityRewriteModeId = "deep-research" | "learn" | "search";

export interface PerplexityRewriteMode {
  id: PerplexityRewriteModeId;
  label: string;
  locked?: boolean;
}

export interface PerplexityRewriteOptions {
  mode: PerplexityRewriteModeId;
  model?: string;
}

export interface PerplexityRewritePanelProps extends Omit<
  ComponentProps<"div">,
  "onSubmit"
> {
  defaultMode?: PerplexityRewriteModeId;
  modes?: readonly PerplexityRewriteMode[];
  models?: readonly PerplexityModel[];
  onClose?: () => void;
  onRewrite?: (options: PerplexityRewriteOptions) => void;
  promoLabel?: ReactNode;
}

export interface PerplexityFocusOption {
  description: string;
  id: PerplexityFocusId;
  label: string;
}

export interface PerplexitySource {
  domain: string;
  title: string;
  url?: string;
  verified?: boolean;
}

export interface PerplexitySearchLabels {
  more: (count: number) => string;
  showLess: string;
}

export interface PerplexityMessageProps extends ComponentProps<"div"> {
  actions?: ReactNode;
  from: PerplexityMessageRole;
  search?: ReactNode;
}

export interface PerplexitySearchProps extends Omit<
  ComponentProps<typeof Collapsible>,
  "title"
> {
  duration?: ReactNode;
  emptyDescription?: ReactNode;
  extraCount?: number;
  label?: ReactNode;
  labels?: PerplexitySearchLabels;
  onStepOpenChange?: (open: boolean) => void;
  previewCount?: number;
  queries: readonly string[];
  reducedMotion?: boolean;
  runningLabel?: ReactNode;
  sequential?: boolean;
  sources: readonly PerplexitySource[];
  stepDefaultOpen?: boolean;
  stepOpen?: boolean;
  title?: ReactNode;
}

export interface PerplexityCitationSource {
  description?: string;
  domain: string;
  title: string;
  url?: string;
}

export interface PerplexityCitationProps extends ComponentProps<"span"> {
  extra?: number;
  label: string;
  sources?: readonly PerplexityCitationSource[];
}

export interface PerplexityFaviconProps extends ComponentProps<typeof Avatar> {
  domain: string;
}

export interface PerplexityActionsProps extends Omit<
  ComponentProps<"div">,
  "onSubmit"
> {
  models?: readonly PerplexityModel[];
  onBadResponse?: () => void;
  onDownload?: () => void;
  onGoodResponse?: () => void;
  onMore?: () => void;
  onRewrite?: (options: PerplexityRewriteOptions) => void;
  onShare?: () => void;
  sources?: readonly PerplexitySource[];
  text: string;
}

export interface PerplexityUserActionsProps extends ComponentProps<"div"> {
  onEdit?: () => void;
  text: string;
  timestamp?: ReactNode;
}

export interface PerplexitySourcesSummaryProps extends ComponentProps<"div"> {
  sources: readonly PerplexitySource[];
}

export interface PerplexityComposerProps extends Omit<
  ComponentProps<"form">,
  "onSubmit"
> {
  busy?: boolean;
  defaultModel?: string;
  focus?: PerplexityFocusId;
  focusOptions?: readonly PerplexityFocusOption[];
  model?: string;
  models?: readonly PerplexityModel[];
  onModelChange?: (model: string) => void;
  onSend?: (text: string) => void;
  onStop?: () => void;
  placeholder?: string;
}

export interface PerplexityModelSelectorProps extends ComponentProps<"button"> {
  label?: ReactNode;
  model?: string;
  models?: readonly PerplexityModel[];
  onModelChange?: (model: string) => void;
  promoLabel?: ReactNode;
}

export interface PerplexityModelIconProps extends ComponentProps<"span"> {
  provider: PerplexityModelProvider;
}

export interface PerplexityThinkingProps extends ComponentProps<"div"> {
  label?: ReactNode;
  reducedMotion?: boolean;
}

export interface PerplexityThreadCitation {
  domain: string;
  extra?: number;
  id: string;
  label: string;
  sources?: readonly PerplexityCitationSource[];
}

export interface PerplexityThreadSearch {
  duration?: string;
  extraCount?: number;
  previewCount?: number;
  queries: string[];
  sources: PerplexitySource[];
  title?: string;
}

export interface PerplexityThreadMessage {
  citations?: readonly PerplexityThreadCitation[];
  from: PerplexityMessageRole;
  id: string;
  search?: PerplexityThreadSearch;
  text: string;
}

export interface PerplexityAnswerProps extends ComponentProps<"div"> {
  citations?: readonly PerplexityThreadCitation[];
  text: string;
}
```

### `registry/notra/perplexity/constants/perplexity.ts`

```ts
import type {
  PerplexityRewriteMode,
  PerplexityFocusId,
  PerplexityFocusOption,
  PerplexityModel,
} from "../types/perplexity";

export const PERPLEXITY_THINKING_MS = 1500;
export const PERPLEXITY_THINKING_GAP_MS = 120;
export const PERPLEXITY_SEARCH_HEADER_MS = 480;
export const PERPLEXITY_SEARCH_QUERY_MS = 520;
export const PERPLEXITY_SEARCH_SOURCES_MS = 720;
export const PERPLEXITY_SEARCH_STAGGER_MS = 70;
export const PERPLEXITY_SEARCH_SETTLE_MS = 180;
export const PERPLEXITY_SEARCH_PREVIEW_COUNT = 4;

export const PERPLEXITY_SOURCES_PREVIEW_COUNT = 3;

export const PERPLEXITY_COPIED_RESET_MS = 1500;

export const PERPLEXITY_TOOLTIP_DELAY_MS = 200;
export const PERPLEXITY_CITATION_OPEN_DELAY_MS = 150;
export const PERPLEXITY_CITATION_CLOSE_DELAY_MS = 120;

export const PERPLEXITY_SEARCH_FOCUS: PerplexityFocusOption = {
  description: "Quick answers from the live web",
  id: "search",
  label: "Search",
};

export const PERPLEXITY_DEFAULT_FOCUS: PerplexityFocusId =
  PERPLEXITY_SEARCH_FOCUS.id;

export const PERPLEXITY_FOCUS_OPTIONS: readonly PerplexityFocusOption[] = [
  PERPLEXITY_SEARCH_FOCUS,
  {
    description: "Longer reports with more sources",
    id: "research",
    label: "Research",
  },
];

export const PERPLEXITY_MODELS: readonly PerplexityModel[] = [
  { id: "sonar-2", label: "Sonar 2", locked: true, provider: "perplexity" },
  {
    id: "gpt-5.6-terra",
    label: "GPT-5.6 Terra",
    locked: true,
    provider: "openai",
  },
  {
    badge: "max",
    id: "gpt-5.6-sol",
    label: "GPT-5.6 Sol",
    locked: true,
    provider: "openai",
  },
  {
    badge: "new",
    id: "gemini-3.7-flash",
    label: "Gemini 3.7 Flash",
    locked: true,
    provider: "google",
  },
  {
    id: "claude-sonnet-5",
    label: "Claude Sonnet 5",
    locked: true,
    provider: "anthropic",
  },
  {
    badge: "max",
    id: "claude-opus-5",
    label: "Claude Opus 5",
    locked: true,
    provider: "anthropic",
  },
  { id: "kimi-k3", label: "Kimi K3", locked: true, provider: "kimi" },
  { id: "glm-5.2", label: "GLM 5.2", locked: true, provider: "zhipu" },
  {
    badge: "new",
    id: "grok-4.7",
    label: "Grok 4.7",
    locked: true,
    provider: "xai",
  },
  {
    id: "nemotron-3-super",
    label: "Nemotron 3 Super",
    locked: true,
    provider: "nvidia",
  },
];

export const PERPLEXITY_REWRITE_MODES: readonly PerplexityRewriteMode[] = [
  { id: "search", label: "Search" },
  { id: "deep-research", label: "Deep research", locked: true },
  { id: "learn", label: "Learn step by step" },
];
```

### `registry/notra/perplexity/constants/perplexity-demo.ts`

```ts
import type {
  PerplexitySource,
  PerplexityThreadMessage,
} from "../types/perplexity";

export const PERPLEXITY_DEMO_SOURCES: PerplexitySource[] = [
  {
    domain: "techcrunch.com",
    title: "Notion acquires Skiff to expand into email and calendar",
    verified: true,
  },
  {
    domain: "arstechnica.com",
    title: "Encrypted productivity startup Skiff is being shut down",
    verified: true,
  },
  {
    domain: "theverge.com",
    title: "Notion Mail launches as the successor to Skiff",
    verified: true,
  },
  {
    domain: "wired.com",
    title: "What happened to Skiff after the Notion deal",
    verified: true,
  },
  {
    domain: "bloomberg.com",
    title: "Notion buys Skiff in a push into email",
    verified: true,
  },
  {
    domain: "theinformation.com",
    title: "Why Notion acquired encrypted workplace suite Skiff",
    verified: true,
  },
  {
    domain: "protocol.com",
    title: "Notion acquires encryption startup Skiff",
    verified: true,
  },
  {
    domain: "forbes.com",
    title: "Notion Mail and the end of Skiff",
    verified: true,
  },
  {
    domain: "reuters.com",
    title: "Notion to acquire encrypted workplace suite Skiff",
    verified: true,
  },
  {
    domain: "cnbc.com",
    title: "Notion expands into email with Skiff acquisition",
    verified: true,
  },
  {
    domain: "businessinsider.com",
    title: "Inside Notion’s plan for Mail after buying Skiff",
    verified: true,
  },
  {
    domain: "fastcompany.com",
    title: "Skiff’s encryption bet ends at Notion",
    verified: true,
  },
  {
    domain: "techradar.com",
    title: "Notion Mail is the successor to Skiff Mail",
    verified: true,
  },
  {
    domain: "engadget.com",
    title: "Notion is shutting down Skiff after the acquisition",
    verified: true,
  },
  {
    domain: "zdnet.com",
    title: "Notion Mail launches as Skiff services wind down",
    verified: true,
  },
  {
    domain: "axios.com",
    title: "Notion’s Skiff deal puts it in the Workspace race",
    verified: true,
  },
];

export const PERPLEXITY_DEMO_QUERIES = [
  "Notion acquire company to make Notion Mail",
  "Skiff acquisition Notion 2024",
  "Notion Mail Skiff history",
];

export const PERPLEXITY_DEMO_THREAD: PerplexityThreadMessage[] = [
  {
    from: "user",
    id: "u-1",
    text: "who did notion buy to build notion mail?",
  },
  {
    citations: [
      {
        domain: "techcrunch.com",
        extra: 2,
        id: "techcrunch",
        label: "techcrunch",
        sources: [
          {
            description:
              "Notion is adding email to its workspace after acquiring Skiff, the privacy-focused mail and calendar startup, and folding its team into Notion Mail.",
            domain: "techcrunch.com",
            title: "Notion acquires Skiff to expand into email and calendar",
            url: "https://techcrunch.com",
          },
          {
            description:
              "The encrypted productivity startup is shutting down its own apps as its team moves over to Notion.",
            domain: "arstechnica.com",
            title: "Encrypted productivity startup Skiff is being shut down",
            url: "https://arstechnica.com",
          },
          {
            description:
              "Notion Mail launches as the successor to Skiff Mail, built on the team and technology Notion acquired in 2024.",
            domain: "theverge.com",
            title: "Notion Mail launches as the successor to Skiff",
            url: "https://theverge.com",
          },
        ],
      },
      {
        domain: "arstechnica.com",
        extra: 1,
        id: "arstechnica",
        label: "arstechnica",
        sources: [
          {
            description:
              "Skiff built email, docs and calendar with end-to-end encryption before Notion acquired it.",
            domain: "arstechnica.com",
            title: "Encrypted productivity startup Skiff is being shut down",
            url: "https://arstechnica.com",
          },
          {
            description:
              "What happened to Skiff's users and product after the Notion deal.",
            domain: "wired.com",
            title: "What happened to Skiff after the Notion deal",
            url: "https://wired.com",
          },
        ],
      },
      {
        domain: "theverge.com",
        id: "theverge",
        label: "theverge",
        sources: [
          {
            description:
              "Skiff's services were wound down and its team joined Notion, which rebuilt the product as a native Notion surface.",
            domain: "theverge.com",
            title: "Notion Mail launches as the successor to Skiff",
            url: "https://theverge.com",
          },
        ],
      },
    ],
    from: "assistant",
    id: "a-1",
    search: {
      duration: "2s",
      queries: PERPLEXITY_DEMO_QUERIES,
      sources: PERPLEXITY_DEMO_SOURCES,
    },
    text: "Notion bought **Skiff**, an encrypted email startup, in 2024 and rebuilt it as **Notion Mail**. {{techcrunch}}\n\n- **Skiff:** encrypted mail, docs and calendar {{arstechnica}}\n- **After:** Skiff shut down, its team joined Notion {{theverge}}",
  },
];

export const PERPLEXITY_DEMO_REPLIES = [
  "Short version: Skiff. Notion acquired the team in 2024 and built Notion Mail from it.",
  "Right. If you want, I can tighten the timeline further.",
  "Sounds good. Ask again if you want the product reasoning behind Mail.",
] as const;
```

### `registry/notra/perplexity/hooks/use-perplexity-playback.ts`

```ts
"use client";

import { useCallback, useEffect, useRef, useState } from "react";

import {
  PERPLEXITY_SEARCH_HEADER_MS,
  PERPLEXITY_SEARCH_QUERY_MS,
  PERPLEXITY_SEARCH_SETTLE_MS,
  PERPLEXITY_SEARCH_SOURCES_MS,
  PERPLEXITY_THINKING_GAP_MS,
  PERPLEXITY_THINKING_MS,
} from "../constants/perplexity";
import type { PerplexityThreadMessage } from "../types/perplexity";

const TOKEN_SPLIT = /(\s+)/;
const USER_TURN_MS = 420;
const TOKEN_MS = 28;
const TURN_SETTLE_MS = 480;
const REDUCED_MOTION_MAX_MS = 70;

const wait = (ms: number) =>
  new Promise<void>((resolve) => {
    window.setTimeout(resolve, ms);
  });

const searchDuration = (queryCount: number) =>
  PERPLEXITY_SEARCH_HEADER_MS +
  queryCount * PERPLEXITY_SEARCH_QUERY_MS +
  PERPLEXITY_SEARCH_SOURCES_MS;

export const usePerplexityPlayback = (
  script: readonly PerplexityThreadMessage[],
  reducedMotion: boolean
) => {
  const [messages, setMessages] = useState<PerplexityThreadMessage[]>(() => [
    ...script,
  ]);
  const [completeIds, setCompleteIds] = useState<ReadonlySet<string>>(
    () => new Set(script.map((item) => item.id))
  );
  const [thinking, setThinking] = useState(false);
  const [playing, setPlaying] = useState(false);
  const runRef = useRef(0);
  const sendingRef = useRef(false);

  const markComplete = useCallback((id: string) => {
    setCompleteIds((current) => new Set(current).add(id));
  }, []);

  const stop = useCallback(() => {
    runRef.current += 1;
    sendingRef.current = false;
    setMessages([...script]);
    setCompleteIds(new Set(script.map((item) => item.id)));
    setThinking(false);
    setPlaying(false);
  }, [script]);

  const streamText = useCallback(
    async (
      message: PerplexityThreadMessage,
      alive: () => boolean,
      delay: (ms: number) => Promise<void>
    ) => {
      let text = "";
      for (const token of message.text.split(TOKEN_SPLIT)) {
        if (!alive()) {
          return;
        }
        text += token;
        const snapshot = text;
        setMessages((current) =>
          current.map((item) =>
            item.id === message.id ? { ...item, text: snapshot } : item
          )
        );
        await delay(TOKEN_MS);
      }
      markComplete(message.id);
      await delay(TURN_SETTLE_MS);
    },
    [markComplete]
  );

  const playTurn = useCallback(
    async (message: PerplexityThreadMessage, alive: () => boolean) => {
      const delay = (ms: number) =>
        wait(reducedMotion ? Math.min(ms, REDUCED_MOTION_MAX_MS) : ms);

      if (message.from === "user") {
        setMessages((current) => [...current, message]);
        markComplete(message.id);
        await delay(USER_TURN_MS);
        return;
      }

      setThinking(true);
      await delay(PERPLEXITY_THINKING_MS);
      if (!alive()) {
        return;
      }
      setThinking(false);
      await delay(PERPLEXITY_THINKING_GAP_MS);
      if (!alive()) {
        return;
      }

      if (reducedMotion) {
        setMessages((current) => [...current, message]);
        markComplete(message.id);
        await delay(PERPLEXITY_SEARCH_SETTLE_MS);
        return;
      }

      setMessages((current) => [...current, { ...message, text: "" }]);
      if (message.search) {
        await delay(
          searchDuration(message.search.queries.length) +
            PERPLEXITY_SEARCH_SETTLE_MS
        );
        if (!alive()) {
          return;
        }
      }

      await streamText(message, alive, delay);
    },
    [markComplete, reducedMotion, streamText]
  );

  const play = useCallback(async () => {
    const run = runRef.current + 1;
    runRef.current = run;
    const alive = () => runRef.current === run;

    setPlaying(true);
    setThinking(false);
    setMessages([]);
    setCompleteIds(new Set());

    for (const message of script) {
      if (!alive()) {
        return;
      }
      // react-doctor-disable-next-line react-doctor/async-await-in-loop -- turns must play one after another
      await playTurn(message, alive);
    }

    if (alive()) {
      setPlaying(false);
    }
  }, [playTurn, script]);

  const send = useCallback(
    async (text: string, reply: string) => {
      if (sendingRef.current) {
        return;
      }
      sendingRef.current = true;
      const run = runRef.current + 1;
      runRef.current = run;
      const alive = () => runRef.current === run;
      const now = Date.now();

      setPlaying(true);
      try {
        await playTurn({ from: "user", id: `user-${now}`, text }, alive);
        if (!alive()) {
          return;
        }
        await playTurn(
          { from: "assistant", id: `assistant-${now}`, text: reply },
          alive
        );
        if (alive()) {
          setPlaying(false);
        }
      } finally {
        sendingRef.current = false;
      }
    },
    [playTurn]
  );

  useEffect(
    () => () => {
      runRef.current += 1;
    },
    []
  );

  return {
    completeIds,
    messages,
    play,
    playing,
    send,
    stop,
    thinking,
  };
};
```

### `registry/notra/perplexity/hooks/use-perplexity-reduced-motion.ts`

```ts
"use client";

import { useSyncExternalStore } from "react";

const QUERY = "(prefers-reduced-motion: reduce)";

const subscribe = (onChange: () => void) => {
  const media = window.matchMedia(QUERY);
  media.addEventListener("change", onChange);
  return () => media.removeEventListener("change", onChange);
};

const getSnapshot = () => window.matchMedia(QUERY).matches;

const getServerSnapshot = () => false;

export const usePerplexityReducedMotion = () =>
  useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
```

### `registry/notra/perplexity/examples/perplexity-demo.tsx`

```tsx
"use client";

import { useEffect, useRef } from "react";

import { Card, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";

import { PerplexityActions } from "../components/perplexity-actions";
import { PerplexityAnswer } from "../components/perplexity-answer";
import { PerplexityComposer } from "../components/perplexity-composer";
import { PerplexityLogoIcon } from "../components/perplexity-icons";
import { PerplexityMessage } from "../components/perplexity-message";
import { PerplexitySearch } from "../components/perplexity-search";
import { PerplexityThinking } from "../components/perplexity-thinking";
import { PerplexityUserActions } from "../components/perplexity-user-actions";
import {
  PERPLEXITY_DEMO_REPLIES,
  PERPLEXITY_DEMO_THREAD,
} from "../constants/perplexity-demo";
import { usePerplexityPlayback } from "../hooks/use-perplexity-playback";
import { usePerplexityReducedMotion } from "../hooks/use-perplexity-reduced-motion";
import type { PerplexityThreadMessage } from "../types/perplexity";

/** Copy should not include the citation markers. */
const CITE_STRIP = /\s?\{\{[a-z0-9,-]+\}\}/g;

interface ThreadMessageProps {
  complete: boolean;
  message: PerplexityThreadMessage;
  reducedMotion: boolean;
  sequential: boolean;
}

const ThreadMessage = ({
  complete,
  message,
  reducedMotion,
  sequential,
}: ThreadMessageProps) => {
  if (message.from === "user") {
    return (
      <PerplexityMessage
        actions={
          <PerplexityUserActions text={message.text} timestamp="09:18" />
        }
        from="user"
      >
        {message.text}
      </PerplexityMessage>
    );
  }

  return (
    <PerplexityMessage
      actions={
        <PerplexityActions
          className={complete ? undefined : "invisible"}
          sources={message.search?.sources}
          text={message.text.replace(CITE_STRIP, "")}
        />
      }
      from="assistant"
      search={
        message.search ? (
          <PerplexitySearch
            extraCount={message.search.extraCount}
            previewCount={message.search.previewCount}
            queries={message.search.queries}
            reducedMotion={reducedMotion}
            sequential={sequential}
            duration={message.search.duration}
            sources={message.search.sources}
            title={message.search.title}
          />
        ) : undefined
      }
    >
      {message.text ? (
        <PerplexityAnswer citations={message.citations} text={message.text} />
      ) : null}
    </PerplexityMessage>
  );
};

export default function PerplexityDemo() {
  const reducedMotion = usePerplexityReducedMotion();
  const playback = usePerplexityPlayback(PERPLEXITY_DEMO_THREAD, reducedMotion);
  const replyIndexRef = useRef(0);
  const scrollRef = useRef<HTMLDivElement>(null);
  const hasMountedRef = useRef(false);

  // Follow new messages inside the fixed-height card. Scrolling the viewport
  // directly (not scrollIntoView) keeps the host page from jumping.
  useEffect(() => {
    if (!hasMountedRef.current) {
      hasMountedRef.current = true;
      return;
    }
    const viewport = scrollRef.current?.querySelector(
      '[data-slot="scroll-area-viewport"]'
    );
    viewport?.scrollTo({ behavior: "smooth", top: viewport.scrollHeight });
  }, [playback.messages.length, playback.thinking]);

  const send = (text: string) => {
    if (playback.playing) {
      return;
    }
    const reply =
      PERPLEXITY_DEMO_REPLIES[
        replyIndexRef.current % PERPLEXITY_DEMO_REPLIES.length
      ] ?? "Sure.";
    replyIndexRef.current += 1;
    playback.send(text, reply).catch(() => undefined);
  };

  return (
    <Card className="bg-pplx-bg font-pplx text-pplx-fg ring-pplx-border h-150 w-full min-w-0 gap-0 rounded-2xl py-0 text-base">
      <CardHeader className="flex h-12 shrink-0 items-center justify-between gap-3 px-4">
        <CardTitle className="text-pplx-muted flex items-center gap-2 text-sm font-medium">
          <PerplexityLogoIcon className="size-5" />
          Perplexity
        </CardTitle>
      </CardHeader>
      <Separator className="bg-pplx-border" />
      <ScrollArea className="min-h-0 flex-1" ref={scrollRef}>
        <div className="mx-auto flex w-full max-w-3xl min-w-0 flex-col gap-5 px-5 pt-6 pb-6 wrap-break-word">
          {playback.messages.map((message) => (
            <ThreadMessage
              complete={playback.completeIds.has(message.id)}
              key={message.id}
              message={message}
              reducedMotion={reducedMotion}
              sequential={
                playback.playing && !playback.completeIds.has(message.id)
              }
            />
          ))}
          {playback.thinking ? (
            <PerplexityThinking reducedMotion={reducedMotion} />
          ) : null}
        </div>
      </ScrollArea>
      <div className="mx-auto w-full max-w-3xl min-w-0 shrink-0 px-4 pb-4">
        <PerplexityComposer
          busy={playback.playing || playback.thinking}
          onSend={send}
          onStop={playback.stop}
        />
      </div>
    </Card>
  );
}
```

### `registry/notra/perplexity/components/perplexity-user-actions.tsx`

```tsx
"use client";

import { cn } from "cn";
import { Check, Copy, Pencil } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { ComponentProps, ReactNode } from "react";

import { Button } from "@/components/ui/button";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip";

import {
  PERPLEXITY_COPIED_RESET_MS,
  PERPLEXITY_TOOLTIP_DELAY_MS,
} from "../constants/perplexity";
import type { PerplexityUserActionsProps } from "../types/perplexity";

const ICON_STROKE = 1.75;

interface UserActionButtonProps extends ComponentProps<typeof Button> {
  children: ReactNode;
  label: string;
}

const UserActionButton = ({
  children,
  label,
  ...props
}: UserActionButtonProps) => (
  <Tooltip>
    <TooltipTrigger
      render={
        <Button
          aria-label={label}
          className="text-pplx-muted hover:bg-pplx-hover hover:text-pplx-fg focus-visible:ring-pplx-ring dark:hover:bg-pplx-hover size-8 rounded-full transition-[color,background-color,transform] duration-150 focus-visible:border-transparent focus-visible:ring-2 active:scale-[0.96] active:not-aria-[haspopup]:translate-y-0 motion-reduce:transition-none [&_svg]:size-4"
          size="icon"
          variant="ghost"
          {...props}
        />
      }
    >
      {children}
    </TooltipTrigger>
    <TooltipContent
      className="bg-pplx-fg font-pplx text-pplx-bg [&>div]:bg-pplx-fg [&>div]:fill-pplx-fg rounded-md border-0 bg-none px-3 py-1.5 shadow-none [corner-shape:round]"
      side="bottom"
      sideOffset={6}
    >
      {label}
    </TooltipContent>
  </Tooltip>
);

export const PerplexityUserActions = ({
  className,
  onEdit,
  text,
  timestamp,
  ...props
}: PerplexityUserActionsProps) => {
  const [copied, setCopied] = useState(false);
  const resetRef = useRef<number | undefined>(undefined);

  useEffect(() => () => window.clearTimeout(resetRef.current), []);

  const copy = async () => {
    try {
      await navigator.clipboard.writeText(text);
    } catch {
      return;
    }
    setCopied(true);
    window.clearTimeout(resetRef.current);
    resetRef.current = window.setTimeout(
      () => setCopied(false),
      PERPLEXITY_COPIED_RESET_MS
    );
  };

  return (
    <TooltipProvider delay={PERPLEXITY_TOOLTIP_DELAY_MS}>
      <div
        className={cn(
          "font-pplx text-pplx-muted flex shrink-0 items-center gap-1 text-sm leading-5 tabular-nums",
          className
        )}
        data-slot="perplexity-user-actions"
        {...props}
      >
        {timestamp ? <span className="pe-1">{timestamp}</span> : null}
        <UserActionButton label="Edit" onClick={onEdit}>
          <Pencil strokeWidth={ICON_STROKE} />
        </UserActionButton>
        <UserActionButton label={copied ? "Copied" : "Copy"} onClick={copy}>
          {copied ? (
            <Check strokeWidth={ICON_STROKE} />
          ) : (
            <Copy strokeWidth={ICON_STROKE} />
          )}
        </UserActionButton>
      </div>
    </TooltipProvider>
  );
};
```

### `registry/notra/perplexity/components/perplexity-rewrite-panel.tsx`

```tsx
"use client";

import { cn } from "cn";
import {
  ArrowRight,
  BookOpen,
  Check,
  LockKeyhole,
  Search,
  Telescope,
} from "lucide-react";
import { useState } from "react";
import type { ComponentType, SVGProps } from "react";

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
  Item,
  ItemActions,
  ItemContent,
  ItemGroup,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";

import {
  PERPLEXITY_MODELS,
  PERPLEXITY_REWRITE_MODES,
} from "../constants/perplexity";
import type {
  PerplexityModel,
  PerplexityRewriteModeId,
  PerplexityRewritePanelProps,
} from "../types/perplexity";
import { PerplexityModelIcon } from "./perplexity-model-icon";

const ICON_STROKE = 1.75;

const MODE_ICONS: Record<
  PerplexityRewriteModeId,
  ComponentType<SVGProps<SVGSVGElement>>
> = {
  "deep-research": Telescope,
  learn: BookOpen,
  search: Search,
};

const ROW_CLASS =
  "text-pplx-fg hover:bg-pplx-hover hover:text-pplx-fg focus-visible:ring-pplx-ring dark:hover:bg-pplx-hover h-9 w-full justify-start gap-3 rounded-xl border-0 px-3 text-[0.9375rem] leading-5 font-normal focus-visible:ring-2 active:not-aria-[haspopup]:translate-y-0";

const ModelBadge = ({
  badge,
}: {
  badge: NonNullable<PerplexityModel["badge"]>;
}) => (
  <Badge
    className={cn(
      "h-auto rounded-full border-0 px-1.5 py-px text-[9px] leading-none font-medium",
      badge === "max"
        ? "bg-pplx-badge text-pplx-badge-fg"
        : "bg-pplx-badge-new text-pplx-badge-new-fg"
    )}
  >
    {badge === "max" ? "Max" : "New"}
  </Badge>
);

export const PerplexityRewritePanel = ({
  className,
  defaultMode = "search",
  modes = PERPLEXITY_REWRITE_MODES,
  models = PERPLEXITY_MODELS,
  onClose,
  onRewrite,
  promoLabel = "Access the top AI models",
  ...props
}: PerplexityRewritePanelProps) => {
  const [mode, setMode] = useState<PerplexityRewriteModeId>(defaultMode);

  return (
    <div
      className={cn(
        "font-pplx text-pplx-fg flex w-72 flex-col gap-1",
        className
      )}
      data-slot="perplexity-rewrite-panel"
      {...props}
    >
      <ToggleGroup
        aria-label="Mode"
        className="w-full rounded-none"
        onValueChange={(next) => {
          const chosen = modes.find((item) => item.id === next[0]);
          if (chosen && !chosen.locked) {
            setMode(chosen.id);
          }
        }}
        orientation="vertical"
        spacing={0}
        value={[mode]}
      >
        {modes.map((item) => {
          const Icon = MODE_ICONS[item.id];
          return (
            <ToggleGroupItem
              aria-disabled={item.locked || undefined}
              className={cn(
                ROW_CLASS,
                "aria-pressed:bg-transparent data-pressed:bg-transparent",
                item.locked && "text-pplx-subtle hover:text-pplx-subtle"
              )}
              key={item.id}
              value={item.id}
            >
              <Icon className="size-4 shrink-0" strokeWidth={ICON_STROKE} />
              <span className="flex-1 text-start">{item.label}</span>
              {item.locked ? (
                <LockKeyhole
                  aria-label="Locked"
                  className="text-pplx-lock size-3.5"
                  strokeWidth={ICON_STROKE}
                />
              ) : null}
              {item.id === mode ? (
                <Check
                  aria-hidden="true"
                  className="text-pplx-badge-new-fg size-4"
                  strokeWidth={2}
                />
              ) : null}
            </ToggleGroupItem>
          );
        })}
      </ToggleGroup>
      <Separator className="bg-pplx-border my-1" />
      <Button
        className="bg-pplx-promo text-pplx-fg hover:bg-pplx-promo-hover hover:text-pplx-fg focus-visible:ring-pplx-ring dark:hover:bg-pplx-promo-hover h-9 w-full justify-between gap-3 rounded-xl border-0 px-3 text-[0.9375rem] leading-5 font-normal focus-visible:ring-2 active:not-aria-[haspopup]:translate-y-0"
        variant="ghost"
      >
        <span>{promoLabel}</span>
        <ArrowRight
          aria-hidden="true"
          className="text-pplx-badge-new-fg size-4"
          strokeWidth={ICON_STROKE}
        />
      </Button>
      <ScrollArea className="h-52">
        <ItemGroup className="gap-0 pe-2">
          {models.map((item) => (
            <Item
              data-locked={item.locked || undefined}
              className="text-pplx-subtle hover:bg-pplx-hover h-9 flex-nowrap gap-3 rounded-xl border-0 px-3 py-0 text-[0.9375rem] leading-5"
              key={item.id}
              role="listitem"
            >
              <ItemMedia className="size-4 translate-y-0 self-center">
                <PerplexityModelIcon provider={item.provider} />
              </ItemMedia>
              <ItemContent className="min-w-0 flex-row items-center gap-2">
                <ItemTitle className="text-pplx-subtle line-clamp-none block w-auto truncate text-[0.9375rem] leading-5 font-normal">
                  {item.label}
                </ItemTitle>
                {item.badge ? <ModelBadge badge={item.badge} /> : null}
              </ItemContent>
              {item.locked ? (
                <ItemActions>
                  <LockKeyhole
                    aria-label="Locked"
                    className="text-pplx-lock size-3.5"
                    strokeWidth={ICON_STROKE}
                  />
                </ItemActions>
              ) : null}
            </Item>
          ))}
        </ItemGroup>
      </ScrollArea>
      <Button
        className="bg-pplx-send text-pplx-send-fg hover:bg-pplx-send-hover hover:text-pplx-send-fg focus-visible:ring-pplx-ring mt-1 h-10 w-full rounded-xl border-0 text-[0.9375rem] font-medium focus-visible:ring-2 active:not-aria-[haspopup]:translate-y-0"
        onClick={() => {
          onRewrite?.({ mode });
          onClose?.();
        }}
      >
        Rewrite
      </Button>
    </div>
  );
};
```

### `registry/notra/perplexity/hooks/use-perplexity-search-sequence.ts`

```ts
"use client";

import { useEffect, useRef, useState } from "react";

import {
  PERPLEXITY_SEARCH_HEADER_MS,
  PERPLEXITY_SEARCH_QUERY_MS,
  PERPLEXITY_SEARCH_SETTLE_MS,
  PERPLEXITY_SEARCH_SOURCES_MS,
  PERPLEXITY_SEARCH_STAGGER_MS,
} from "../constants/perplexity";

interface PerplexitySearchSequence {
  done: boolean;
  queries: number;
  sources: boolean;
}

interface PerplexitySearchSequenceOptions {
  enabled: boolean;
  onDone: () => void;
  onStart: () => void;
  previewCount: number;
  queryCount: number;
}

/**
 * Plays the search step in: queries one by one, then the sources, then done.
 * `onStart` and `onDone` let the step open while it plays and close after.
 */
export const usePerplexitySearchSequence = ({
  enabled,
  onDone,
  onStart,
  previewCount,
  queryCount,
}: PerplexitySearchSequenceOptions): PerplexitySearchSequence => {
  const [sequence, setSequence] = useState<PerplexitySearchSequence>({
    done: false,
    queries: 0,
    sources: false,
  });

  // Latest callbacks live in refs so a new function identity never restarts the sequence.
  const onStartRef = useRef(onStart);
  const onDoneRef = useRef(onDone);
  useEffect(() => {
    onStartRef.current = onStart;
    onDoneRef.current = onDone;
  });

  useEffect(() => {
    if (!enabled) {
      return;
    }

    onStartRef.current();
    const timers: number[] = [];
    let elapsed = PERPLEXITY_SEARCH_HEADER_MS;

    for (let index = 0; index < queryCount; index += 1) {
      timers.push(
        window.setTimeout(() => {
          setSequence((current) => ({ ...current, queries: index + 1 }));
        }, elapsed)
      );
      elapsed += PERPLEXITY_SEARCH_QUERY_MS;
    }

    timers.push(
      window.setTimeout(() => {
        setSequence((current) => ({ ...current, sources: true }));
      }, elapsed)
    );
    elapsed += Math.max(
      PERPLEXITY_SEARCH_SOURCES_MS,
      previewCount * PERPLEXITY_SEARCH_STAGGER_MS + PERPLEXITY_SEARCH_SETTLE_MS
    );
    timers.push(
      window.setTimeout(() => {
        setSequence((current) => ({ ...current, done: true }));
        onDoneRef.current();
      }, elapsed)
    );

    return () => {
      for (const timer of timers) {
        window.clearTimeout(timer);
      }
    };
  }, [enabled, previewCount, queryCount]);

  return sequence;
};
```
