# Claude

> A full recreation of the claude.ai chat with web search, serif replies, message actions and the model picker, built on shadcn Base UI.

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

- Page, with a live preview: https://shadcn.exchange/r/notra/claude
- Screenshot: https://shadcn.exchange/shot/notra/claude/light.png
- Registry item: https://ui.usenotra.com/r/claude.json
- Listed: 2026-10-13, source last changed 2026-10-21

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/notra/claude.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`, `button`, `card`, `collapsible`, `https://ui.usenotra.com/r/notra-dropdown-menu.json`, `hover-card`, `input-group`, `item`, `kbd`, `progress`, `scroll-area`, `separator`, `tooltip`, `https://ui.usenotra.com/r/shimmer.json`

## Files

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

```tsx
"use client";

import { cn } from "cn";
import {
  ChevronDownIcon,
  CornerDownLeftIcon,
  MicIcon,
  SquareIcon,
} from "lucide-react";
import type { FormEvent, KeyboardEvent, MouseEvent, ReactNode } from "react";
import { useState } from "react";

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

import { CLAUDE_DEFAULT_CONTEXT_USAGE } from "../constants/claude";
import {
  CLAUDE_DEFAULT_EFFORT,
  CLAUDE_DEFAULT_MODEL,
} from "../constants/claude-models";
import type { ClaudeComposerProps } from "../types/claude";
import { ClaudeModelSelector } from "./claude-model-selector";
import { ClaudePlusMenu } from "./claude-plus-menu";
import { ClaudeUsageRing } from "./claude-usage-ring";

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

const GHOST_CLASS =
  "hover:bg-claude-hover focus-visible:ring-claude-fg/20 aria-expanded:bg-claude-hover dark:hover:bg-claude-hover focus-visible:border-transparent focus-visible:ring-2 active:not-aria-[haspopup]:translate-y-0";

const ClaudeComposerGlyph = ({
  children,
  show,
}: {
  children: ReactNode;
  show: boolean;
}) => (
  <span
    aria-hidden="true"
    className={cn(
      "absolute inset-0 flex items-center justify-center transition-[opacity,scale] duration-200 motion-reduce:transition-opacity",
      EASE,
      show
        ? "scale-100 opacity-100"
        : "pointer-events-none scale-[0.92] opacity-0"
    )}
  >
    {children}
  </span>
);

const ClaudeComposerSubmit = ({
  busy,
  canSend,
  onStop,
}: {
  busy: boolean;
  canSend: boolean;
  onStop?: () => void;
}) => {
  const handleClick = busy
    ? (event: MouseEvent<HTMLButtonElement>) => {
        event.preventDefault();
        onStop?.();
      }
    : undefined;

  return (
    <InputGroupButton
      aria-label={busy ? "Stop" : "Send"}
      className={cn(
        GHOST_CLASS,
        "text-claude-faint hover:text-claude-fg relative size-8 rounded-lg transition-colors duration-150 motion-reduce:transition-none",
        (busy || canSend) && "text-claude-fg"
      )}
      onClick={handleClick}
      size="icon-sm"
      type={canSend && !busy ? "submit" : "button"}
      variant="ghost"
    >
      <ClaudeComposerGlyph show={!busy}>
        <CornerDownLeftIcon className="size-4.5" strokeWidth={1.25} />
      </ClaudeComposerGlyph>
      <ClaudeComposerGlyph show={busy}>
        <SquareIcon className="size-3 fill-current" strokeWidth={1.5} />
      </ClaudeComposerGlyph>
    </InputGroupButton>
  );
};

export const ClaudeComposer = ({
  busy = false,
  className,
  defaultEffort = CLAUDE_DEFAULT_EFFORT,
  defaultModel = CLAUDE_DEFAULT_MODEL,
  contextUsage = CLAUDE_DEFAULT_CONTEXT_USAGE,
  disclaimer,
  effort: effortProp,
  model: modelProp,
  onEffortChange,
  mode = "Manual",
  onModeClick,
  onModelChange,
  onPlusSelect,
  onSend,
  onStop,
  placeholder = "Reply",
  ...props
}: ClaudeComposerProps) => {
  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 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.nativeEvent.isComposing
    ) {
      event.preventDefault();
      submit();
    }
  };

  return (
    <div
      className={cn("font-claude @container/claude-composer w-full", className)}
      data-slot="claude-composer"
      {...props}
    >
      <form onSubmit={handleSubmit}>
        <InputGroup className="border-claude-input-border bg-claude-input dark:bg-claude-input has-[[data-slot=input-group-control]:focus-visible]:border-claude-input-border h-auto items-end rounded-2xl ps-4 pe-2 shadow-[0_1px_2px_var(--claude-input-shadow)] has-[[data-slot=input-group-control]:focus-visible]:ring-0">
          <InputGroupTextarea
            aria-label="Message"
            className="text-claude-fg placeholder:text-claude-muted max-h-60 min-h-12 overflow-y-auto px-0 py-3 text-[0.9375rem] leading-6 md:text-[0.9375rem]"
            onChange={(event) => setValue(event.target.value)}
            onKeyDown={handleKeyDown}
            placeholder={placeholder}
            rows={1}
            value={value}
          />
          <InputGroupAddon
            align="inline-end"
            className="cursor-default py-2 pe-0"
          >
            <ClaudeComposerSubmit
              busy={busy}
              canSend={canSend}
              onStop={onStop}
            />
          </InputGroupAddon>
        </InputGroup>
      </form>
      <div className="mt-1.5 flex flex-wrap items-center justify-between gap-x-3 gap-y-0.5 px-2">
        <div className="flex items-center gap-0.5">
          <ClaudePlusMenu onSelect={onPlusSelect} />
          <Button
            aria-label="Dictate"
            className={cn(
              GHOST_CLASS,
              "text-claude-text hover:text-claude-fg h-8 gap-1 rounded-lg px-2"
            )}
            type="button"
            variant="ghost"
          >
            <MicIcon className="size-4" strokeWidth={1.25} />
            <ChevronDownIcon
              className="text-claude-muted size-3"
              strokeWidth={1.5}
            />
          </Button>
        </div>
        {disclaimer && (
          <p className="text-claude-muted order-last basis-full text-center text-xs leading-5 @xl/claude-composer:order-none @xl/claude-composer:basis-auto">
            {disclaimer}
          </p>
        )}
        <div className="flex items-center gap-1">
          <ClaudeModelSelector
            effort={effort}
            model={model}
            onEffortChange={(next) => {
              if (effortProp === undefined) {
                setUncontrolledEffort(next);
              }
              onEffortChange?.(next);
            }}
            onModelChange={(next) => {
              if (modelProp === undefined) {
                setUncontrolledModel(next);
              }
              onModelChange?.(next);
            }}
          />
          {mode && (
            <Button
              className={cn(
                GHOST_CLASS,
                "text-claude-text hover:text-claude-fg h-7 rounded-lg px-1.5 text-xs font-normal"
              )}
              onClick={onModeClick}
              type="button"
              variant="ghost"
            >
              {mode}
            </Button>
          )}
          <ClaudeUsageRing className="ms-1.5 me-1" value={contextUsage} />
        </div>
      </div>
    </div>
  );
};
```

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

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

export const ClaudeLogoIcon = (props: ComponentProps<"svg">) => (
  <svg
    aria-hidden="true"
    fill="currentColor"
    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" />
  </svg>
);

export const ClaudeResearchIcon = (props: ComponentProps<"svg">) => (
  <svg
    aria-hidden="true"
    fill="none"
    stroke="currentColor"
    strokeLinecap="round"
    strokeLinejoin="round"
    strokeWidth={1.25}
    viewBox="0 0 24 24"
    {...props}
  >
    <circle cx="10.5" cy="10.5" r="7" />
    <path d="m15.5 15.5 5 5" />
    <path d="m7 12.5 2.25-2.5 2 1.75 2.75-3.25" />
  </svg>
);
```

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

```tsx
"use client";

import { cn } from "cn";

import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuSeparator,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";

import {
  CLAUDE_MENU_ITEM_CLASS,
  CLAUDE_MENU_SURFACE_CLASS,
  CLAUDE_MODEL_MENU_CLOSE_DELAY_MS,
  CLAUDE_MODEL_MENU_OPEN_DELAY_MS,
} from "../constants/claude";
import { CLAUDE_EFFORTS, CLAUDE_MODELS } from "../constants/claude-models";
import {
  getClaudeEffort,
  getClaudeModel,
  getLatestClaudeModels,
  getPreviousClaudeModels,
} from "../lib/claude-models";
import type {
  ClaudeEffortId,
  ClaudeModelId,
  ClaudeModelSelectorProps,
} from "../types/claude";

const MENU_SURFACE_CLASS = cn(
  CLAUDE_MENU_SURFACE_CLASS,
  "w-72 p-1.5 shadow-[0_4px_24px_var(--claude-popover-shadow)]"
);

const ROW_CLASS = cn(
  CLAUDE_MENU_ITEM_CLASS,
  "focus:bg-claude-hover data-open:bg-claude-hover data-open:text-claude-fg data-popup-open:bg-claude-hover data-popup-open:text-claude-fg [&>svg:last-child]:text-claude-muted h-auto gap-2 rounded-lg px-2.5 py-2 text-sm leading-5 [&>svg:last-child]:size-4"
);

const CHECK_CLASS =
  "pe-9 [&_[data-slot=dropdown-menu-radio-item-indicator]]:end-2.5 [&_[data-slot=dropdown-menu-radio-item-indicator]]:top-1/2 [&_[data-slot=dropdown-menu-radio-item-indicator]]:-translate-y-1/2 [&_[data-slot=dropdown-menu-radio-item-indicator]_svg]:text-claude-check [&_[data-slot=dropdown-menu-radio-item-indicator]_svg]:size-4";

const SEPARATOR_CLASS = "bg-claude-input-border mx-3 my-1";

const isEffortId = (value: unknown): value is ClaudeEffortId =>
  CLAUDE_EFFORTS.some((item) => item.id === value);

const isModelId = (value: unknown): value is ClaudeModelId =>
  CLAUDE_MODELS.some((item) => item.id === value);

export const ClaudeModelSelector = ({
  className,
  effort,
  effortLabel = "Effort",
  model,
  moreModelsLabel = "More models",
  onEffortChange,
  onModelChange,
}: ClaudeModelSelectorProps) => {
  const selectedModel = getClaudeModel(model);
  const selectedEffort = getClaudeEffort(effort);

  return (
    <DropdownMenu modal={false}>
      <DropdownMenuTrigger
        closeDelay={CLAUDE_MODEL_MENU_CLOSE_DELAY_MS}
        delay={CLAUDE_MODEL_MENU_OPEN_DELAY_MS}
        openOnHover
        render={
          <Button
            aria-label={`Model ${selectedModel.label}, effort ${selectedEffort.label}`}
            className={cn(
              "group/claude-model font-claude text-claude-fg hover:bg-claude-hover hover:text-claude-fg focus-visible:ring-claude-fg/20 aria-expanded:bg-claude-hover aria-expanded:text-claude-fg data-popup-open:bg-claude-hover dark:hover:bg-claude-hover h-7 gap-1.5 rounded-lg px-1.5 text-xs leading-5 font-normal focus-visible:border-transparent focus-visible:ring-2 active:not-aria-[haspopup]:translate-y-0",
              className
            )}
            data-slot="claude-model-selector"
            type="button"
            variant="ghost"
          />
        }
      >
        <span>{selectedModel.label}</span>
        <span className="text-claude-muted">{selectedEffort.label}</span>
      </DropdownMenuTrigger>
      <DropdownMenuContent
        align="end"
        className={MENU_SURFACE_CLASS}
        side="top"
        sideOffset={8}
      >
        <DropdownMenuRadioGroup
          onValueChange={(value) => {
            if (isModelId(value)) {
              onModelChange?.(value);
            }
          }}
          value={model}
        >
          {getLatestClaudeModels().map((item) => (
            <DropdownMenuRadioItem
              className={cn(ROW_CLASS, CHECK_CLASS)}
              key={item.id}
              value={item.id}
            >
              <span className="flex min-w-0 flex-col">
                <span className="text-claude-fg">{item.label}</span>
                <span className="text-claude-muted text-[0.8125rem] leading-5">
                  {item.description}
                </span>
              </span>
            </DropdownMenuRadioItem>
          ))}
        </DropdownMenuRadioGroup>
        <DropdownMenuSeparator className={SEPARATOR_CLASS} />
        <DropdownMenuSub>
          <DropdownMenuSubTrigger className={ROW_CLASS} openOnHover>
            <span>{effortLabel}</span>
            <span className="text-claude-muted ms-auto">
              {selectedEffort.label}
            </span>
          </DropdownMenuSubTrigger>
          <DropdownMenuSubContent
            className={cn(MENU_SURFACE_CLASS, "w-auto min-w-44")}
            side="right"
            sideOffset={6}
          >
            <DropdownMenuRadioGroup
              onValueChange={(value) => {
                if (isEffortId(value)) {
                  onEffortChange?.(value);
                }
              }}
              value={effort}
            >
              {CLAUDE_EFFORTS.map((item) => (
                <DropdownMenuRadioItem
                  className={cn(ROW_CLASS, CHECK_CLASS)}
                  key={item.id}
                  value={item.id}
                >
                  {item.label}
                </DropdownMenuRadioItem>
              ))}
            </DropdownMenuRadioGroup>
          </DropdownMenuSubContent>
        </DropdownMenuSub>
        <DropdownMenuSeparator className={SEPARATOR_CLASS} />
        <DropdownMenuSub>
          <DropdownMenuSubTrigger className={ROW_CLASS} openOnHover>
            {moreModelsLabel}
          </DropdownMenuSubTrigger>
          <DropdownMenuSubContent
            className={cn(MENU_SURFACE_CLASS, "w-auto min-w-44")}
            side="right"
            sideOffset={6}
          >
            <DropdownMenuRadioGroup
              onValueChange={(value) => {
                if (isModelId(value)) {
                  onModelChange?.(value);
                }
              }}
              value={model}
            >
              {getPreviousClaudeModels().map((item) => (
                <DropdownMenuRadioItem
                  className={cn(ROW_CLASS, CHECK_CLASS)}
                  key={item.id}
                  value={item.id}
                >
                  {item.label}
                </DropdownMenuRadioItem>
              ))}
            </DropdownMenuRadioGroup>
          </DropdownMenuSubContent>
        </DropdownMenuSub>
      </DropdownMenuContent>
    </DropdownMenu>
  );
};
```

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

```ts
import type { ClaudeSpinnerFrame } from "../types/claude";

export const CLAUDE_SEARCH_VERB_HOLD_MS = 1100;

export const CLAUDE_SEARCH_QUERY_MS = 420;

export const CLAUDE_SEARCH_RESULTS_MS = 640;

export const CLAUDE_SEARCH_STEP_MS = 380;

export const CLAUDE_SPINNER_FRAME_MS = 120;

export const CLAUDE_SPINNER_VIEWBOX = 24;

export const CLAUDE_SPINNER_CENTER = 12;

export const CLAUDE_SPINNER_FRAMES: readonly ClaudeSpinnerFrame[] = [
  { inner: 0, outer: 0, rays: 0, strokeWidth: 0 },
  { inner: 1.35, jitter: 0.08, outer: 4.4, rays: 4, strokeWidth: 1.55 },
  { inner: 1.45, jitter: 0.1, outer: 5.3, rays: 8, strokeWidth: 1.45 },
  { inner: 1.3, jitter: 0.06, outer: 6.5, rays: 6, strokeWidth: 1.55 },
  { inner: 1.5, jitter: 0.12, outer: 6.9, rays: 8, strokeWidth: 1.5 },
  { inner: 1.65, jitter: 0.16, outer: 7.5, rays: 10, strokeWidth: 1.4 },
];

const SPINNER_FRAME_INDEXES = CLAUDE_SPINNER_FRAMES.map((_, index) => index);

export const CLAUDE_SPINNER_SEQUENCE: readonly number[] = [
  ...SPINNER_FRAME_INDEXES,
  ...[...SPINNER_FRAME_INDEXES].reverse().slice(1, -1),
];

export const CLAUDE_SPINNER_STATIC_FRAME = CLAUDE_SPINNER_FRAMES.length - 1;

export const CLAUDE_THINKING_VERBS = [
  "Untangling",
  "Pondering",
  "Gathering",
  "Sorting",
  "Weaving",
] as const;

export const CLAUDE_THINKING_INTERVAL_MS = 2200;

export const CLAUDE_COPIED_RESET_MS = 1500;

export const CLAUDE_TOOLTIP_DELAY_MS = 200;

export const CLAUDE_MODEL_MENU_OPEN_DELAY_MS = 75;

export const CLAUDE_MODEL_MENU_CLOSE_DELAY_MS = 200;

export const CLAUDE_PLUS_MENU_SKILLS = ["Docx", "PDF", "Spreadsheets"] as const;

export const CLAUDE_PLUS_MENU_CONNECTORS = [
  "GitHub",
  "Google Drive",
  "Slack",
] as const;

export const CLAUDE_PLUS_MENU_DESIGN_SYSTEMS = [
  "Notra UI",
  "shadcn/ui",
] as const;

export const CLAUDE_DEFAULT_CONTEXT_USAGE = 85;

export const CLAUDE_MENU_SURFACE_CLASS =
  "bg-claude-popover font-claude text-claude-fg ring-claude-popover-border rounded-2xl p-1.5 shadow-[0_4px_24px_var(--claude-popover-shadow)]";

export const CLAUDE_MENU_ITEM_CLASS =
  "text-claude-fg focus:bg-claude-hover focus:text-claude-fg not-data-[variant=destructive]:focus:**:text-claude-fg data-highlighted:bg-claude-hover h-8 cursor-pointer gap-2.5 rounded-lg px-2.5 py-0 text-sm leading-5 [&_svg:not([class*='size-'])]:size-4.5";

export const CLAUDE_HOVER_OPEN_DELAY_MS = 150;

export const CLAUDE_HOVER_CLOSE_DELAY_MS = 100;
```

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

```ts
import type {
  ClaudeEffortId,
  ClaudeEffortOption,
  ClaudeModelId,
  ClaudeModelOption,
} from "../types/claude";

export const CLAUDE_DEFAULT_MODEL: ClaudeModelId = "sonnet-5.5";

export const CLAUDE_DEFAULT_EFFORT: ClaudeEffortId = "medium";

export const CLAUDE_MEDIUM_EFFORT: ClaudeEffortOption = {
  id: "medium",
  label: "Medium",
};

const CLAUDE_SONNET_5_5_MODEL: ClaudeModelOption = {
  description: "Most efficient for simpler tasks",
  group: "latest",
  id: "sonnet-5.5",
  label: "Sonnet 5.5",
};

const previous = (
  id: ClaudeModelId,
  label: string,
  family: string
): ClaudeModelOption => ({
  description: `Previous ${family} generation`,
  group: "previous",
  id,
  label,
});

export const CLAUDE_MODELS: readonly ClaudeModelOption[] = [
  {
    description: "For your toughest challenges",
    group: "latest",
    id: "fable-5.1",
    label: "Fable 5.1",
  },
  {
    description: "For complex work and everyday tasks",
    group: "latest",
    id: "opus-5.5",
    label: "Opus 5.5",
  },
  CLAUDE_SONNET_5_5_MODEL,
  {
    description: "Fastest for quick answers",
    group: "latest",
    id: "haiku-4.5",
    label: "Haiku 4.5",
  },
  previous("fable-5", "Fable 5", "Fable"),
  previous("opus-5", "Opus 5", "Opus"),
  previous("sonnet-5", "Sonnet 5", "Sonnet"),
  previous("opus-4.8", "Opus 4.8", "Opus"),
  previous("opus-4.7", "Opus 4.7", "Opus"),
  previous("opus-4.6", "Opus 4.6", "Opus"),
  previous("opus-3", "Opus 3", "Opus"),
  previous("sonnet-4.6", "Sonnet 4.6", "Sonnet"),
];

export const CLAUDE_EFFORTS: readonly ClaudeEffortOption[] = [
  { id: "low", label: "Low" },
  CLAUDE_MEDIUM_EFFORT,
  { id: "high", label: "High" },
  { id: "extra", label: "Extra" },
  { id: "max", label: "Max" },
];

export const CLAUDE_DEFAULT_MODEL_OPTION: ClaudeModelOption =
  CLAUDE_SONNET_5_5_MODEL;
```

### `registry/notra/claude/lib/claude-models.ts`

```ts
import {
  CLAUDE_EFFORTS,
  CLAUDE_MEDIUM_EFFORT,
  CLAUDE_MODELS,
  CLAUDE_DEFAULT_MODEL_OPTION,
} from "../constants/claude-models";
import type {
  ClaudeEffortId,
  ClaudeEffortOption,
  ClaudeModelId,
  ClaudeModelOption,
} from "../types/claude";

export const getClaudeModel = (id: ClaudeModelId): ClaudeModelOption =>
  CLAUDE_MODELS.find((item) => item.id === id) ?? CLAUDE_DEFAULT_MODEL_OPTION;

export const getClaudeEffort = (id: ClaudeEffortId): ClaudeEffortOption =>
  CLAUDE_EFFORTS.find((item) => item.id === id) ?? CLAUDE_MEDIUM_EFFORT;

export const getLatestClaudeModels = (): ClaudeModelOption[] =>
  CLAUDE_MODELS.filter((item) => item.group === "latest");

export const getPreviousClaudeModels = (): ClaudeModelOption[] =>
  CLAUDE_MODELS.filter((item) => item.group === "previous");
```

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

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

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

export type ClaudeModelId =
  | "fable-5.1"
  | "sonnet-5.5"
  | "opus-5.5"
  | "opus-5"
  | "fable-5"
  | "sonnet-5"
  | "haiku-4.5"
  | "opus-4.8"
  | "opus-4.7"
  | "opus-4.6"
  | "opus-3"
  | "sonnet-4.6";

export type ClaudeEffortId = "low" | "medium" | "high" | "extra" | "max";

export interface ClaudeModelOption {
  description: string;
  group: "latest" | "previous";
  id: ClaudeModelId;
  label: string;
}

export interface ClaudeEffortOption {
  badge?: string;
  id: ClaudeEffortId;
  info?: string;
  label: string;
}

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

export interface ClaudeSearchResult {
  domain: string;
  title: string;
}

export interface ClaudeSearchGroup {
  count: number;
  query: string;
  results: ClaudeSearchResult[];
}

export interface ClaudeStepThought {
  text: ReactNode;
  type: "thought";
}

export interface ClaudeStepTool {
  code?: string;
  content?: ReactNode;
  count?: number;
  detail?: ReactNode;
  label: ReactNode;
  results?: ClaudeSearchResult[];
  tool?: string;
  type: "tool";
}

export type ClaudeStepItem = ClaudeStepThought | ClaudeStepTool;

export type ClaudeSearchStepIcon = "clock" | "check" | "error";

export interface ClaudeSearchStep {
  icon: ClaudeSearchStepIcon;
  label: string;
}

export interface ClaudeSourceItem {
  domain: string;
  favicon?: string;
  href: string;
  title: string;
}

export interface ClaudeSpinnerFrame {
  inner: number;
  jitter?: number;
  outer: number;
  rays: number;
  strokeWidth: number;
}

export interface ClaudeSpinnerProps extends ComponentProps<"span"> {
  animated?: boolean;
  reducedMotion?: boolean;
  size?: number;
}

export interface ClaudeThinkingProps extends ComponentProps<"div"> {
  intervalMs?: number;
  reducedMotion?: boolean;
  verbs?: readonly string[];
}

export interface ClaudeSearchProps extends Omit<
  ComponentProps<typeof Collapsible>,
  "children"
> {
  groups?: readonly ClaudeSearchGroup[];
  items?: readonly ClaudeStepItem[];
  queryLabel?: ReactNode;
  reducedMotion?: boolean;
  resultsLabel?: (count: number) => ReactNode;
  sequential?: boolean;
  steps?: readonly ClaudeSearchStep[];
  summary?: ReactNode;
  thought?: ReactNode;
  verb?: ReactNode;
}

export interface ClaudeSourcesProps extends ComponentProps<"div"> {
  label?: ReactNode;
  sources: readonly ClaudeSourceItem[];
}

export interface ClaudeSourceLinkProps extends Omit<
  ComponentProps<"a">,
  "href"
> {
  source: ClaudeSourceItem;
}

export interface ClaudeMessageProps extends ComponentProps<"div"> {
  actions?: ReactNode;
  from: ClaudeMessageRole;
  search?: ReactNode;
  sources?: ReactNode;
}

export interface ClaudeActionsProps extends ComponentProps<"div"> {
  from?: ClaudeMessageRole;
  onBadResponse?: () => void;
  onEdit?: () => void;
  onGoodResponse?: () => void;
  onReadAloud?: () => void;
  onRetry?: () => void;
  text: string;
  timestamp?: ReactNode;
}

export interface ClaudeActionButtonProps {
  children: ReactNode;
  label: string;
  onClick?: () => void;
}

export interface ClaudeModelSelectorProps {
  className?: string;
  effort: ClaudeEffortId;
  effortLabel?: ReactNode;
  model: ClaudeModelId;
  moreModelsLabel?: ReactNode;
  onEffortChange?: (effort: ClaudeEffortId) => void;
  onModelChange?: (model: ClaudeModelId) => void;
}

export interface ClaudeComposerProps extends Omit<
  ComponentProps<"div">,
  "onSubmit"
> {
  busy?: boolean;
  defaultEffort?: ClaudeEffortId;
  defaultModel?: ClaudeModelId;
  disclaimer?: ReactNode;
  effort?: ClaudeEffortId;
  model?: ClaudeModelId;
  onEffortChange?: (effort: ClaudeEffortId) => void;
  onModelChange?: (model: ClaudeModelId) => void;
  contextUsage?: number;
  mode?: ReactNode;
  onModeClick?: () => void;
  onPlusSelect?: (item: ClaudePlusMenuItemId) => void;
  onSend?: (text: string) => void;
  onStop?: () => void;
  placeholder?: string;
}

export type ClaudePlusMenuItemId =
  | "add-files"
  | "add-plugins"
  | "research"
  | `skill:${string}`
  | `connector:${string}`
  | `design-system:${string}`;

export interface ClaudePlusMenuProps {
  className?: string;
  onSelect?: (item: ClaudePlusMenuItemId) => void;
}

export interface ClaudeUsageRingProps extends ComponentProps<"div"> {
  label?: string;
  size?: number;
  value: number;
}

export interface ClaudeDemoSearch {
  groups: ClaudeSearchGroup[];
  items?: ClaudeStepItem[];
  steps?: ClaudeSearchStep[];
  summary?: string;
  thought?: string;
  verb: string;
}

export interface ClaudeDemoMessage {
  from: ClaudeMessageRole;
  id: string;
  search?: ClaudeDemoSearch;
  sources?: ClaudeSourceItem[];
  text: string;
}

export interface ClaudeTextSegment {
  offset: number;
  text: string;
}
```

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

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

import type { ClaudeMessageProps } from "../types/claude";

const ACTIONS_REVEAL_CLASS =
  "opacity-0 transition-opacity duration-150 group-focus-within/claude-message:opacity-100 motion-reduce:transition-none [@media(hover:hover)]:group-hover/claude-message:opacity-100 [@media(hover:none)]:opacity-100";

export const ClaudeMessage = ({
  actions,
  children,
  className,
  from,
  search,
  sources,
  ...props
}: ClaudeMessageProps) => {
  if (from === "user") {
    return (
      <div
        className={cn(
          "group/claude-message font-claude flex flex-col items-end gap-1",
          className
        )}
        data-from="user"
        data-slot="claude-message"
        {...props}
      >
        <div className="bg-claude-bubble text-claude-fg max-w-[75%] rounded-xl px-4 py-2.5 text-[0.9375rem] leading-6 wrap-break-word">
          {children}
        </div>
        {actions && <div className={ACTIONS_REVEAL_CLASS}>{actions}</div>}
      </div>
    );
  }

  return (
    <div
      className={cn(
        "group/claude-message font-claude flex w-full flex-col items-start gap-3",
        className
      )}
      data-from="assistant"
      data-slot="claude-message"
      {...props}
    >
      {search}
      {(children != null || sources) && (
        <div className="font-claude-serif text-claude-fg flex max-w-full flex-col gap-4 text-[0.9375rem] leading-[1.65] wrap-break-word">
          {children}
          {sources}
        </div>
      )}
      {actions && <div className={ACTIONS_REVEAL_CLASS}>{actions}</div>}
    </div>
  );
};
```

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

```tsx
"use client";

import { cn } from "cn";
import {
  CheckIcon,
  CopyIcon,
  PencilIcon,
  RotateCcwIcon,
  ThumbsDownIcon,
  ThumbsUpIcon,
  Volume2Icon,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";

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

import {
  CLAUDE_COPIED_RESET_MS,
  CLAUDE_TOOLTIP_DELAY_MS,
} from "../constants/claude";
import type {
  ClaudeActionButtonProps,
  ClaudeActionsProps,
} from "../types/claude";

const ICON_CLASS = "size-4";

export const ClaudeActionButton = ({
  children,
  label,
  onClick,
}: ClaudeActionButtonProps) => (
  <Tooltip>
    <TooltipTrigger
      render={
        <Button
          aria-label={label}
          className="text-claude-muted hover:bg-claude-hover hover:text-claude-fg focus-visible:ring-claude-fg/20 aria-expanded:bg-claude-hover dark:hover:bg-claude-hover size-7 rounded-lg focus-visible:border-transparent focus-visible:ring-2 active:not-aria-[haspopup]:translate-y-0"
          data-slot="claude-action-button"
          onClick={onClick}
          size="icon-sm"
          type="button"
          variant="ghost"
        />
      }
    >
      {children}
    </TooltipTrigger>
    <TooltipContent
      className="bg-claude-tooltip font-claude text-claude-tooltip-fg rounded-md border-0 bg-none px-2 py-1 text-xs shadow-none [corner-shape:round] [&>div:last-child]:hidden"
      side="bottom"
      sideOffset={6}
    >
      {label}
    </TooltipContent>
  </Tooltip>
);

export const ClaudeActions = ({
  className,
  from = "assistant",
  onBadResponse,
  onEdit,
  onGoodResponse,
  onReadAloud,
  onRetry,
  text,
  timestamp,
  ...props
}: ClaudeActionsProps) => {
  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),
      CLAUDE_COPIED_RESET_MS
    );
  };

  const copyButton = (
    <ClaudeActionButton
      label={copied ? "Copied" : "Copy"}
      onClick={() => {
        copy().catch(() => undefined);
      }}
    >
      {copied ? (
        <CheckIcon className={ICON_CLASS} strokeWidth={1.25} />
      ) : (
        <CopyIcon className={ICON_CLASS} strokeWidth={1.25} />
      )}
    </ClaudeActionButton>
  );

  const timestampNode = timestamp && (
    <span
      className={cn(
        "text-xs leading-none",
        from === "user" ? "me-1" : "ms-1.5"
      )}
    >
      {timestamp}
    </span>
  );

  return (
    <TooltipProvider delay={CLAUDE_TOOLTIP_DELAY_MS}>
      <div
        className={cn(
          "font-claude text-claude-muted flex items-center gap-0.5",
          from === "assistant" && "-ms-1.5",
          className
        )}
        data-from={from}
        data-slot="claude-actions"
        {...props}
      >
        {from === "user" ? (
          <>
            {timestampNode}
            <ClaudeActionButton label="Retry" onClick={onRetry}>
              <RotateCcwIcon className={ICON_CLASS} strokeWidth={1.25} />
            </ClaudeActionButton>
            <ClaudeActionButton label="Edit" onClick={onEdit}>
              <PencilIcon className={ICON_CLASS} strokeWidth={1.25} />
            </ClaudeActionButton>
            {copyButton}
          </>
        ) : (
          <>
            {copyButton}
            <ClaudeActionButton label="Read aloud" onClick={onReadAloud}>
              <Volume2Icon className={ICON_CLASS} strokeWidth={1.25} />
            </ClaudeActionButton>
            <ClaudeActionButton label="Good response" onClick={onGoodResponse}>
              <ThumbsUpIcon className={ICON_CLASS} strokeWidth={1.25} />
            </ClaudeActionButton>
            <ClaudeActionButton label="Bad response" onClick={onBadResponse}>
              <ThumbsDownIcon className={ICON_CLASS} strokeWidth={1.25} />
            </ClaudeActionButton>
            <ClaudeActionButton label="Retry" onClick={onRetry}>
              <RotateCcwIcon className={ICON_CLASS} strokeWidth={1.25} />
            </ClaudeActionButton>
            {timestampNode}
          </>
        )}
      </div>
    </TooltipProvider>
  );
};
```

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

```tsx
"use client";

import { cn } from "cn";
import {
  CheckIcon,
  ChevronDownIcon,
  ChevronRightIcon,
  CircleXIcon,
  ClockIcon,
} from "lucide-react";
import type { ReactNode } from "react";
import { Fragment, useState } from "react";

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
  Item,
  ItemActions,
  ItemContent,
  ItemGroup,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";

import { Shimmer } from "../../shimmer/components/shimmer";
import { useClaudeReducedMotion } from "../hooks/use-claude-reduced-motion";
import { useClaudeSearchSequence } from "../hooks/use-claude-search-sequence";
import { claudeFaviconSrc, claudeStepSummary } from "../lib/claude-search";
import type {
  ClaudeSearchGroup,
  ClaudeSearchProps,
  ClaudeSearchStep,
  ClaudeSearchStepIcon,
  ClaudeStepItem,
  ClaudeStepTool,
} from "../types/claude";
import { ClaudeSpinner } from "./claude-spinner";

const EMPTY_STEPS: readonly ClaudeSearchStep[] = [];
const EMPTY_GROUPS: readonly ClaudeSearchGroup[] = [];
const EMPTY_ITEMS: readonly ClaudeStepItem[] = [];

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

const ENTER_CLASS = cn(
  "translate-y-0 opacity-100 transition-[opacity,translate] duration-200 motion-reduce:transition-none starting:translate-y-1 starting:opacity-0",
  EASE
);

const TRIGGER_CLASS =
  "focus-visible:ring-claude-fg/20 h-auto min-w-0 justify-start rounded-md font-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";

const STEP_TEXT_CLASS: Record<ClaudeSearchStepIcon, string> = {
  check: "text-claude-muted",
  clock: "text-claude-muted",
  error: "text-claude-danger",
};

const STEP_ICON_CLASS = "size-3.5 shrink-0";

const defaultResultsLabel = () => "Results";

const ClaudeStepIcon = ({ icon }: { icon: ClaudeSearchStepIcon }) => {
  if (icon === "check") {
    return <CheckIcon className={STEP_ICON_CLASS} strokeWidth={1.5} />;
  }
  if (icon === "error") {
    return <CircleXIcon className={STEP_ICON_CLASS} strokeWidth={1.5} />;
  }
  return <ClockIcon className={STEP_ICON_CLASS} strokeWidth={1.5} />;
};

const ClaudeResultsPanel = ({
  count,
  resultsLabel,
  results,
}: {
  count: number;
  resultsLabel: (count: number) => ReactNode;
  results: NonNullable<ClaudeStepTool["results"]>;
}) => (
  <div className="bg-claude-card rounded-xl px-2.5 pt-2 pb-1.5">
    <p className="text-claude-muted px-0.5 pb-1 text-sm leading-5">
      {resultsLabel(count)}
    </p>
    <ItemGroup className="gap-0">
      {results.map((result) => (
        <Item
          className="h-7 flex-nowrap gap-2 rounded-none border-0 px-0.5 py-0 text-sm"
          key={`${result.domain}-${result.title}`}
          role="listitem"
        >
          <ItemMedia>
            <Avatar className="size-4 rounded-[0.1875rem] bg-white after:hidden">
              <AvatarImage
                alt=""
                className="rounded-[0.1875rem]"
                src={claudeFaviconSrc(result.domain)}
              />
              <AvatarFallback className="bg-claude-hover rounded-[0.1875rem]" />
            </Avatar>
          </ItemMedia>
          <ItemContent className="min-w-0">
            <ItemTitle className="text-claude-fg block w-full truncate text-sm leading-5 font-normal">
              {result.title}
            </ItemTitle>
          </ItemContent>
          <ItemActions className="text-claude-muted w-[36%] shrink-0">
            <span className="truncate text-sm leading-5">{result.domain}</span>
          </ItemActions>
        </Item>
      ))}
    </ItemGroup>
  </div>
);

const ClaudeCodeBlock = ({ code }: { code: string }) => (
  <ScrollArea className="bg-claude-card rounded-xl">
    <pre className="text-claude-text w-max min-w-full px-3 py-2.5 font-mono text-xs leading-5">
      {code}
    </pre>
    <ScrollBar orientation="horizontal" />
  </ScrollArea>
);

const ClaudeToolRow = ({
  resultsLabel,
  tool,
}: {
  resultsLabel: (count: number) => ReactNode;
  tool: ClaudeStepTool;
}) => {
  const labelNode = (
    <>
      <span className="text-claude-muted shrink-0">{tool.label}</span>
      {tool.detail && (
        <span className="text-claude-fg min-w-0 truncate">{tool.detail}</span>
      )}
    </>
  );
  const expandable = Boolean(tool.results || tool.code || tool.content);

  if (!expandable) {
    return (
      <p className="flex items-center gap-2 px-4 py-2.5 text-[0.9375rem] leading-6">
        {labelNode}
      </p>
    );
  }

  return (
    <Collapsible data-slot="claude-tool-row">
      <CollapsibleTrigger
        render={
          <Button
            className={cn(
              TRIGGER_CLASS,
              "group/claude-query w-full gap-2 rounded-none px-4 py-2.5 text-[0.9375rem] leading-6"
            )}
            type="button"
            variant="ghost"
          />
        }
      >
        {labelNode}
        <ChevronRightIcon
          className="text-claude-muted ms-auto size-3.5 shrink-0 transition-transform duration-200 group-data-panel-open/claude-query:rotate-90 motion-reduce:transition-none"
          strokeWidth={1.5}
        />
      </CollapsibleTrigger>
      <CollapsibleContent className="px-2 pb-2">
        {tool.results && (
          <ClaudeResultsPanel
            count={tool.count ?? tool.results.length}
            results={tool.results}
            resultsLabel={resultsLabel}
          />
        )}
        {tool.code && <ClaudeCodeBlock code={tool.code} />}
        {tool.content && (
          <div className="text-claude-text px-2 py-1 text-sm leading-6">
            {tool.content}
          </div>
        )}
      </CollapsibleContent>
    </Collapsible>
  );
};

