Skip to content

feat(media): let a host extend Undo, and add the Redo it never had - #354

Merged
garrity-miepub merged 5 commits into
mieweb:mainfrom
jlocala1:feat/media-editor-host-undo
Aug 4, 2026
Merged

feat(media): let a host extend Undo, and add the Redo it never had#354
garrity-miepub merged 5 commits into
mieweb:mainfrom
jlocala1:feat/media-editor-host-undo

Conversation

@jlocala1

@jlocala1 jlocala1 commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

MediaEditor's undo is word-level. That is the right grain for typing and the wrong one for a host that also versions the document — undoing a batch change one word at a time is not undoing it.

A host that wants both ends up putting a second Undo somewhere else on screen, which is exactly what happened in PulseClip: a version-level Undo/Redo pair in the page header, and the editor's own Undo (3) down in the stats bar. Two Undos, no way to tell which one you want.

What this adds

The existing Undo learns to hand over. onUndoBeyond fires when Undo is pressed and the editor's own stack is empty; canUndoBeyond keeps the control alive at depth 0 instead of letting it vanish. Word-level steps are always spent first, so for a host that passes neither prop nothing changes.

Redo is new, and host-only. The editor cannot implement it — undo() pops and discards, so there is nothing to redo from. Rather than rework that stack, the button renders only when onRedo is given and is disabled unless canRedo.

Both carry their shortcut in a <kbd>, the way Del ⌫ / Cut ⌘X / Paste ⌘V already do. This is also the first time Undo advertises ⌘Z anywhere but a tooltip.

<MediaEditor
  
  canUndoBeyond={canUndoVersion}
  onUndoBeyond={() => restore(historyIndex - 1)}
  undoBeyondLabel={checkpoints[historyIndex - 1]?.label}
  canRedo={canRedoVersion}
  onRedo={() => restore(historyIndex + 1)}
  redoLabel={checkpoints[historyIndex + 1]?.label}
/>

The labels are optional and feed the tooltip, so Undo can say back to "Trim to 45 seconds" rather than just "Undo".

Deliberately generic

No "version" or "checkpoint" vocabulary in the props — the library should not learn a host's model. It only knows that someone else may be able to undo further, which is true of any host with its own history stack.

Testing

Five new tests: no Undo when neither side has one · host-only Undo before any edit exists · the editor spending its own history before handing over · Redo appearing only when provided · Redo disabled when the host has nothing forward.

420/420 across the suite. pnpm lint, pnpm typecheck and pnpm format all clean.

Consumer

Used by mieweb/pulseclip#33, which versions every edit — the agent's and the user's — on one timeline. That is where the second Undo came from, and this removes it.

MediaEditor's undo is word-level, which is the right grain for typing and the
wrong one for a host that also versions the document: undoing a batch change one
word at a time is not undoing it. A host that wants both ends up putting a
second Undo somewhere else on screen, and then there are two Undos and no way to
tell which one you want.

So the existing control learns to hand over. `onUndoBeyond` is called when Undo
is pressed and the editor's own stack is empty, and `canUndoBeyond` keeps the
control alive at depth 0 instead of letting it disappear. Word-level steps are
always spent first, so nothing about the existing behaviour changes for a host
that passes neither.

Redo is new and host-only. The editor cannot implement it: `undo()` pops and
discards, so there is nothing to redo from. Rather than rework that stack, the
button renders only when `onRedo` is provided and is disabled unless `canRedo`.

Both now carry their shortcut in a `kbd` the same way Del, Cut and Paste already
do, which is also the first time Undo advertised ⌘Z anywhere but a tooltip.

Five tests: no Undo when neither side has one, host-only Undo before any edit,
the editor spending its own history before handing over, Redo appearing only
when provided, and Redo disabled when the host has nothing forward.
Copilot AI lite review requested due to automatic review settings August 4, 2026 18:53
jlocala1 added a commit to jlocala1/pulseclip that referenced this pull request Aug 4, 2026
The version Undo/Redo sat in the page header while the editor kept its own
word-level Undo down in the stats bar. Two Undos in two places, and the one you
wanted depended on what you had just done — which is not something anyone should
have to reason about.

