Edit Message
In-place editor for a message that was already sent — it states how many replies the fork will drop, guards the draft on cancel, keeps it on a rejected resubmit, and never forks for unchanged text.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/edit-message.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "EditMessage" component (lucide-react
icons, an autosizing <textarea>, and the project's cn() helper). It edits a
message that was ALREADY SENT in a chat transcript, so saving is not a field
update — it cuts the thread at this message and asks the model again.
Contract
- forwardRef<HTMLDivElement>, props extend HTMLAttributes<HTMLDivElement> minus
children. Spread the rest onto the root.
- value: string — the message as the transcript holds it. The
component never mutates it.
- onResubmit(next: string): void | Promise<void>
— the fork. Return a Promise to get the
pending lock.
- followUpCount?: number (default 0) — how many messages follow this one; the
price of saving.
- editing? / defaultEditing? / onEditingChange?(editing) — controlled or
uncontrolled edit mode (one useControllable
-State-style hook, `undefined` = uncontrolled).
- children?: ReactNode — what the resting bubble renders (rendered
Markdown, mentions…); defaults to `value`
in a whitespace-pre-wrap span.
- trigger?: "hover-focus" | "always" | "none" (default "hover-focus").
- align?: "start" | "end" (default "end") — which side the bubble hugs.
- minRows? / maxRows? (2 / 12), maxLength?, disabled?
- confirmDiscard?: boolean (default true) — ask before dropping a dirty draft.
- resubmitUnchanged?: boolean (default false).
- labels?: Partial<Labels> — every string (edit, save, saveFork, cancel,
discard, keepEditing, discardQuestion, saving, empty, failed, busy, fork).
`fork` carries a "{count}" token that expands to "3 replies" / "1 reply".
Behavior — the fork is the feature
- Edit mode opens with draft = value. Reset the draft by adjusting state DURING
RENDER when `editing` flips true (not in an effect), so the field's very first
commit already holds the fresh text — a controlled parent may open the editor
at any time, and an effect-based reset paints one stale frame.
- When followUpCount > 0, render one muted notice inside the editor BEFORE the
user commits: "Saving forks the conversation here: the N replies after this
message are dropped and answered again", and label the button "Save &
resubmit". With followUpCount === 0 there is no notice and the button is just
"Save". Never surprise someone with a destroyed tail.
- Commit pipeline: trim → reject empty (inline error, focus back, editor stays
open) → if the trimmed text equals the current value and !resubmitUnchanged,
just close: onResubmit is NOT called. A byte-identical message would burn every
reply below it for nothing.
- Take a one-shot lock (a ref, not state) before calling onResubmit and release
it on every exit path. Forking twice is not an idempotent mistake: the second
fork throws away the reply the first one produced. Guard the double click, the
second ⌘↵, and the Enter-on-focused-button path with the same ref.
- Async: enter pending — the textarea becomes readOnly (never `disabled`: the
browser blurs a node the moment it is disabled, dropping focus on <body>
mid-save), both buttons go aria-disabled, a spinner rides the save button, and
an sr-only role="status" announces "Resubmitting the message".
- Rejection (async OR a synchronous throw) keeps the editor open with the draft
EXACTLY as typed and prints the reason (Error.message, else a fallback) with
role="alert". Do not roll back to `value`: the fork never happened, so the
draft is the only copy of what the user wrote — the opposite of a click-to-edit
field, where rolling back is the safe move.
- After a resolved save the parent is expected to write `next` into the
transcript and drop the tail; under controlled `editing` it must also set
editing=false — the component only asks.
Behavior — draft safety and keyboard
- Escape and Cancel run the same dirty guard: if the trimmed draft differs from
the trimmed value and confirmDiscard is on, swap the footer for a role="alert"
row ("Discard your changes to this message?" + Keep editing / Discard) instead
of discarding. Focus moves to "Keep editing" — the safe answer — and a second
Escape confirms the discard. Typing again un-arms the guard and clears a stale
error, because typing is a decision too. "Keep editing" must move focus back
into the field itself: the row it lives in unmounts on that same click, and a
button that unmounts under the caret drops focus on <body>.
- ⌘/Ctrl + Enter saves. Plain Enter inserts a newline: this is a message body,
and the send gesture already cost the user a fork once.
- Escape is preventDefault'ed AND stopPropagation'ed while the editor is open,
so a message being edited inside a Dialog does not close the Dialog (and lose
the draft) on the first press. While pending, Escape is swallowed with no
effect: the fork is already in flight.
- Ignore Enter/Escape while an IME composition is active (nativeEvent.isComposing).
- Focus follows the OPENING, not the mounting: keep a ref holding what the
previous commit rendered, and only focus the field when it goes false → true.
A transcript rendered with one message parked in edit mode must not yank focus
(and scroll the page) on hydration. On open, put the caret at the END rather
than selecting all — an already-sent message is refined, and select-all means
one keystroke destroys it. Keyboard/button exits hand focus back to the
trigger; keep a mounted ref so a save that resolves after unmount is a no-op.
- `disabled` (e.g. the answer is still streaming) blocks entering edit mode and
blocks saving with an inline reason. Express it with aria-disabled + a handler
guard, never the native attribute, so the control stays focusable and
explainable.
Rendering & styling
- Semantic tokens only: bg-muted for the resting bubble, bg-card + border for
the editor, ring-ring / border-ring for focus-within, text-muted-foreground
for the notice, hint and counter, text-destructive + border-destructive/40 +
bg-destructive/5 for errors and the discard row, bg-destructive with
text-background for the destructive button (there is no
--destructive-foreground token; a hardcoded white sits at 2.3:1 on dark red),
bg-primary/text-primary-foreground for the commit button. No hex, rgb() or
oklch().
- The editor takes the full column width even when the bubble is right-aligned
at 85%: editing a long message in a narrow bubble is the reason people give up
and retype the whole thing.
- The trigger's reveal is opacity-only and hidden ONLY behind
@media (hover:hover) and (pointer:fine); group-hover / group-focus-within beat
that rule on specificity. On touch it is always visible — there is no hover to
reveal it with.
- Autosizing: prefer CSS `field-sizing: content` when supported, else
height:auto → measure scrollHeight → clamp between minRows and maxRows and
switch to internal scrolling. Write the style imperatively after mount so SSR
cannot mismatch.
- The ⌘/Ctrl hint is read through useSyncExternalStore (server snapshot "Ctrl"),
never from `navigator` during render, and renders as <kbd> elements.
- Transitions are motion-reduce:transition-none and the spinner is
motion-reduce:animate-none — with animation off, every state still works.
- a11y: the field is labelled, aria-invalid on error, aria-describedby wires it
to the fork notice + the error + the keyboard hint; icons are aria-hidden; the
polite live region lives OUTSIDE the swapping subtree so the pending
announcement is not lost when the bubble unmounts.
Customization levers
- Fork copy: `labels.fork` with its {count} token, or drop followUpCount to 0 to
turn the whole warning off for transcripts that keep both branches.
- Guard strength: confirmDiscard={false} for a throwaway playground; or raise it
to a modal AlertDialog if a stray Escape is expensive in your product.
- Commit gesture: swap ⌘↵ for plain Enter (and Shift+Enter for newline) if your
messages are one-liners; the state machine does not care which key calls it.
- Trigger placement: trigger="none" and drive `editing` from your own message
action bar — you own focus restoration then, since there is no trigger left to
hand it back to. align="start" for assistant-side bubbles.
- Density: the editor's p-2 / gap-2 / h-8 buttons and the bubble's px-3.5 py-2
are one place each; minRows/maxRows decide how tall the field gets before it
scrolls.
- Bubble content: pass `children` to render Markdown, mentions or attachments in
the resting state while the editor still works on the raw `value`.
- Cancellation: the contract has no AbortSignal; if your resubmit is slow, hand
one to onResubmit and let Cancel abort it — then release the lock in the
rejection branch, which already keeps the draft.
- Optimistic forking: drop the tail in your store the moment onResubmit is
called and restore it in the catch; the component already keeps the draft, so
only your store needs the extra step.Concepts
- Editing forks, it does not patch — every reply below this message was written in answer to the old text, so a save cuts the thread here and hands the new text back for a fresh answer. The component's whole job is to make that consequence visible before the click, not to explain it afterwards.
- The cost is stated before it is paid —
followUpCountturns "N replies will be dropped and answered again" into part of the editor, and the commit button renames itself to "Save & resubmit". Zero follow-ups means zero warning: a warning that fires when nothing is at stake trains people to ignore the one that matters. - A no-op save is not a fork — text that trims back to the current value closes the editor without ever calling
onResubmit, so opening the editor by accident and pressing Save costs nothing. Forking for a byte-identical message would destroy the tail for free. - The draft is never destroyed silently — Cancel and Escape run through a dirty guard that asks first and puts focus on "Keep editing"; a second Escape is the shortcut for the destructive answer, and typing again un-arms the whole thing.
- A rejection keeps the draft — the opposite of a click-to-edit field. When the resubmit fails, the fork never happened, so the typed text is the only copy that exists: it stays on screen with the server's reason beside it instead of being rolled back to the old message.
- One-shot lock — the commit path is guarded by a ref taken before the call and released on every exit, because a double-fired fork is not a harmless duplicate: the second fork throws away the reply the first one just produced.
- Focus follows the opening, not the mounting — the field grabs focus when edit mode goes false → true, never on the first commit, so a transcript that hydrates with one message already in edit mode does not steal focus and scroll the page.
Message Search
In-conversation search that expands from an icon into a field — debounced dispatch, a hit counter walked with Enter and Shift+Enter, and a result list of role, time and an excerpt with the query emphasised.
Quote Reply
Quote-and-reply for a transcript — a floating Quote action over a text selection, a dismissible quote chip above the composer, and a whole-message fallback.