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.
Loading preview…
- Dependencies
- lucide-react
- Registry dependencies
- button, dropdown-menu, utils
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/open-in-chat.jsonPrompt
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.
Request History
A playground's run rail — every request with the model and knobs it was sent with, pinned favourites kept above the churn, one click to load a run back into the editor, and a confirmation when that would overwrite your draft.
MCP Install Button
A one-click MCP install control — one server description, translated per client into a Claude Code command, a Claude Desktop or VS Code config or a Cursor deeplink, with copy feedback, placeholder env keys and a verify-connection check.