# ChatGPT

> A full recreation of a ChatGPT conversation with thinking, web search, reasoning and the model picker, 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/chatgpt).

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

## Install

```bash
npx shadcn@latest add https://ui.usenotra.com/r/chatgpt.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`, `hover-card`, `input-group`, `item`, `popover`, `scroll-area`, `separator`, `sheet`, `slider`, `toggle-group`, `tooltip`, `https://ui.usenotra.com/r/shimmer.json`

## Files

### `registry/notra/chatgpt/components/chatgpt-action-button.tsx`

```tsx
"use client";

import { cn } from "cn";

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

import type { ChatgptActionButtonProps } from "../types/chatgpt";

export const ChatgptActionButton = ({
  children,
  className,
  label,
  ...props
}: ChatgptActionButtonProps) => (
  <Tooltip>
    <TooltipTrigger
      render={
        <Button
          aria-label={label}
          className={cn(
            "text-chatgpt-muted hover:bg-chatgpt-hover hover:text-chatgpt-fg focus-visible:ring-chatgpt-focus/35 dark:hover:bg-chatgpt-hover size-8 rounded-lg border-0 transition-colors duration-150 focus-visible:ring-2 active:not-aria-[haspopup]:translate-y-0 motion-reduce:transition-none [&_svg:not([class*='size-'])]:size-4",
            className
          )}
          data-slot="chatgpt-action-button"
          size="icon"
          variant="ghost"
          {...props}
        />
      }
    >
      {children}
    </TooltipTrigger>
    <TooltipContent
      className="bg-chatgpt-tooltip font-chatgpt text-chatgpt-tooltip-fg rounded-lg border-0 bg-none px-2 py-1 text-xs font-medium shadow-none [--color-background:var(--chatgpt-tooltip-fg)] [--color-foreground:var(--chatgpt-tooltip)] [corner-shape:round]"
      side="bottom"
      sideOffset={6}
    >
      {label}
    </TooltipContent>
  </Tooltip>
);
```

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

```tsx
"use client";

import { cn } from "cn";
import {
  CheckIcon,
  CopyIcon,
  EllipsisIcon,
  RefreshCwIcon,
  ShareIcon,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";

import { TooltipProvider } from "@/components/ui/tooltip";

import {
  CHATGPT_COPIED_RESET_MS,
  CHATGPT_TOOLTIP_DELAY_MS,
} from "../constants/chatgpt";
import type { ChatgptActionsProps } from "../types/chatgpt";
import { ChatgptActionButton } from "./chatgpt-action-button";

const ICON_STROKE = 1.75;

const ICON_CLASS_NAME = "size-4";

export const ChatgptActions = ({
  className,
  onMore,
  onRedo,
  onShare,
  text,
  ...props
}: ChatgptActionsProps) => {
  const [copied, setCopied] = useState(false);
  const resetRef = useRef<number | undefined>(undefined);

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

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

  return (
    <TooltipProvider delay={CHATGPT_TOOLTIP_DELAY_MS}>
      <div
        className={cn(
          "font-chatgpt text-chatgpt-muted flex items-center gap-0.5",
          className
        )}
        data-slot="chatgpt-actions"
        {...props}
      >
        <ChatgptActionButton
          label={copied ? "Copied" : "Copy"}
          onClick={handleCopy}
        >
          {copied ? (
            <CheckIcon className={ICON_CLASS_NAME} strokeWidth={ICON_STROKE} />
          ) : (
            <CopyIcon className={ICON_CLASS_NAME} strokeWidth={ICON_STROKE} />
          )}
        </ChatgptActionButton>
        <ChatgptActionButton label="Share" onClick={onShare}>
          <ShareIcon className={ICON_CLASS_NAME} strokeWidth={ICON_STROKE} />
        </ChatgptActionButton>
        <ChatgptActionButton label="Redo" onClick={onRedo}>
          <RefreshCwIcon
            className={ICON_CLASS_NAME}
            strokeWidth={ICON_STROKE}
          />
        </ChatgptActionButton>
        <ChatgptActionButton label="More" onClick={onMore}>
          <EllipsisIcon className={ICON_CLASS_NAME} strokeWidth={ICON_STROKE} />
        </ChatgptActionButton>
      </div>
    </TooltipProvider>
  );
};
```

### `registry/notra/chatgpt/components/chatgpt-activity.tsx`

```tsx
"use client";

import { cn } from "cn";
import { CheckIcon, GlobeIcon, XIcon } from "lucide-react";

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
  Item,
  ItemContent,
  ItemDescription,
  ItemGroup,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet";

import { CHATGPT_VISIBLE_SITES } from "../constants/chatgpt";
import type {
  ChatgptActivityProps,
  ChatgptActivitySite,
} from "../types/chatgpt";
import { ChatgptFavicon } from "./chatgpt-favicon";
import { ChatgptSearch } from "./chatgpt-search";

const stepClassName =
  "relative flex-nowrap items-start gap-3 rounded-none border-0 p-0";

const sourceClassName =
  "flex-nowrap items-start gap-2.5 rounded-lg border-0 p-0 text-sm";

const pillClassName =
  "border-chatgpt-border bg-chatgpt-bg text-chatgpt-fg h-auto gap-1.5 rounded-full px-2 py-1 text-xs leading-none font-normal";

const ChatgptWebsitePills = ({
  sites,
}: {
  sites: readonly ChatgptActivitySite[];
}) => {
  const visible = sites.slice(0, CHATGPT_VISIBLE_SITES);
  const rest = sites.slice(CHATGPT_VISIBLE_SITES);

  return (
    <div className="flex flex-wrap gap-1.5">
      {visible.map((site) => (
        <Badge className={pillClassName} key={site.domain} variant="outline">
          <ChatgptFavicon
            className="size-3.5"
            domain={site.domain}
            src={site.favicon}
          />
          {site.label}
        </Badge>
      ))}
      {rest.length > 0 && (
        <Badge className={pillClassName} variant="outline">
          <span className="flex -space-x-1.5">
            {rest.map((site) => (
              <ChatgptFavicon
                className="ring-chatgpt-bg size-3.5 ring-1"
                domain={site.domain}
                key={site.domain}
                src={site.favicon}
              />
            ))}
          </span>
          {`+ ${rest.length} more`}
        </Badge>
      )}
    </div>
  );
};

export const ChatgptActivity = ({
  className,
  seconds,
  sites,
  sourceCount,
  sources,
  websites,
  ...props
}: ChatgptActivityProps) => (
  // trap-focus keeps focus inside without locking page scroll, so the page
  // behind the sheet does not jump when the scrollbar disappears.
  <Sheet modal="trap-focus" {...props}>
    <SheetTrigger
      className={className}
      render={<ChatgptSearch sites={sites} websites={websites} />}
    />
    <SheetContent
      className="bg-chatgpt-bg font-chatgpt text-chatgpt-fg data-[side=right]:border-chatgpt-border w-full gap-0 p-0 data-[side=right]:w-full data-[side=right]:sm:max-w-104"
      data-slot="chatgpt-activity"
      showCloseButton={false}
    >
      <SheetHeader className="flex-row items-center justify-between px-5 py-4">
        <SheetTitle className="text-chatgpt-fg flex items-baseline gap-1.5 text-base font-semibold">
          Activity
          <span className="text-chatgpt-muted font-normal">· {seconds}s</span>
        </SheetTitle>
        <SheetDescription className="sr-only">
          Search activity and sources
        </SheetDescription>
        <SheetClose
          render={
            <Button
              aria-label="Close"
              className="text-chatgpt-muted hover:bg-chatgpt-hover hover:text-chatgpt-fg focus-visible:ring-chatgpt-focus/35 dark:hover:bg-chatgpt-hover rounded-full border-0 focus-visible:ring-2"
              size="icon"
              variant="ghost"
            />
          }
        >
          <XIcon className="size-4" />
        </SheetClose>
      </SheetHeader>

      <ScrollArea className="min-h-0 flex-1">
        <div className="px-5 pb-6">
          <section className="flex flex-col gap-4">
            <h3 className="text-chatgpt-fg text-[0.9375rem] font-semibold">
              Thinking
            </h3>
            <div className="relative">
              <Separator
                className="bg-chatgpt-border absolute top-4 bottom-8 left-1.75"
                orientation="vertical"
              />
              <ItemGroup className="gap-5">
                <Item className={stepClassName} role="listitem">
                  <ItemMedia className="text-chatgpt-muted relative mt-0.5 w-4 translate-y-0">
                    <GlobeIcon className="size-4" strokeWidth={1.75} />
                  </ItemMedia>
                  <ItemContent className="min-w-0 gap-2.5">
                    <ItemTitle className="text-chatgpt-fg line-clamp-none w-auto text-sm leading-5 font-normal">
                      {`Searching ${websites} websites`}
                    </ItemTitle>
                    <ChatgptWebsitePills sites={sites} />
                  </ItemContent>
                </Item>
                <Item className={stepClassName} role="listitem">
                  <ItemMedia className="text-chatgpt-muted relative mt-0.5 w-4 translate-y-0">
                    <CheckIcon className="size-4" strokeWidth={2} />
                  </ItemMedia>
                  <ItemContent className="min-w-0 gap-0.5">
                    <ItemTitle className="text-chatgpt-fg line-clamp-none w-auto text-sm leading-5 font-normal">
                      {`Worked for ${seconds}s`}
                    </ItemTitle>
                    <ItemDescription className="text-chatgpt-muted text-[0.8125rem] leading-5">
                      Done
                    </ItemDescription>
                  </ItemContent>
                </Item>
              </ItemGroup>
            </div>
          </section>

          <section className="mt-8 flex flex-col gap-4">
            <h3 className="text-chatgpt-fg text-[0.9375rem] font-semibold">
              Sources
              <span className="text-chatgpt-muted font-normal">
                {` · ${sourceCount ?? sources.length}`}
              </span>
            </h3>
            <ItemGroup className="gap-5">
              {sources.map((source) => (
                <li className="list-none" key={source.id}>
                  <Item
                    className={cn(
                      sourceClassName,
                      source.href &&
                        "focus-visible:ring-chatgpt-focus/35 [a]:hover:bg-chatgpt-hover -m-1.5 p-1.5 focus-visible:ring-2 motion-reduce:transition-none"
                    )}
                    render={
                      source.href
                        ? (linkProps) => (
                            <a
                              {...linkProps}
                              href={source.href}
                              rel="noopener noreferrer"
                              target="_blank"
                            >
                              {linkProps.children}
                            </a>
                          )
                        : undefined
                    }
                  >
                    <ItemMedia className="mt-0.5 translate-y-0">
                      <ChatgptFavicon
                        className="size-4"
                        domain={source.domain}
                        src={source.favicon}
                      />
                    </ItemMedia>
                    <ItemContent className="min-w-0 gap-1">
                      <p className="text-chatgpt-muted text-xs leading-4">
                        {source.publisher}
                      </p>
                      <ItemTitle className="text-chatgpt-fg line-clamp-none w-auto text-sm leading-5 font-semibold">
                        {source.title}
                      </ItemTitle>
                      <ItemDescription className="text-chatgpt-muted line-clamp-none text-[0.8125rem] leading-5">
                        {`${source.timeLabel} — ${source.snippet}`}
                      </ItemDescription>
                    </ItemContent>
                  </Item>
                </li>
              ))}
            </ItemGroup>
          </section>
        </div>
      </ScrollArea>
    </SheetContent>
  </Sheet>
);
```

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