They are one control now, in the editor toolbar, using the host hooks added in
mieweb/ui#354: word-level steps are spent first and the same button carries on
into the version timeline once they run out. Exactly the layering ⌘Z already
had, now visible instead of implied.

Redo moves there too, and both show their shortcut in a kbd like Del, Cut and
Paste do. The header keeps only the History toggle, which opens a panel rather
than performing an edit.
jlocala1 added a commit to jlocala1/pulseclip that referenced this pull request Aug 4, 2026
The consolidated Undo needs the MediaEditor props added in mieweb/ui#354.
Reverts to a mieweb/ui commit once that merges — same temporary arrangement
this branch already uses for the media components.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extends MediaEditor’s editing toolbar to support host-level history controls by keeping Undo available after the editor’s internal undo stack is exhausted (delegating via onUndoBeyond), and adds an optional host-provided Redo button.

Changes:

  • Added host-extension props for Undo-beyond (onUndoBeyond, canUndoBeyond, undoBeyondLabel) and host-only Redo (onRedo, canRedo, redoLabel).
  • Updated the toolbar to keep Undo visible at depth 0 when the host can undo further, improve tooltips/ARIA labels, and render a Redo button only when provided.
  • Added tests covering host-only Undo, editor-first Undo behavior, Redo rendering/disabled behavior, and absence of controls when unavailable.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src/components/MediaEditor/MediaEditor.tsx Adds host-level undo/redo props and updates toolbar rendering/labels to support host-extensible Undo and optional Redo.
src/components/MediaEditor/MediaEditor.test.tsx Adds coverage for host-extensible Undo behavior and host-only Redo rendering/disabled states.
Suppressed comments (1)

