# Gemini

> A full recreation of the Gemini chat built from the Gemini components 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/gemini).

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

## Install

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

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

## Dependencies

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

## Files

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

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

import type { GeminiMessageProps } from "../types/gemini";

export const GeminiMessage = ({
  actions,
  children,
  className,
  from,
  status,
  thoughts,
  ...props
}: GeminiMessageProps) => {
  if (from === "user") {
    return (
      <div
        className={cn("font-gemini flex justify-end", className)}
        data-from="user"
        data-slot="gemini-message"
        {...props}
      >
        <div className="bg-gemini-bubble text-gemini-fg max-w-[70%] rounded-3xl rounded-se-sm px-4 py-2.5 text-[0.9375rem] leading-6 wrap-break-word">
          {children}
        </div>
      </div>
    );
  }

  return (
    <div
      className={cn(
        "group/gemini-message font-gemini flex w-full flex-col items-start gap-3",
        className
      )}
      data-from="assistant"
      data-slot="gemini-message"
      {...props}
    >
      {thoughts}
      {status}
      {children != null && children !== false ? (
        <div className="text-gemini-fg max-w-full text-[0.9375rem] leading-[1.65] wrap-break-word">
          {children}
        </div>
      ) : null}
      {actions ? (
        <div className="-ms-1.5 opacity-0 transition-opacity duration-150 group-focus-within/gemini-message:opacity-100 motion-reduce:transition-none [@media(hover:hover)]:group-hover/gemini-message:opacity-100 [@media(hover:none)]:opacity-100">
          {actions}
        </div>
      ) : null}
    </div>
  );
};
```

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

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

import { Shimmer } from "../../shimmer/components/shimmer";
import { GEMINI_SEARCHING_LABEL } from "../constants/gemini";
import type { GeminiThinkingProps } from "../types/gemini";
import { GeminiSparkle } from "./gemini-sparkle";

export const GeminiThinking = ({
  className,
  label = GEMINI_SEARCHING_LABEL,
  reducedMotion,
  ...props
}: GeminiThinkingProps) => (
  <div
    aria-live="polite"
    className={cn(
      "font-gemini text-gemini-fg flex items-center gap-2.5 text-[0.9375rem] leading-none",
      className
    )}
    data-slot="gemini-thinking"
    role="status"
    {...props}
  >
    <GeminiSparkle
      animated
      aria-hidden="true"
      aria-label={undefined}
      reducedMotion={reducedMotion}
      role="presentation"
      size={20}
    />
    <Shimmer
      className="[--shimmer-highlight:var(--gemini-subtle)]"
      paused={reducedMotion}
    >
      {label}
    </Shimmer>
  </div>
);
```

### `registry/notra/gemini/components/gemini-sparkle.tsx`

```tsx
"use client";

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

import { GEMINI_SPARKLE_CYCLE_MS } from "../constants/gemini";
import { useGeminiReducedMotion } from "../hooks/use-gemini-reduced-motion";
import type { GeminiSparkleProps } from "../types/gemini";

const VIEW = 24;
const DOT_R = 2.15;
const BOUNCE_END = 0.4;
const MORPH_IN_END = 0.56;
const HOLD_END = 0.7;
const MORPH_OUT_END = 0.86;
const BOUNCE_HEIGHT = 2.4;
const BOUNCE_GROWTH = 0.08;
const BOUNCES_PER_CYCLE = 5;

interface Point {
  x: number;
  y: number;
}

const LINE: readonly Point[] = [
  { x: 6, y: 13.2 },
  { x: 12, y: 13.2 },
  { x: 18, y: 13.2 },
];

const TRIANGLE: readonly Point[] = [
  { x: 7.2, y: 15.8 },
  { x: 12, y: 6.8 },
  { x: 16.8, y: 15.8 },
];

const lerp = (from: number, to: number, t: number) => from + (to - from) * t;

const easeInOut = (t: number) =>
  t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;

const morphAmount = (progress: number) => {
  if (progress < BOUNCE_END) {
    return 0;
  }
  if (progress < MORPH_IN_END) {
    return easeInOut((progress - BOUNCE_END) / (MORPH_IN_END - BOUNCE_END));
  }
  if (progress < HOLD_END) {
    return 1;
  }
  if (progress < MORPH_OUT_END) {
    return 1 - easeInOut((progress - HOLD_END) / (MORPH_OUT_END - HOLD_END));
  }
  return 0;
};

const bounceLift = (progress: number, index: number, morph: number) => {
  const wave = Math.max(
    0,
    Math.sin((progress * BOUNCES_PER_CYCLE - index / 3) * Math.PI * 2)
  );
  const rest = 1 - morph;
  return wave * wave * rest * rest;
};

const GeminiSparkleMark = ({ progress }: { progress: number }) => {
  const morph = morphAmount(progress);

  return (
    <svg
      aria-hidden="true"
      className="size-full"
      fill="currentColor"
      viewBox={`0 0 ${VIEW} ${VIEW}`}
    >
      {LINE.map((from, index) => {
        const to = TRIANGLE[index] ?? from;
        const bounce = bounceLift(progress, index, morph);
        return (
          <circle
            cx={lerp(from.x, to.x, morph)}
            cy={lerp(from.y, to.y, morph) - bounce * BOUNCE_HEIGHT}
            key={from.x}
            r={DOT_R * (1 + bounce * BOUNCE_GROWTH)}
          />
        );
      })}
    </svg>
  );
};

export const GeminiSparkle = ({
  animated = false,
  className,
  reducedMotion,
  size = 18,
  style,
  ...props
}: GeminiSparkleProps) => {
  const prefersReducedMotion = useGeminiReducedMotion();
  const shouldAnimate = animated && !(reducedMotion ?? prefersReducedMotion);
  const [progress, setProgress] = useState(0);

  useEffect(() => {
    if (!shouldAnimate) {
      setProgress(0);
      return;
    }

    const started = performance.now();
    let frame = 0;

    const tick = (now: number) => {
      setProgress(
        ((now - started) % GEMINI_SPARKLE_CYCLE_MS) / GEMINI_SPARKLE_CYCLE_MS
      );
      frame = window.requestAnimationFrame(tick);
    };

    frame = window.requestAnimationFrame(tick);
    return () => {
      window.cancelAnimationFrame(frame);
    };
  }, [shouldAnimate]);

  return (
    <span
      className={cn(
        "text-gemini-fg inline-flex size-(--gemini-sparkle-size) shrink-0 items-center justify-center",
        className
      )}
      aria-hidden={animated ? undefined : true}
      aria-label={animated ? "Loading" : undefined}
      data-animated={shouldAnimate || undefined}
      data-slot="gemini-sparkle"
      role={animated ? "status" : undefined}
      style={
        { "--gemini-sparkle-size": `${size}px`, ...style } as CSSProperties
      }
      {...props}
    >
      <GeminiSparkleMark progress={shouldAnimate ? progress : 0} />
    </span>
  );
};
```

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

```ts
export const GEMINI_SPARKLE_CYCLE_MS = 3400;

export const GEMINI_TOOLTIP_DELAY_MS = 200;

export const GEMINI_SEARCHING_LABEL = "Searching the web";

export const GEMINI_THINKING_LABEL = "Thinking";

export const GEMINI_HOVER_OPEN_DELAY_MS = 150;

export const GEMINI_HOVER_CLOSE_DELAY_MS = 120;

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

export const GEMINI_SHOW_THINKING_LABEL = "Show thinking";

export const GEMINI_HIDE_THINKING_LABEL = "Hide thinking";
```

### `registry/notra/gemini/hooks/use-gemini-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 useGeminiReducedMotion = (): boolean =>
  useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
```

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

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

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

export type GeminiModelId = "flash-lite" | "flash" | "pro" | "thinking";

export type GeminiModelGroup = "core" | "thinking";

export interface GeminiModelOption {
  badge?: string;
  chip: string;
  description: string;
  group: GeminiModelGroup;
  id: GeminiModelId;
  label: string;
}

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

export interface GeminiMessageProps extends ComponentProps<"div"> {
  actions?: ReactNode;
  from: GeminiMessageRole;
  status?: ReactNode;
  thoughts?: ReactNode;
}

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

export interface GeminiActionsProps extends ComponentProps<"div"> {
  onBadResponse?: () => void;
  onGoodResponse?: () => void;
}

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

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

export interface GeminiModelSelectorProps extends Omit<
  ComponentProps<typeof Button>,
  "defaultValue" | "onChange" | "value"
> {
  defaultModel?: GeminiModelId;
  model?: GeminiModelId;
  models?: readonly GeminiModelOption[];
  onModelChange?: (model: GeminiModelId) => void;
}

export interface GeminiComposerProps extends Omit<
  ComponentProps<"div">,
  "onSubmit"
> {
  busy?: boolean;
  defaultModel?: GeminiModelId;
  disclaimer?: ReactNode;
  model?: GeminiModelId;
  models?: readonly GeminiModelOption[];
  onModelChange?: (model: GeminiModelId) => void;
  onSend?: (text: string) => void;
  onStop?: () => void;
  placeholder?: string;
  privacyHref?: string;
  privacyLabel?: ReactNode;
}

export interface GeminiSource {
  description?: string;
  domain: string;
  favicon?: string;
  href?: string;
  id: string;
  name: string;
  title: string;
}

export type GeminiThoughtStep =
  | { kind: "search"; queries: readonly string[] }
  | { kind: "thought"; text: string };

export interface GeminiSourceChipProps extends Omit<
  ComponentProps<"a">,
  "children" | "href"
> {
  source: GeminiSource;
}

export interface GeminiThoughtsProps extends Omit<
  ComponentProps<typeof Collapsible>,
  "children"
> {
  hideLabel?: ReactNode;
  showLabel?: ReactNode;
  steps: readonly GeminiThoughtStep[];
}

export interface GeminiStoryTextProps extends Omit<
  ComponentProps<"div">,
  "children"
> {
  sources?: readonly GeminiSource[];
  text: string;
}

export interface GeminiStoryMessage {
  from: GeminiMessageRole;
  id: string;
  search?: boolean;
  sources?: readonly GeminiSource[];
  text: string;
  thoughts?: readonly GeminiThoughtStep[];
}

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

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

```tsx
"use client";

import { cn } from "cn";
import { ThumbsDownIcon, ThumbsUpIcon } from "lucide-react";

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

import { GEMINI_TOOLTIP_DELAY_MS } from "../constants/gemini";
import type { GeminiActionsProps } from "../types/gemini";
import { GeminiActionButton } from "./gemini-action-button";

const ICON_STROKE = 1.5;

export const GeminiActions = ({
  className,
  onBadResponse,
  onGoodResponse,
  ...props
}: GeminiActionsProps) => (
  <TooltipProvider delay={GEMINI_TOOLTIP_DELAY_MS}>
    <div
      className={cn("flex items-center gap-1", className)}
      data-slot="gemini-actions"
      {...props}
    >
      <GeminiActionButton label="Good response" onClick={onGoodResponse}>
        <ThumbsUpIcon strokeWidth={ICON_STROKE} />
      </GeminiActionButton>
      <GeminiActionButton label="Bad response" onClick={onBadResponse}>
        <ThumbsDownIcon strokeWidth={ICON_STROKE} />
      </GeminiActionButton>
    </div>
  </TooltipProvider>
);
```

### `registry/notra/gemini/components/gemini-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 { GeminiActionButtonProps } from "../types/gemini";

export const GeminiActionButton = ({
  children,
  className,
  label,
  ...props
}: GeminiActionButtonProps) => (
  <Tooltip>
    <TooltipTrigger
      render={
        <Button
          aria-label={label}
          className={cn(
            "text-gemini-muted hover:bg-gemini-hover hover:text-gemini-muted aria-expanded:bg-gemini-hover aria-expanded:text-gemini-muted dark:hover:bg-gemini-hover focus-visible:ring-gemini-focus/30 size-7 rounded-full 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="gemini-action-button"
          size="icon"
          variant="ghost"
          {...props}
        />
      }
    >
      {children}
    </TooltipTrigger>
    <TooltipContent
      className="bg-gemini-tooltip font-gemini text-gemini-tooltip-fg *:bg-gemini-tooltip *:fill-gemini-tooltip rounded-md border-0 bg-none px-3 py-1.5 shadow-none [corner-shape:round]"
      side="bottom"
      sideOffset={6}
    >
      {label}
    </TooltipContent>
  </Tooltip>
);
```

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

```tsx
"use client";

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

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

import {
  GEMINI_DEFAULT_MODEL,
  GEMINI_MODELS,
} from "../constants/gemini-models";
import type { GeminiComposerProps } from "../types/gemini";
import { GeminiModelSelector } from "./gemini-model-selector";

type SubmitState = "idle" | "ready" | "busy";

const SUBMIT_LABELS: Record<SubmitState, string> = {
  busy: "Stop",
  idle: "Voice input",
  ready: "Send",
};

const ICON_BUTTON =
  "focus-visible:ring-gemini-focus/30 size-9 shrink-0 rounded-full border-0 focus-visible:ring-2 active:not-aria-[haspopup]:translate-y-0";

const ActionGlyph = ({
  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 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-opacity",
      show
        ? "scale-100 opacity-100"
        : "pointer-events-none scale-[0.92] opacity-0"
    )}
  >
    {children}
  </span>
);

const getSubmitState = (busy: boolean, canSend: boolean): SubmitState => {
  if (busy) {
    return "busy";
  }
  return canSend ? "ready" : "idle";
};

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

  return (
    <InputGroupButton
      aria-label={SUBMIT_LABELS[state]}
      className="focus-visible:ring-gemini-focus/30 text-gemini-fg hover:bg-gemini-hover hover:text-gemini-fg dark:hover:bg-gemini-hover data-[state=busy]:bg-gemini-stop data-[state=busy]:hover:bg-gemini-stop-hover dark:data-[state=busy]:hover:bg-gemini-stop-hover data-[state=ready]:bg-gemini-send data-[state=ready]:text-gemini-send-fg data-[state=ready]:hover:bg-gemini-send-hover data-[state=ready]:hover:text-gemini-send-fg dark:data-[state=ready]:hover:bg-gemini-send-hover relative size-9 shrink-0 rounded-full border-0 transition-[background-color,color] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] focus-visible:ring-2 active:not-aria-[haspopup]:translate-y-0 motion-reduce:transition-none"
      data-slot="gemini-composer-submit"
      data-state={state}
      onClick={handleClick}
      size="icon-sm"
      type={state === "ready" ? "submit" : "button"}
      variant="ghost"
    >
      <ActionGlyph show={state === "idle"}>
        <MicIcon className="size-5" strokeWidth={1.5} />
      </ActionGlyph>
      <ActionGlyph show={state === "ready"}>
        <ArrowUpIcon className="size-4.5" strokeWidth={1.75} />
      </ActionGlyph>
      <ActionGlyph show={state === "busy"}>
        <span className="block size-3.5 rounded-[0.1875rem] bg-current" />
      </ActionGlyph>
    </InputGroupButton>
  );
};

export const GeminiComposer = ({
  busy = false,
  className,
  defaultModel = GEMINI_DEFAULT_MODEL,
  disclaimer = "Gemini is AI and can make mistakes, including about people.",
  model,
  models = GEMINI_MODELS,
  onModelChange,
  onSend,
  onStop,
  placeholder = "Ask Gemini",
  privacyHref,
  privacyLabel = "Your privacy & Gemini",
  ...props
}: GeminiComposerProps) => {
  const [value, setValue] = useState("");
  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("");
    }
  };

  return (
    <div
      className={cn("font-gemini w-full", className)}
      data-slot="gemini-composer"
      {...props}
    >
      <form onSubmit={handleSubmit}>
        <InputGroup className="border-gemini-composer-border bg-gemini-composer shadow-gemini-composer dark:bg-gemini-composer has-[[data-slot=input-group-control]:focus-visible]:border-gemini-composer-border h-14 gap-1 rounded-full py-0 ps-3 pe-3 has-[[data-slot=input-group-control]:focus-visible]:ring-0 has-[>[data-align=inline-end]]:[&>input]:pr-2 has-[>[data-align=inline-start]]:[&>input]:pl-0">
          <InputGroupAddon
            align="inline-start"
            className="text-gemini-fg p-0 has-[>button]:ml-0"
          >
            <InputGroupButton
              aria-label="Add files"
              className={cn(
                ICON_BUTTON,
                "text-gemini-fg hover:bg-gemini-hover hover:text-gemini-fg dark:hover:bg-gemini-hover transition-colors duration-150 motion-reduce:transition-none"
              )}
              size="icon-sm"
              variant="ghost"
            >
              <PlusIcon className="size-5" strokeWidth={1.5} />
            </InputGroupButton>
          </InputGroupAddon>
          <InputGroupInput
            aria-label="Message"
            className="text-gemini-fg placeholder:text-gemini-placeholder h-auto py-2 ps-1 pe-2 text-[0.9375rem] leading-6 md:text-[0.9375rem]"
            onChange={(event) => setValue(event.target.value)}
            placeholder={placeholder}
            type="text"
            value={value}
          />
          <InputGroupAddon
            align="inline-end"
            className="text-gemini-fg gap-1 p-0 has-[>button]:mr-0"
          >
            <GeminiModelSelector
              defaultModel={defaultModel}
              model={model}
              models={models}
              onModelChange={onModelChange}
            />
            <GeminiComposerSubmit
              busy={busy}
              canSend={canSend}
              onStop={onStop}
            />
          </InputGroupAddon>
        </InputGroup>
      </form>
      {disclaimer ? (
        <p className="text-gemini-muted mt-2 text-center text-xs leading-4">
          {disclaimer}{" "}
          {privacyHref ? (
            <a
              className="decoration-gemini-muted focus-visible:outline-gemini-focus rounded-sm underline underline-offset-2 focus-visible:outline-2"
              href={privacyHref}
              rel="noopener noreferrer"
              target="_blank"
            >
              {privacyLabel}
            </a>
          ) : (
            <span className="decoration-gemini-muted underline underline-offset-2">
              {privacyLabel}
            </span>
          )}
        </p>
      ) : null}
    </div>
  );
};
```

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

```tsx
"use client";

import { cn } from "cn";
import { ChevronDownIcon } from "lucide-react";
import { useState } from "react";

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

import {
  GEMINI_DEFAULT_MODEL,
  GEMINI_MODELS,
} from "../constants/gemini-models";
import { getGeminiModel, getGeminiModelsByGroup } from "../lib/gemini-model";
import type {
  GeminiModelId,
  GeminiModelOption,
  GeminiModelSelectorProps,
} from "../types/gemini";

const GeminiModelRow = ({ option }: { option: GeminiModelOption }) => (
  <DropdownMenuRadioItem
    className="focus:bg-gemini-hover focus:text-gemini-fg data-highlighted:bg-gemini-hover focus:**:text-gemini-fg text-gemini-fg cursor-pointer items-start gap-3 rounded-[1.1rem] py-2.5 pr-2.5 pl-10.5 [&_[data-slot=dropdown-menu-radio-item-indicator]]:top-2.5 [&_[data-slot=dropdown-menu-radio-item-indicator]]:right-auto [&_[data-slot=dropdown-menu-radio-item-indicator]]:left-2.5 [&_[data-slot=dropdown-menu-radio-item-indicator]]:size-5"
    closeOnClick
    data-slot="gemini-model-option"
    value={option.id}
  >
    <span className="flex min-w-0 flex-1 flex-col gap-0.5">
      <span className="flex min-w-0 items-center gap-2">
        <span className="text-gemini-fg truncate text-sm leading-5 font-medium">
          {option.label}
        </span>
        {option.badge ? (
          <Badge className="bg-gemini-hover text-gemini-badge-fg! h-auto rounded-full border-0 px-2 py-0.5 text-[0.6875rem] leading-none font-medium">
            {option.badge}
          </Badge>
        ) : null}
      </span>
      <span className="text-gemini-muted! text-xs leading-4">
        {option.description}
      </span>
    </span>
  </DropdownMenuRadioItem>
);

export const GeminiModelSelector = ({
  className,
  defaultModel = GEMINI_DEFAULT_MODEL,
  model: modelProp,
  models = GEMINI_MODELS,
  onModelChange,
  ...props
}: GeminiModelSelectorProps) => {
  const [uncontrolledModel, setUncontrolledModel] = useState(defaultModel);
  const model = modelProp ?? uncontrolledModel;
  const selected = getGeminiModel(model, models);
  const coreModels = getGeminiModelsByGroup("core", models);
  const thinkingModels = getGeminiModelsByGroup("thinking", models);

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

  const renderRow = (option: GeminiModelOption) => (
    <GeminiModelRow key={option.id} option={option} />
  );

  return (
    <DropdownMenu modal={false}>
      <DropdownMenuTrigger
        render={
          <Button
            aria-label={`Model: ${selected.label}`}
            className={cn(
              "group/gemini-model text-gemini-chip-fg dark:text-gemini-muted hover:bg-gemini-hover hover:text-gemini-chip-fg dark:hover:text-gemini-muted aria-expanded:bg-gemini-hover aria-expanded:text-gemini-chip-fg dark:aria-expanded:text-gemini-muted data-popup-open:bg-gemini-hover dark:hover:bg-gemini-hover focus-visible:ring-gemini-focus/30 h-9 shrink-0 gap-1 rounded-full border-0 ps-3.5 pe-2.5 text-[0.8125rem] leading-5 font-medium transition-[background-color,scale] duration-150 focus-visible:ring-2 active:scale-[0.96] motion-reduce:transition-none motion-reduce:active:scale-100",
              className
            )}
            data-slot="gemini-model-selector"
            variant="ghost"
            {...props}
          />
        }
      >
        <span className="font-medium">{selected.chip}</span>
        <ChevronDownIcon
          className="text-gemini-muted size-4 transition-transform duration-150 group-data-popup-open/gemini-model:rotate-180 motion-reduce:transition-none"
          strokeWidth={1.5}
        />
      </DropdownMenuTrigger>
      <DropdownMenuContent
        align="end"
        className="bg-gemini-popover font-gemini text-gemini-fg shadow-gemini-popover w-72 rounded-[1.5rem] p-1.5 ring-0"
        side="top"
        sideOffset={10}
      >
        <DropdownMenuRadioGroup
          onValueChange={(next) => {
            const option = models.find((item) => item.id === next);
            if (option) {
              handleSelect(option.id);
            }
          }}
          value={selected.id}
        >
          {coreModels.map(renderRow)}
          {thinkingModels.length > 0 ? (
            <DropdownMenuSeparator className="bg-gemini-separator mx-2 my-1" />
          ) : null}
          {thinkingModels.map(renderRow)}
        </DropdownMenuRadioGroup>
      </DropdownMenuContent>
    </DropdownMenu>
  );
};
```

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

```ts
import type { GeminiModelId, GeminiModelOption } from "../types/gemini";

export const GEMINI_DEFAULT_MODEL: GeminiModelId = "pro";

export const GEMINI_PRO_MODEL: GeminiModelOption = {
  chip: "Pro",
  description: "Reasoning",
  group: "core",
  id: "pro",
  label: "3.1 Pro",
};

export const GEMINI_MODELS: readonly GeminiModelOption[] = [
  {
    chip: "Flash-Lite",
    description: "Fastest answers",
    group: "core",
    id: "flash-lite",
    label: "3.5 Flash-Lite",
  },
  {
    badge: "New",
    chip: "Flash",
    description: "Versatile help",
    group: "core",
    id: "flash",
    label: "3.7 Flash",
  },
  GEMINI_PRO_MODEL,
  {
    chip: "Thinking",
    description: "Complex problem solving",
    group: "thinking",
    id: "thinking",
    label: "Thinking (extended)",
  },
];
```

### `registry/notra/gemini/lib/gemini-model.ts`

```ts
import { GEMINI_MODELS, GEMINI_PRO_MODEL } from "../constants/gemini-models";
import type {
  GeminiModelGroup,
  GeminiModelId,
  GeminiModelOption,
} from "../types/gemini";

export const getGeminiModel = (
  id: GeminiModelId,
  models: readonly GeminiModelOption[] = GEMINI_MODELS
): GeminiModelOption =>
  models.find((item) => item.id === id) ?? models[0] ?? GEMINI_PRO_MODEL;

export const getGeminiModelsByGroup = (
  group: GeminiModelGroup,
  models: readonly GeminiModelOption[] = GEMINI_MODELS
): GeminiModelOption[] => models.filter((item) => item.group === group);
```

### `registry/notra/gemini/components/gemini-story-text.tsx`

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

import {
  geminiCitationId,
  splitGeminiBold,
  splitGeminiCitations,
  splitGeminiParagraphs,
} from "../lib/gemini-text";
import type { GeminiSource, GeminiStoryTextProps } from "../types/gemini";
import { GeminiSourceChip } from "./gemini-source-chip";

const GeminiBoldText = ({ text }: { text: string }) => (
  <>
    {splitGeminiBold(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 GeminiInlineText = ({
  sources,
  text,
}: {
  sources: ReadonlyMap<string, GeminiSource>;
  text: string;
}) => (
  <>
    {splitGeminiCitations(text).map((piece) => {
      const id = geminiCitationId(piece.text);
      if (id === null) {
        return <GeminiBoldText key={piece.offset} text={piece.text} />;
      }
      const source = sources.get(id);
      return source ? (
        <GeminiSourceChip key={piece.offset} source={source} />
      ) : null;
    })}
  </>
);

export const GeminiStoryText = ({
  className,
  sources = [],
  text,
  ...props
}: GeminiStoryTextProps) => {
  const lookup = new Map(sources.map((source) => [source.id, source]));

  return (
    <div
      className={cn("flex flex-col gap-3", className)}
      data-slot="gemini-story-text"
      {...props}
    >
      {splitGeminiParagraphs(text).map((paragraph) => (
        <p key={paragraph.offset}>
          <GeminiInlineText sources={lookup} text={paragraph.text} />
        </p>
      ))}
    </div>
  );
};
```

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

```ts
import type { GeminiTextSegment } from "../types/gemini";

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

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

const PARAGRAPH_SEPARATOR = "\n\n";

export const splitWithOffsets = (
  text: string,
  separator: string | RegExp,
  separatorLength: number
): GeminiTextSegment[] => {
  const segments: GeminiTextSegment[] = [];
  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 splitGeminiParagraphs = (text: string): GeminiTextSegment[] =>
  splitWithOffsets(text, PARAGRAPH_SEPARATOR, PARAGRAPH_SEPARATOR.length);

export const splitGeminiBold = (text: string): GeminiTextSegment[] =>
  splitWithOffsets(text, BOLD_PATTERN, 0);

export const splitGeminiCitations = (text: string): GeminiTextSegment[] =>
  splitWithOffsets(text, CITE_PATTERN, 0);

export const geminiCitationId = (text: string): string | null =>
  text.match(/^\{\{([a-z0-9-]+)\}\}$/)?.[1] ?? null;
```

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

```ts
import type {
  GeminiSource,
  GeminiStoryMessage,
  GeminiThoughtStep,
} from "../types/gemini";

export const GEMINI_STORY_SOURCES: GeminiSource[] = [
  {
    description:
      "Cursor's cloud agents run on remote machines, so a task keeps going after you close the laptop, and you can pick it up from the editor again.",
    domain: "cursor.com",
    href: "https://cursor.com",
    id: "cursor-blog",
    name: "Cursor",
    title: "Cloud agents: work that keeps running without your laptop",
  },
  {
    description:
      "Write-ahead log segments are shipped to object storage, so state stays durable and versioned while the hot path stays off the editor's thread.",
    domain: "aws.amazon.com",
    href: "https://aws.amazon.com/s3",
    id: "s3-docs",
    name: "AWS",
    title: "Amazon S3 versioning and durable storage",
  },
];

export const GEMINI_STORY_THOUGHTS: GeminiThoughtStep[] = [
  {
    kind: "thought",
    text: "The user asks about two terms. I should check what each one means before answering.",
  },
  {
    kind: "search",
    queries: ["cursor origin cloud workspace", "wal-on-s3 snapshots artifacts"],
  },
  {
    kind: "thought",
    text: "Both results agree: Origin is the remote workspace and wal-on-s3 is where its state is stored.",
  },
];

export const GEMINI_STORY_THREAD: GeminiStoryMessage[] = [
  {
    from: "user",
    id: "u-1",
    text: "what are cursor origin and wal-on-s3?",
  },
  {
    from: "assistant",
    id: "a-1",
    search: true,
    sources: GEMINI_STORY_SOURCES,
    text: "**Cursor Origin** is Cursor's cloud workspace: the agent keeps working on your repo remotely, even with your laptop closed. {{cursor-blog}}\n\n**wal-on-s3** stores its snapshots and artifacts on S3, so files stay versioned and nothing blocks the editor. {{s3-docs}}",
    thoughts: GEMINI_STORY_THOUGHTS,
  },
];

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

export const GEMINI_STORY_ASSISTANT_MESSAGES = GEMINI_STORY_THREAD.filter(
  (message) => message.from === "assistant"
);
```

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

```ts
"use client";

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

import {
  GEMINI_SEARCHING_LABEL,
  GEMINI_THINKING_LABEL,
} from "../constants/gemini";
import type { GeminiStoryMessage } from "../types/gemini";

const TOKEN_SPLIT = /(\s+)/;
const REDUCED_MOTION_MAX_DELAY_MS = 70;
const USER_TURN_DELAY_MS = 420;
const SEARCH_DELAY_MS = 1800;
const THINK_DELAY_MS = 1100;
const REDUCED_REPLY_DELAY_MS = 180;
const TOKEN_DELAY_MS = 28;
const TURN_END_DELAY_MS = 480;

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

export const useGeminiPlayback = (
  script: readonly GeminiStoryMessage[],
  reducedMotion: boolean
) => {
  const [messages, setMessages] = useState<GeminiStoryMessage[]>(() => [
    ...script,
  ]);
  const [completeIds, setCompleteIds] = useState<ReadonlySet<string>>(
    () => new Set(script.map((item) => item.id))
  );
  const [thinking, setThinking] = useState(false);
  const [thinkingLabel, setThinkingLabel] = useState(GEMINI_SEARCHING_LABEL);
  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 resetToScript = useCallback(() => {
    setMessages([...script]);
    setCompleteIds(new Set(script.map((item) => item.id)));
    setThinking(false);
    setThinkingLabel(GEMINI_SEARCHING_LABEL);
    setPlaying(false);
  }, [script]);

  const stop = useCallback(() => {
    runRef.current += 1;
    sendingRef.current = false;
    resetToScript();
  }, [resetToScript]);

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

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

      setThinkingLabel(
        message.search ? GEMINI_SEARCHING_LABEL : GEMINI_THINKING_LABEL
      );
      setThinking(true);
      await delay(message.search ? SEARCH_DELAY_MS : THINK_DELAY_MS);
      if (!alive()) {
        return;
      }
      setThinking(false);

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

      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_DELAY_MS);
      }
      if (!alive()) {
        return;
      }
      markComplete(message.id);
      await delay(TURN_END_DELAY_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();

      const playExchange = async () => {
        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);
        }
      };

      await playExchange().finally(() => {
        // A stopped run must not release the lock of a newer send.
        if (runRef.current === run) {
          sendingRef.current = false;
        }
      });
    },
    [playTurn]
  );

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

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

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

```tsx
"use client";

