feat(preview B.1 #17): raw-transcript revert button in preview overlay
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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user