const ClaudeThoughtRow = ({ children }: { children: ReactNode }) => (
  <p className="text-claude-muted px-4 py-2.5 text-[0.9375rem] leading-6">
    {children}
  </p>
);

const ClaudeStatusRow = ({ step }: { step: ClaudeSearchStep }) => (
  <p
    className={cn(
      "flex items-center gap-2 px-4 py-2.5 text-[0.9375rem] leading-6",
      STEP_TEXT_CLASS[step.icon]
    )}
  >
    <ClaudeStepIcon icon={step.icon} />
    {step.label}
  </p>
);

interface ClaudeEntry {
  key: string;
  node: ReactNode;
  tool: boolean;
}

interface ClaudeEntriesInput {
  groups: readonly ClaudeSearchGroup[];
  items: readonly ClaudeStepItem[];
  queryLabel: ReactNode;
  resultsLabel: NonNullable<ClaudeSearchProps["resultsLabel"]>;
  steps: readonly ClaudeSearchStep[];
  thought: ReactNode;
}

const buildClaudeEntries = ({
  groups,
  items,
  queryLabel,
  resultsLabel,
  steps,
  thought,
}: ClaudeEntriesInput): ClaudeEntry[] => {
  const entries: { key: string; node: ReactNode; tool: boolean }[] = [];
  if (thought) {
    entries.push({
      key: "thought",
      node: <ClaudeThoughtRow>{thought}</ClaudeThoughtRow>,
      tool: false,
    });
  }
  for (const group of groups) {
    entries.push({
      key: `query-${group.query}`,
      node: (
        <ClaudeToolRow
          resultsLabel={resultsLabel}
          tool={{
            count: group.count,
            detail: group.query,
            label: queryLabel,
            results: group.results,
            type: "tool",
          }}
        />
      ),
      tool: true,
    });
  }
  for (const [index, item] of items.entries()) {
    entries.push({
      key: `item-${index}`,
      node:
        item.type === "thought" ? (
          <ClaudeThoughtRow>{item.text}</ClaudeThoughtRow>
        ) : (
          <ClaudeToolRow resultsLabel={resultsLabel} tool={item} />
        ),
      tool: item.type === "tool",
    });
  }
  for (const step of steps) {
    entries.push({
      key: `step-${step.label}`,
      node: <ClaudeStatusRow step={step} />,
      tool: false,
    });
  }

  return entries;
};

export const ClaudeSearch = ({
  className,
  defaultOpen = false,
  groups = EMPTY_GROUPS,
  items = EMPTY_ITEMS,
  onOpenChange,
  open: openProp,
  queryLabel = "Searched the web",
  reducedMotion,
  resultsLabel = defaultResultsLabel,
  sequential = false,
  steps = EMPTY_STEPS,
  summary,
  thought,
  verb = "Untangling",
  ...props
}: ClaudeSearchProps) => {
  const reduced = useClaudeReducedMotion(reducedMotion);
  const shouldSequence = sequential && !reduced;

  const entries = buildClaudeEntries({
    groups,
    items,
    queryLabel,
    resultsLabel,
    steps,
    thought,
  });

  const toolFlags = entries.map((entry) => entry.tool).join(",");
  const { done, visibleCount } = useClaudeSearchSequence(
    toolFlags,
    shouldSequence
  );
  const shown = shouldSequence ? visibleCount : entries.length;
  const finished = shouldSequence ? done : true;

  const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
  const running = shouldSequence && !finished;
  const open = openProp ?? (running || uncontrolledOpen);
  const handleOpenChange: NonNullable<ClaudeSearchProps["onOpenChange"]> = (
    next,
    details
  ) => {
    setUncontrolledOpen(next);
    onOpenChange?.(next, details);
  };

  const autoSummary = claudeStepSummary(items, groups);
  const doneLabel = summary ?? (autoSummary || verb);

  return (
    <Collapsible
      aria-busy={!finished}
      className={cn("font-claude w-full", className)}
      data-slot="claude-search"
      onOpenChange={handleOpenChange}
      open={open}
      {...props}
    >
      <div className="flex items-center gap-3.5">
        {!finished && (
          <span className="flex w-4 shrink-0 items-center justify-center">
            <ClaudeSpinner
              animated
              aria-label="Searching"
              reducedMotion={reduced}
              size={16}
            />
          </span>
        )}
        <CollapsibleTrigger
          render={
            <Button
              className={cn(
                TRIGGER_CLASS,
                "group/claude-search-trigger text-claude-text hover:text-claude-fg aria-expanded:text-claude-text -mx-1 w-fit gap-1.5 px-1 py-0.5 font-sans text-base leading-6"
              )}
              type="button"
              variant="ghost"
            />
          }
        >
          {finished ? (
            doneLabel
          ) : (
            <Shimmer className="[--shimmer-highlight:var(--claude-fg)]">
              {verb}
            </Shimmer>
          )}
          <ChevronDownIcon
            className="text-claude-muted size-3.5"
            strokeWidth={1.5}
          />
        </CollapsibleTrigger>
      </div>

      {entries.length > 0 && (
        <CollapsibleContent className={cn("pt-2.5", !finished && "ps-7.5")}>
          <Card className="border-claude-card-border gap-0 rounded-[0.75rem] border bg-transparent py-0 text-[0.9375rem] ring-0">
            {entries.slice(0, shown).map((entry, index) => (
              <Fragment key={entry.key}>
                {index > 0 && (
                  <Separator className="bg-claude-card-divider h-px w-full" />
                )}
                <div className={shouldSequence ? ENTER_CLASS : undefined}>
                  {entry.node}
                </div>
              </Fragment>
            ))}
          </Card>
        </CollapsibleContent>
      )}
    </Collapsible>
  );
};
```

### `registry/notra/claude/lib/claude-search.ts`

```ts
import {
  CLAUDE_SEARCH_QUERY_MS,
  CLAUDE_SEARCH_RESULTS_MS,
  CLAUDE_SEARCH_STEP_MS,
  CLAUDE_SEARCH_VERB_HOLD_MS,
} from "../constants/claude";
import type {
  ClaudeDemoSearch,
  ClaudeSearchGroup,
  ClaudeStepItem,
} from "../types/claude";

export const claudeSearchDuration = (
  search: ClaudeDemoSearch,
  reducedMotion: boolean
): number => {
  if (reducedMotion) {
    return 0;
  }

  const items = search.items ?? [];
  const toolCount =
    search.groups.length + items.filter((item) => item.type === "tool").length;
  const stepCount =
    (search.steps?.length ?? 0) +
    items.filter((item) => item.type !== "tool").length +
    (search.thought ? 1 : 0);

  return (
    CLAUDE_SEARCH_VERB_HOLD_MS +
    toolCount * (CLAUDE_SEARCH_QUERY_MS + CLAUDE_SEARCH_RESULTS_MS) +
    stepCount * CLAUDE_SEARCH_STEP_MS
  );
};

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

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

export const claudeStepSummary = (
  items: readonly ClaudeStepItem[],
  groups: readonly ClaudeSearchGroup[] = []
): string => {
  const tools = new Set<string>();
  let searched = groups.length > 0;
  for (const item of items) {
    if (item.type !== "tool") {
      continue;
    }
    if (item.results) {
      searched = true;
    } else if (item.tool) {
      tools.add(item.tool);
    }
  }
  const parts: string[] = [];
  if (tools.size > 0) {
    parts.push(`Used ${[...tools].join(", ")}`);
  }
  if (searched) {
    parts.push(tools.size > 0 ? "searched the web" : "Searched the web");
  }
  return parts.join(", ");
};
```

### `registry/notra/claude/components/claude-spinner.tsx`

```tsx
"use client";

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

import {
  CLAUDE_SPINNER_CENTER,
  CLAUDE_SPINNER_FRAME_MS,
  CLAUDE_SPINNER_FRAMES,
  CLAUDE_SPINNER_SEQUENCE,
  CLAUDE_SPINNER_STATIC_FRAME,
  CLAUDE_SPINNER_VIEWBOX,
} from "../constants/claude";
import { useClaudeReducedMotion } from "../hooks/use-claude-reduced-motion";
import type { ClaudeSpinnerFrame, ClaudeSpinnerProps } from "../types/claude";

const rayPoint = (angle: number, radius: number) => ({
  x: CLAUDE_SPINNER_CENTER + Math.sin(angle) * radius,
  y: CLAUDE_SPINNER_CENTER - Math.cos(angle) * radius,
});

const frameRays = (frame: ClaudeSpinnerFrame) => {
  const jitter = frame.jitter ?? 0;
  return Array.from({ length: frame.rays }, (_, index) => {
    const angle =
      (Math.PI * 2 * index) / frame.rays + Math.sin(index * 2.35) * 0.07;
    const length = frame.outer * (1 + Math.sin(index * 1.7) * jitter);
    return {
      end: rayPoint(angle, length),
      id: `ray-${frame.rays}-${index}`,
      start: rayPoint(angle, frame.inner),
    };
  });
};

