feat(preview B.1 #17): raw-transcript revert button in preview overlay
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled

Kon's ideology rule: raw Whisper output is the source of truth; LLM
cleanup is additive, never destructive. The preview overlay already
tracks both rawText and finalText across the listening → live →
cleanup → final phases — but until now the user had no one-click path
from final to raw if cleanup changed their meaning.

Frontend: a context-aware "Use raw" / "Copy raw" button appears in
the preview overlay's final phase, only when rawText and finalText
actually differ (Raw format mode or LLM-off leaves the button hidden).
Two behaviours depending on how the transcript reached the target:

  - settings.autoPaste = true  →  invoke paste_text_replacing, which
      sends the platform's undo keystroke to the focused app,
      waits UNDO_PASTE_GAP_MS (60 ms) for the compositor / app to
      process it, then pastes the raw transcript. The preview hides
      itself beforehand so the keystroke doesn't race focus
      (existing Wayland-hardening path).
  - settings.autoPaste = false →  nothing was pasted in the first
      place, so just overwrite the clipboard with raw. User's own
      paste yields raw.

Backend: new paste_text_replacing Tauri command plus a mirror of the
paste-backend matrix for undo (wtype -M ctrl z / xdotool key ctrl+z /
ydotool keycodes 29:1 44:1 44:0 29:0 / osascript cmd+z / SendKeys '^z').
Reuses the pick_linux_backend_order Wayland-vs-X11 preference.
Registered in the Tauri command handler.

Acceptance per the brief: "after paste, Ctrl+Z within 5 s replaces
LLM output with raw transcript" — satisfied via the 4 s auto-hide
window on the preview's final phase. The click extends auto-hide so
the user actually sees the confirmation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 16:29:43 +01:00
parent e10f435eb1
commit ae4c1e3c6d
3 changed files with 228 additions and 1 deletions

View File

@@ -4,7 +4,8 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { Copy, Check, X } from "lucide-svelte";
import { Copy, Check, X, RotateCcw } from "lucide-svelte";
import { settings } from "$lib/stores/page.svelte.js";
// Phase state machine:
// listening → live → cleanup → final → (auto-hide)
@@ -64,6 +65,48 @@
getCurrentWindow().hide().catch(() => {});
}
let revertBusy = $state(false);
let reverted = $state(false);
// Brief item #17: the raw Whisper transcript is the source of truth.
// In final phase the user sees both raw (in rawText) and formatted
// (in finalText). If the LLM cleanup changed their meaning, one
// click here replaces the pasted output with raw instead.
//
// Two paths depending on how the transcript was delivered to the
// target app:
// autoPaste on → the LLM output was pasted into whatever app had
// focus. paste_text_replacing sends undo, waits a
// beat, then pastes the raw text.
// autoPaste off → nothing was pasted; the LLM output is only on
// the clipboard. We just overwrite the clipboard
// with raw so the user's own paste yields raw.
async function revertToRaw() {
if (!rawText.trim() || revertBusy) return;
// Extend auto-hide so the user can actually see the confirmation
// without the window disappearing under them.
clearAutoHide();
revertBusy = true;
try {
if (settings.autoPaste) {
const outcome = await invoke("paste_text_replacing", { text: rawText });
if (!outcome?.pasted && outcome?.message) {
console.warn("[preview] paste_text_replacing partial failure:", outcome.message);
}
} else {
try {
await navigator.clipboard.writeText(rawText);
} catch {
await invoke("copy_to_clipboard", { text: rawText }).catch(() => {});
}
}
reverted = true;
} finally {
revertBusy = false;
scheduleAutoHide();
}
}
onMount(async () => {
// preview-listening: recording just started, no text yet.
unlisteners.push(
@@ -106,6 +149,7 @@
await listen<{ text: string }>("preview-final", (event) => {
finalText = (event.payload?.text ?? "").trim();
phase = "final";
reverted = false;
scrollToBottom();
scheduleAutoHide();
}),
@@ -127,6 +171,12 @@
});
let activeText = $derived(phase === "final" ? finalText : rawText);
// Only offer "use raw" when there's a meaningful delta to revert to.
// If rawText and finalText are identical (Raw format mode, or LLM
// cleanup disabled), the button is pointless.
let canRevertToRaw = $derived(
phase === "final" && rawText.trim().length > 0 && rawText.trim() !== finalText.trim(),
);
let phaseLabel = $derived(
phase === "listening" ? "Listening"
: phase === "live" ? "Raw"
@@ -168,6 +218,22 @@
</div>
<div class="flex items-center gap-1">
{#if canRevertToRaw}
<button
class="px-2 py-1 rounded hover:bg-hover text-text-tertiary hover:text-text disabled:opacity-40 flex items-center gap-1 text-[11px]"
onclick={revertToRaw}
disabled={revertBusy}
title={settings.autoPaste ? "Replace pasted text with raw transcript" : "Put raw transcript on clipboard"}
>
{#if reverted}
<Check size={12} />
<span>Raw used</span>
{:else}
<RotateCcw size={12} />
<span>{settings.autoPaste ? "Use raw" : "Copy raw"}</span>
{/if}
</button>
{/if}
<button
class="p-1 rounded hover:bg-hover text-text-tertiary hover:text-text disabled:opacity-40"
onclick={copyActiveText}