import { useEffect, useRef } from "react";

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

import { GeminiActions } from "../components/gemini-actions";
import { GeminiComposer } from "../components/gemini-composer";
import { GeminiMessage } from "../components/gemini-message";
import { GeminiStoryText } from "../components/gemini-story-text";
import { GeminiThinking } from "../components/gemini-thinking";
import { GeminiThoughts } from "../components/gemini-thoughts";
import {
  GEMINI_STORY_REPLIES,
  GEMINI_STORY_THREAD,
} from "../constants/gemini-story";
import { useGeminiPlayback } from "../hooks/use-gemini-playback";
import { useGeminiReducedMotion } from "../hooks/use-gemini-reduced-motion";

export default function GeminiDemo() {
  const reducedMotion = useGeminiReducedMotion();
  const playback = useGeminiPlayback(GEMINI_STORY_THREAD, reducedMotion);
  const replyIndexRef = useRef(0);
  const scrollRef = useRef<HTMLDivElement>(null);
  const hasMountedRef = useRef(false);

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

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

  return (
    <Card
      className="border-gemini-border bg-gemini-bg font-gemini text-gemini-fg h-150 w-full min-w-0 gap-0 overflow-hidden rounded-2xl border py-0 text-base ring-0"
      data-slot="gemini-demo"
    >
      <CardHeader className="flex h-12 shrink-0 items-center justify-between gap-3 ps-5 pe-3">
        <CardTitle className="text-gemini-muted text-base leading-6 font-normal">
          Gemini
        </CardTitle>
      </CardHeader>
      <ScrollArea className="min-h-0 flex-1" ref={scrollRef}>
        <CardContent
          aria-live="polite"
          className="mx-auto flex w-full max-w-3xl min-w-0 flex-col gap-8 px-5 pt-4 pb-8"
          role="log"
        >
          {playback.messages.map((message) => (
            <GeminiMessage
              actions={
                message.from === "assistant" ? (
                  <div
                    className={
                      playback.completeIds.has(message.id)
                        ? undefined
                        : "invisible"
                    }
                  >
                    <GeminiActions />
                  </div>
                ) : undefined
              }
              from={message.from}
              key={message.id}
              thoughts={
                message.thoughts ? (
                  <GeminiThoughts steps={message.thoughts} />
                ) : undefined
              }
            >
              {message.from === "user" ? (
                message.text
              ) : (
                <GeminiStoryText
                  sources={message.sources}
                  text={message.text}
                />
              )}
            </GeminiMessage>
          ))}
          {playback.thinking ? (
            <GeminiThinking
              label={playback.thinkingLabel}
              reducedMotion={reducedMotion}
            />
          ) : null}
        </CardContent>
      </ScrollArea>
      <CardContent className="mx-auto w-full max-w-3xl min-w-0 shrink-0 px-5 pb-5">
        <GeminiComposer
          busy={playback.playing || playback.thinking}
          onSend={handleSend}
          onStop={playback.stop}
        />
      </CardContent>
    </Card>
  );
}
```

### `registry/notra/gemini/components/gemini-thoughts.tsx`

```tsx
"use client";

import { cn } from "cn";
import { ChevronDownIcon, SearchIcon } from "lucide-react";

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

import {
  GEMINI_HIDE_THINKING_LABEL,
  GEMINI_SHOW_THINKING_LABEL,
} from "../constants/gemini";
import type { GeminiThoughtsProps } from "../types/gemini";

/** Pairs each item with a key that stays unique when steps repeat. */
const withOccurrenceKeys = <T,>(
  items: readonly T[],
  getKey: (item: T) => string
) => {
  const seen = new Map<string, number>();
  return items.map((item) => {
    const base = getKey(item);
    const count = (seen.get(base) ?? 0) + 1;
    seen.set(base, count);
    return [item, `${base}#${count}`] as const;
  });
};