const ClaudeStarMark = ({ frame }: { frame: ClaudeSpinnerFrame }) => (
  <svg
    aria-hidden="true"
    className="size-full"
    fill="none"
    viewBox={`0 0 ${CLAUDE_SPINNER_VIEWBOX} ${CLAUDE_SPINNER_VIEWBOX}`}
  >
    {frame.rays === 0 ? (
      <circle
        cx={CLAUDE_SPINNER_CENTER}
        cy={CLAUDE_SPINNER_CENTER}
        fill="currentColor"
        r="1.7"
      />
    ) : (
      frameRays(frame).map((ray) => (
        <line
          key={ray.id}
          stroke="currentColor"
          strokeLinecap="round"
          strokeWidth={frame.strokeWidth}
          x1={ray.start.x}
          x2={ray.end.x}
          y1={ray.start.y}
          y2={ray.end.y}
        />
      ))
    )}
  </svg>
);

export const ClaudeSpinner = ({
  "aria-label": ariaLabel = "Loading",
  animated = false,
  className,
  reducedMotion,
  size = 16,
  style,
  ...props
}: ClaudeSpinnerProps) => {
  const reduced = useClaudeReducedMotion(reducedMotion);
  const shouldAnimate = animated && !reduced;
  const [sequenceIndex, setSequenceIndex] = useState<number | null>(null);

  useEffect(() => {
    if (!shouldAnimate) {
      setSequenceIndex(null);
      return;
    }

    setSequenceIndex(0);
    const id = window.setInterval(() => {
      setSequenceIndex(
        (current) => ((current ?? 0) + 1) % CLAUDE_SPINNER_SEQUENCE.length
      );
    }, CLAUDE_SPINNER_FRAME_MS);

    return () => window.clearInterval(id);
  }, [shouldAnimate]);

  const frameIndex =
    sequenceIndex === null
      ? CLAUDE_SPINNER_STATIC_FRAME
      : (CLAUDE_SPINNER_SEQUENCE[sequenceIndex] ?? CLAUDE_SPINNER_STATIC_FRAME);
  const frame =
    CLAUDE_SPINNER_FRAMES[frameIndex] ??
    CLAUDE_SPINNER_FRAMES[CLAUDE_SPINNER_STATIC_FRAME];

  return (
    <span
      className={cn(
        "text-claude-accent inline-flex shrink-0 items-center justify-center",
        className
      )}
      aria-label={ariaLabel}
      data-slot="claude-spinner"
      role="status"
      style={{ height: size, width: size, ...style }}
      {...props}
    >
      {frame && <ClaudeStarMark frame={frame} />}
    </span>
  );
};
```

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

```ts
"use client";

import { useSyncExternalStore } from "react";

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

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

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

const getServerSnapshot = () => false;

export const useClaudeReducedMotion = (override?: boolean): boolean => {
  const prefersReduced = useSyncExternalStore(
    subscribe,
    getSnapshot,
    getServerSnapshot
  );
  return override ?? prefersReduced;
};
```

### `registry/notra/claude/components/claude-sources.tsx`

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

import type { ClaudeSourcesProps } from "../types/claude";
import { ClaudeSourceLink } from "./claude-source-link";

export const ClaudeSources = ({
  className,
  label = "Sources:",
  sources,
  ...props
}: ClaudeSourcesProps) => {
  if (sources.length === 0) {
    return null;
  }

  return (
    <div
      className={cn(
        "font-claude-serif text-claude-fg text-[0.9375rem] leading-[1.65]",
        className
      )}
      data-slot="claude-sources"
      {...props}
    >
      <p>{label}</p>
      <ul className="mt-2 flex list-disc flex-col gap-1.5 ps-6 marker:text-current">
        {sources.map((source) => (
          <li key={source.href}>
            <ClaudeSourceLink source={source}>{source.title}</ClaudeSourceLink>
          </li>
        ))}
      </ul>
    </div>
  );
};
```

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

```tsx
"use client";

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

import { Shimmer } from "../../shimmer/components/shimmer";
import {
  CLAUDE_THINKING_INTERVAL_MS,
  CLAUDE_THINKING_VERBS,
} from "../constants/claude";
import { useClaudeReducedMotion } from "../hooks/use-claude-reduced-motion";
import type { ClaudeThinkingProps } from "../types/claude";
import { ClaudeSpinner } from "./claude-spinner";

export const ClaudeThinking = ({
  className,
  intervalMs = CLAUDE_THINKING_INTERVAL_MS,
  reducedMotion,
  verbs = CLAUDE_THINKING_VERBS,
  ...props
}: ClaudeThinkingProps) => {
  const reduced = useClaudeReducedMotion(reducedMotion);
  const [verbIndex, setVerbIndex] = useState(0);
  const verbCount = verbs.length;
  const verb =
    verbs[verbIndex % verbs.length] ?? verbs[0] ?? CLAUDE_THINKING_VERBS[0];

  useEffect(() => {
    if (reduced || verbCount <= 1) {
      return;
    }

    const id = window.setInterval(() => {
      setVerbIndex((current) => (current + 1) % verbCount);
    }, intervalMs);

    return () => window.clearInterval(id);
  }, [intervalMs, reduced, verbCount]);

  return (
    <div
      aria-live="polite"
      className={cn("text-claude-text flex items-center gap-3.5", className)}
      data-slot="claude-thinking"
      role="status"
      {...props}
    >
      <ClaudeSpinner
        animated
        aria-hidden="true"
        reducedMotion={reduced}
        size={16}
      />
      <Shimmer
        className="text-[0.9375rem] leading-6 [--shimmer-highlight:var(--claude-fg)]"
        paused={reduced}
      >
        {verb}
      </Shimmer>
    </div>
  );
};
```

### `registry/notra/claude/lib/claude-text.ts`

```ts
import type { ClaudeTextSegment } from "../types/claude";

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

export const splitWithOffsets = (
  text: string,
  separator: string | RegExp,
  separatorLength: number
): ClaudeTextSegment[] => {
  const segments: ClaudeTextSegment[] = [];
  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;
};

export const splitBoldSegments = (text: string): ClaudeTextSegment[] =>
  splitWithOffsets(text, BOLD_PATTERN, 0);
```

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

```ts
import type {
  ClaudeDemoMessage,
  ClaudeDemoSearch,
  ClaudeSourceItem,
} from "../types/claude";

export const CLAUDE_DEMO_SOURCES: ClaudeSourceItem[] = [
  {
    domain: "reuters.com",
    href: "https://www.reuters.com/world/",
    title: "Reuters: World news for Tuesday, September 29",
  },
  {
    domain: "apnews.com",
    href: "https://apnews.com/",
    title: "AP News: Top headlines and what to know today",
  },
];

export const CLAUDE_DEMO_THREAD: ClaudeDemoMessage[] = [
  {
    from: "user",
    id: "u-1",
    text: "what's in the news today",
  },
  {
    from: "assistant",
    id: "a-1",
    search: {
      groups: [],
      items: [
        { detail: "Executor", label: "Loaded", tool: "Executor", type: "tool" },
        {
          count: 10,
          detail: "news today September 29 2026",
          label: "Searched the web",
          results: [
            {
              domain: "www.reuters.com",
              title: "World news for Tuesday, September 29",
            },
            {
              domain: "apnews.com",
              title: "Top headlines: what to know today",
            },
            { domain: "en.wikipedia.org", title: "Portal: Current events" },
          ],
          type: "tool",
        },
        { text: "Summarizing today's headlines.", type: "thought" },
      ],
      verb: "Triangulating",
    },
    sources: CLAUDE_DEMO_SOURCES,
    text: "European markets open higher after strong **US tech earnings**, while EU states split over how to enforce the **AI Act**.",
  },
];

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

export const CLAUDE_DEMO_SEARCH: ClaudeDemoSearch = {
  groups: [
    {
      count: 3,
      query: "hoplite.sh",
      results: [
        {
          domain: "hoplite.sh",
          title: "Hoplite: sandboxed shells for AI agents",
        },
        { domain: "github.com", title: "hoplite-sh/hoplite on GitHub" },
        { domain: "news.ycombinator.com", title: "Show HN: Hoplite" },
      ],
    },
  ],
  steps: [{ icon: "error", label: "Fetch failed" }],
  summary: "Researched hoplite.sh",
  thought: "Researching hoplite.sh.",
  verb: "Triangulating",
};
```

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

```ts
"use client";

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

import { claudeSearchDuration, claudeWait } from "../lib/claude-search";
import type { ClaudeDemoMessage } from "../types/claude";

const TOKEN_SPLIT = /(\s+)/;

const USER_TURN_MS = 420;
const THINKING_MS = 1200;
const SETTLE_MS = 180;
const TOKEN_MS = 28;
const TURN_GAP_MS = 480;
const REDUCED_MAX_MS = 70;

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

export const useClaudePlayback = (
  script: readonly ClaudeDemoMessage[],
  reducedMotion: boolean
) => {
  const [messages, setMessages] = useState<ClaudeDemoMessage[]>(() => [
    ...script,
  ]);
  const [completeIds, setCompleteIds] = useState(() => 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: ClaudeDemoMessage, alive: () => boolean) => {
      const delay = (ms: number) =>
        claudeWait(reducedMotion ? Math.min(ms, REDUCED_MAX_MS) : ms);

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

      if (reducedMotion) {
        if (!message.search) {
          setThinking(true);
          await delay(THINKING_MS);
          if (!alive()) {
            return;
          }
          setThinking(false);
        }
        setMessages((current) => [...current, message]);
        markComplete(message.id);
        await delay(SETTLE_MS);
        return;
      }

      if (message.search) {
        setThinking(false);
        setMessages((current) => [...current, { ...message, text: "" }]);
        await delay(
          claudeSearchDuration(message.search, reducedMotion) + SETTLE_MS
        );
      } else {
        setThinking(true);
        await delay(THINKING_MS);
        if (!alive()) {
          return;
        }
        setThinking(false);
        setMessages((current) => [...current, { ...message, text: "" }]);
      }

      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_GAP_MS);
    },
    [markComplete, reducedMotion]
  );

  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/claude/examples/claude-demo.tsx`