```tsx
"use client";

import { cn } from "cn";
import { ArrowUpIcon, PlusIcon, SquareIcon } from "lucide-react";
import type { FormEvent } from "react";
import { useState } from "react";

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

import {
  CHATGPT_DEFAULT_EFFORT,
  CHATGPT_DEFAULT_MODEL,
} from "../constants/chatgpt-models";
import type {
  ChatgptComposerProps,
  ChatgptEffortId,
  ChatgptModelId,
} from "../types/chatgpt";
import { ChatgptModelSelector } from "./chatgpt-model-selector";

const sendClassName =
  "bg-chatgpt-send text-chatgpt-send-fg hover:bg-chatgpt-send-hover hover:text-chatgpt-send-fg focus-visible:ring-chatgpt-focus/35 dark:hover:bg-chatgpt-send-hover size-9 rounded-full border-0 transition-colors duration-150 focus-visible:ring-2 active:not-aria-[haspopup]:translate-y-0 motion-reduce:transition-none";

export const ChatgptComposer = ({
  busy = false,
  className,
  defaultEffort = CHATGPT_DEFAULT_EFFORT,
  defaultModel = CHATGPT_DEFAULT_MODEL,
  effort: effortProp,
  efforts,
  model: modelProp,
  models,
  onEffortChange,
  onModelChange,
  onSend,
  onStop,
  placeholder = "Ask anything",
  ...props
}: ChatgptComposerProps) => {
  const [value, setValue] = useState("");
  const [uncontrolledModel, setUncontrolledModel] = useState(defaultModel);
  const [uncontrolledEffort, setUncontrolledEffort] = useState(defaultEffort);
  const model = modelProp ?? uncontrolledModel;
  const effort = effortProp ?? uncontrolledEffort;
  const canSend = value.trim().length > 0;

  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const text = value.trim();
    if (!text || busy) {
      return;
    }
    onSend?.(text);
    if (onSend) {
      setValue("");
    }
  };

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

  const handleEffortChange = (next: ChatgptEffortId) => {
    if (effortProp === undefined) {
      setUncontrolledEffort(next);
    }
    onEffortChange?.(next);
  };

  return (
    <form
      className={cn("font-chatgpt w-full", className)}
      data-slot="chatgpt-composer"
      onSubmit={handleSubmit}
      {...props}
    >
      <InputGroup
        className={cn(
          "bg-chatgpt-composer border-chatgpt-composer-border has-disabled:bg-chatgpt-composer dark:has-disabled:bg-chatgpt-composer text-chatgpt-fg shadow-chatgpt-composer has-[[data-slot=input-group-control]:focus-visible]:border-chatgpt-composer-border dark:bg-chatgpt-composer h-auto min-h-13 gap-1.5 rounded-[1.75rem] px-2 py-2 has-disabled:opacity-100 has-[[data-slot=input-group-control]:focus-visible]:ring-0"
        )}
      >
        <InputGroupAddon
          align="inline-start"
          className="p-0 has-[>button]:ml-0"
        >
          <InputGroupButton
            aria-label="Add"
            className="text-chatgpt-fg/80 hover:bg-chatgpt-hover hover:text-chatgpt-fg focus-visible:ring-chatgpt-focus/35 dark:hover:bg-chatgpt-hover size-9 rounded-full border-0 transition-colors duration-150 focus-visible:ring-2 active:not-aria-[haspopup]:translate-y-0 motion-reduce:transition-none"
            size="icon-xs"
          >
            <PlusIcon className="size-5" strokeWidth={1.5} />
          </InputGroupButton>
        </InputGroupAddon>
        <InputGroupInput
          aria-label="Message"
          className="placeholder:text-chatgpt-muted h-auto py-1.5 pr-3! pl-0! text-base md:text-base"
          onChange={(event) => setValue(event.target.value)}
          placeholder={placeholder}
          type="text"
          value={value}
        />
        <InputGroupAddon
          align="inline-end"
          className="gap-1.5 p-0 has-[>button]:mr-0"
        >
          <ChatgptModelSelector
            effort={effort}
            efforts={efforts}
            model={model}
            models={models}
            onEffortChange={handleEffortChange}
            onModelChange={handleModelChange}
          />
          {busy ? (
            <InputGroupButton
              aria-label="Stop"
              className={sendClassName}
              onClick={onStop}
              size="icon-sm"
            >
              <SquareIcon className="size-3.5 fill-current" strokeWidth={2} />
            </InputGroupButton>
          ) : (
            <InputGroupButton
              aria-label="Send"
              className={cn(
                sendClassName,
                "disabled:bg-chatgpt-send/35 disabled:text-chatgpt-send-fg disabled:opacity-100"
              )}
              disabled={!canSend}
              size="icon-sm"
              type="submit"
            >
              <ArrowUpIcon className="size-5" strokeWidth={2} />
            </InputGroupButton>
          )}
        </InputGroupAddon>
      </InputGroup>
    </form>
  );
};
```

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

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

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

import { CHATGPT_FAVICON_URL } from "../constants/chatgpt";
import type { ChatgptFaviconProps } from "../types/chatgpt";

const faviconSrc = (domain: string) =>
  `${CHATGPT_FAVICON_URL}?domain=${encodeURIComponent(domain)}&sz=64`;

export const ChatgptFavicon = ({
  className,
  domain,
  src,
  ...props
}: ChatgptFaviconProps) => (
  <Avatar
    className={cn("bg-chatgpt-hover size-4 after:hidden", className)}
    data-slot="chatgpt-favicon"
    {...props}
  >
    <AvatarImage alt="" src={src ?? faviconSrc(domain)} />
    <AvatarFallback className="bg-chatgpt-hover" />
  </Avatar>
);
```

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

```tsx
import type { ComponentProps } from "react";

export const ChatgptLogoIcon = (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 ChatgptSearchIcon = (props: ComponentProps<"svg">) => (
  <svg aria-hidden="true" fill="none" viewBox="0 0 16 16" {...props}>
    <circle
      cx="8"
      cy="8"
      r="5.35"
      stroke="currentColor"
      strokeDasharray="1.05 1.7"
      strokeLinecap="round"
      strokeWidth="1.35"
    />
    <ellipse
      cx="8"
      cy="8"
      rx="5.35"
      ry="2.15"
      stroke="currentColor"
      strokeDasharray="0.9 1.65"
      strokeLinecap="round"
      strokeWidth="1.05"
    />
  </svg>
);

export const ChatgptChevronIcon = (props: ComponentProps<"svg">) => (
  <svg aria-hidden="true" fill="none" viewBox="0 0 16 16" {...props}>
    <path
      d="M4 6.25 8 10.25 12 6.25"
      stroke="currentColor"
      strokeLinecap="round"
      strokeLinejoin="round"
      strokeWidth="1.5"
    />
  </svg>
);
```

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

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

import type { ChatgptMessageProps } from "../types/chatgpt";

export const ChatgptMessage = ({
  actions,
  children,
  className,
  from,
  reasoning,
  ...props
}: ChatgptMessageProps) => {
  if (from === "user") {
    return (
      <div
        className={cn("font-chatgpt flex justify-end", className)}
        data-from="user"
        data-slot="chatgpt-message"
        {...props}
      >
        <div className="bg-chatgpt-bubble text-chatgpt-fg max-w-[70%] rounded-[1.125rem] px-4 py-1.5 text-base leading-6 wrap-break-word">
          {children}
        </div>
      </div>
    );
  }

  return (
    <div
      className={cn(
        "group/chatgpt-message font-chatgpt flex flex-col items-start gap-2",
        className
      )}
      data-from="assistant"
      data-slot="chatgpt-message"
      {...props}
    >
      {reasoning}
      <div className="text-chatgpt-fg max-w-full text-base leading-7 wrap-break-word">
        {children}
      </div>
      {actions && (
        <div className="-ms-2 opacity-0 transition-opacity duration-150 group-focus-within/chatgpt-message:opacity-100 motion-reduce:transition-none [@media(hover:hover)]:group-hover/chatgpt-message:opacity-100 [@media(hover:none)]:opacity-100">
          {actions}
        </div>
      )}
    </div>
  );
};
```

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

```tsx
"use client";

import { cn } from "cn";
import { CheckIcon, ChevronDownIcon, ChevronRightIcon } from "lucide-react";
import {
  type CSSProperties,
  useEffect,
  useLayoutEffect,
  useState,
} from "react";

import { Button } from "@/components/ui/button";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";
import { Slider } from "@/components/ui/slider";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";

import {
  CHATGPT_PRO_SPARKLES,
  CHATGPT_PRO_TWINKLES,
} from "../constants/chatgpt";
import {
  CHATGPT_EFFORTS,
  CHATGPT_LATEST_MODEL,
  CHATGPT_MEDIUM_EFFORT,
  CHATGPT_MODELS,
} from "../constants/chatgpt-models";
import type {
  ChatgptEffortId,
  ChatgptEffortOption,
  ChatgptModelOption,
  ChatgptModelSelectorProps,
} from "../types/chatgpt";

/** Half the thumb width, so dots line up with where the thumb stops. */
const BURST_MS = 1000;
const THUMB_INSET = "0.875rem";

const PRO_EFFORT_ID: ChatgptEffortId = "pro";

const sliderClassName = [
  "[&_[data-slot=slider-track]]:bg-chatgpt-slider-track [&_[data-slot=slider-track]]:h-6",
  "[&_[data-slot=slider-range]]:bg-chatgpt-slider-fill [&_[data-slot=slider-range]]:before:absolute [&_[data-slot=slider-range]]:before:inset-0 [&_[data-slot=slider-range]]:before:bg-(image:--chatgpt-pro-gradient) [&_[data-slot=slider-range]]:before:bg-size-[200%_100%] [&_[data-slot=slider-range]]:before:animate-chatgpt-flow motion-reduce:[&_[data-slot=slider-range]]:before:animate-none [&_[data-slot=slider-range]]:before:opacity-0 [&_[data-slot=slider-range]]:before:transition-opacity [&_[data-slot=slider-range]]:before:duration-500 group-data-[pro=true]/slider:[&_[data-slot=slider-range]]:before:opacity-100",
  // Ease the thumb and fill between stops, but follow the pointer 1:1 while dragging.
  "[&_[data-slot=slider-thumb]]:transition-[inset-inline-start,left,scale] [&_[data-slot=slider-thumb]]:duration-300 [&_[data-slot=slider-thumb]]:ease-[cubic-bezier(0.22,1,0.36,1)] [&_[data-slot=slider-range]]:transition-[width,inset-inline-start] [&_[data-slot=slider-range]]:duration-300 [&_[data-slot=slider-range]]:ease-[cubic-bezier(0.22,1,0.36,1)]",
  "group-data-[moving=true]/slider:[&_[data-slot=slider-thumb]]:transition-none group-data-[moving=true]/slider:[&_[data-slot=slider-range]]:transition-none motion-reduce:[&_[data-slot=slider-thumb]]:transition-none motion-reduce:[&_[data-slot=slider-range]]:transition-none [&_[data-slot=slider-thumb]]:active:scale-95",
  "[&_[data-slot=slider-thumb]]:bg-chatgpt-slider-thumb [&_[data-slot=slider-thumb]]:size-7 [&_[data-slot=slider-thumb]]:border-0 [&_[data-slot=slider-thumb]]:shadow-[0_2px_8px_rgb(0_0_0/0.35)] [&_[data-slot=slider-thumb]]:ring-0 [&_[data-slot=slider-thumb]]:hover:ring-0 [&_[data-slot=slider-thumb]]:active:ring-0 [&_[data-slot=slider-thumb]]:focus-visible:ring-2 [&_[data-slot=slider-thumb]]:focus-visible:ring-chatgpt-focus/60",
].join(" ");

const effortText = (
  effort: ChatgptEffortOption,
  model: ChatgptModelOption,
  { compact }: { compact: boolean }
) => {
  if (effort.id !== PRO_EFFORT_ID) {
    return <span>{effort.label}</span>;
  }
  const prefix =
    compact && model.id === CHATGPT_LATEST_MODEL.id ? null : model.shortLabel;

  return (
    <span>
      {prefix ? `${prefix} ` : null}
      <span className="text-chatgpt-pro">{effort.label}</span>
    </span>
  );
};

const useElementHeight = () => {
  const [element, setElement] = useState<HTMLDivElement | null>(null);
  const [height, setHeight] = useState<number>();

  useLayoutEffect(() => {
    if (!element) {
      return;
    }
    // offsetHeight ignores the popup's zoom-in scale, unlike getBoundingClientRect.
    setHeight(element.offsetHeight);
    const observer = new ResizeObserver(([entry]) => {
      if (entry) {
        setHeight((entry.target as HTMLElement).offsetHeight);
      }
    });
    observer.observe(element);
    return () => observer.disconnect();
  }, [element]);

  return [height, setElement] as const;
};

const useElementWidth = () => {
  const [element, setElement] = useState<HTMLSpanElement | null>(null);
  const [width, setWidth] = useState<number>();

  useLayoutEffect(() => {
    if (!element) {
      return;
    }
    setWidth(element.offsetWidth);
    const observer = new ResizeObserver(([entry]) => {
      if (entry) {
        setWidth((entry.target as HTMLElement).offsetWidth);
      }
    });
    observer.observe(element);
    return () => observer.disconnect();
  }, [element]);

  return [width, setElement] as const;
};

interface ModelListProps {
  model: ChatgptModelOption["id"];
  models: readonly ChatgptModelOption[];
  /** Called with `undefined` when the selected model is pressed again. */
  onSelect: (model: ChatgptModelOption | undefined) => void;
}

const ModelList = ({ model, models, onSelect }: ModelListProps) => (
  <ToggleGroup
    aria-label="Model"
    className="w-full rounded-none"
    onValueChange={(next) => {
      const chosen = models.find((item) => item.id === next[0]);
      onSelect(chosen);
    }}
    orientation="vertical"
    spacing={0}
    value={[model]}
  >
    {models.map((item) => (
      <ToggleGroupItem
        className="text-chatgpt-fg hover:bg-chatgpt-hover hover:text-chatgpt-fg focus-visible:ring-chatgpt-focus/35 dark:hover:bg-chatgpt-hover h-auto w-full justify-between gap-3 rounded-xl border-0 px-3 py-2 text-start text-[0.9375rem] leading-5 font-normal focus-visible:ring-2 aria-pressed:bg-transparent data-pressed:bg-transparent"
        key={item.id}
        value={item.id}
      >
        <span className="flex flex-col">
          <span>{item.label}</span>
          {item.description ? (
            <span className="text-chatgpt-muted text-sm">
              {item.description}
            </span>
          ) : null}
        </span>
        {item.id === model ? (
          <CheckIcon aria-hidden="true" className="size-4" strokeWidth={1.75} />
        ) : null}
      </ToggleGroupItem>
    ))}
  </ToggleGroup>
);

export const ChatgptModelSelector = ({
  className,
  effort,
  efforts = CHATGPT_EFFORTS,
  model,
  models = CHATGPT_MODELS,
  onEffortChange,
  onModelChange,
  ...props
}: ChatgptModelSelectorProps) => {
  const [open, setOpen] = useState(false);
  const [view, setView] = useState<"effort" | "models">("effort");
  // Both views stay mounted in one grid cell, so the popup never changes size. Only the
  // background card animates its height, which avoids the positioner lagging a frame behind.
  const [effortHeight, effortRef] = useElementHeight();
  const [modelsHeight, modelsRef] = useElementHeight();
  const cardHeight = view === "effort" ? effortHeight : modelsHeight;

  // The card only animates after its first measurement, so opening never plays a resize.
  const [cardReady, setCardReady] = useState(false);
  useEffect(() => {
    if (!open) {
      setCardReady(false);
      return;
    }
    const frame = requestAnimationFrame(() =>
      requestAnimationFrame(() => setCardReady(true))
    );
    return () => cancelAnimationFrame(frame);
  }, [open]);

  const [moving, setMoving] = useState(false);

  const changeView = (next: "effort" | "models") => setView(next);

  const selectedModel =
    models.find((item) => item.id === model) ??
    models[0] ??
    CHATGPT_LATEST_MODEL;
  const effortIndex = Math.max(
    efforts.findIndex((item) => item.id === effort),
    0
  );
  const selectedEffort =
    efforts[effortIndex] ?? efforts[0] ?? CHATGPT_MEDIUM_EFFORT;
  const isPro = selectedEffort.id === PRO_EFFORT_ID;
  const lastIndex = Math.max(efforts.length - 1, 1);

  // Both trigger labels stay mounted and crossfade while the trigger eases between their
  // widths, so opening and closing never snap the trigger's size.
  const [closedLabelWidth, closedLabelRef] = useElementWidth();
  const [openLabelWidth, openLabelRef] = useElementWidth();
  const labelWidth = open ? openLabelWidth : closedLabelWidth;

  const handleOpenChange = (next: boolean) => {
    setOpen(next);
    if (next) {
      setView("effort");
    }
  };

  // The burst plays once when the slider lands on Pro, not whenever the view remounts.
  const [burstActive, setBurstActive] = useState(false);
  useEffect(() => {
    if (!burstActive) {
      return;
    }
    const timer = window.setTimeout(() => setBurstActive(false), BURST_MS);
    return () => window.clearTimeout(timer);
  }, [burstActive]);

  const handleSlide = (value: number | readonly number[]) => {
    const index = Array.isArray(value) ? value[0] : value;
    const next = efforts[index as number];
    if (next && next.id !== effort) {
      setBurstActive(next.id === PRO_EFFORT_ID);
      onEffortChange?.(next.id);
    }
  };

  return (
    <Popover onOpenChange={handleOpenChange} open={open}>
      <PopoverTrigger
        render={
          <Button
            aria-label={`Thinking effort: ${selectedEffort.label}`}
            className={cn(
              "group/chatgpt-model text-chatgpt-muted hover:bg-chatgpt-hover hover:text-chatgpt-fg focus-visible:ring-chatgpt-focus/35 aria-expanded:bg-chatgpt-hover aria-expanded:text-chatgpt-fg dark:hover:bg-chatgpt-hover h-9 gap-1.5 rounded-full border-0 bg-transparent px-4 text-[0.9375rem] leading-none font-normal transition-[background-color,scale] duration-150 focus-visible:ring-2 active:scale-[0.96] active:not-aria-[haspopup]:translate-y-0 motion-reduce:transition-none",
              className
            )}
            data-slot="chatgpt-model-selector"
            variant="ghost"
            {...props}
          />
        }
      >
        <span
          className="relative block h-5 overflow-hidden transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
          style={{ width: labelWidth }}
        >
          <span
            className={cn(
              "absolute inset-y-0 left-0 flex items-center whitespace-nowrap transition-opacity duration-200 motion-reduce:transition-none",
              open ? "opacity-0" : "opacity-100"
            )}
            ref={closedLabelRef}
          >
            {effortText(selectedEffort, selectedModel, { compact: true })}
          </span>
          <span
            aria-hidden={!open}
            className={cn(
              "absolute inset-y-0 left-0 flex items-center whitespace-nowrap transition-opacity duration-200 motion-reduce:transition-none",
              open ? "opacity-100" : "opacity-0"
            )}
            ref={openLabelRef}
          >
            Thinking effort
          </span>
        </span>
        <ChevronDownIcon
          aria-hidden="true"
          className="text-chatgpt-muted size-4 transition-transform duration-150 group-aria-expanded/chatgpt-model:rotate-180 motion-reduce:transition-none"
          strokeWidth={1.75}
        />
      </PopoverTrigger>
      <PopoverContent
        align="end"
        className="font-chatgpt text-chatgpt-fg data-open:zoom-in-90 data-closed:zoom-out-95 w-64 gap-0 rounded-3xl bg-transparent p-0 shadow-none ring-0 duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] data-closed:duration-[240ms] data-closed:ease-[cubic-bezier(0.4,0,0.2,1)] motion-reduce:duration-0"
        side="top"
        sideOffset={8}
      >
        <div className="relative grid">
          <div
            aria-hidden="true"
            className={cn(
              "bg-chatgpt-popover shadow-chatgpt-menu absolute inset-x-0 bottom-0 rounded-3xl",
              cardReady &&
                "transition-[height] duration-[300ms] ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none"
            )}
            style={{ height: cardHeight ?? "100%" }}
          />
          <div
            className={cn(
              "relative col-start-1 row-start-1 flex flex-col gap-2 self-end p-3 transition-[opacity,filter,translate] motion-reduce:transition-none",
              view === "effort"
                ? "blur-0 translate-y-0 opacity-100 delay-50 duration-[260ms] ease-[cubic-bezier(0.22,1,0.36,1)]"
                : "pointer-events-none -translate-y-3 opacity-0 blur-[4px] duration-[180ms] ease-[cubic-bezier(0.4,0,0.2,1)]"
            )}
            inert={view !== "effort"}
            ref={effortRef}
          >
            <Button
              aria-label={`Model: ${selectedModel.label}. Change model`}
              className="text-chatgpt-fg hover:bg-chatgpt-hover hover:text-chatgpt-fg focus-visible:ring-chatgpt-focus/35 dark:hover:bg-chatgpt-hover mx-auto h-8 gap-1 rounded-full border-0 px-3 text-base leading-none font-normal focus-visible:ring-2 active:not-aria-[haspopup]:translate-y-0"
              onClick={() => changeView("models")}
              variant="ghost"
            >
              {effortText(selectedEffort, selectedModel, {
                compact: false,
              })}
              <ChevronRightIcon
                aria-hidden="true"
                className="text-chatgpt-muted size-3.5"
                strokeWidth={2}
              />
            </Button>
            <div
              className="group/slider relative px-0.5 pt-0.5 pb-1"
              data-moving={moving}
              data-pro={isPro}
              data-slot="chatgpt-effort-slider"
              // Clicking a stop eases the thumb there; only a real drag follows the pointer 1:1.
              onPointerCancel={() => setMoving(false)}
              onPointerDown={() => setMoving(false)}
              onPointerMove={(event) => {
                if (event.buttons > 0) {
                  setMoving(true);
                }
              }}
              onPointerUp={() => setMoving(false)}
            >
              <Slider
                className={sliderClassName}
                aria-label="Thinking effort"
                max={efforts.length - 1}
                min={0}
                onValueChange={handleSlide}
                step={1}
                value={[effortIndex]}
              />
              <div
                aria-hidden="true"
                className="pointer-events-none absolute inset-x-0.5 top-0.5 bottom-1 z-10 transition-opacity duration-500 group-data-[pro=true]/slider:opacity-0 motion-reduce:transition-none"
              >
                {efforts.map((item, index) =>
                  index === effortIndex ? null : (
                    <span
                      className={cn(
                        "absolute top-1/2 size-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full transition-colors duration-300",
                        index < effortIndex
                          ? "bg-chatgpt-slider-dot-fill"
                          : "bg-chatgpt-slider-dot"
                      )}
                      key={item.id}
                      style={{
                        left: `calc(${THUMB_INSET} + (100% - 2 * ${THUMB_INSET}) * ${index / lastIndex})`,
                      }}
                    />
                  )
                )}
              </div>
              <div
                aria-hidden="true"
                className="pointer-events-none absolute inset-x-0.5 top-0.5 bottom-1 z-10 opacity-0 transition-opacity duration-500 group-data-[pro=true]/slider:opacity-100 motion-reduce:transition-none"
              >
                {CHATGPT_PRO_TWINKLES.map((star) => (
                  <span
                    className="animate-chatgpt-twinkle absolute rounded-full bg-white opacity-0 shadow-[0_0_4px_1px_rgb(255_255_255/0.7)] motion-reduce:animate-none motion-reduce:opacity-60"
                    key={`${star.x}:${star.y}`}
                    style={{
                      animationDelay: `${star.delay}ms`,
                      animationDuration: `${star.duration}ms`,
                      height: star.size,
                      left: `${star.x}%`,
                      top: `${star.y}%`,
                      width: star.size,
                    }}
                  />
                ))}
              </div>
              {isPro && burstActive ? (
                <div
                  aria-hidden="true"
                  className="pointer-events-none absolute inset-x-0.5 top-0.5 bottom-1 z-10"
                >
                  <div
                    className="absolute size-0"
                    style={{
                      left: `calc(100% - ${THUMB_INSET})`,
                      top: "50%",
                    }}
                  >
                    {CHATGPT_PRO_SPARKLES.map((sparkle) => (
                      <span
                        className="animate-chatgpt-burst bg-chatgpt-pro absolute -top-px -left-px rounded-full opacity-0 motion-reduce:hidden"
                        key={`${sparkle.x}:${sparkle.y}`}
                        style={
                          {
                            "--burst-x": `${sparkle.x}px`,
                            "--burst-y": `${sparkle.y}px`,
                            animationDelay: `${sparkle.delay}ms`,
                            height: sparkle.size,
                            width: sparkle.size,
                          } as CSSProperties
                        }
                      />
                    ))}
                  </div>
                </div>
              ) : null}
            </div>
          </div>
          <div
            className={cn(
              "relative col-start-1 row-start-1 flex flex-col gap-2 self-end p-3 transition-[opacity,filter,translate] motion-reduce:transition-none",
              view === "models"
                ? "blur-0 translate-y-0 opacity-100 delay-50 duration-[260ms] ease-[cubic-bezier(0.22,1,0.36,1)]"
                : "pointer-events-none translate-y-3 opacity-0 blur-[4px] duration-[180ms] ease-[cubic-bezier(0.4,0,0.2,1)]"
            )}
            inert={view !== "models"}
            ref={modelsRef}
          >
            <ModelList
              model={selectedModel.id}
              models={models}
              onSelect={(next) => {
                if (next) {
                  onModelChange?.(next.id);
                }
                changeView("effort");
              }}
            />
          </div>
        </div>
      </PopoverContent>
    </Popover>
  );
};
```

### `registry/notra/chatgpt/components/chatgpt-reasoning.tsx`

```tsx
"use client";

import { cn } from "cn";
import { useState } from "react";

import { Button } from "@/components/ui/button";
import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
} from "@/components/ui/collapsible";

import type { ChatgptReasoningProps } from "../types/chatgpt";
import { ChatgptChevronIcon } from "./chatgpt-icons";

const traceEnterClassName =
  "transition-[opacity,translate] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none starting:-translate-y-2 starting:opacity-0";

export const ChatgptReasoning = ({
  children,
  className,
  complete = true,
  defaultOpen,
  onOpenChange,
  open: openProp,
  search,
  seconds,
  ...props
}: ChatgptReasoningProps) => {
  const [internalOpen, setInternalOpen] = useState(defaultOpen ?? !complete);
  const [previousComplete, setPreviousComplete] = useState(complete);

  if (previousComplete !== complete) {
    setPreviousComplete(complete);
    setInternalOpen(!complete);
  }

  const label = `Worked for ${seconds}s`;
  const rootClassName = cn(
    "font-chatgpt text-chatgpt-muted text-base leading-7 font-normal",
    className
  );

  if (!(children || search || children === 0 || search === 0)) {
    return (
      <Collapsible
        className={rootClassName}
        data-slot="chatgpt-reasoning"
        {...props}
      >
        {label}
      </Collapsible>
    );
  }

  return (
    <Collapsible
      className={rootClassName}
      data-slot="chatgpt-reasoning"
      onOpenChange={(next, details) => {
        setInternalOpen(next);
        onOpenChange?.(next, details);
      }}
      open={openProp ?? internalOpen}
      {...props}
    >
      <CollapsibleTrigger
        render={
          <Button
            className="text-chatgpt-muted hover:text-chatgpt-muted aria-expanded:text-chatgpt-muted focus-visible:ring-chatgpt-focus/35 h-auto gap-1 rounded-sm border-0 bg-transparent p-0 text-start text-base leading-7 font-normal hover:bg-transparent focus-visible:ring-2 active:not-aria-[haspopup]:translate-y-0 aria-expanded:bg-transparent dark:hover:bg-transparent [&[data-panel-open]>svg]:rotate-180"
            variant="ghost"
          />
        }
      >
        <span>{label}</span>
        <ChatgptChevronIcon className="size-4 shrink-0 transition-transform duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none" />
      </CollapsibleTrigger>
      <CollapsibleContent className="h-(--collapsible-panel-height) overflow-hidden transition-[height,opacity] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] outline-none data-ending-style:h-0 data-ending-style:opacity-0 data-starting-style:h-0 data-starting-style:opacity-0 motion-reduce:transition-opacity">
        <div className="mt-2 flex flex-col items-start gap-2.5 font-normal">
          {children && <div className={traceEnterClassName}>{children}</div>}
          {search && (
            <div
              className={cn(
                traceEnterClassName,
                "delay-75 motion-reduce:delay-0"
              )}
            >
              {search}
            </div>
          )}
        </div>
      </CollapsibleContent>
    </Collapsible>
  );
};
```

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

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

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

import type { ChatgptSearchProps } from "../types/chatgpt";
import { ChatgptFavicon } from "./chatgpt-favicon";
import { ChatgptSearchIcon } from "./chatgpt-icons";

const STACK_LIMIT = 3;

export const ChatgptSearch = ({
  className,
  sites,
  websites,
  ...props
}: ChatgptSearchProps) => {
  const noun = websites === 1 ? "website" : "websites";

  return (
    <Button
      className={cn(
        "font-chatgpt text-chatgpt-muted hover:text-chatgpt-fg focus-visible:ring-chatgpt-focus/35 aria-expanded:text-chatgpt-fg h-auto gap-1.5 rounded-sm border-0 p-0 text-sm leading-5 font-normal transition-colors hover:bg-transparent focus-visible:ring-2 active:not-aria-[haspopup]:translate-y-0 aria-expanded:bg-transparent dark:hover:bg-transparent",
        className
      )}
      data-slot="chatgpt-search"
      variant="ghost"
      {...props}
    >
      {sites && sites.length > 0 ? (
        <AvatarGroup
          aria-hidden="true"
          className="*:data-[slot=avatar]:ring-chatgpt-bg -space-x-1.5 *:data-[slot=avatar]:ring-2"
        >
          {sites.slice(0, STACK_LIMIT).map((site) => (
            <ChatgptFavicon
              className="size-5"
              domain={site.domain}
              key={site.domain}
              src={site.favicon}
            />
          ))}
        </AvatarGroup>
      ) : (
        <ChatgptSearchIcon className="text-chatgpt-search size-4" />
      )}
      <span>{`Searched ${websites} ${noun}`}</span>
    </Button>
  );
};
```

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

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

import { Shimmer } from "../../shimmer/components/shimmer";
import type { ChatgptThinkingProps } from "../types/chatgpt";

export const ChatgptThinking = ({
  className,
  label = "Thinking",
  ...props
}: ChatgptThinkingProps) => (
  <div
    aria-live="polite"
    className={cn(
      "font-chatgpt text-chatgpt-muted text-base leading-7 transition-opacity duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none starting:opacity-0",
      className
    )}
    data-slot="chatgpt-thinking"
    role="status"
    {...props}
  >
    <Shimmer className="font-medium [--shimmer-highlight:var(--chatgpt-fg)]">
      {label}
    </Shimmer>
  </div>
);
```

### `registry/notra/chatgpt/components/chatgpt-thread.tsx`

```tsx
"use client";

import { cn } from "cn";
import { ArrowDownIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";

import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";

import { CHATGPT_SCROLL_STICK_THRESHOLD_PX } from "../constants/chatgpt";
import type { ChatgptThreadProps } from "../types/chatgpt";

const distanceFromBottom = (element: HTMLElement) =>
  element.scrollHeight - element.scrollTop - element.clientHeight;

const getViewport = (root: HTMLDivElement | null) =>
  root?.querySelector<HTMLDivElement>("[data-slot=scroll-area-viewport]") ??
  null;

export const ChatgptThread = ({
  autoScroll = true,
  children,
  className,
  footer,
  ...props
}: ChatgptThreadProps) => {
  const scrollAreaRef = useRef<HTMLDivElement>(null);
  const contentRef = useRef<HTMLDivElement>(null);
  const stickRef = useRef(true);
  const [atBottom, setAtBottom] = useState(true);

  useEffect(() => {
    const viewport = getViewport(scrollAreaRef.current);
    if (!viewport) {
      return;
    }
    const handleScroll = () => {
      const nearBottom =
        distanceFromBottom(viewport) <= CHATGPT_SCROLL_STICK_THRESHOLD_PX;
      stickRef.current = nearBottom;
      setAtBottom(nearBottom);
    };
    // Measure only: the initial position must not clear the stick-to-bottom flag.
    setAtBottom(
      distanceFromBottom(viewport) <= CHATGPT_SCROLL_STICK_THRESHOLD_PX
    );
    viewport.addEventListener("scroll", handleScroll, { passive: true });
    return () => viewport.removeEventListener("scroll", handleScroll);
  }, []);

  useEffect(() => {
    const viewport = getViewport(scrollAreaRef.current);
    const content = contentRef.current;
    if (!(autoScroll && viewport && content)) {
      return;
    }
    const observer = new ResizeObserver(() => {
      if (stickRef.current) {
        viewport.scrollTop = viewport.scrollHeight;
      }
      setAtBottom(
        distanceFromBottom(viewport) <= CHATGPT_SCROLL_STICK_THRESHOLD_PX
      );
    });
    observer.observe(content);
    observer.observe(viewport);
    return () => observer.disconnect();
  }, [autoScroll]);

  const scrollToBottom = () => {
    const viewport = getViewport(scrollAreaRef.current);
    if (!viewport) {
      return;
    }
    stickRef.current = true;
    const reduceMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches;
    viewport.scrollTo({
      behavior: reduceMotion ? "auto" : "smooth",
      top: viewport.scrollHeight,
    });
  };

  return (
    <div
      className={cn(
        "bg-chatgpt-bg font-chatgpt text-chatgpt-fg relative flex min-h-0 flex-col antialiased",
        className
      )}
      data-slot="chatgpt-thread"
      {...props}
    >
      <div className="relative flex min-h-0 flex-1 flex-col">
        <ScrollArea
          className="min-h-0 flex-1 overscroll-contain"
          ref={scrollAreaRef}
        >
          <div
            className="mx-auto flex w-full max-w-3xl flex-col gap-10 px-4 py-8"
            ref={contentRef}
          >
            {children}
          </div>
        </ScrollArea>
        {!atBottom && (
          <Button
            aria-label="Scroll to bottom"
            className="border-chatgpt-border bg-chatgpt-bg text-chatgpt-fg hover:bg-chatgpt-hover hover:text-chatgpt-fg focus-visible:ring-chatgpt-focus/35 dark:bg-chatgpt-bg dark:hover:bg-chatgpt-hover absolute bottom-3 left-1/2 size-8 -translate-x-1/2 rounded-full shadow-sm transition-opacity duration-150 focus-visible:ring-2 active:not-aria-[haspopup]:translate-y-0 motion-reduce:transition-none starting:opacity-0"
            onClick={scrollToBottom}
            size="icon"
            variant="ghost"
          >
            <ArrowDownIcon className="size-4" />
          </Button>
        )}
      </div>
      {footer && (
        <div className="mx-auto w-full max-w-3xl px-4 pb-4">{footer}</div>
      )}
    </div>
  );
};
```

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

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

import type { Avatar } from "@/components/ui/avatar";
import type { Button } from "@/components/ui/button";
import type { Collapsible } from "@/components/ui/collapsible";
import type { Sheet } from "@/components/ui/sheet";

export type ChatgptModelId =
  | "latest"
  | "sol"
  | "terra"
  | "luna"
  | "gpt-5.5"
  | "gpt-5.4"
  | "gpt-5.4-mini";

export type ChatgptEffortId =
  | "instant"
  | "medium"
  | "high"
  | "extra-high"
  | "pro";

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

export interface ChatgptModelOption {
  description?: string;
  id: ChatgptModelId;
  label: string;
  shortLabel: string;
}

export interface ChatgptEffortOption {
  id: ChatgptEffortId;
  label: string;
}

export interface ChatgptActivitySite {
  domain: string;
  favicon?: string;
  label: string;
}

export interface ChatgptActivitySource {
  domain: string;
  favicon?: string;
  href?: string;
  id: string;
  publisher: string;
  snippet: string;
  timeLabel: string;
  title: string;
}

export interface ChatgptThreadProps extends ComponentProps<"div"> {
  autoScroll?: boolean;
  footer?: ReactNode;
}

export interface ChatgptMessageProps extends ComponentProps<"div"> {
  actions?: ReactNode;
  from: ChatgptMessageRole;
  reasoning?: ReactNode;
}

export interface ChatgptReasoningProps extends Omit<
  ComponentProps<typeof Collapsible>,
  "children"
> {
  children?: ReactNode;
  complete?: boolean;
  search?: ReactNode;
  seconds: number;
}

export interface ChatgptSearchProps extends Omit<
  ComponentProps<typeof Button>,
  "children"
> {
  sites?: readonly ChatgptActivitySite[];
  websites: number;
}

export interface ChatgptActivityProps extends Omit<
  ComponentProps<typeof Sheet>,
  "children"
> {
  className?: string;
  seconds: number;
  sites: readonly ChatgptActivitySite[];
  sourceCount?: number;
  sources: readonly ChatgptActivitySource[];
  websites: number;
}

export interface ChatgptFaviconProps extends Omit<
  ComponentProps<typeof Avatar>,
  "children"
> {
  domain: string;
  src?: string;
}

export interface ChatgptThinkingProps extends ComponentProps<"div"> {
  label?: ReactNode;
}

export interface ChatgptActionsProps extends ComponentProps<"div"> {
  onMore?: () => void;
  onRedo?: () => void;
  onShare?: () => void;
  text: string;
}

export interface ChatgptActionButtonProps extends ComponentProps<
  typeof Button
> {
  label: string;
}

export interface ChatgptModelSelectorProps extends Omit<
  ComponentProps<typeof Button>,
  "children"
> {
  effort: ChatgptEffortId;
  efforts?: readonly ChatgptEffortOption[];
  model: ChatgptModelId;
  models?: readonly ChatgptModelOption[];
  onEffortChange?: (effort: ChatgptEffortId) => void;
  onModelChange?: (model: ChatgptModelId) => void;
}

export interface ChatgptComposerProps extends Omit<
  ComponentProps<"form">,
  "onSubmit"
> {
  busy?: boolean;
  defaultEffort?: ChatgptEffortId;
  defaultModel?: ChatgptModelId;
  effort?: ChatgptEffortId;
  efforts?: readonly ChatgptEffortOption[];
  model?: ChatgptModelId;
  models?: readonly ChatgptModelOption[];
  onEffortChange?: (effort: ChatgptEffortId) => void;
  onModelChange?: (model: ChatgptModelId) => void;
  onSend?: (text: string) => void;
  onStop?: () => void;
  placeholder?: string;
}

export interface ChatgptStorySearch {
  sites: readonly ChatgptActivitySite[];
  sourceCount?: number;
  sources: readonly ChatgptActivitySource[];
  websites: number;
}

export type ChatgptStoryReasoningStep =
  | { kind: "search"; search: ChatgptStorySearch }
  | { kind: "text"; muted?: boolean; text: string };

export interface ChatgptStoryReasoning {
  search?: ChatgptStorySearch;
  seconds: number;
  steps?: readonly ChatgptStoryReasoningStep[];
  text: string;
}

export interface ChatgptStoryMessage {
  from: ChatgptMessageRole;
  id: string;
  reasoning?: ChatgptStoryReasoning;
  text: string;
}

export interface ChatgptPlayback {
  completeIds: ReadonlySet<string>;
  messages: ChatgptStoryMessage[];
  play: () => Promise<void>;
  playing: boolean;
  send: (text: string, reply: string) => Promise<void>;
  stop: () => void;
  thinking: boolean;
}

export interface ChatgptTextSegment {
  offset: number;
  text: string;
}

export interface ChatgptSourceChipProps extends Omit<
  ComponentProps<"a">,
  "children" | "href"
> {
  sources: readonly ChatgptActivitySource[];
}
```

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

```ts
export const CHATGPT_TOOLTIP_DELAY_MS = 200;

export const CHATGPT_COPIED_RESET_MS = 1500;

export const CHATGPT_VISIBLE_SITES = 3;

export const CHATGPT_SCROLL_STICK_THRESHOLD_PX = 48;

export const CHATGPT_FAVICON_URL = "https://www.google.com/s2/favicons";

export const CHATGPT_PLAYBACK_TIMING = {
  afterAssistantMs: 480,
  afterInstantMs: 180,
  afterUserMs: 420,
  reducedMaxMs: 70,
  thinkingMs: 1100,
  thinkingWithReasoningMs: 1600,
  tokenMs: 28,
} as const;

/** Where each burst particle ends up, as an offset from the thumb center in px, plus size in px and delay in ms. */
export const CHATGPT_PRO_SPARKLES = [
  { delay: 0, size: 4, x: -30, y: -26 },
  { delay: 30, size: 3, x: 0, y: -34 },
  { delay: 10, size: 4, x: 30, y: -24 },
  { delay: 50, size: 3, x: -38, y: 2 },
  { delay: 20, size: 3, x: 38, y: 4 },
  { delay: 40, size: 4, x: -28, y: 26 },
  { delay: 0, size: 3, x: 2, y: 34 },
  { delay: 60, size: 4, x: 28, y: 26 },
  { delay: 80, size: 2, x: -18, y: -14 },
  { delay: 70, size: 2, x: 18, y: 16 },
] as const;

/** Stars inside the Pro track: position in % of the track, size in px, and animation timing in ms. */
export const CHATGPT_PRO_TWINKLES = [
  { delay: 0, duration: 3000, size: 2, x: 6, y: 32 },
  { delay: 900, duration: 3600, size: 3, x: 15, y: 66 },
  { delay: 400, duration: 2800, size: 2, x: 24, y: 28 },
  { delay: 1500, duration: 3400, size: 2, x: 33, y: 60 },
  { delay: 700, duration: 3200, size: 3, x: 43, y: 36 },
  { delay: 2100, duration: 3800, size: 2, x: 52, y: 70 },
  { delay: 300, duration: 3000, size: 2, x: 61, y: 30 },
  { delay: 1200, duration: 3500, size: 3, x: 70, y: 62 },
  { delay: 1800, duration: 2900, size: 2, x: 79, y: 34 },
  { delay: 600, duration: 3300, size: 2, x: 87, y: 64 },
] as const;

export const CHATGPT_CHIP_HOVER_OPEN_DELAY_MS = 150;

export const CHATGPT_CHIP_HOVER_CLOSE_DELAY_MS = 120;
```

### `registry/notra/chatgpt/constants/chatgpt-models.ts`

```ts
import type {
  ChatgptEffortId,
  ChatgptEffortOption,
  ChatgptModelId,
  ChatgptModelOption,
} from "../types/chatgpt";

export const CHATGPT_DEFAULT_MODEL: ChatgptModelId = "latest";

export const CHATGPT_DEFAULT_EFFORT: ChatgptEffortId = "medium";

export const CHATGPT_LATEST_MODEL: ChatgptModelOption = {
  id: "latest",
  label: "Latest",
  shortLabel: "6",
};

export const CHATGPT_MEDIUM_EFFORT: ChatgptEffortOption = {
  id: "medium",
  label: "Medium",
};

/** Ordered like the slider stops, from the left. */
export const CHATGPT_EFFORTS: readonly ChatgptEffortOption[] = [
  { id: "instant", label: "Instant" },
  CHATGPT_MEDIUM_EFFORT,
  { id: "high", label: "High" },
  { id: "extra-high", label: "Extra High" },
  { id: "pro", label: "Pro" },
];

export const CHATGPT_MODELS: readonly ChatgptModelOption[] = [
  CHATGPT_LATEST_MODEL,
  { id: "sol", label: "GPT-5.6 Sol", shortLabel: "5.6" },
  {
    description: "Leaving on October 14",
    id: "gpt-5.5",
    label: "GPT-5.5",
    shortLabel: "5.5",
  },
];
```

### `registry/notra/chatgpt/constants/chatgpt-story.ts`

```ts
import type {
  ChatgptActivitySite,
  ChatgptActivitySource,
  ChatgptStoryMessage,
  ChatgptStoryReasoningStep,
  ChatgptStorySearch,
} from "../types/chatgpt";

export const CHATGPT_STORY_SITES: ChatgptActivitySite[] = [
  { domain: "reuters.com", label: "www.reuters.com" },
  { domain: "theguardian.com", label: "www.theguardian.com" },
  { domain: "apnews.com", label: "apnews.com" },
  { domain: "bbc.com", label: "www.bbc.com" },
  { domain: "ft.com", label: "www.ft.com" },
  { domain: "spiegel.de", label: "www.spiegel.de" },
];

export const CHATGPT_STORY_SOURCES: ChatgptActivitySource[] = [
  {
    domain: "reuters.com",
    id: "src-1",
    publisher: "Reuters",
    snippet:
      "Foreign ministers in Ottawa said a coordinated package remains on the table after overnight consultations.",
    timeLabel: "Today — 3 hours ago",
    title: "G7 ministers weigh new sanctions as Geneva talks stall",
  },
  {
    domain: "apnews.com",
    id: "src-2",
    publisher: "AP News",
    snippet:
      "Negotiators left the session without a timetable, citing unresolved security guarantees.",
    timeLabel: "Today — 4 hours ago",
    title: "Ceasefire talks in Geneva hit another delay",
  },
  {
    domain: "theguardian.com",
    id: "src-3",
    publisher: "The Guardian",
    snippet:
      "Premiums jumped again as operators rerouted tankers away from the strait.",
    timeLabel: "Today — 5 hours ago",
    title: "Shipping insurers raise rates after Hormuz disruption",
  },
  {
    domain: "ft.com",
    id: "src-4",
    publisher: "Financial Times",
    snippet: "The DAX tracked overnight gains in megacap software and chips.",
    timeLabel: "Today — 6 hours ago",
    title: "US tech earnings lift European markets at the open",
  },
  {
    domain: "spiegel.de",
    id: "src-5",
    publisher: "Spiegel",
    snippet:
      "Parliamentary leaders want tighter fiscal language ahead of this week’s Bundestag session.",
    timeLabel: "Today — 7 hours ago",
    title: "Union demands changes before growth package vote",
  },
  {
    domain: "bbc.com",
    id: "src-6",
    publisher: "BBC",
    snippet:
      "Member states are still split on how national regulators should share oversight.",
    timeLabel: "Today — 8 hours ago",
    title: "EU officials outline next steps on AI Act enforcement",
  },
];

export const CHATGPT_STORY_SEARCH: ChatgptStorySearch = {
  sites: CHATGPT_STORY_SITES,
  sourceCount: 91,
  sources: CHATGPT_STORY_SOURCES,
  websites: 6,
};

const CHATGPT_STORY_SEARCH_NEWS: ChatgptStorySearch = {
  sites: CHATGPT_STORY_SITES.slice(0, 3),
  sourceCount: 42,
  sources: CHATGPT_STORY_SOURCES.slice(0, 3),
  websites: 3,
};

const CHATGPT_STORY_SEARCH_MARKETS: ChatgptStorySearch = {
  sites: CHATGPT_STORY_SITES.slice(3, 5),
  sourceCount: 49,
  sources: CHATGPT_STORY_SOURCES.slice(3, 5),
  websites: 2,
};

const CHATGPT_STORY_REASONING_STEPS: ChatgptStoryReasoningStep[] = [
  {
    kind: "text",
    text: "Sure. I'll pull together the biggest news from **today, Monday, August 17, 2026**.",
  },
  { kind: "search", search: CHATGPT_STORY_SEARCH_NEWS },
  { kind: "text", muted: true, text: "Markets and economy" },
  { kind: "search", search: CHATGPT_STORY_SEARCH_MARKETS },
];

export const CHATGPT_STORY_THREAD: ChatgptStoryMessage[] = [
  { from: "user", id: "u-1", text: "What are the biggest news stories today?" },
  {
    from: "assistant",
    id: "a-1",
    reasoning: {
      search: CHATGPT_STORY_SEARCH,
      seconds: 21,
      steps: CHATGPT_STORY_REASONING_STEPS,
      text: "Sure. I'll pull together the biggest news from **today, Monday, August 17, 2026**.",
    },
    text: "Here's where things stand on **Monday, August 17, 2026**:\n\nG7 foreign ministers are weighing new sanctions in Ottawa while the Geneva ceasefire talks stall again. {{src-1,src-2}} Markets opened higher after strong US tech earnings. {{src-4}}",
  },
];

export const CHATGPT_STORY_REPLIES = [
  "Got it. Let me know when you want to keep going.",
  "Great. I'm here.",
  "Sure. What's next?",
] as const;

export const CHATGPT_SMALL_TALK: ChatgptStoryMessage[] = [
  { from: "user", id: "st-u-1", text: "how r you?" },
  {
    from: "assistant",
    id: "st-a-1",
    text: "I'm good 😭 ready when you are. What's up with you?",
  },
];
```

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

```ts
"use client";

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

import { CHATGPT_PLAYBACK_TIMING } from "../constants/chatgpt";
import type { ChatgptPlayback, ChatgptStoryMessage } from "../types/chatgpt";

const WHITESPACE_PATTERN = /(\s+)/;

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

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

const prefersReducedMotion = () =>
  window.matchMedia(REDUCED_MOTION_QUERY).matches;

const idsOf = (script: readonly ChatgptStoryMessage[]) =>
  new Set(script.map((item) => item.id));

export const useChatgptPlayback = (
  script: readonly ChatgptStoryMessage[]
): ChatgptPlayback => {
  const [messages, setMessages] = useState<ChatgptStoryMessage[]>(() => [
    ...script,
  ]);
  const [completeIds, setCompleteIds] = useState<ReadonlySet<string>>(() =>
    idsOf(script)
  );
  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(idsOf(script));
    setThinking(false);
    setPlaying(false);
  }, [script]);

  const playTurn = useCallback(
    async (message: ChatgptStoryMessage, alive: () => boolean) => {
      const reduced = prefersReducedMotion();
      const delay = (ms: number) =>
        wait(reduced ? Math.min(ms, CHATGPT_PLAYBACK_TIMING.reducedMaxMs) : ms);

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

      setThinking(true);
      await delay(
        message.reasoning
          ? CHATGPT_PLAYBACK_TIMING.thinkingWithReasoningMs
          : CHATGPT_PLAYBACK_TIMING.thinkingMs
      );
      if (!alive()) {
        return;
      }
      setThinking(false);

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

      setMessages((current) => [...current, { ...message, text: "" }]);
      let text = "";
      for (const token of message.text.split(WHITESPACE_PATTERN)) {
        if (!alive()) {
          return;
        }
        text += token;
        const snapshot = text;
        setMessages((current) =>
          current.map((item) =>
            item.id === message.id ? { ...item, text: snapshot } : item
          )
        );
        await delay(CHATGPT_PLAYBACK_TIMING.tokenMs);
      }
      if (!alive()) {
        return;
      }
      markComplete(message.id);
      await delay(CHATGPT_PLAYBACK_TIMING.afterAssistantMs);
    },
    [markComplete]
  );

  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();

      try {
        setPlaying(true);
        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/chatgpt/examples/chatgpt-demo.tsx`

```tsx
"use client";

import { useEffect, useRef } from "react";

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

import { ChatgptActions } from "../components/chatgpt-actions";
import { ChatgptActivity } from "../components/chatgpt-activity";
import { ChatgptComposer } from "../components/chatgpt-composer";
import { ChatgptLogoIcon } from "../components/chatgpt-icons";
import { ChatgptMessage } from "../components/chatgpt-message";
import { ChatgptReasoning } from "../components/chatgpt-reasoning";
import { ChatgptSourceChip } from "../components/chatgpt-source-chip";
import { ChatgptThinking } from "../components/chatgpt-thinking";
import {
  CHATGPT_STORY_REPLIES,
  CHATGPT_STORY_THREAD,
} from "../constants/chatgpt-story";
import { useChatgptPlayback } from "../hooks/use-chatgpt-playback";
import type {
  ChatgptActivitySource,
  ChatgptStoryMessage,
  ChatgptTextSegment,
} from "../types/chatgpt";

const BOLD_PATTERN = /(\*\*[^*]+\*\*)/g;

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

const CITE_PATTERN = /(\{\{[a-z0-9,-]+\}\})/g;

const CITE_TOKEN_PATTERN = /^\{\{([a-z0-9,-]+)\}\}$/;

const PARAGRAPH_SEPARATOR = "\n\n";

const splitWithOffsets = (
  text: string,
  separator: string | RegExp,
  separatorLength: number
) => {
  const segments: ChatgptTextSegment[] = [];
  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 ChatgptStoryText = ({
  sources = [],
  text,
}: {
  sources?: readonly ChatgptActivitySource[];
  text: string;
}) => {
  const paragraphs = splitWithOffsets(
    text,
    PARAGRAPH_SEPARATOR,
    PARAGRAPH_SEPARATOR.length
  );

  return (
    <span className={paragraphs.length > 1 ? "flex flex-col gap-3" : undefined}>
      {paragraphs.map((paragraph) => (
        <span key={paragraph.offset}>
          {splitWithOffsets(paragraph.text, CITE_PATTERN, 0).map((piece) => {
            const ids = piece.text.match(CITE_TOKEN_PATTERN)?.[1]?.split(",");
            if (ids) {
              const wanted = new Set(ids);
              const cited = sources.filter((source) => wanted.has(source.id));
              return <ChatgptSourceChip key={piece.offset} sources={cited} />;
            }
            return splitWithOffsets(piece.text, BOLD_PATTERN, 0).map((part) =>
              part.text.startsWith("**") && part.text.endsWith("**") ? (
                <strong key={`${piece.offset}:${part.offset}`}>
                  {part.text.slice(2, -2)}
                </strong>
              ) : (
                <span key={`${piece.offset}:${part.offset}`}>{part.text}</span>
              )
            );
          })}
        </span>
      ))}
    </span>
  );
};

const ChatgptStoryBody = ({
  complete = true,
  message,
}: {
  complete?: boolean;
  message: ChatgptStoryMessage;
}) => {
  if (message.from === "user") {
    return <ChatgptMessage from="user">{message.text}</ChatgptMessage>;
  }

  const { reasoning } = message;

  return (
    <ChatgptMessage
      actions={
        <div className={complete ? undefined : "invisible"}>
          <ChatgptActions text={message.text.replace(CITE_STRIP, "")} />
        </div>
      }
      from="assistant"
      reasoning={
        reasoning && (
          <ChatgptReasoning
            complete={complete}
            search={
              !reasoning.steps &&
              reasoning.search && (
                <ChatgptActivity
                  seconds={reasoning.seconds}
                  sites={reasoning.search.sites}
                  sourceCount={reasoning.search.sourceCount}
                  sources={reasoning.search.sources}
                  websites={reasoning.search.websites}
                />
              )
            }
            seconds={reasoning.seconds}
          >
            {reasoning.steps ? (
              <div className="flex flex-col items-start gap-3">
                {reasoning.steps.map((step) =>
                  step.kind === "search" ? (
                    <ChatgptActivity
                      key={`search-${step.search.websites}`}
                      seconds={reasoning.seconds}
                      sites={step.search.sites}
                      sourceCount={step.search.sourceCount}
                      sources={step.search.sources}
                      websites={step.search.websites}
                    />
                  ) : (
                    <div
                      className={
                        step.muted
                          ? "text-chatgpt-muted text-[0.9375rem] leading-7"
                          : "text-chatgpt-fg text-[0.9375rem] leading-7"
                      }
                      key={step.text}
                    >
                      <ChatgptStoryText text={step.text} />
                    </div>
                  )
                )}
              </div>
            ) : (
              <div className="text-chatgpt-fg text-[0.9375rem] leading-7">
                <ChatgptStoryText text={reasoning.text} />
              </div>
            )}
          </ChatgptReasoning>
        )
      }
    >
      <ChatgptStoryText
        sources={reasoning?.steps
          ?.flatMap((step) =>
            step.kind === "search" ? step.search.sources : []
          )
          .concat(reasoning.search?.sources ?? [])}
        text={message.text}
      />
    </ChatgptMessage>
  );
};

export default function ChatgptDemo() {
  const playback = useChatgptPlayback(CHATGPT_STORY_THREAD);
  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 handleSend = async (text: string) => {
    if (playback.playing) {
      return;
    }
    const reply =
      CHATGPT_STORY_REPLIES[
        replyIndexRef.current % CHATGPT_STORY_REPLIES.length
      ];
    replyIndexRef.current += 1;
    await playback.send(text, reply);
  };

  return (
    <Card className="border-chatgpt-border bg-chatgpt-bg font-chatgpt text-chatgpt-fg h-150 w-full min-w-0 gap-0 rounded-2xl border py-0 text-base antialiased ring-0">
      <CardHeader className="flex h-12 shrink-0 items-center justify-between gap-3 rounded-none ps-4 pe-2">
        <CardTitle className="text-chatgpt-muted flex min-w-0 items-center gap-2 text-base leading-6 font-normal">
          <ChatgptLogoIcon className="size-4.5 shrink-0" />
          <span className="truncate">ChatGPT</span>
        </CardTitle>
      </CardHeader>
      <ScrollArea className="min-h-0 flex-1" ref={scrollRef}>
        <CardContent className="mx-auto flex w-full max-w-3xl min-w-0 flex-col gap-6 px-5 pt-4 pb-8">
          {playback.messages.map((message) => (
            <ChatgptStoryBody
              complete={playback.completeIds.has(message.id)}
              key={message.id}
              message={message}
            />
          ))}
          {playback.thinking && <ChatgptThinking />}
        </CardContent>
      </ScrollArea>
      <CardFooter className="mx-auto w-full max-w-3xl min-w-0 shrink-0 rounded-none border-t-0 bg-transparent px-5 pt-0 pb-5">
        <ChatgptComposer
          busy={playback.playing || playback.thinking}
          onSend={handleSend}
          onStop={playback.stop}
        />
      </CardFooter>
    </Card>
  );
}
```

### `registry/notra/chatgpt/components/chatgpt-source-chip.tsx`

```tsx
"use client";

import { cn } from "cn";

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

import {
  CHATGPT_CHIP_HOVER_CLOSE_DELAY_MS,
  CHATGPT_CHIP_HOVER_OPEN_DELAY_MS,
} from "../constants/chatgpt";
import type { ChatgptSourceChipProps } from "../types/chatgpt";
import { ChatgptFavicon } from "./chatgpt-favicon";

export const ChatgptSourceChip = ({
  className,
  rel = "noopener noreferrer",
  sources,
  target = "_blank",
  ...props
}: ChatgptSourceChipProps) => {
  const [first] = sources;
  if (!first) {
    return null;
  }
  const extra = sources.length - 1;

  return (
    <HoverCard>
      <HoverCardTrigger
        aria-label={
          extra > 0 ? `${first.publisher} (+${extra})` : first.publisher
        }
        className={cn(
          badgeVariants({ variant: "secondary" }),
          "bg-chatgpt-hover text-chatgpt-muted hover:bg-chatgpt-border hover:text-chatgpt-fg focus-visible:outline-chatgpt-focus mx-0.5 h-5 gap-1 rounded-full border-0 px-2 align-middle text-xs leading-none font-normal no-underline focus-visible:ring-0 focus-visible:outline-2 focus-visible:outline-solid motion-reduce:transition-none",
          className
        )}
        closeDelay={CHATGPT_CHIP_HOVER_CLOSE_DELAY_MS}
        data-slot="chatgpt-source-chip"
        delay={CHATGPT_CHIP_HOVER_OPEN_DELAY_MS}
        href={first.href ?? `https://${first.domain}`}
        rel={rel}
        target={target}
        {...props}
      >
        {first.publisher}
        {extra > 0 ? <span>+{extra}</span> : null}
      </HoverCardTrigger>
      <HoverCardContent
        align="start"
        className="bg-chatgpt-popover font-chatgpt text-chatgpt-fg shadow-chatgpt-menu w-80 max-w-[calc(100vw-2rem)] gap-0 rounded-2xl p-0 ring-0"
        sideOffset={8}
      >
        <ul className="flex flex-col">
          {sources.slice(0, 3).map((source, index) => (
            <li key={source.id}>
              {index > 0 ? <Separator className="bg-chatgpt-border" /> : null}
              <div className="flex flex-col gap-1 p-3">
                <div className="text-chatgpt-muted flex items-center gap-1.5 text-xs leading-4">
                  <ChatgptFavicon
                    className="size-4"
                    domain={source.domain}
                    src={source.favicon}
                  />
                  {source.publisher}
                </div>
                <p className="text-[0.9375rem] leading-5 font-medium">
                  {source.title}
                </p>
                <p className="text-chatgpt-muted line-clamp-2 text-[0.8125rem] leading-[1.15rem]">
                  {source.snippet}
                </p>
              </div>
            </li>
          ))}
        </ul>
      </HoverCardContent>
    </HoverCard>
  );
};
```