export const GeminiThoughts = ({
  className,
  hideLabel = GEMINI_HIDE_THINKING_LABEL,
  showLabel = GEMINI_SHOW_THINKING_LABEL,
  steps,
  ...props
}: GeminiThoughtsProps) => (
  <Collapsible
    className={cn("group/gemini-thoughts font-gemini w-full", className)}
    data-slot="gemini-thoughts"
    {...props}
  >
    <CollapsibleTrigger
      render={
        <Button
          className="text-gemini-muted hover:bg-gemini-hover hover:text-gemini-fg focus-visible:ring-gemini-focus/30 aria-expanded:text-gemini-fg dark:hover:bg-gemini-hover -ms-2 h-8 gap-2 rounded-full border-0 px-2 text-[0.9375rem] leading-5 font-normal focus-visible:ring-2 active:not-aria-[haspopup]:translate-y-0 [&[data-panel-open]>svg:last-child]:rotate-180"
          variant="ghost"
        />
      }
    >
      <span className="group-data-open/gemini-thoughts:hidden">
        {showLabel}
      </span>
      <span className="hidden group-data-open/gemini-thoughts:inline">
        {hideLabel}
      </span>
      <ChevronDownIcon
        aria-hidden="true"
        className="size-4 transition-transform duration-200 motion-reduce:transition-none"
        strokeWidth={1.5}
      />
    </CollapsibleTrigger>
    <CollapsibleContent className="h-(--collapsible-panel-height) overflow-hidden transition-[height,opacity] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] data-ending-style:h-0 data-ending-style:opacity-0 data-starting-style:h-0 data-starting-style:opacity-0 motion-reduce:transition-none">
      <div className="border-gemini-separator text-gemini-muted mt-2 flex flex-col gap-3 border-s-2 ps-4 text-[0.875rem] leading-6">
        {withOccurrenceKeys(steps, (item) =>
          item.kind === "thought" ? item.text : item.queries.join("|")
        ).map(([step, key]) =>
          step.kind === "thought" ? (
            <p key={key}>{step.text}</p>
          ) : (
            <div className="flex flex-col gap-2" key={key}>
              <span className="text-gemini-fg flex items-center gap-2 text-[0.8125rem] leading-5">
                <SearchIcon
                  aria-hidden="true"
                  className="size-3.5"
                  strokeWidth={1.75}
                />
                Searching the web
              </span>
              <span className="flex flex-wrap gap-1.5">
                {withOccurrenceKeys(step.queries, (query) => query).map(
                  ([query, queryKey]) => (
                    <Badge
                      className="border-gemini-separator text-gemini-muted h-auto rounded-full bg-transparent px-2.5 py-0.5 text-[0.8125rem] leading-5 font-normal"
                      key={queryKey}
                      variant="outline"
                    >
                      {query}
                    </Badge>
                  )
                )}
              </span>
            </div>
          )
        )}
      </div>
    </CollapsibleContent>
  </Collapsible>
);
```

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

```tsx
"use client";

import { cn } from "cn";

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

import {
  GEMINI_FAVICON_URL,
  GEMINI_HOVER_CLOSE_DELAY_MS,
  GEMINI_HOVER_OPEN_DELAY_MS,
} from "../constants/gemini";
import type { GeminiSourceChipProps } from "../types/gemini";

export const GeminiSourceChip = ({
  className,
  rel = "noopener noreferrer",
  source,
  target = "_blank",
  ...props
}: GeminiSourceChipProps) => (
  <HoverCard>
    <HoverCardTrigger
      className={cn(
        badgeVariants({ variant: "secondary" }),
        "bg-gemini-bubble text-gemini-muted hover:bg-gemini-hover hover:text-gemini-fg focus-visible:outline-gemini-focus mx-1 h-6 rounded-full border-0 px-2.5 align-baseline text-[0.8125rem] leading-none font-normal no-underline focus-visible:ring-0 focus-visible:outline-2 focus-visible:outline-solid motion-reduce:transition-none",
        className
      )}
      closeDelay={GEMINI_HOVER_CLOSE_DELAY_MS}
      data-slot="gemini-source-chip"
      delay={GEMINI_HOVER_OPEN_DELAY_MS}
      href={source.href}
      rel={rel}
      target={target}
      {...props}
    >
      {source.name}
    </HoverCardTrigger>
    <HoverCardContent
      align="start"
      className="bg-gemini-popover font-gemini text-gemini-fg shadow-gemini-popover ring-gemini-border w-80 max-w-[calc(100vw-2rem)] gap-0 rounded-2xl p-4"
      sideOffset={8}
    >
      <div className="text-gemini-muted flex items-center gap-2 text-sm leading-5">
        <Avatar className="size-4 rounded-sm after:hidden">
          <AvatarImage
            alt=""
            className="rounded-sm"
            src={
              source.favicon ??
              `${GEMINI_FAVICON_URL}?domain=${encodeURIComponent(source.domain)}&sz=64`
            }
          />
          <AvatarFallback className="bg-gemini-hover rounded-sm" />
        </Avatar>
        {source.name}
      </div>
      <p className="mt-2 text-[0.9375rem] leading-5">{source.title}</p>
      {source.description ? (
        <p className="text-gemini-muted mt-2 line-clamp-2 text-[0.8125rem] leading-[1.15rem]">
          {source.description}
        </p>
      ) : null}
    </HoverCardContent>
  </HoverCard>
);
```