```tsx
"use client";

import { useEffect, useRef } from "react";

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

import { ClaudeActions } from "../components/claude-actions";
import { ClaudeComposer } from "../components/claude-composer";
import { ClaudeLogoIcon } from "../components/claude-icons";
import { ClaudeMessage } from "../components/claude-message";
import { ClaudeSearch } from "../components/claude-search";
import { ClaudeSources } from "../components/claude-sources";
import { ClaudeThinking } from "../components/claude-thinking";
import {
  CLAUDE_DEMO_REPLIES,
  CLAUDE_DEMO_THREAD,
} from "../constants/claude-demo";
import { useClaudePlayback } from "../hooks/use-claude-playback";
import { useClaudeReducedMotion } from "../hooks/use-claude-reduced-motion";
import { splitBoldSegments, splitWithOffsets } from "../lib/claude-text";
import type { ClaudeDemoMessage } from "../types/claude";

const LIST_MARKER = "- ";

const ClaudeInlineText = ({ text }: { text: string }) =>
  splitBoldSegments(text).map((part) =>
    part.text.startsWith("**") && part.text.endsWith("**") ? (
      <strong key={part.offset}>{part.text.slice(2, -2)}</strong>
    ) : (
      <span key={part.offset}>{part.text}</span>
    )
  );

const ClaudeRichText = ({ text }: { text: string }) => {
  const blocks = splitWithOffsets(text, "\n\n", 2);

  return (
    <span className={blocks.length > 1 ? "flex flex-col gap-3.5" : undefined}>
      {blocks.map((block) => {
        const lines = splitWithOffsets(block.text, "\n", 1);
        if (lines.every((line) => line.text.startsWith(LIST_MARKER))) {
          return (
            <span className="flex flex-col gap-1.5 ps-1" key={block.offset}>
              {lines.map((line) => (
                <span className="flex gap-2.5" key={line.offset}>
                  <span
                    aria-hidden="true"
                    className="mt-[0.7em] size-1 shrink-0 rounded-full bg-current"
                  />
                  <span>
                    <ClaudeInlineText
                      text={line.text.slice(LIST_MARKER.length)}
                    />
                  </span>
                </span>
              ))}
            </span>
          );
        }
        return (
          <span key={block.offset}>
            <ClaudeInlineText text={block.text} />
          </span>
        );
      })}
    </span>
  );
};

const ClaudeDemoTurn = ({
  complete,
  message,
  reducedMotion,
  sequential,
}: {
  complete: boolean;
  message: ClaudeDemoMessage;
  reducedMotion: boolean;
  sequential: boolean;
}) => (
  <ClaudeMessage
    actions={
      <ClaudeActions
        className={complete ? undefined : "invisible"}
        from={message.from}
        text={message.text}
        timestamp={message.from === "user" ? "now" : undefined}
      />
    }
    from={message.from}
    search={
      message.search && (
        <ClaudeSearch
          groups={message.search.groups}
          items={message.search.items}
          reducedMotion={reducedMotion}
          sequential={sequential}
          steps={message.search.steps}
          summary={message.search.summary}
          thought={message.search.thought}
          verb={message.search.verb}
        />
      )
    }
    sources={
      message.sources &&
      message.text && <ClaudeSources sources={message.sources} />
    }
  >
    {message.from === "user" ? (
      message.text
    ) : (
      <ClaudeRichText text={message.text} />
    )}
  </ClaudeMessage>
);

export default function ClaudeDemo() {
  const reducedMotion = useClaudeReducedMotion();
  const playback = useClaudePlayback(CLAUDE_DEMO_THREAD, reducedMotion);
  const replyIndexRef = useRef(0);
  const scrollRef = useRef<HTMLDivElement>(null);
  const hasMountedRef = useRef(false);

  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 = (text: string) => {
    if (playback.playing) {
      return;
    }
    const reply =
      CLAUDE_DEMO_REPLIES[replyIndexRef.current % CLAUDE_DEMO_REPLIES.length];
    replyIndexRef.current += 1;
    playback.send(text, reply).catch(() => undefined);
  };

  return (
    <Card className="border-claude-border bg-claude-bg font-claude text-claude-fg h-150 w-full min-w-0 gap-0 rounded-2xl border py-0 text-base ring-0">
      <CardHeader className="flex h-10.75 shrink-0 items-center justify-between gap-3 px-3">
        <span className="text-claude-muted flex items-center gap-2 ps-1 text-sm font-medium">
          <ClaudeLogoIcon className="text-claude-accent size-4" />
          Claude
        </span>
      </CardHeader>
      <Separator className="bg-claude-border h-px w-full" />
      <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-4 px-5 py-6">
          {playback.messages.map((message) => {
            const complete = playback.completeIds.has(message.id);
            return (
              <ClaudeDemoTurn
                complete={complete}
                key={message.id}
                message={message}
                reducedMotion={reducedMotion}
                sequential={playback.playing && !complete}
              />
            );
          })}
          {playback.thinking && (
            <ClaudeThinking reducedMotion={reducedMotion} />
          )}
        </CardContent>
      </ScrollArea>
      <div className="mx-auto w-full max-w-3xl min-w-0 shrink-0 px-5 pb-4">
        <ClaudeComposer
          busy={playback.playing || playback.thinking}
          onSend={handleSend}
          onStop={playback.stop}
        />
      </div>
    </Card>
  );
}
```

### `registry/notra/claude/components/claude-plus-menu.tsx`

```tsx
"use client";

import { cn } from "cn";
import {
  BlocksIcon,
  PaletteIcon,
  PaperclipIcon,
  PlugIcon,
  PlusIcon,
  ScrollTextIcon,
} from "lucide-react";
import type { ComponentType, ReactNode, SVGProps } from "react";

import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuShortcut,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Kbd, KbdGroup } from "@/components/ui/kbd";

import {
  CLAUDE_MENU_ITEM_CLASS,
  CLAUDE_MENU_SURFACE_CLASS,
  CLAUDE_PLUS_MENU_CONNECTORS,
  CLAUDE_PLUS_MENU_DESIGN_SYSTEMS,
  CLAUDE_PLUS_MENU_SKILLS,
} from "../constants/claude";
import type {
  ClaudePlusMenuItemId,
  ClaudePlusMenuProps,
} from "../types/claude";
import { ClaudeResearchIcon } from "./claude-icons";

const SUB_TRIGGER_CLASS = cn(
  CLAUDE_MENU_ITEM_CLASS,
  "data-open:bg-claude-hover data-open:text-claude-fg data-popup-open:bg-claude-hover data-popup-open:text-claude-fg [&>svg:last-child]:text-claude-muted [&>svg:last-child]:size-4"
);

const SEPARATOR_CLASS = "bg-claude-input-border mx-2 my-1.5";

const ICON_PROPS = { strokeWidth: 1.25 } as const;

type IconComponent = ComponentType<SVGProps<SVGSVGElement>>;

const ClaudePlusSubmenu = ({
  icon: Icon,
  items,
  label,
  onSelect,
  prefix,
}: {
  icon: IconComponent;
  items: readonly string[];
  label: ReactNode;
  onSelect?: (item: ClaudePlusMenuItemId) => void;
  prefix: "skill" | "connector" | "design-system";
}) => (
  <DropdownMenuSub>
    <DropdownMenuSubTrigger className={SUB_TRIGGER_CLASS} openOnHover>
      <Icon {...ICON_PROPS} />
      {label}
    </DropdownMenuSubTrigger>
    <DropdownMenuSubContent
      className={cn("min-w-44", CLAUDE_MENU_SURFACE_CLASS)}
      sideOffset={6}
    >
      {items.map((item) => (
        <DropdownMenuItem
          className={CLAUDE_MENU_ITEM_CLASS}
          key={item}
          onClick={() => onSelect?.(`${prefix}:${item}`)}
        >
          {item}
        </DropdownMenuItem>
      ))}
    </DropdownMenuSubContent>
  </DropdownMenuSub>
);

export const ClaudePlusMenu = ({
  className,
  onSelect,
}: ClaudePlusMenuProps) => (
  <DropdownMenu modal={false}>
    <DropdownMenuTrigger
      render={
        <Button
          aria-label="Add files, connectors and more"
          className={cn(
            "text-claude-text hover:bg-claude-hover hover:text-claude-fg focus-visible:ring-claude-fg/20 aria-expanded:bg-claude-hover aria-expanded:text-claude-fg dark:hover:bg-claude-hover size-8 rounded-lg focus-visible:border-transparent focus-visible:ring-2 active:not-aria-[haspopup]:translate-y-0",
            className
          )}
          data-slot="claude-plus-menu"
          size="icon"
          type="button"
          variant="ghost"
        />
      }
    >
      <PlusIcon className="size-4.5" strokeWidth={1.25} />
    </DropdownMenuTrigger>
    <DropdownMenuContent
      align="start"
      className={cn("w-57", CLAUDE_MENU_SURFACE_CLASS)}
      side="top"
      sideOffset={8}
    >
      <DropdownMenuItem
        className={CLAUDE_MENU_ITEM_CLASS}
        onClick={() => onSelect?.("add-files")}
      >
        <PaperclipIcon {...ICON_PROPS} />
        Add files or photos
        <DropdownMenuShortcut className="tracking-normal">
          <KbdGroup className="text-claude-muted gap-1">
            <Kbd className="text-claude-muted h-auto min-w-0 bg-transparent px-0 text-sm font-normal">
              ⌘
            </Kbd>
            <Kbd className="text-claude-muted h-auto min-w-0 bg-transparent px-0 text-sm font-normal">
              U
            </Kbd>
          </KbdGroup>
        </DropdownMenuShortcut>
      </DropdownMenuItem>
      <DropdownMenuSeparator className={SEPARATOR_CLASS} />
      <ClaudePlusSubmenu
        icon={ScrollTextIcon}
        items={CLAUDE_PLUS_MENU_SKILLS}
        label="Skills"
        onSelect={onSelect}
        prefix="skill"
      />
      <ClaudePlusSubmenu
        icon={BlocksIcon}
        items={CLAUDE_PLUS_MENU_CONNECTORS}
        label="Connectors"
        onSelect={onSelect}
        prefix="connector"
      />
      <ClaudePlusSubmenu
        icon={PaletteIcon}
        items={CLAUDE_PLUS_MENU_DESIGN_SYSTEMS}
        label="Design system"
        onSelect={onSelect}
        prefix="design-system"
      />
      <DropdownMenuItem
        className={CLAUDE_MENU_ITEM_CLASS}
        onClick={() => onSelect?.("add-plugins")}
      >
        <PlugIcon {...ICON_PROPS} />
        Add plugins
      </DropdownMenuItem>
      <DropdownMenuSeparator className={SEPARATOR_CLASS} />
      <DropdownMenuItem
        className={CLAUDE_MENU_ITEM_CLASS}
        onClick={() => onSelect?.("research")}
      >
        <ClaudeResearchIcon />
        Research
      </DropdownMenuItem>
    </DropdownMenuContent>
  </DropdownMenu>
);
```

