Share Conversation
A publish-a-link share dialog for a chat thread — server-issued URL, optimistic visibility and attachment writes with rollback, copy feedback and a destructive revoke confirm.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/share-conversation.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ShareConversation" component: the dialog
that turns a chat thread into a revocable public link. Ship it as two exports —
a headless-of-chrome <ShareConversationPanel> (the whole flow) and a
<ShareConversation> dialog wrapper built on the shadcn/Radix dialog primitive.
Icons from lucide-react. No data fetching inside: every server call is an
injected async callback.
Contract
- type ShareVisibility = "anyone" | "workspace"
- interface ShareSettings { visibility: ShareVisibility; includeAttachments: boolean }
- interface ShareLink extends ShareSettings { url: string; createdAtLabel?: string }
createdAtLabel is ALREADY formatted — the component never formats dates.
- interface ShareActivity { busy: boolean; confirming: boolean }
- ShareConversationPanel: forwardRef<HTMLDivElement>, extends
React.HTMLAttributes<HTMLDivElement> minus children and onCopy.
- conversationTitle: string (required), messageCount?: number,
attachmentCount?: number
- link?: ShareLink | null (controlled; null = nothing published),
defaultLink?: ShareLink | null (uncontrolled seed),
onLinkChange?: (link: ShareLink | null) => void
- onCreateLink: (settings: ShareSettings) => ShareLink | Promise<ShareLink>
- onUpdateSettings?: (settings, previous) => void | Promise<void>
- onRevokeLink?: (link: ShareLink) => void | Promise<void>
- onCopy?: (url: string) => void
- onActivityChange?: (activity: ShareActivity) => void
- defaultVisibility = "anyone", defaultIncludeAttachments = true,
createLabel = "Create public link", disabled = false
- ShareConversation: every panel prop except onActivityChange, plus
open / defaultOpen / onOpenChange, trigger?: ReactNode, title, description,
contentClassName.
- Phases are derived, never stored twice: "idle" | "creating" | "created" |
"revoking", mirrored onto the root as data-phase (and data-visibility) so a
host page can style around it.
Behavior — creating the link (the one action that cannot be optimistic)
- A URL can only be minted by the server, so onCreateLink RESOLVES with the
link and the UI shows a real pending state instead of inventing an address.
- One-shot lock: a ref flag is raised before the call and lowered in finally,
so a double click, an Enter repeat or a second Space can never start a second
request — disabled alone is not enough, the click can land in the same tick.
- On resolve, adopt the link the server returned INCLUDING its settings: a
workspace policy may downgrade "anyone" on the way in, and the radio must
show what was actually published, not what was asked for.
- On reject, stay private, print the reason inline (role="alert") and re-arm
the button. Never clear the user's draft settings.
- Guard every callback with a mounted ref that is SET in the effect body (not
only cleared in cleanup): StrictMode mounts → cleans up → mounts, and a flag
that is only ever set to false would freeze the live instance forever.
Behavior — settings (optimistic write + rollback)
- Visibility and include-attachments flip IMMEDIATELY, then call
onUpdateSettings(next, previous). The stored link is updated in the same
breath so a controlled parent stays in sync.
- A rejection rolls back BOTH the control and the stored link to `previous` and
states the reason. Silent rollback is worse than no rollback.
- Last-write-wins: stamp each write with an incrementing sequence number and
ignore any settle whose stamp is not the newest — a stale rejection must
never undo a newer choice.
- Before anything is published these controls are a local draft: no server
call, and the draft ships as the argument to onCreateLink.
- attachmentCount === 0 disables the switch, pins includeAttachments to false
in every callback, and swaps the hint for "no attachments".
Behavior — copy
- Read-only <input> holding the URL, selected on focus; copy writes with
navigator.clipboard and shows "Copied" for ~2s (one timer, cleared on
re-entry and on unmount).
- No Clipboard API (insecure context) or a rejected write: select the field and
say "Copy failed — the link is selected, press Ctrl/Cmd+C". Never fail
silently; never leave the user with a masked or truncated string to copy.
- A new URL resets the copy state — "Copied" must never describe a dead link.
Behavior — revoke (destructive, optimistic, reversible on failure)
- Two steps: "Revoke link" opens an inline confirm strip that spells out the
consequence ("breaks the link for everyone who has it; a new link gets a new
address"), with Keep / Revoke. Focus moves to the destructive button.
- Escape belongs to the confirm while it is open: preventDefault +
stopPropagation cancel the confirmation and must NOT close the host dialog.
- On confirm the row disappears immediately — a destructive action left looking
pending reads as "maybe it worked" — and a rejection restores the exact same
link object with "it is still live".
- Every transition destroys the control the user is standing on, so park the
next focus target in a ref and honour it in a post-commit effect: create →
Copy, create failed → Create, revoke started → the pending line (tabIndex=-1),
revoke settled → Create or back to Revoke.
Behavior — the dialog wrapper
- The link state lives in the WRAPPER, above the dialog content: Radix unmounts
content on close, so a panel that owned the link would forget one it had just
minted and let the user mint a second one on the next open.
- The panel reports { busy, confirming } upward; while either is true the
dialog refuses to close — Esc and outside clicks are preventDefault'd and the
close button is disabled. Store the report only when the values actually
changed, otherwise the callback loops.
- onOpenAutoFocus is redirected to the content element instead of the first
radio, so the first thing announced is what is being shared.
Rendering & styling
- Semantic tokens only: bg-card, bg-muted/40, bg-primary, bg-primary/5,
border-primary/40, text-primary, text-muted-foreground, bg-destructive/10,
border-destructive/30, text-destructive, border-input, ring-ring/50. No hex,
no rgb(), no oklch().
- Merge every className through cn(); spread the remaining native props on the
panel root.
- Visibility is a hand-rolled role="radiogroup" with roving tabindex — one tab
stop, arrows and Home/End both move and select, each option is a
role="radio" button carrying label + hint + a token-drawn dot.
- Attachments is a role="switch" button (track h-5 w-9 p-0.5, knob size-4 with
translate-x-4) — not a checkbox: the label is a state, not a value.
- Motion is decoration: the published row enters with
motion-safe:animate-in fade-in/slide-in and the spinners are
animate-spin motion-reduce:animate-none — under reduced motion everything
still works, it just stops moving.
- One sr-only aria-live="polite" region for transitions with no visible text
(created / copied / revoked); failures announce themselves through their own
role="alert" so nothing is spoken twice.
Customization levers
- Visibility options: the option list is one array of { value, label, hint,
icon } — add "Anyone in the org", "Password required", or cut it to one
option and the radiogroup collapses gracefully.
- Extra controls: an expiry select, a "hide my name" switch or a domain
allow-list all belong next to the attachments row and route through the same
applySettings(next) optimistic path — no new state machine needed.
- Copy feedback window (2000ms) and the create/revoke pending copy are single
constants; the whole copy row can be swapped for your own CopyButton.
- Destructive confirm: keep it inline (it stays inside the dialog) or lift it
into an AlertDialog / type-to-confirm — only the two-step gate is load
bearing.
- Chrome: render the panel alone inside a sheet, a settings page or a popover;
the dialog wrapper is 60 lines and nothing in the panel depends on it.
- Density: the root's gap-4 / text-sm and the rows' px-3 py-2.5 are the only
spacing knobs; pass className to tighten them.
- Close policy: relax the "held" rule to busy-only if your product prefers a
dialog that can always be dismissed — the mounted-ref guards already make an
in-flight promise safe to abandon.Concepts
- Server-issued value — visibility and attachments can be flipped optimistically because the client already knows the answer; a URL cannot, so
onCreateLinkresolves with the link and the button shows a real pending state instead of inventing an address the server might not agree to. - Optimistic write with rollback — the control moves first and the request follows; a rejection restores both the control and the stored link to the exact previous values and prints why, because a switch that silently springs back reads as a broken widget.
- Newest-stamp-wins — every settings write carries an incrementing stamp and only the newest one may act on its result, so a slow rejection from two clicks ago can never undo the choice the user just made.
- One-shot lock — a ref raised before the call and lowered in
finallyis what actually prevents a duplicate link;disabledalone loses to a double click that lands in the same tick, and the cost of losing that race is a second live URL nobody can see. - Destructive confirm owns Escape — while the revoke confirmation is open, Escape cancels the confirmation instead of closing the surface underneath it: one key, one job, and the user never dismisses a dialog they were only trying to back out of.
- Held surface — a request in flight (or an open confirmation) blocks Esc, outside clicks and the close button, because unmounting the panel mid-promise leaves the user unable to tell whether a public link now exists.
Response Feedback
Thumbs up or down on one AI answer, with a follow-up panel of reason chips and a comment, and exactly one submission per message — locked into a receipt when it lands.
Conversation Export
An export menu for a whole conversation — scope radio, a size estimate on every format, and a row that spins, ticks or turns into Retry without the menu ever closing under it.