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…

"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. 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