### `registry/notra/claude/components/claude-usage-ring.tsx`

```tsx
"use client";

import { cn } from "cn";

import { Progress } from "@/components/ui/progress";
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from "@/components/ui/tooltip";

import type { ClaudeUsageRingProps } from "../types/claude";

const RING_RADIUS = 5;
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;

const clampPercent = (value: number) => Math.min(100, Math.max(0, value));

export const ClaudeUsageRing = ({
  className,
  label = "Context left",
  size = 14,
  value,
  ...props
}: ClaudeUsageRingProps) => {
  const percent = clampPercent(value);
  const text = `${Math.round(percent)}% ${label.toLowerCase()}`;

  return (
    <Tooltip>
      <TooltipTrigger
        render={
          <div
            className={cn(
              "text-claude-usage inline-flex shrink-0 items-center justify-center",
              className
            )}
            data-slot="claude-usage-ring"
            {...props}
          />
        }
      >
        <svg
          aria-hidden="true"
          className="-rotate-90"
          fill="none"
          height={size}
          viewBox="0 0 14 14"
          width={size}
        >
          <circle
            className="stroke-claude-hover"
            cx="7"
            cy="7"
            r={RING_RADIUS}
            strokeWidth={1.75}
          />
          <circle
            cx="7"
            cy="7"
            r={RING_RADIUS}
            stroke="currentColor"
            strokeDasharray={RING_CIRCUMFERENCE}
            strokeDashoffset={RING_CIRCUMFERENCE * (1 - percent / 100)}
            strokeLinecap="round"
            strokeWidth={1.75}
          />
        </svg>
        <Progress
          aria-label={label}
          className="sr-only"
          getAriaValueText={() => text}
          value={Math.round(percent)}
        />
      </TooltipTrigger>
      <TooltipContent
        className="bg-claude-tooltip font-claude text-claude-tooltip-fg rounded-md border-0 bg-none px-2 py-1 text-xs shadow-none [corner-shape:round] [&>div:last-child]:hidden"
        side="top"
        sideOffset={6}
      >
        {text}
      </TooltipContent>
    </Tooltip>
  );
};
```

### `registry/notra/claude/constants/claude-tool-calls.ts`

```ts
import type { ClaudeStepItem } from "../types/claude";

export const CLAUDE_TOOL_CALLS_INTRO =
  "I'll load the Executor tools for PlanetScale and search the web for PostgreSQL best practices in parallel.";

export const CLAUDE_TOOL_CALLS_ANSWER =
  "I've reviewed the web search and the PlanetScale docs through Executor. Here are the best practices, grouped by topic.";

const SEARCH_CODE = `const { items } = await tools.search({
  namespace: "planetscale",
  query: "postgres best practices",
  limit: 5,
});
return items.map((item) => item.path);`;

export const CLAUDE_TOOL_CALLS_ITEMS: ClaudeStepItem[] = [
  { detail: "Executor", label: "Loaded", tool: "Executor", type: "tool" },
  {
    count: 10,
    detail: "PostgreSQL best practices 2026",
    label: "Searched the web",
    results: [
      { domain: "www.postgresql.org", title: "PostgreSQL: Documentation" },
      { domain: "planetscale.com", title: "Postgres best practices" },
      { domain: "en.wikipedia.org", title: "PostgreSQL" },
    ],
    type: "tool",
  },
  {
    code: 'await tools.skills.execute({ name: "postgres" });',
    detail: "execute",
    label: "Used Executor: Skills",
    type: "tool",
  },
  {
    code: SEARCH_CODE,
    detail:
      'const { items } = await tools.search({ namespace: "planetscale", query: "postgres best practices" })',
    label: "Used Executor: Execute",
    type: "tool",
  },
  {
    text: "Researching PlanetScale documentation for the requested details.",
    type: "thought",
  },
];
```

### `registry/notra/claude/examples/claude-tool-calls.tsx`

```tsx
import { ClaudeSearch } from "../components/claude-search";
import {
  CLAUDE_TOOL_CALLS_ANSWER,
  CLAUDE_TOOL_CALLS_INTRO,
  CLAUDE_TOOL_CALLS_ITEMS,
} from "../constants/claude-tool-calls";

export default function ClaudeToolCallsExample() {
  return (
    <div className="bg-claude-bg font-claude flex w-[calc(100vw-3rem)] max-w-full min-w-0 flex-col gap-4 p-6">
      <p className="font-claude-serif text-claude-fg text-base leading-[1.65]">
        {CLAUDE_TOOL_CALLS_INTRO}
      </p>
      <ClaudeSearch items={CLAUDE_TOOL_CALLS_ITEMS} />
      <p className="font-claude-serif text-claude-fg text-base leading-[1.65]">
        {CLAUDE_TOOL_CALLS_ANSWER}
      </p>
    </div>
  );
}
```

### `registry/notra/claude/components/claude-source-link.tsx`

```tsx
"use client";

import { cn } from "cn";

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from "@/components/ui/hover-card";

import {
  CLAUDE_HOVER_CLOSE_DELAY_MS,
  CLAUDE_HOVER_OPEN_DELAY_MS,
} from "../constants/claude";
import { claudeFaviconSrc } from "../lib/claude-search";
import type { ClaudeSourceLinkProps } from "../types/claude";

export const ClaudeSourceLink = ({
  children,
  className,
  rel = "noopener noreferrer",
  source,
  target = "_blank",
  ...props
}: ClaudeSourceLinkProps) => (
  <HoverCard>
    <HoverCardTrigger
      className={cn(
        "text-claude-check decoration-claude-check focus-visible:outline-claude-check rounded-xs underline decoration-2 underline-offset-2 focus-visible:outline-2 focus-visible:outline-solid",
        className
      )}
      closeDelay={CLAUDE_HOVER_CLOSE_DELAY_MS}
      data-slot="claude-source-link"
      delay={CLAUDE_HOVER_OPEN_DELAY_MS}
      href={source.href}
      rel={rel}
      target={target}
      {...props}
    >
      {children ?? source.title}
    </HoverCardTrigger>
    <HoverCardContent
      align="start"
      className="bg-claude-popover font-claude text-claude-fg ring-claude-popover-border w-72 max-w-[calc(100vw-2rem)] rounded-xl p-3 shadow-[0_4px_24px_var(--claude-popover-shadow)]"
      sideOffset={8}
    >
      <p className="text-sm leading-5 text-balance">{source.title}</p>
      <p className="text-claude-muted mt-1.5 flex items-center gap-1.5 text-xs leading-4">
        <Avatar className="size-4 rounded-sm after:hidden">
          <AvatarImage
            alt=""
            className="rounded-sm bg-white"
            src={source.favicon ?? claudeFaviconSrc(source.domain)}
          />
          <AvatarFallback className="bg-claude-hover rounded-sm" />
        </Avatar>
        {source.domain}
      </p>
    </HoverCardContent>
  </HoverCard>
);
```

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

```ts
"use client";

import { useEffect, useState } from "react";

import {
  CLAUDE_SEARCH_QUERY_MS,
  CLAUDE_SEARCH_RESULTS_MS,
  CLAUDE_SEARCH_STEP_MS,
  CLAUDE_SEARCH_VERB_HOLD_MS,
} from "../constants/claude";

interface ClaudeSearchSequence {
  done: boolean;
  visibleCount: number;
}

export const useClaudeSearchSequence = (
  toolFlags: string,
  enabled: boolean
): ClaudeSearchSequence => {
  const [sequence, setSequence] = useState<ClaudeSearchSequence>({
    done: false,
    visibleCount: 0,
  });

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

    const flags = toolFlags ? toolFlags.split(",") : [];
    const timers: number[] = [];
    let elapsed = CLAUDE_SEARCH_VERB_HOLD_MS;

    for (const [index, isTool] of flags.entries()) {
      timers.push(
        window.setTimeout(() => {
          setSequence((current) => ({ ...current, visibleCount: index + 1 }));
        }, elapsed)
      );
      elapsed +=
        isTool === "true"
          ? CLAUDE_SEARCH_QUERY_MS + CLAUDE_SEARCH_RESULTS_MS
          : CLAUDE_SEARCH_STEP_MS;
    }
    timers.push(
      window.setTimeout(() => {
        setSequence((current) => ({ ...current, done: true }));
      }, elapsed)
    );

    return () => {
      for (const timer of timers) {
        window.clearTimeout(timer);
      }
      setSequence({ done: false, visibleCount: 0 });
    };
  }, [enabled, toolFlags]);

  return sequence;
};
```
