Skip to content

Commit f7794e5

Browse files
committed
feat: add history tab and optional clipboard copying
History: - New History tab with persistent storage of all dictations - Each entry stores final text, raw transcription, style, app name, timestamp - Expandable entries with copy/remove actions and raw transcription diff - Thread-safe JSON persistence with async lock (max 500 entries) Clipboard: - New "Copy to clipboard" toggle in Preferences (default: off) - Clipboard write only happens when explicitly enabled or when auto-paste needs it - Status messages updated to reference history instead of clipboard
1 parent e63f7fa commit f7794e5

9 files changed

Lines changed: 402 additions & 17 deletions

File tree

src/main/defaults.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export function createDefaultSettings(): AppSettings {
3939
cloudLanguage: 'de',
4040
openaiApiKeyEncrypted: '',
4141
autoPaste: true,
42+
copyToClipboard: false,
4243
showOverlay: false,
4344
launchAtLogin: false,
4445
setupComplete: false,

src/main/dictation.ts

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -236,12 +236,18 @@ export async function processDictationAudio({
236236

237237
console.log('[openwhisp:dictation] final', { rewriteFallback: usedRewriteFallback, text: finalText });
238238

239-
clipboard.writeText(finalText);
239+
if (settings.copyToClipboard) {
240+
clipboard.writeText(finalText);
241+
}
240242

241243
let pasted = false;
242244
let focusInfo = targetFocus;
243245

244246
if (settings.autoPaste) {
247+
if (!settings.copyToClipboard) {
248+
clipboard.writeText(finalText);
249+
}
250+
245251
setStatus({
246252
phase: 'pasting',
247253
title: 'Pasting',
@@ -254,18 +260,19 @@ export async function processDictationAudio({
254260
pasted = await triggerPaste(focusInfo).catch(() => false);
255261
}
256262

263+
const doneTitle = pasted ? 'Pasted' : 'Done';
264+
const doneDetail = usedRewriteFallback
265+
? pasted
266+
? 'The raw transcription was pasted because the rewrite model was unavailable.'
267+
: 'The raw transcription was saved to history because the rewrite model was unavailable.'
268+
: pasted
269+
? 'The refined text was pasted into the active app.'
270+
: 'The refined text was saved to history.';
271+
257272
setStatus({
258273
phase: 'done',
259-
title: pasted ? 'Pasted' : 'Copied',
260-
detail: usedRewriteFallback
261-
? pasted
262-
? 'The raw transcription was pasted because the rewrite model was unavailable.'
263-
: 'The raw transcription is on the clipboard because the rewrite model was unavailable.'
264-
: pasted
265-
? 'The refined text was pasted into the active app.'
266-
: focusInfo?.appName
267-
? `OpenWhisp copied the text, but it could not paste into ${focusInfo.appName}.`
268-
: 'The refined text is on the clipboard.',
274+
title: doneTitle,
275+
detail: doneDetail,
269276
preview: finalText,
270277
rawText,
271278
});
@@ -280,6 +287,8 @@ export async function processDictationAudio({
280287
pasted,
281288
focusInfo,
282289
transcriptionSource,
290+
styleMode: resolved.styleMode,
291+
enhancementLevel: resolved.enhancementLevel,
283292
};
284293
}
285294

src/main/history.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { app } from 'electron';
2+
import { randomUUID } from 'node:crypto';
3+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
4+
import path from 'node:path';
5+
6+
import type { EnhancementLevel, HistoryEntry, StyleMode } from '../shared/types';
7+
8+
const HISTORY_FILE = 'history.json';
9+
const MAX_HISTORY_ENTRIES = 500;
10+
11+
const locks = new Map<string, Promise<void>>();
12+
13+
async function withLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
14+
const previous = locks.get(key) ?? Promise.resolve();
15+
let resolve: () => void;
16+
const current = new Promise<void>((r) => { resolve = r; });
17+
locks.set(key, current);
18+
await previous;
19+
try {
20+
return await fn();
21+
} finally {
22+
resolve!();
23+
}
24+
}
25+
26+
function getHistoryPath(): string {
27+
return path.join(app.getPath('userData'), HISTORY_FILE);
28+
}
29+
30+
export async function loadHistory(): Promise<HistoryEntry[]> {
31+
try {
32+
const raw = await readFile(getHistoryPath(), 'utf8');
33+
return JSON.parse(raw) as HistoryEntry[];
34+
} catch {
35+
return [];
36+
}
37+
}
38+
39+
async function saveHistory(entries: HistoryEntry[]): Promise<void> {
40+
const filePath = getHistoryPath();
41+
await mkdir(path.dirname(filePath), { recursive: true });
42+
await writeFile(filePath, `${JSON.stringify(entries, null, 2)}\n`, 'utf8');
43+
}
44+
45+
export interface AddHistoryInput {
46+
rawText: string;
47+
finalText: string;
48+
transcriptionSource: 'cloud' | 'local';
49+
styleMode: StyleMode;
50+
enhancementLevel: EnhancementLevel;
51+
appName?: string;
52+
}
53+
54+
export async function addHistoryEntry(input: AddHistoryInput): Promise<HistoryEntry[]> {
55+
return withLock('history', async () => {
56+
const entries = await loadHistory();
57+
58+
entries.unshift({
59+
id: randomUUID(),
60+
rawText: input.rawText,
61+
finalText: input.finalText,
62+
transcriptionSource: input.transcriptionSource,
63+
styleMode: input.styleMode,
64+
enhancementLevel: input.enhancementLevel,
65+
appName: input.appName,
66+
createdAt: new Date().toISOString(),
67+
});
68+
69+
if (entries.length > MAX_HISTORY_ENTRIES) {
70+
entries.length = MAX_HISTORY_ENTRIES;
71+
}
72+
73+
await saveHistory(entries);
74+
return entries;
75+
});
76+
}
77+
78+
export async function removeHistoryEntry(id: string): Promise<HistoryEntry[]> {
79+
return withLock('history', async () => {
80+
const entries = await loadHistory();
81+
const filtered = entries.filter((e) => e.id !== id);
82+
await saveHistory(filtered);
83+
return filtered;
84+
});
85+
}
86+
87+
export async function clearHistory(): Promise<HistoryEntry[]> {
88+
return withLock('history', async () => {
89+
await saveHistory([]);
90+
return [];
91+
});
92+
}

src/main/ipc.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { testCloudConnection } from './cloud-transcription';
1313
import { loadAppRules, addAppRule, removeAppRule, updateAppRule } from './app-rules';
1414
import { loadDictionary, addDictionaryEntry, removeDictionaryEntry, loadCorrections, addCorrection, removeCorrection } from './dictionary';
1515
import { processDictationAudio } from './dictation';
16+
import { loadHistory, addHistoryEntry, removeHistoryEntry, clearHistory } from './history';
1617
import { applyLaunchAtLogin } from './login-item';
1718
import { pullOllamaModel, listOllamaModels, isOllamaReachable, ensureOllamaRunning } from './ollama';
1819
import { getFocusInfo } from './native-helper';
@@ -56,6 +57,7 @@ export function registerIpcHandlers(dependencies: IpcDependencies): void {
5657
dictionary: await loadDictionary(),
5758
corrections: await loadCorrections(),
5859
appRules: await loadAppRules(),
60+
history: await loadHistory(),
5961
status: dependencies.getStatus(),
6062
};
6163
};
@@ -207,17 +209,37 @@ export function registerIpcHandlers(dependencies: IpcDependencies): void {
207209

208210
ipcMain.handle('dictation:captureTarget', async () => getFocusInfo());
209211

210-
ipcMain.handle('dictation:processAudio', async (_event, request: DictationRequest) =>
211-
processDictationAudio({
212+
ipcMain.handle('dictation:processAudio', async (_event, request: DictationRequest) => {
213+
const result = await processDictationAudio({
212214
wavBase64: request.wavBase64,
213215
settings: dependencies.getSettings(),
214216
dictionary: await loadDictionary(),
215217
corrections: await loadCorrections(),
216218
appRules: await loadAppRules(),
217219
targetFocus: request.targetFocus,
218220
setStatus: dependencies.setStatus,
219-
}),
220-
);
221+
});
222+
223+
await addHistoryEntry({
224+
rawText: result.rawText,
225+
finalText: result.finalText,
226+
transcriptionSource: result.transcriptionSource,
227+
styleMode: result.styleMode,
228+
enhancementLevel: result.enhancementLevel,
229+
appName: result.focusInfo?.appName,
230+
}).catch((error) => {
231+
console.warn('[openwhisp] Failed to save history entry:', error instanceof Error ? error.message : error);
232+
});
233+
234+
return result;
235+
});
236+
237+
ipcMain.handle('history:remove', async (_event, id: unknown) => {
238+
if (typeof id !== 'string') throw new Error('Expected string for history entry id.');
239+
return removeHistoryEntry(id);
240+
});
241+
242+
ipcMain.handle('history:clear', async () => clearHistory());
221243

222244
ipcMain.on('dictation:status', (_event, status: AppStatus) => {
223245
dependencies.setStatus(status);

src/preload/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
DictionaryEntry,
99
DictationRequest,
1010
FocusInfo,
11+
HistoryEntry,
1112
HotkeyEvent,
1213
UpdateSettingsInput,
1314
} from '../shared/types';
@@ -44,6 +45,10 @@ const api = {
4445
ipcRenderer.invoke('appRules:remove', appIdentifier) as Promise<AppRule[]>,
4546
updateAppRule: (appIdentifier: string, styleMode: string, enhancementLevel: string) =>
4647
ipcRenderer.invoke('appRules:update', appIdentifier, styleMode, enhancementLevel) as Promise<AppRule[]>,
48+
removeHistoryEntry: (id: string) =>
49+
ipcRenderer.invoke('history:remove', id) as Promise<HistoryEntry[]>,
50+
clearHistory: () =>
51+
ipcRenderer.invoke('history:clear') as Promise<HistoryEntry[]>,
4752
captureFocusTarget: () =>
4853
ipcRenderer.invoke('dictation:captureTarget') as Promise<FocusInfo>,
4954
processAudio: (request: DictationRequest) =>

src/renderer/App.tsx

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useEffect, useRef, useState } from 'react';
22
import { HugeiconsIcon } from '@hugeicons/react';
3-
import { Home01Icon, PaintBrush01Icon, CubeIcon, Settings01Icon, BookOpen01Icon } from '@hugeicons/core-free-icons';
3+
import { Home01Icon, PaintBrush01Icon, CubeIcon, Settings01Icon, BookOpen01Icon, Clock01Icon } from '@hugeicons/core-free-icons';
44

55
import { AudioRecorder } from './audio-recorder';
66
import logoUrl from './logo.png';
@@ -10,7 +10,7 @@ import type { CloudTranscriptionModel } from '../shared/types';
1010

1111
const OVERLAY_VIEW = window.location.hash === '#overlay';
1212

13-
type Page = 'home' | 'style' | 'models' | 'dictionary' | 'preferences';
13+
type Page = 'home' | 'style' | 'models' | 'dictionary' | 'history' | 'preferences';
1414

1515
interface LevelOption {
1616
value: EnhancementLevel;
@@ -482,6 +482,9 @@ function MainView({ bootstrap, status, busyAction, onAction, onRefresh }: {
482482
<button className={`nav-item${page === 'dictionary' ? ' nav-item-active' : ''}`} onClick={() => setPage('dictionary')}>
483483
<HugeiconsIcon icon={BookOpen01Icon} size={18} strokeWidth={2} /> Dictionary
484484
</button>
485+
<button className={`nav-item${page === 'history' ? ' nav-item-active' : ''}`} onClick={() => setPage('history')}>
486+
<HugeiconsIcon icon={Clock01Icon} size={18} strokeWidth={2} /> History
487+
</button>
485488
<button className={`nav-item${page === 'preferences' ? ' nav-item-active' : ''}`} onClick={() => setPage('preferences')}>
486489
<HugeiconsIcon icon={Settings01Icon} size={18} strokeWidth={2} /> Preferences
487490
</button>
@@ -504,6 +507,7 @@ function MainView({ bootstrap, status, busyAction, onAction, onRefresh }: {
504507
{page === 'style' && <StylePage bootstrap={bootstrap} onAction={onAction} onRefresh={onRefresh} />}
505508
{page === 'models' && <ModelsPage bootstrap={bootstrap} busyAction={busyAction} onAction={onAction} />}
506509
{page === 'dictionary' && <DictionaryPage bootstrap={bootstrap} onRefresh={onRefresh} />}
510+
{page === 'history' && <HistoryPage bootstrap={bootstrap} onRefresh={onRefresh} />}
507511
{page === 'preferences' && <PreferencesPage bootstrap={bootstrap} onAction={onAction} />}
508512
</main>
509513
</div>
@@ -1037,6 +1041,105 @@ function DictionaryPage({ bootstrap, onRefresh }: { bootstrap: BootstrapState; o
10371041
);
10381042
}
10391043

1044+
/* ── History ──────────────────────────────────── */
1045+
1046+
function formatHistoryDate(iso: string): string {
1047+
const date = new Date(iso);
1048+
const now = new Date();
1049+
const isToday = date.toDateString() === now.toDateString();
1050+
const yesterday = new Date(now);
1051+
yesterday.setDate(yesterday.getDate() - 1);
1052+
const isYesterday = date.toDateString() === yesterday.toDateString();
1053+
1054+
const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
1055+
if (isToday) return `Today, ${time}`;
1056+
if (isYesterday) return `Yesterday, ${time}`;
1057+
return `${date.toLocaleDateString([], { day: 'numeric', month: 'short' })}, ${time}`;
1058+
}
1059+
1060+
function HistoryPage({ bootstrap, onRefresh }: { bootstrap: BootstrapState; onRefresh: () => Promise<BootstrapState> }) {
1061+
const [expandedId, setExpandedId] = useState<string | null>(null);
1062+
const history = bootstrap.history;
1063+
1064+
const handleRemove = async (id: string) => {
1065+
await window.openWhisp.removeHistoryEntry(id);
1066+
await onRefresh();
1067+
};
1068+
1069+
const handleClear = async () => {
1070+
await window.openWhisp.clearHistory();
1071+
await onRefresh();
1072+
};
1073+
1074+
const handleCopy = (text: string) => {
1075+
void navigator.clipboard.writeText(text);
1076+
};
1077+
1078+
return (
1079+
<div className="page">
1080+
<div className="page-header">
1081+
<h2 className="page-title serif">History</h2>
1082+
<p className="page-desc">Every dictation is saved here automatically.</p>
1083+
</div>
1084+
1085+
<div className="card">
1086+
<div className="card-head">
1087+
<h3>Recent dictations</h3>
1088+
{history.length > 0 && (
1089+
<button className="btn btn-link btn-muted" onClick={() => void handleClear()}>Clear all</button>
1090+
)}
1091+
</div>
1092+
1093+
{history.length === 0 && (
1094+
<p className="empty-state">No dictations yet. Hold the right Option key and start speaking.</p>
1095+
)}
1096+
1097+
{history.map((entry) => {
1098+
const isExpanded = expandedId === entry.id;
1099+
const hasRaw = entry.rawText !== entry.finalText;
1100+
return (
1101+
<div key={entry.id} className={`history-entry${isExpanded ? ' history-entry-expanded' : ''}`}>
1102+
<div className="history-entry-header" onClick={() => setExpandedId(isExpanded ? null : entry.id)}>
1103+
<div className="history-entry-main">
1104+
<p className="history-entry-text">{entry.finalText}</p>
1105+
<div className="history-entry-meta">
1106+
<span className="history-meta-time">{formatHistoryDate(entry.createdAt)}</span>
1107+
{entry.appName && <span className="history-meta-tag">{entry.appName}</span>}
1108+
<span className="history-meta-tag">{entry.transcriptionSource}</span>
1109+
</div>
1110+
</div>
1111+
<svg className={`history-chevron${isExpanded ? ' history-chevron-open' : ''}`} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1112+
<polyline points="6 9 12 15 18 9" />
1113+
</svg>
1114+
</div>
1115+
1116+
{isExpanded && (
1117+
<div className="history-entry-detail">
1118+
{hasRaw && (
1119+
<div className="history-raw-block">
1120+
<span className="history-detail-label">Raw transcription</span>
1121+
<p className="history-raw-text">{entry.rawText}</p>
1122+
</div>
1123+
)}
1124+
<div className="history-detail-row">
1125+
<span className="history-detail-label">Style</span>
1126+
<span className="history-detail-value">{entry.styleMode} / {entry.enhancementLevel}</span>
1127+
</div>
1128+
<div className="history-entry-actions">
1129+
<button className="btn btn-sm btn-primary" onClick={() => handleCopy(entry.finalText)}>Copy</button>
1130+
{hasRaw && <button className="btn btn-sm btn-secondary" onClick={() => handleCopy(entry.rawText)}>Copy raw</button>}
1131+
<button className="btn btn-sm btn-muted" onClick={() => void handleRemove(entry.id)}>Remove</button>
1132+
</div>
1133+
</div>
1134+
)}
1135+
</div>
1136+
);
1137+
})}
1138+
</div>
1139+
</div>
1140+
);
1141+
}
1142+
10401143
/* ── Preferences ──────────────────────────────── */
10411144

10421145
function PreferencesPage({ bootstrap, onAction }: { bootstrap: BootstrapState; onAction: (l: string, a: () => Promise<BootstrapState>) => Promise<void> }) {
@@ -1054,6 +1157,7 @@ function PreferencesPage({ bootstrap, onAction }: { bootstrap: BootstrapState; o
10541157
<div className="card">
10551158
<div className="card-head"><h3>Behavior</h3></div>
10561159
<ToggleRow title="Auto-paste" description="Paste into the active app after rewriting" checked={bootstrap.settings.autoPaste} onChange={(v) => void onAction('settings', () => window.openWhisp.updateSettings({ autoPaste: v }))} />
1160+
<ToggleRow title="Copy to clipboard" description="Copy the result to your clipboard after each dictation" checked={bootstrap.settings.copyToClipboard} onChange={(v) => void onAction('settings', () => window.openWhisp.updateSettings({ copyToClipboard: v }))} />
10571161
<ToggleRow title="Show overlay" description="Show the dictation badge on screen" checked={bootstrap.settings.showOverlay} onChange={(v) => void onAction('settings', () => window.openWhisp.updateSettings({ showOverlay: v }))} />
10581162
<ToggleRow title="Launch at login" description="Start Openwhisp when you log in" checked={bootstrap.settings.launchAtLogin} onChange={(v) => void onAction('settings', () => window.openWhisp.updateSettings({ launchAtLogin: v }))} />
10591163
</div>

0 commit comments

Comments
 (0)