AI

Open In Chat

A provider menu that turns the content on the page into a written prompt and hands it to ChatGPT, Claude, Cursor or the clipboard — deeplink when it fits the URL, copy when it does not.

Preview in your theme

Loading preview…

Dependencies
lucide-react
Registry dependencies
button, dropdown-menu, utils
"use client"

import * as React from "react"
import {
  Check,
  ChevronDown,
  ClipboardCopy,
  ExternalLink,
  MessageSquare,
  Search,
  Sparkles,
  SquareTerminal,
  TriangleAlert,
  WandSparkles,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/open-in-chat.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "OpenInChat" component (lucide-react icons,
shadcn Button + DropdownMenu, cn() from your utils). It hands whatever is on the
page to an AI assistant as an already-written prompt: pick an assistant, land in
a new tab with the question typed — or on the clipboard when that assistant has
no prefill URL.

Contract
- forwardRef<HTMLDivElement>, extends the div props minus "content" and "title":
  both exist as global/RDFa attributes and would collide with the props below.
- content: string (required) — the material handed over verbatim. Empty or
  whitespace-only content locks the whole control (see Behavior — locked).
- instruction?: string — the FIRST line of the prompt, the job rather than the
  material. Default: "Explain what this does, then help me adapt it to my project."
- sourceTitle?: string, sourceUrl?: string — provenance quoted into the prompt.
  sourceUrl is what lets the model fetch whatever the budget cut.
- language?: string ("tsx", "sql", "") — fence info string for the content.
- buildPrompt?: (parts: { content, instruction, title?, sourceUrl?, language? })
  => string — replaces the whole recipe. Default recipe: instruction line, then
  "Title: …" / "Source: …", then the content inside a fence.
- providers?: OpenInChatProvider[] — { id, label, href?: (prompt: string) =>
  string, maxUrlLength?, icon?: ReactNode, hint?, disabled? }. Ship a default
  catalogue (ChatGPT, Claude, Perplexity, v0, Cursor, plus one clipboard-only
  row) and export it, so consumers can spread it and push their own assistant.
- maxPromptLength? (6000), maxUrlLength? (4000, per-provider value wins),
  truncationNotice? (the line that replaces the cut content).
- label? ("Open in chat"), menuLabel? ("Send this to"), overflowHint?, icon?,
  iconOnly? (drops label and chevron, keeps the accessible name),
  size? "sm" | "default", variant? "default" | "outline" | "ghost" | "secondary",
  align? "start" | "center" | "end", feedbackMs? (2600), showPromptSize? (true),
  disabled?.
- onOpenIn?: (event: { provider, prompt, route, outcome, truncated, href? }) =>
  void — the single output, fired once per completed interaction. route is
  "link" | "copy" | "copy-link"; outcome is "opened" | "copied" |
  "copied-opened" | "error". `prompt` is the exact string that was sent.
- Also export the prompt builder and the provider catalogue as standalone
  functions/consts: a server route that emails the same prompt should not have
  to render the component to build it.

Behavior — prompt assembly
- Instruction first (an opening line is what a model weights most), then the
  provenance lines, then the content inside a fence. Never send a bare snippet:
  pasted alone it tells the model neither what it is nor where it came from.
- Compute the fence as one backtick longer than the longest backtick run inside
  the content. Docs snippets contain fences of their own, and a plain
  three-backtick fence closes on the first one it meets — after which the model
  reads the rest of the page as prose.
- Trim the content before fencing; return only the head when the content is
  empty after trimming.

Behavior — the budget (the part everyone gets wrong)
- Providers truncate long query strings silently, and proxies answer 414. A
  prompt that arrives cut in half is worse than one the reader pastes, so the
  finished prompt is capped at maxPromptLength and the CONTENT is what shrinks.
- The instruction and the source URL are never touched: they are what lets the
  model ask for the rest.
- Find the surviving length by BINARY SEARCH over the content, not by arithmetic
  on the template: buildPrompt is a black box that a consumer can replace with
  any amount of scaffolding, and template arithmetic silently overshoots the
  moment they do. ~log2(n) builder calls, memoised once per prompt.
- Cut at a line boundary when one is within the last 40% of the kept slice —
  half a line reads as corruption — then append the truncationNotice.
- If even an empty body busts the budget (a huge instruction, a long URL), send
  the head alone rather than nothing, and still report truncated: true.

Behavior — route resolution (per provider, from the finished prompt)
- No href builder                 -> "copy": clipboard only.
- href fits provider.maxUrlLength -> "link": a real anchor to the prefilled URL.
- href busts the cap              -> "copy-link": clipboard AND a blank chat tab
  (provider.href("")), so the reader lands where the paste belongs.
- Render link and copy-link rows as real <a target="_blank" rel="noopener
  noreferrer"> through the menu item's asChild — not window.open. Anchors are
  not popup-blocked, they support middle-click and modifier-click, and "copy
  link address" hands over a shareable prefilled chat. The cost is the prompt
  appearing once per row in the DOM, which is why the budget is not optional.
- Copy rows call preventDefault() on the menu's select event so the menu STAYS
  OPEN: the row's entire result is a clipboard write, and a menu that vanishes
  leaves nothing to confirm it.
- A disabled provider renders as a visible inert row with its reason, never as
  a missing row.

Behavior — clipboard, feedback, locks, cleanup
- Clipboard: async API first, a hidden textarea + execCommand second, `false`
  third. The fallback matters — the async API needs a secure context and docs
  get read from previews and intranets. Nothing throws out of the helper: a
  refusal is a state the UI renders. Restore focus to the previously active
  element after the textarea path, or the keyboard journey dead-ends on <body>.
- One feedback slot: { providerId, outcome }. The trigger label becomes
  "Opened in Claude" / "Copied for Claude Desktop" / "Copy failed", the row
  shows the same words plus a check (or a destructive triangle), and both revert
  after feedbackMs. Store the provider id, not the label, so a re-ordered
  catalogue cannot mislabel a confirmation.
- One-shot lock (~400ms) around every emission: a menu row can be clicked and
  Enter-ed in the same gesture, and a double click on a link row would open two
  tabs. A link row's click that loses the lock calls preventDefault, so the
  anchor does not navigate either. Start the clipboard write synchronously
  inside the click handler so Safari still counts it as user-initiated.
- Locked = disabled || empty content. While locked the trigger is natively
  disabled AND onOpenChange refuses to open, so the pointer path and the
  keyboard path are both closed; an open menu closes when the lock lands, by
  resetting the state rather than only the rendered `open`.
- Guard every post-await setState with a mounted ref (set it back to true at the
  start of the mount effect, or StrictMode's double-invoke leaves it false), and
  clear BOTH timers — the feedback reset and the lock release — on unmount.

Rendering & styling
- Semantic tokens only: the Button primitive's own variants, bg-card / border for
  the surrounding surface, text-muted-foreground for hints and the size footer,
  text-destructive for a refused copy. No hex, no rgb(), no oklch().
- Menu content is w-auto min-w-72 max-w-80 — the primitive defaults to the
  trigger width, which is far too narrow for a labelled catalogue with hints.
- Each row: provider glyph, label, one muted hint line that WRAPS (an
  explanation cut in half explains nothing), and a trailing affordance icon —
  external-link for link rows, clipboard for copy rows, check when done.
- Footer line under a separator: prompt size (locale-free formatting, so server
  and client agree) plus what the budget trimmed. Hide it with showPromptSize.
- Icons are generic glyphs, not brand marks: brand artwork is trademarked and
  has its own usage rules, and every provider takes an `icon` node so swapping
  in the official SVG is a one-line change.
- Motion is decoration only: the chevron rotation is motion-reduce:
  transition-none and the confirmation check is motion-reduce:animate-none. The
  control works identically with animation off.
- Accessibility: focus-visible ring from the Button primitive, aria-hidden on
  every glyph, the label surviving as sr-only text in iconOnly mode, and one
  sr-only role="status" aria-live="polite" region that is EMPTY while idle and
  receives a single sentence per interaction ("Prompt copied. Paste it into
  Claude Desktop."). Never announce per keystroke of state.

Customization levers
- The catalogue is the main lever: providers is plain data. Add an internal
  assistant with { id, label, hint } and no href to get a copy row; give it an
  href builder to get a deeplink; re-cap any row with maxUrlLength.
- The prompt recipe: keep the default and change `instruction` for a per-page
  ask, or pass buildPrompt for a whole different brief (a review checklist, a
  bug report template, a translation request). Keep the function identity stable
  — an inline lambda re-runs the budget search on every render.
- Budgets: maxPromptLength trades completeness for reliability; drop it to ~2000
  if your audience is on proxies that clamp URLs, raise it when the assistants
  you list are known to accept long queries.
- Density: size="sm" plus iconOnly turns the control into a snippet-toolbar
  glyph; variant picks how loud the trigger is; align="end" keeps the menu
  inside a right-aligned toolbar.
- Confirmation: feedbackMs 0-ish makes it feel instant and silent, longer values
  suit a page where the new tab steals attention. Or set showPromptSize={false}
  and let onOpenIn drive your own toast.
- Empty-state policy: the control locks itself on empty content. If your surface
  prefers an always-clickable trigger that explains the emptiness inside the
  menu, drop the `empty` term from the lock and render a single disabled row.

Concepts

  • Provider catalogue as data — an assistant is a label plus one function, prompt -> URL; everything else about "hand this over" is identical everywhere, so adding a company-internal assistant is one object in an array rather than a fork of the component.
  • Prompt budget — the finished prompt is capped and only the content shrinks, because the instruction and the source link are what let the model ask for the rest; the surviving length is found by binary search so a replaced template can never silently overshoot.
  • Route degradation — the same row is an anchor, a copy button, or copy-plus-a-blank-tab depending on whether the prefilled URL fits that provider's limit; degrading loudly beats sending a question the provider quietly truncates.
  • Anchor over window.open — rendering the row as a real <a target="_blank"> keeps middle-click, modifier-click and "copy link address" working and never trips a popup blocker; the price is the prompt living in the DOM once per row, which is exactly what the budget pays for.
  • Confirmation window — one feedback slot keyed by provider id flips the trigger to "Opened in Claude" and reverts after feedbackMs, while a one-shot lock swallows the second fire of a double click so a menu row can never open two tabs.
  • Refusal is a state — a clipboard the browser will not write to produces a visible "Copy failed" row and one spoken sentence, not an unhandled rejection; the prompt stays on screen so the reader can still take it manually.

On This Page