src/components/MediaEditor/MediaEditor.tsx:1417

  • Undo beyond only works via the button click right now: the keyboard handler always calls the editor’s undo() (which no-ops when undoStack is empty) and never invokes onUndoBeyond, so keyboard users can’t reach host-level undo.
                    // Word-level first; the host only gets it once the
                    // editor's own history is spent.
                    onClick={undoStack.length > 0 ? undo : onUndoBeyond}
                    aria-label={

Comment thread src/components/MediaEditor/MediaEditor.tsx Outdated
Comment thread src/components/MediaEditor/MediaEditor.tsx
jlocala1 added a commit to jlocala1/pulseclip that referenced this pull request Aug 4, 2026
The version Undo/Redo sat in the page header while the editor kept its own
word-level Undo down in the stats bar. Two Undos in two places, and the one you
wanted depended on what you had just done — which is not something anyone should
have to reason about.

They are one control now, in the editor toolbar, using the host hooks added in
mieweb/ui#354: word-level steps are spent first and the same button carries on
into the version timeline once they run out. Exactly the layering ⌘Z already
had, now visible instead of implied.

Redo moves there too, and both show their shortcut in a kbd like Del, Cut and
Paste do. The header keeps only the History toggle, which opens a panel rather
than performing an edit.
jlocala1 added a commit to jlocala1/pulseclip that referenced this pull request Aug 4, 2026
The consolidated Undo needs the MediaEditor props from mieweb/ui#354, which is
not merged yet. Both this URL and the pin revert to mieweb/ui once it lands.
Undo vanished at the end of history while Redo merely greyed out, so the pair
followed two different rules. Worse, a host that passes onRedo got the Redo
button unconditionally: on a document with no history at all that left a lone,
permanently grey Redo and no Undo beside it, which reads as broken rather than
inapplicable.

Whichever of the two cannot act is now disabled rather than removed, and both
are absent only when neither has anything to offer. The disabled Undo also
stops advertising a target it cannot reach — with canUndoBeyond false it says
"Nothing to undo" instead of naming the label it was handed.
Copilot AI review requested due to automatic review settings August 4, 2026 19:11
jlocala1 added a commit to jlocala1/pulseclip that referenced this pull request Aug 4, 2026
A permanently greyed Redo on a pulse that has never been edited reads as a
broken button rather than an inapplicable one. Gated on the same condition as
the History button, and paired with mieweb/ui#354 keeping Undo and Redo
on screen together so neither disappears out from under the other.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (3)

src/components/MediaEditor/MediaEditor.tsx:1422

  • Undo can become enabled but do nothing when canUndoBeyond is true but onUndoBeyond is not provided. The current logic treats canUndoBeyond as sufficient to enable the button and to advertise undoBeyondLabel, but the click handler will be undefined in that case.
                    disabled={undoStack.length === 0 && !canUndoBeyond}
                    // Word-level first; the host only gets it once the
                    // editor's own history is spent.
                    onClick={undoStack.length > 0 ? undo : onUndoBeyond}
                    aria-label={

src/components/MediaEditor/MediaEditor.tsx:89

  • The new onUndoBeyond behavior is only wired to the toolbar button click. The keyboard shortcut path still always calls the editor’s destructive undo() (MediaEditor.tsx:1065-1067), so Cmd/Ctrl+Z will not “hand over” to onUndoBeyond when the editor stack is empty, which contradicts the prop docs/PR description.
   * change one word at a time is not undoing it. When a host provides this, the
   * Undo control stays available once the editor's own stack is empty and hands
   * over instead of going dead, so there is one Undo rather than two.
   */
  onUndoBeyond?: () => void;

src/components/MediaEditor/MediaEditor.test.tsx:301

  • The new tests cover click behavior for host-extensible Undo/Redo, but there’s no coverage for the documented keyboard Undo path (Cmd/Ctrl+Z) handing over to onUndoBeyond when the editor stack is empty. Adding a keydown test would prevent regressions once the keyboard path is updated.
  it('spends its own history before handing over to the host', () => {
    const onUndoBeyond = vi.fn();
    render(
      <MediaEditor
        src="clip.mp3"

jlocala1 added a commit to jlocala1/pulseclip that referenced this pull request Aug 4, 2026
A permanently greyed Redo on a pulse that has never been edited reads as a
broken button rather than an inapplicable one. Gated on the same condition as
the History button, and paired with mieweb/ui#354 keeping Undo and Redo
on screen together so neither disappears out from under the other.
…hrome eats

Two things made Redo look broken.

The editor had no word-level redo at all: undo() popped and discarded. So with
one word deleted you got a live "Undo (1)" next to a dead Redo, and taking the
undo back was impossible. useTranscriptEdits now keeps a redo stack alongside
the undo one — speed snapshots included, since they already rode along with
undo — cleared by any new edit and by a transcript change, the same way the
undo stack is. Redo spends the editor's own steps first and only then calls a
host's onRedo, mirroring how Undo already hands over.

And the shortcut was wrong for the platform. ⌘Y is Chrome's own History on
macOS, so pressing it opened a browser tab instead of redoing. The button now
advertises ⇧⌘Z. Hosts may still accept ⌘Y for people arriving from Windows,
but nothing should tell a Mac user to press it.

Undo and Redo are now rendered under one condition and each disabled on its own
ability, so they cannot appear, vanish, or shift position independently.

Four tests: the word-level round trip, the editor spending its own redo before
the host's, Redo present-but-inert beside a live Undo, and the pair staying
together at both ends of the history.
Copilot AI review requested due to automatic review settings August 4, 2026 19:28
jlocala1 added a commit to jlocala1/pulseclip that referenced this pull request Aug 4, 2026
⌘Y is Chrome's History shortcut on macOS, so pressing it opened a browser tab
rather than redoing. Still accepted for anyone arriving from Windows, but no
longer advertised. Picks up the word-level redo added in mieweb/ui#354, which
is what makes Redo live after an ordinary word undo.
jlocala1 added a commit to jlocala1/pulseclip that referenced this pull request Aug 4, 2026
⌘Y is Chrome's History shortcut on macOS, so pressing it opened a browser tab
rather than redoing. Still accepted for anyone arriving from Windows, but no
longer advertised. Picks up the word-level redo added in mieweb/ui#354, which
is what makes Redo live after an ordinary word undo.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Suppressed comments (5)

src/components/MediaEditor/MediaEditor.tsx:1438

  • The Undo button’s aria-label/title logic uses canUndoBeyond even if onUndoBeyond is missing. After gating the actual enabled state, this can still present a host-undo tooltip/label for a control that cannot do anything. Use the gated canUndoBeyondEffective when deciding whether to show host-specific labeling.
                    aria-label={
                      undoStack.length > 0
                        ? `Undo (${undoStack.length} available)`
                        : canUndoBeyond && undoBeyondLabel
                          ? `Undo: ${undoBeyondLabel}`

src/components/MediaEditor/MediaEditor.tsx:1467

  • Similarly, the Redo button’s aria-label/title uses canRedo even if onRedo is not provided, which can advertise a redo target when the button cannot actually perform a host redo. Use the gated canRedoBeyondEffective for host-specific labeling.
                    aria-label={
                      !canRedoWords && canRedo && redoLabel
                        ? `Redo: ${redoLabel}`
                        : 'Redo'
                    }

src/components/MediaEditor/MediaEditor.tsx:350

  • canUndoBeyond/canRedo can enable the buttons even when the corresponding handler (onUndoBeyond/onRedo) is not provided. In that case the button becomes clickable but onClick is undefined, so clicking does nothing. Gate the host capability booleans by the presence of the handler when computing canUndoAnything / canRedoAnything (and reuse those gated values elsewhere).

This issue also appears in the following locations of the same file:

  • line 1434
  • line 1463
    const canUndoAnything = undoStack.length > 0 || canUndoBeyond;
    const canRedoAnything = canRedoWords || canRedo;
    const showUndoRedo = canUndoAnything || canRedoAnything || !!onRedo;

src/hooks/useTranscriptEdits.ts:578

  • redo() forces setHasEdits(true), but redo can legitimately land back on the baseline state (e.g. when the initial state already had edits and the redone step returns to the untouched baseline, especially for speed markers/default speed). This makes hasEdits inaccurate after redo. Recompute hasEdits the same way undo() does, based on the state being restored.
    // Redoing always lands on a state that was reached by editing, so it is by
    // definition not the untouched original.
    setHasEdits(true);

src/hooks/useTranscriptEdits.ts:474

  • Now that redo history exists, any operation that invalidates/drops undo history should also clear redoStack/speedRedoStack to avoid redoing into snapshots captured under an old model (e.g. setSilenceThresholds currently drops undoStack/speedUndoStack but leaves redo history intact).
  // Helper to save current state to undo stack before making changes
  const pushUndo = useCallback(() => {
    setUndoStack((prev) => [...prev, editedWords]);
    setSpeedUndoStack((prev) => [...prev, { speedMarkers, defaultSpeed }]);
    // A fresh edit abandons the redo branch, as it does in every editor.
    setRedoStack([]);
    setSpeedRedoStack([]);
  }, [editedWords, speedMarkers, defaultSpeed]);

… no handler

Both from Copilot review on mieweb#354.

The Redo button advertised a shortcut the component did not implement. It
worked in PulseClip only because that app wires its own document-level
listener, so for any other host the hint was a lie. MediaEditor already
handled ⌘Z for undo; it now handles ⇧⌘Z beside it, spending word-level steps
first and then the host's, exactly as the button does. ⌘Y is accepted for
people arriving from Windows but stays unadvertised — on macOS it is Chrome's
own History shortcut and the browser wins.

And a host could set canUndoBeyond or canRedo without the matching handler,
which rendered an enabled button whose onClick was undefined: a control that
looks live and silently does nothing. Both capability flags now require their
handler to count.

Three tests: the disabled-without-handler case, the advertised shortcut
actually firing, and the keyboard path spending word-level redo before the
host's.
Copilot AI review requested due to automatic review settings August 4, 2026 21:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Suppressed comments (3)

src/components/MediaEditor/MediaEditor.tsx:1082

  • The Cmd/Ctrl+Z keyboard handler always calls the editor’s undo() and never falls through to onUndoBeyond when the editor undo stack is empty. This makes host-extensible undo work via the button click but not via the advertised shortcut, so hosts still need to add their own listener to get version-level undo from the keyboard.
        } else if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) {
          undo();
          handled = true;
        } else if (

src/hooks/useTranscriptEdits.ts:578

  • redo() unconditionally sets hasEdits to true, but it can legally redo to the baseline/original state (e.g., a user edits back to the original text/speed, then undoes, then redoes). In that case the UI will incorrectly report there are edits. Consider computing hasEdits the same way undo() does (compare against the silence-inserted baseline + speed snapshot).
    // Redoing always lands on a state that was reached by editing, so it is by
    // definition not the untouched original.
    setHasEdits(true);

src/hooks/useTranscriptEdits.ts:294

  • The PR description says Redo is “host-only” and that the editor “cannot implement it”, but this change set adds a word-level redo stack in useTranscriptEdits and MediaEditor uses it (e.g. redo/canRedo) even when onRedo is not provided. Please update the PR description (and any external docs/release notes) to reflect the new behavior: the editor now supports word-level redo, while onRedo/canRedo are for host-level redo beyond the editor’s own history.
  /** Step forward again after an undo. Cleared by any new edit. */
  redo: () => void;
  /** Whether there is anything to redo */
  canRedo: boolean;

Copilot AI review requested due to automatic review settings August 4, 2026 23:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Suppressed comments (3)

src/components/MediaEditor/MediaEditor.tsx:1464

  • Undo’s aria-label/title logic uses canUndoBeyond to describe a host undo target even when onUndoBeyond is missing. In that misconfigured host case the button is correctly disabled, but the tooltip/label can still claim it would undo to a specific label, which is inconsistent with the component’s own “handler required” rule. Gate the label/tooltip text on canUndoBeyond && !!onUndoBeyond (not just canUndoBeyond).
                      undoStack.length > 0
                        ? `Undo (⌘Z) — ${undoStack.length} step${undoStack.length === 1 ? '' : 's'}`
                        : canUndoBeyond
                          ? undoBeyondLabel
                            ? `Undo (⌘Z) — back to "${undoBeyondLabel}"`

src/components/MediaEditor/MediaEditor.tsx:1485

  • Redo’s aria-label/title logic also uses canRedo/redoLabel even when onRedo is missing. The button is disabled in that case, but the tooltip/label can still suggest a redo target exists. Gate these strings on canRedo && !!onRedo so the UI copy matches the actionable capability the component actually exposes.
                    aria-label={
                      !canRedoWords && canRedo && redoLabel
                        ? `Redo: ${redoLabel}`
                        : 'Redo'
                    }

src/components/MediaEditor/MediaEditor.tsx:1084

  • The ⌘Z/ctrl+Z keyboard handler always calls the editor’s word-level undo() and never falls through to onUndoBeyond when the editor undo stack is empty. That means host-only undo-beyond works via the button but not via the advertised shortcut, and it also diverges from the PR’s stated behavior (hand over once the editor stack is exhausted). Update the keybinding branch to call onUndoBeyond() when undoStack.length === 0 and canUndoBeyond && onUndoBeyond are true (and include the new dependencies in the useCallback dependency list).

This issue also appears in the following locations of the same file:

  • line 1460
  • line 1481
        } else if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) {
          undo();
          handled = true;
        } else if (
          (e.metaKey || e.ctrlKey) &&

@garrity-miepub
garrity-miepub merged commit 6ae6e79 into mieweb:main Aug 4, 2026
8 checks passed
jlocala1 added a commit to jlocala1/pulseclip that referenced this pull request Aug 5, 2026
mieweb/ui#354 merged, so the MediaEditor undo/redo props this branch needed are
on main. Pinned at the merge commit rather than main's head — that is exactly
main at the moment #354 landed, without pulling in seven unrelated commits.

Reverts the temporary fork pin. Nothing on a mieweb branch should point at a
personal fork: it is fragile if the branch is ever deleted, and it means anyone
cloning pulseclip fetches ui from somewhere other than the org.
jlocala1 added a commit to jlocala1/pulseclip that referenced this pull request Aug 5, 2026
mieweb/ui#354 merged, so this branch no longer needs the temporary fork pin.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants