Release v0.1.11

This commit is contained in:
David Perov 2026-04-27 04:22:56 +03:00
parent 9d9c81add1
commit 7dd5f54174
24 changed files with 765 additions and 119 deletions

View file

@ -10,6 +10,8 @@ It sits in a small floating widget, listens while you hold a hotkey, sends audio
- Release the hotkey to stop and process the audio
- The recognized text is pasted automatically into the current app
- A second press during recording locks the recording mode
- The floating widget can start/stop recording with a mouse click and copy the latest result
- Autostart can be enabled from settings
- The settings window lets you choose language, microphone, API key, and text cleanup style
- Recent recordings are saved in local history with processing time
@ -110,6 +112,16 @@ If you want to speak longer without holding the keys:
3. Recording becomes locked
4. Press the hotkey once more to stop and process
### Floating widget controls
When the widget is idle, hover it to show quick controls:
- click the red record button to start a locked recording
- click the stop button during recording to finish and process
- click the copy button to copy the latest successful result from local history
The copy shortcut is cleared when history is cleared, and it refreshes after entries are deleted.
### Settings you can change
- Recognition language (`ru`, `en`, or auto)
@ -118,6 +130,7 @@ If you want to speak longer without holding the keys:
- STT / LLM endpoints and API keys (custom mode)
- STT / LLM model names
- Global hotkey
- Autostart at system login
## Text styles
@ -135,6 +148,7 @@ The `Main` tab stores recent recordings locally so you can:
- see processing time for each entry
- retry failed entries
- copy text again
- copy the latest successful result from the floating widget
- delete individual entries
- clear the full history
@ -223,7 +237,7 @@ The repository includes a GitHub Actions workflow at `.github/workflows/release.
- The canonical release process is documented in `docs/release/rule.md`
- Before every release, refresh `README.md` and create a release review file from `docs/release/review-template.md`
- Push a tag like `v0.1.10` to build and publish a GitHub Release
- Push a tag like `v0.1.11` to build and publish a GitHub Release
- Or run the workflow manually and provide a tag
- The current workflow publishes macOS release artifacts
- For macOS release builds, move `Talkis.app` to `Applications` before granting Accessibility access
@ -250,4 +264,4 @@ Without these secrets, the workflow can still produce unsigned macOS release art
## Status
Talkis is an active work in progress. Current version: **0.1.10**.
Talkis is an active work in progress. Current version: **0.1.11**.

View file

@ -5,6 +5,7 @@
"name": "talk-flow",
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-autostart": "^2.5.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-deep-link": "^2.4.8",
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
@ -235,6 +236,8 @@
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.10.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg=="],
"@tauri-apps/plugin-autostart": ["@tauri-apps/plugin-autostart@2.5.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-zS/xx7yzveCcotkA+8TqkI2lysmG2wvQXv2HGAVExITmnFfHAdj1arGsbbfs3o6EktRHf6l34pJxc3YGG2mg7w=="],
"@tauri-apps/plugin-clipboard-manager": ["@tauri-apps/plugin-clipboard-manager@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ=="],
"@tauri-apps/plugin-deep-link": ["@tauri-apps/plugin-deep-link@2.4.8", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-Cd2Cs960MGuGONeIwxOPx9wqwedetAHOGlwK5boJ/SMTfAtAyfErpfVPEn+EJzgXsJun8EKzsEumHjr+64V4fw=="],

View file

@ -0,0 +1,68 @@
## Release
- Version: 0.1.11
- Release branch: release/v0.1.11
- Target tag: v0.1.11
- Reviewer: Codex
- Date: 2026-04-27
## Scope
- Key changes included in this release:
- Added autostart setting backed by `tauri-plugin-autostart`.
- Added hover controls to the floating widget: click-to-record, click-to-stop, and copy latest successful result.
- Refined widget active/idle sizing, recording controls, waveform rendering, and visual edge treatment.
- Added Accessibility preflight for automatic paste attempts.
- Fixed latest-copy cache invalidation when history entries are deleted or history is cleared.
- Adjusted release packaging so Tauri builds the `.app` and the project postprocess script creates the DMG.
- User-facing changes:
- Users can enable launch-at-login in Settings.
- Users can start and stop a locked recording with the widget mouse control.
- Users can copy the latest successful transcription directly from the idle widget.
- Widget hover/recording states are larger and easier to target.
- Risky areas:
- Floating widget hit testing and hover polling on macOS transparent always-on-top windows.
- Manual recording start/stop interactions sharing the same state machine as hotkeys.
- Release packaging target changed from Tauri `all` to `.app` plus project DMG postprocess.
## Checks run
- `bun run check:release` - passed
- `bun run build:release:macos` - passed
- Additional manual checks:
- Confirmed generated macOS app bundle at `src-tauri/target/release/bundle/macos/Talkis.app`.
- Confirmed generated DMG at `src-tauri/target/release/bundle/dmg/Talkis_0.1.11_aarch64.dmg`.
- Reviewed release diff for version, README, widget controls, autostart permissions, and history-clear copy-cache handling.
## Manual review
- Hotkey flow:
- Existing hotkey FSM smoke tests passed.
- Manual recording actions were added as separate state-machine actions and do not bypass the existing recording pipeline.
- Onboarding permissions:
- Existing permission flow remains in place.
- `paste_text` now checks macOS Accessibility permission before simulating Cmd+V.
- Widget position and notice behavior:
- Widget resize constants now use a stable active hit-test area to avoid hover clipping.
- Notice window behavior is unchanged after reverting the earlier fallback-copy notice.
- Transcription quality and short-utterance handling:
- Existing short-recording guards remain unchanged.
- Waveform rendering changes are visual only.
- README refreshed:
- README documents autostart, widget mouse controls, latest-copy behavior, and version 0.1.11.
## Findings
- Blockers:
- None remaining.
- Non-blocking issues:
- Hover detection relies on polling cursor position relative to the Tauri window because React hover events are unreliable for this transparent widget before activation.
- macOS release artifacts are ad-hoc signed unless signing/notarization secrets are configured.
- Follow-ups after release:
- Consider adding automated tests for history deletion/clear events and latest-copy cache behavior.
- Consider manual QA on a clean macOS account for autostart and Accessibility paste preflight.
## Decision
- Ready for `main` merge: yes
- Ready for tag publish: yes

View file

@ -1,7 +1,7 @@
{
"name": "talkis",
"private": true,
"version": "0.1.10",
"version": "0.1.11",
"type": "module",
"packageManager": "bun@1.2.13",
"scripts": {
@ -9,10 +9,10 @@
"build": "tsc && vite build",
"check": "bunx tsc --noEmit && cargo check --manifest-path src-tauri/Cargo.toml",
"test": "bun test",
"smoke:hotkey": "bun test src/windows/widget/services/hotkeyFsm.test.js",
"smoke:hotkey": "bun test ./src/windows/widget/services/hotkeyFsm.test.js",
"check:versions": "bash scripts/check-version-sync.sh",
"check:release": "bun run check:versions && bun run check && bun run smoke:hotkey && bun run build",
"postprocess:macos-release": "VERSION=$(python3 -c \"import json; print(json.load(open('package.json'))['version'])\") && bash scripts/postprocess-macos-release.sh \"$VERSION\"",
"postprocess:macos-release": "VERSION=$(python3 -c \"import json; print(json.load(open('package.json'))['version'])\") && BUILD_ROOT=src-tauri/target/release/bundle bash scripts/postprocess-macos-release.sh \"$VERSION\"",
"build:release:macos": "bun run tauri build && bun run postprocess:macos-release",
"preview": "vite preview",
"tauri": "tauri",
@ -21,6 +21,7 @@
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-autostart": "^2.5.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-deep-link": "^2.4.8",
"@tauri-apps/plugin-global-shortcut": "^2.3.1",

View file

@ -9,6 +9,7 @@ BUILD_ROOT="${BUILD_ROOT:-/tmp/talkis-target/release/bundle}"
APP_PATH="${BUILD_ROOT}/macos/${APP_NAME}.app"
DMG_PATH="${BUILD_ROOT}/dmg/${APP_NAME}_${VERSION}_aarch64.dmg"
STAGING_DIR="${BUILD_ROOT}/macos/dmg-staging"
DMG_DIR="$(dirname "${DMG_PATH}")"
if [[ ! -d "${APP_PATH}" ]]; then
echo "App bundle not found at ${APP_PATH}" >&2
@ -21,6 +22,7 @@ codesign --force --deep --sign - --identifier "${APP_IDENTIFIER}" "${APP_PATH}"
echo "Rebuilding DMG from signed app bundle"
rm -rf "${STAGING_DIR}"
mkdir -p "${STAGING_DIR}"
mkdir -p "${DMG_DIR}"
cp -R "${APP_PATH}" "${STAGING_DIR}/"
ln -s /Applications "${STAGING_DIR}/Applications"
rm -f "${DMG_PATH}"

59
src-tauri/Cargo.lock generated
View file

@ -228,6 +228,17 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "auto-launch"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471"
dependencies = [
"dirs 4.0.0",
"thiserror 1.0.69",
"winreg 0.10.1",
]
[[package]]
name = "autocfg"
version = "1.5.0"
@ -830,6 +841,15 @@ dependencies = [
"crypto-common",
]
[[package]]
name = "dirs"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
dependencies = [
"dirs-sys 0.3.7",
]
[[package]]
name = "dirs"
version = "5.0.1"
@ -848,6 +868,17 @@ dependencies = [
"dirs-sys 0.5.0",
]
[[package]]
name = "dirs-sys"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
dependencies = [
"libc",
"redox_users 0.4.6",
"winapi",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
@ -995,7 +1026,7 @@ dependencies = [
"rustc_version",
"toml 0.9.12+spec-1.1.0",
"vswhom",
"winreg",
"winreg 0.55.0",
]
[[package]]
@ -4337,7 +4368,7 @@ dependencies = [
[[package]]
name = "talkis"
version = "0.1.10"
version = "0.1.11"
dependencies = [
"base64 0.22.1",
"block2 0.6.2",
@ -4353,6 +4384,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-autostart",
"tauri-plugin-clipboard-manager",
"tauri-plugin-deep-link",
"tauri-plugin-global-shortcut",
@ -4550,6 +4582,20 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-autostart"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "459383cebc193cdd03d1ba4acc40f2c408a7abce419d64bdcd2d745bc2886f70"
dependencies = [
"auto-launch",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
]
[[package]]
name = "tauri-plugin-clipboard-manager"
version = "2.3.2"
@ -6270,6 +6316,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [
"winapi",
]
[[package]]
name = "winreg"
version = "0.55.0"

View file

@ -1,6 +1,6 @@
[package]
name = "talkis"
version = "0.1.10"
version = "0.1.11"
description = "Talkis - Voice to Text macOS Widget"
authors = ["trixter"]
edition = "2021"
@ -36,3 +36,6 @@ block2 = "0.6"
objc2 = "0.6"
objc2-app-kit = { version = "0.3", features = ["NSView", "NSWindow", "NSEvent", "block2"] }
objc2-foundation = "0.3"
[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies]
tauri-plugin-autostart = "2.5.1"

View file

@ -24,6 +24,9 @@
"store:allow-delete",
"clipboard-manager:allow-write-text",
"clipboard-manager:allow-read-text",
"deep-link:default"
"deep-link:default",
"autostart:allow-enable",
"autostart:allow-disable",
"autostart:allow-is-enabled"
]
}

View file

@ -7,8 +7,8 @@ pub const NOTICE_WIDTH: f64 = 212.0;
pub const NOTICE_HEIGHT: f64 = 52.0;
/// Must match NOTICE_WIDGET_GAP in src/windows/widget/widgetConstants.ts (logical pixels).
pub const NOTICE_GAP: f64 = 2.0;
pub const WIDGET_WIDTH: f64 = 50.0;
pub const WIDGET_HEIGHT: f64 = 18.0;
pub const WIDGET_WIDTH: f64 = 78.0;
pub const WIDGET_HEIGHT: f64 = 32.0;
#[derive(Clone, Serialize)]
struct WidgetNoticePayload {
@ -155,7 +155,12 @@ fn resize_widget_window(app: &AppHandle, width: f64, height: f64) -> Result<(),
}
#[tauri::command]
pub async fn show_widget_notice(app: AppHandle, message: String, tone: String, _anchor_state: String) -> Result<(), String> {
pub async fn show_widget_notice(
app: AppHandle,
message: String,
tone: String,
_anchor_state: String,
) -> Result<(), String> {
let widget_window = app
.get_webview_window("widget")
.ok_or_else(|| "Widget window not found".to_string())?;

View file

@ -23,6 +23,12 @@ pub fn run() {
.setup(|app| {
logger::log_info("INIT", "Application starting...");
#[cfg(desktop)]
app.handle().plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
None,
))?;
// Auto-prompt for accessibility if not granted.
// AXIsProcessTrustedWithOptions(prompt=true) makes macOS
// show its native dialog and auto-register the binary.

View file

@ -3,6 +3,27 @@ use std::time::Duration;
use crate::logger;
#[cfg(target_os = "macos")]
#[link(name = "ApplicationServices", kind = "framework")]
unsafe extern "C" {
fn AXIsProcessTrusted() -> u8;
}
#[cfg(target_os = "macos")]
fn ensure_input_event_permission() -> Result<(), String> {
let trusted = unsafe { AXIsProcessTrusted() != 0 };
if trusted {
return Ok(());
}
Err("Accessibility permission is required to paste text into other apps".into())
}
#[cfg(not(target_os = "macos"))]
fn ensure_input_event_permission() -> Result<(), String> {
Ok(())
}
/// Simulate Cmd+V using CoreGraphics directly.
///
/// Unlike enigo (which sends separate Meta-press and V-click events that can
@ -93,6 +114,8 @@ fn simulate_cmd_v() -> Result<(), String> {
pub async fn paste_text(app: tauri::AppHandle, text: String) -> Result<(), String> {
use tauri_plugin_clipboard_manager::ClipboardExt;
ensure_input_event_permission()?;
let char_count = text.chars().count();
let previous_clipboard_text = app.clipboard().read_text().ok();

View file

@ -2,7 +2,7 @@
"$schema": "https://schema.tauri.app/config/2",
"productName": "Talkis",
"mainBinaryName": "Talkis",
"version": "0.1.10",
"version": "0.1.11",
"identifier": "com.trixter.talkis",
"build": {
"beforeDevCommand": "bun run dev",
@ -16,8 +16,8 @@
"label": "widget",
"title": "Talkis",
"url": "index.html?window=widget",
"width": 50,
"height": 18,
"width": 78,
"height": 32,
"minWidth": 18,
"minHeight": 18,
"resizable": false,
@ -38,7 +38,7 @@
},
"bundle": {
"active": true,
"targets": "all",
"targets": ["app"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",

View file

@ -61,24 +61,20 @@ export function Waveform({ stream, isActive }: WaveformProps) {
const time = performance.now() / 420;
const centerY = displayHeight / 2;
const baseAmplitude = 1 + levelRef.current * displayHeight * 0.34;
const baseAmplitude = 0.7 + levelRef.current * displayHeight * 0.26;
const lineConfigs = [
{ amplitude: 1.42, speed: 1, phase: 0, alpha: 0.36, width: 1.08 },
{ amplitude: 1.34, speed: 1.06, phase: Math.PI / 10, alpha: 0.33, width: 1.02 },
{ amplitude: 1.26, speed: 1.12, phase: Math.PI / 8, alpha: 0.3, width: 0.98 },
{ amplitude: 1.18, speed: 1.18, phase: Math.PI / 6.2, alpha: 0.28, width: 0.94 },
{ amplitude: 1.1, speed: 1.24, phase: Math.PI / 5.2, alpha: 0.25, width: 0.9 },
{ amplitude: 1.02, speed: 0.92, phase: Math.PI / 4.5, alpha: 0.23, width: 0.87 },
{ amplitude: 0.95, speed: 1.3, phase: Math.PI / 3.8, alpha: 0.21, width: 0.84 },
{ amplitude: 0.88, speed: 0.86, phase: Math.PI / 3.15, alpha: 0.19, width: 0.8 },
{ amplitude: 0.81, speed: 1.38, phase: Math.PI / 2.8, alpha: 0.17, width: 0.76 },
{ amplitude: 0.74, speed: 0.8, phase: Math.PI / 2.45, alpha: 0.15, width: 0.73 },
{ amplitude: 0.67, speed: 1.46, phase: Math.PI / 2.05, alpha: 0.14, width: 0.7 },
{ amplitude: 0.61, speed: 0.74, phase: Math.PI / 1.8, alpha: 0.13, width: 0.67 },
{ amplitude: 0.55, speed: 1.54, phase: Math.PI / 1.56, alpha: 0.12, width: 0.64 },
{ amplitude: 0.49, speed: 0.68, phase: Math.PI / 1.34, alpha: 0.11, width: 0.61 },
{ amplitude: 0.44, speed: 1.62, phase: Math.PI / 1.18, alpha: 0.1, width: 0.58 },
{ amplitude: 0.39, speed: 0.62, phase: Math.PI / 1.04, alpha: 0.09, width: 0.54 },
{ amplitude: 1.16, speed: 1.42, phase: 0.18, alpha: 0.3, width: 0.62, wobble: 0.9 },
{ amplitude: 0.92, speed: 0.81, phase: 1.7, alpha: 0.27, width: 0.58, wobble: 1.8 },
{ amplitude: 1.31, speed: 1.18, phase: 2.85, alpha: 0.24, width: 0.55, wobble: 1.2 },
{ amplitude: 0.78, speed: 1.67, phase: 4.1, alpha: 0.22, width: 0.52, wobble: 2.4 },
{ amplitude: 1.04, speed: 0.96, phase: 5.35, alpha: 0.2, width: 0.5, wobble: 1.5 },
{ amplitude: 0.68, speed: 1.92, phase: 0.9, alpha: 0.18, width: 0.48, wobble: 2.9 },
{ amplitude: 1.22, speed: 1.05, phase: 3.55, alpha: 0.16, width: 0.46, wobble: 2.1 },
{ amplitude: 0.84, speed: 1.74, phase: 2.25, alpha: 0.14, width: 0.44, wobble: 3.2 },
{ amplitude: 1.08, speed: 0.72, phase: 4.85, alpha: 0.13, width: 0.42, wobble: 1.1 },
{ amplitude: 0.56, speed: 2.15, phase: 1.28, alpha: 0.12, width: 0.4, wobble: 3.8 },
{ amplitude: 0.98, speed: 1.31, phase: 5.9, alpha: 0.11, width: 0.38, wobble: 2.6 },
{ amplitude: 0.72, speed: 1.58, phase: 3.08, alpha: 0.1, width: 0.36, wobble: 4.1 },
];
ctx.lineCap = "round";
@ -87,14 +83,16 @@ export function Waveform({ stream, isActive }: WaveformProps) {
lineConfigs.forEach((line) => {
ctx.beginPath();
for (let x = 0; x <= displayWidth; x += 1) {
for (let x = 0; x <= displayWidth; x += 1.25) {
const progress = x / displayWidth;
const edgeFade = Math.sin(progress * Math.PI);
const envelope = Math.pow(Math.max(0, edgeFade), 1.35);
const primary = Math.sin(progress * Math.PI * 2.8 + time * line.speed + line.phase);
const secondary = Math.sin(progress * Math.PI * 5.6 - time * (line.speed * 1.08) + line.phase * 0.72);
const tertiary = Math.cos(progress * Math.PI * 8.2 + time * 0.74 + line.phase);
const displacement = ((primary * 0.68) + (secondary * 0.22) + (tertiary * 0.1)) * baseAmplitude * line.amplitude * envelope;
const envelope = Math.pow(Math.max(0, edgeFade), 1.08);
const drift = Math.sin(time * 0.37 + line.phase) * 0.18;
const primary = Math.sin(progress * Math.PI * (3.1 + line.wobble * 0.18) + time * line.speed + line.phase);
const secondary = Math.sin(progress * Math.PI * (7.3 + line.wobble * 0.42) - time * (line.speed * 0.83) + line.phase * 1.37);
const tertiary = Math.cos(progress * Math.PI * (13.4 + line.wobble * 0.31) + time * (0.56 + line.wobble * 0.04) + line.phase);
const grain = Math.sin((progress + line.phase) * 38 + time * (1.1 + line.wobble * 0.11)) * 0.055;
const displacement = ((primary * 0.52) + (secondary * 0.31) + (tertiary * 0.12) + grain + drift) * baseAmplitude * line.amplitude * envelope;
const y = centerY + displacement;
if (x === 0) {
@ -106,8 +104,7 @@ export function Waveform({ stream, isActive }: WaveformProps) {
ctx.strokeStyle = `rgba(0, 0, 0, ${line.alpha})`;
ctx.lineWidth = line.width;
ctx.shadowBlur = line.alpha > 0.28 ? 4 : 0;
ctx.shadowColor = "rgba(0, 0, 0, 0.08)";
ctx.shadowBlur = 0;
ctx.stroke();
});

View file

@ -1,5 +1,7 @@
export const SETTINGS_UPDATED_EVENT = "settings-updated";
export const HISTORY_UPDATED_EVENT = "history-updated";
export const HISTORY_DELETED_EVENT = "history-deleted";
export const HISTORY_CLEARED_EVENT = "history-cleared";
export const HOTKEY_CHANGE_REQUEST_EVENT = "hotkey-change-request";
export const HOTKEY_REGISTRATION_RESULT_EVENT = "hotkey-registration-result";
export const NATIVE_HOTKEY_CAPTURE_EVENT = "native-hotkey-capture";

View file

@ -1,8 +1,8 @@
import { useState, useEffect, useMemo } from "react";
import { listen } from "@tauri-apps/api/event";
import { emit, listen } from "@tauri-apps/api/event";
import { clearHistory, DEFAULT_HOTKEY, deleteHistoryEntry, formatHotkeyLabel, getHistory, getSettings, HistoryEntry } from "../../../lib/store";
import { AlertCircle, Check, Copy, RotateCcw, Trash2 } from "lucide-react";
import { HISTORY_UPDATED_EVENT, SETTINGS_UPDATED_EVENT } from "../../../lib/hotkeyEvents";
import { HISTORY_CLEARED_EVENT, HISTORY_DELETED_EVENT, HISTORY_UPDATED_EVENT, SETTINGS_UPDATED_EVENT } from "../../../lib/hotkeyEvents";
import { retryHistoryEntry } from "../../widget/services/transcriptionPipeline";
interface MainTabProps {
@ -73,6 +73,7 @@ export function MainTab({ initialHistory = [] }: MainTabProps) {
const deleteEntry = async (id: string) => {
await deleteHistoryEntry(id);
setHistory((h) => h.filter((x) => x.id !== id));
await emit(HISTORY_DELETED_EVENT, { id });
};
const clearAllHistory = async () => {
@ -87,6 +88,7 @@ export function MainTab({ initialHistory = [] }: MainTabProps) {
await clearHistory();
setHistory([]);
setIsClearArmed(false);
await emit(HISTORY_CLEARED_EVENT);
};
const copyText = async (id: string, text: string) => {

View file

@ -1,8 +1,10 @@
import { useState, useEffect, useRef } from "react";
import { invoke } from "@tauri-apps/api/core";
import { emit, listen } from "@tauri-apps/api/event";
import { getSettings, saveSettings, AppSettings, DEFAULT_HOTKEY, formatHotkeyLabel } from "../../../lib/store";
import { disable as disableAutostart, enable as enableAutostart, isEnabled as isAutostartEnabled } from "@tauri-apps/plugin-autostart";
import { Check, ChevronDown, Search } from "lucide-react";
import { getSettings, saveSettings, AppSettings, DEFAULT_HOTKEY, formatHotkeyLabel } from "../../../lib/store";
import {
HOTKEY_CAPTURE_STATE_EVENT,
HOTKEY_CHANGE_REQUEST_EVENT,
@ -41,6 +43,9 @@ export function SettingsTab() {
const [hotkeyDraft, setHotkeyDraft] = useState<string | null>(null);
const [hotkeyFeedback, setHotkeyFeedback] = useState("Нажмите на поле и введите новую комбинацию. Esc отменяет ввод.");
const [hotkeyFeedbackTone, setHotkeyFeedbackTone] = useState<HotkeyFeedbackTone>("idle");
const [autostartEnabled, setAutostartEnabled] = useState(false);
const [autostartLoaded, setAutostartLoaded] = useState(false);
const [autostartPending, setAutostartPending] = useState(false);
type MicAvailabilityState = "ready" | "missing-selected" | "permission-needed" | "empty";
@ -60,6 +65,29 @@ export function SettingsTab() {
});
}, []);
useEffect(() => {
let mounted = true;
const loadAutostartState = async (): Promise<void> => {
try {
const enabled = await isAutostartEnabled();
if (!mounted) return;
setAutostartEnabled(enabled);
setAutostartLoaded(true);
} catch (error) {
if (!mounted) return;
setAutostartLoaded(true);
void logError("SETTINGS", `Failed to load autostart state: ${error instanceof Error ? error.message : String(error)}`);
}
};
void loadAutostartState();
return () => {
mounted = false;
};
}, []);
useEffect(() => {
const unlistenHotkeyResult = listen<HotkeyRegistrationResultPayload>(
HOTKEY_REGISTRATION_RESULT_EVENT,
@ -335,6 +363,32 @@ export function SettingsTab() {
void startHotkeyCapture();
};
const toggleAutostart = async (): Promise<void> => {
if (autostartPending) {
return;
}
const nextEnabled = !autostartEnabled;
setAutostartPending(true);
try {
if (nextEnabled) {
await enableAutostart();
} else {
await disableAutostart();
}
const confirmedEnabled = await isAutostartEnabled();
setAutostartEnabled(confirmedEnabled);
setAutostartLoaded(true);
void logInfo("SETTINGS", `Autostart ${confirmedEnabled ? "enabled" : "disabled"}`);
} catch (error) {
void logError("SETTINGS", `Failed to update autostart: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setAutostartPending(false);
}
};
const getMicrophoneLabel = (mic: MediaDeviceInfo, index: number): string => {
const label = mic.label?.trim();
return label ? label : `Микрофон ${index + 1}`;
@ -364,6 +418,7 @@ export function SettingsTab() {
: hotkeyFeedbackTone === "success"
? "#027a48"
: "var(--text-mid)";
const autostartDisabled = !autostartLoaded || autostartPending;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
@ -518,6 +573,68 @@ export function SettingsTab() {
</div>
</div>
</div>
<div className="card" style={{ display: "grid", gap: 10, background: "rgba(255,255,255,0.82)" }}>
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr) 280px", alignItems: "center", gap: 16 }}>
<div>
<div style={{ fontSize: 16, fontWeight: 700, color: "var(--text-hi)", margin: 0 }}>Автозапуск</div>
<div style={{ fontSize: 13, color: "var(--text-mid)", lineHeight: 1.65, marginTop: 6 }}>
Запускать Talkis автоматически при входе в систему.
</div>
</div>
<button
type="button"
role="switch"
aria-checked={autostartEnabled}
aria-disabled={autostartDisabled}
onClick={() => { void toggleAutostart(); }}
className="btn"
style={{
width: "100%",
minHeight: 46,
display: "grid",
gridTemplateColumns: "minmax(0, 1fr) 42px",
alignItems: "center",
gap: 12,
opacity: autostartDisabled ? 0.72 : 1,
cursor: autostartDisabled ? "wait" : "pointer",
transform: "none",
}}
>
<span style={{ color: "var(--text-hi)", fontSize: 13, fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", minWidth: 74 }}>
{autostartEnabled ? "Включен" : "Выключен"}
</span>
<span
aria-hidden="true"
style={{
width: 42,
height: 24,
borderRadius: 999,
background: autostartEnabled ? "#111" : "rgba(0,0,0,0.12)",
padding: 3,
position: "relative",
transition: "background 0.15s ease",
flexShrink: 0,
}}
>
<span
style={{
position: "absolute",
top: 3,
left: 3,
width: 18,
height: 18,
borderRadius: "50%",
background: "#fff",
boxShadow: "0 1px 3px rgba(0,0,0,0.18)",
transform: autostartEnabled ? "translateX(18px)" : "translateX(0)",
transition: "transform 0.18s ease",
}}
/>
</span>
</button>
</div>
</div>
</div>
);
}

View file

@ -1,17 +1,84 @@
import { useRef } from "react";
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent, ReactNode } from "react";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { listen } from "@tauri-apps/api/event";
import { cursorPosition, getCurrentWindow } from "@tauri-apps/api/window";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { Circle, Copy, Square } from "lucide-react";
import { Waveform } from "../../components/Waveform";
import { HISTORY_CLEARED_EVENT, HISTORY_DELETED_EVENT, HISTORY_UPDATED_EVENT } from "../../lib/hotkeyEvents";
import { getHistory, type HistoryEntry } from "../../lib/store";
import { logError } from "../../lib/logger";
import { useWidgetController } from "./hooks/useWidgetController";
import { WIDGET_SHELL_HEIGHT, WIDGET_SHELL_WIDTH } from "./widgetConstants";
import {
ACTIVE_WIDGET_SHELL_HEIGHT,
ACTIVE_WIDGET_SHELL_WIDTH,
IDLE_HOVER_WIDGET_HEIGHT,
IDLE_HOVER_WIDGET_WIDTH,
IDLE_HOVER_SCALE,
WIDGET_SHELL_HEIGHT,
WIDGET_SHELL_WIDTH,
} from "./widgetConstants";
const WIDGET_RECORD_BUTTON_LEFT = 10;
const WIDGET_RECORD_BUTTON_SIZE = 18;
function getCopyableText(entry: HistoryEntry | null | undefined): string | null {
if (!entry || entry.status === "failed") {
return null;
}
const cleaned = entry.cleaned.trim();
return cleaned.length > 0 ? cleaned : null;
}
export function Widget() {
const widgetWindow = getCurrentWindow();
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
const dragTriggeredRef = useRef(false);
const { state, stream, lockedRecording } = useWidgetController();
const { state, stream, lockedRecording, toggleManualRecording } = useWidgetController();
const [latestCopyText, setLatestCopyText] = useState<string | null>(null);
useEffect(() => {
let mounted = true;
const refreshLatestCopyText = async () => {
try {
const history = await getHistory();
if (!mounted) {
return;
}
const latestCompleted = history.find((entry) => getCopyableText(entry) !== null);
setLatestCopyText(getCopyableText(latestCompleted));
} catch (error) {
logError("WIDGET", `Failed to load latest history entry: ${error instanceof Error ? error.message : String(error)}`);
}
};
void refreshLatestCopyText();
const unlistenUpdatedPromise = listen<HistoryEntry>(HISTORY_UPDATED_EVENT, ({ payload }) => {
const text = getCopyableText(payload);
if (text) {
setLatestCopyText(text);
}
});
const unlistenDeletedPromise = listen<{ id: string }>(HISTORY_DELETED_EVENT, () => {
void refreshLatestCopyText();
});
const unlistenClearedPromise = listen(HISTORY_CLEARED_EVENT, () => {
setLatestCopyText(null);
});
return () => {
mounted = false;
void unlistenUpdatedPromise.then((unlisten) => unlisten());
void unlistenDeletedPromise.then((unlisten) => unlisten());
void unlistenClearedPromise.then((unlisten) => unlisten());
};
}, []);
const handleDragPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0) {
@ -73,6 +140,8 @@ export function Widget() {
>
{state === "idle" && (
<IdlePill
latestCopyText={latestCopyText}
onToggleRecording={toggleManualRecording}
onClick={handleIdleClick}
onPointerDown={handleDragPointerDown}
onPointerMove={handleDragPointerMove}
@ -84,6 +153,7 @@ export function Widget() {
<RecordingPill
stream={stream}
locked={lockedRecording}
onToggleRecording={toggleManualRecording}
onPointerDown={handleDragPointerDown}
onPointerMove={handleDragPointerMove}
onPointerUp={handleDragPointerUp}
@ -109,38 +179,196 @@ interface DragHandlers {
onPointerCancel: () => void;
}
function IdlePill({ onClick, onPointerDown, onPointerMove, onPointerUp, onPointerCancel }: DragHandlers & { onClick: () => void }) {
function IdlePill({
latestCopyText,
onToggleRecording,
onClick,
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
}: DragHandlers & { latestCopyText: string | null; onToggleRecording: () => void; onClick: () => void }) {
const widgetWindow = getCurrentWindow();
const [isHovered, setIsHovered] = useState(false);
const canCopy = Boolean(latestCopyText);
const controlsVisible = isHovered;
useEffect(() => {
let disposed = false;
const hoverMarginPx = 2;
const updateHoverState = async () => {
try {
const [cursor, position, size] = await Promise.all([
cursorPosition(),
widgetWindow.outerPosition(),
widgetWindow.outerSize(),
]);
if (disposed) {
return;
}
const hovered =
cursor.x >= position.x - hoverMarginPx &&
cursor.x <= position.x + size.width + hoverMarginPx &&
cursor.y >= position.y - hoverMarginPx &&
cursor.y <= position.y + size.height + hoverMarginPx;
setIsHovered(hovered);
} catch (error) {
logError("WIDGET", `Failed to poll widget hover state: ${error instanceof Error ? error.message : String(error)}`);
}
};
void updateHoverState();
const interval = window.setInterval(() => {
void updateHoverState();
}, 80);
return () => {
disposed = true;
window.clearInterval(interval);
};
}, [widgetWindow]);
const copyLatestText = async () => {
if (!latestCopyText) {
return;
}
await writeText(latestCopyText);
};
return (
<ActiveWidgetShell
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerCancel}
onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)}
width={IDLE_HOVER_WIDGET_WIDTH}
height={IDLE_HOVER_WIDGET_HEIGHT}
cursor="pointer"
onClick={() => {
void onClick();
}}
>
<WidgetCoreShell />
<WidgetCoreShell width={WIDGET_SHELL_WIDTH} height={WIDGET_SHELL_HEIGHT} scale={isHovered ? IDLE_HOVER_SCALE : 1} />
<div
style={{
position: "absolute",
inset: 0,
pointerEvents: "none",
}}
>
<button
type="button"
aria-label="Начать запись"
title="Начать запись"
onPointerDown={(event) => {
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
onToggleRecording();
}}
style={{
position: "absolute",
left: WIDGET_RECORD_BUTTON_LEFT,
top: "50%",
width: WIDGET_RECORD_BUTTON_SIZE,
height: WIDGET_RECORD_BUTTON_SIZE,
border: "none",
borderRadius: 999,
padding: 0,
background: "rgba(217,45,32,0.12)",
color: "#d92d20",
display: "flex",
alignItems: "center",
justifyContent: "center",
opacity: controlsVisible ? 1 : 0,
transform: controlsVisible ? "translateY(-50%) scale(1)" : "translateY(-50%) scale(0.84)",
transition: "opacity 0.14s ease, transform 0.14s ease, background 0.14s ease",
pointerEvents: controlsVisible ? "auto" : "none",
cursor: "pointer",
WebkitFontSmoothing: "antialiased",
}}
>
<Circle size={10} strokeWidth={2.4} fill="currentColor" />
</button>
{canCopy && (
<button
type="button"
aria-label="Скопировать последнюю запись"
title="Скопировать"
onPointerDown={(event) => {
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
void copyLatestText();
}}
style={{
position: "absolute",
right: 10,
top: "50%",
width: 18,
height: 18,
minWidth: 18,
border: "none",
borderRadius: 999,
padding: 0,
background: "rgba(0,0,0,0.06)",
color: "rgba(0,0,0,0.7)",
display: "flex",
alignItems: "center",
justifyContent: "center",
opacity: controlsVisible ? 1 : 0,
transform: controlsVisible ? "translateY(-50%) scale(1)" : "translateY(-50%) scale(0.84)",
transition: "opacity 0.14s ease, transform 0.14s ease, background 0.14s ease, color 0.14s ease",
pointerEvents: controlsVisible ? "auto" : "none",
cursor: "pointer",
WebkitFontSmoothing: "antialiased",
}}
>
<Copy size={12} strokeWidth={2} />
</button>
)}
</div>
</ActiveWidgetShell>
);
}
function WidgetCoreShell({ children }: { children?: ReactNode }) {
function WidgetCoreShell({
children,
width = "100%",
height = "100%",
scale = 1,
}: {
children?: ReactNode;
width?: number | string;
height?: number | string;
scale?: number;
}) {
return (
<div
style={{
width: "100%",
height: "100%",
width,
height,
borderRadius: 999,
background: "linear-gradient(180deg, rgba(252,251,248,0.98) 0%, rgba(245,241,234,0.98) 100%)",
border: "1px solid rgba(0,0,0,0.1)",
backdropFilter: "blur(18px)",
WebkitBackdropFilter: "blur(18px)",
background: "linear-gradient(180deg, #fcfbf8 0%, #f6f2eb 100%)",
border: "1px solid rgba(0,0,0,0.13)",
boxShadow: "0 1px 2px rgba(0,0,0,0.08)",
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "relative",
overflow: "hidden",
transform: `scale(${scale})`,
transformOrigin: "center center",
transition: "transform 0.18s cubic-bezier(0.22, 1, 0.36, 1)",
}}
>
{children}
@ -151,14 +379,35 @@ function WidgetCoreShell({ children }: { children?: ReactNode }) {
interface RecordingPillProps {
stream: MediaStream | null;
locked: boolean;
onToggleRecording: () => void;
}
function ActiveWidgetShell({ children, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onClick, cursor = "grab" }: { children: ReactNode; onClick?: () => void; cursor?: string } & DragHandlers) {
function ActiveWidgetShell({
children,
width = WIDGET_SHELL_WIDTH,
height = WIDGET_SHELL_HEIGHT,
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
onPointerEnter,
onPointerLeave,
onClick,
cursor = "grab",
}: {
children: ReactNode;
width?: number;
height?: number;
onClick?: () => void;
cursor?: string;
onPointerEnter?: () => void;
onPointerLeave?: () => void;
} & DragHandlers) {
return (
<div
style={{
width: WIDGET_SHELL_WIDTH,
height: WIDGET_SHELL_HEIGHT,
width,
height,
borderRadius: 999,
background: "transparent",
display: "flex",
@ -179,21 +428,25 @@ function ActiveWidgetShell({ children, onPointerDown, onPointerMove, onPointerUp
}}
onPointerUp={onPointerUp}
onPointerCancel={onPointerCancel}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
>
{children}
</div>
);
}
function RecordingPill({ stream, locked, onPointerDown, onPointerMove, onPointerUp, onPointerCancel }: RecordingPillProps & DragHandlers) {
function RecordingPill({ stream, locked, onToggleRecording, onPointerDown, onPointerMove, onPointerUp, onPointerCancel }: RecordingPillProps & DragHandlers) {
return (
<ActiveWidgetShell
width={IDLE_HOVER_WIDGET_WIDTH}
height={IDLE_HOVER_WIDGET_HEIGHT}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerCancel}
>
<WidgetCoreShell>
<WidgetCoreShell width={ACTIVE_WIDGET_SHELL_WIDTH} height={ACTIVE_WIDGET_SHELL_HEIGHT}>
<div
style={{
position: "relative",
@ -202,27 +455,52 @@ function RecordingPill({ stream, locked, onPointerDown, onPointerMove, onPointer
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: locked ? "0 10px 0 3px" : "0 3px",
padding: "0 4px 0 24px",
}}
>
<Waveform stream={stream} isActive={true} />
{locked && (
<span
style={{
position: "absolute",
top: "50%",
right: 4,
width: 4,
height: 4,
borderRadius: 999,
background: "#d92d20",
boxShadow: "0 0 0 2px rgba(217,45,32,0.14)",
transform: "translateY(-50%)",
}}
/>
)}
</div>
</WidgetCoreShell>
<div
style={{
position: "absolute",
inset: 0,
pointerEvents: "none",
}}
>
<button
type="button"
aria-label="Закончить запись"
title="Закончить запись"
onPointerDown={(event) => {
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
onToggleRecording();
}}
style={{
position: "absolute",
top: "50%",
left: WIDGET_RECORD_BUTTON_LEFT,
width: WIDGET_RECORD_BUTTON_SIZE,
height: WIDGET_RECORD_BUTTON_SIZE,
border: "none",
borderRadius: 999,
padding: 0,
background: locked ? "rgba(217,45,32,0.14)" : "rgba(217,45,32,0.1)",
color: "#d92d20",
display: "flex",
alignItems: "center",
justifyContent: "center",
transform: "translateY(-50%)",
pointerEvents: "auto",
cursor: "pointer",
}}
>
<Square size={8} strokeWidth={2.4} fill="currentColor" />
</button>
</div>
</ActiveWidgetShell>
);
}
@ -230,12 +508,14 @@ function RecordingPill({ stream, locked, onPointerDown, onPointerMove, onPointer
function ProcessingPill({ onPointerDown, onPointerMove, onPointerUp, onPointerCancel }: DragHandlers) {
return (
<ActiveWidgetShell
width={IDLE_HOVER_WIDGET_WIDTH}
height={IDLE_HOVER_WIDGET_HEIGHT}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerCancel}
>
<WidgetCoreShell>
<WidgetCoreShell width={ACTIVE_WIDGET_SHELL_WIDTH} height={ACTIVE_WIDGET_SHELL_HEIGHT}>
<div
style={{
display: "flex",

View file

@ -24,6 +24,7 @@ interface WidgetControllerState {
stream: MediaStream | null;
notice: WidgetNoticeState | null;
lockedRecording: boolean;
toggleManualRecording: () => void;
}
export function useWidgetController(): WidgetControllerState {
@ -233,7 +234,7 @@ export function useWidgetController(): WidgetControllerState {
stateRefForNotice.current = widgetState;
}, [widgetState]);
const { showNotice } = useWidgetNotice({ stateRef: stateRefForNotice });
const { showNotice, hideNotice } = useWidgetNotice({ stateRef: stateRefForNotice });
// ── Error handler ───────────────────────────────────────────────────────
const showError = useCallback(
@ -254,7 +255,7 @@ export function useWidgetController(): WidgetControllerState {
setStream,
resizeWidget,
showError,
showNotice,
hideNotice,
stopAndProcessRef,
});
@ -275,6 +276,19 @@ export function useWidgetController(): WidgetControllerState {
showError,
});
const toggleManualRecording = useCallback(() => {
const currentState = machineRef.current.widgetState;
if (currentState === "idle") {
dispatch({ type: "MANUAL_RECORDING_START" });
return;
}
if (currentState === "recording") {
dispatch({ type: "MANUAL_RECORDING_STOP" });
}
}, [dispatch]);
// ── Cleanup ─────────────────────────────────────────────────────────────
useEffect(() => {
return () => {
@ -288,5 +302,6 @@ export function useWidgetController(): WidgetControllerState {
stream,
notice: null,
lockedRecording,
toggleManualRecording,
};
}

View file

@ -10,15 +10,26 @@ interface UseWidgetNoticeParams {
interface UseWidgetNoticeResult {
showNotice: (message: string, tone?: WidgetNoticeTone) => void;
hideNotice: () => void;
}
export function useWidgetNotice({ stateRef }: UseWidgetNoticeParams): UseWidgetNoticeResult {
const noticeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hideNotice = useCallback(() => {
if (noticeTimerRef.current) {
clearTimeout(noticeTimerRef.current);
noticeTimerRef.current = null;
}
void invoke("hide_widget_notice");
}, []);
const showNotice = useCallback(
(message: string, tone: WidgetNoticeTone = "error") => {
if (noticeTimerRef.current) {
clearTimeout(noticeTimerRef.current);
noticeTimerRef.current = null;
}
void invoke("show_widget_notice", {
@ -28,24 +39,20 @@ export function useWidgetNotice({ stateRef }: UseWidgetNoticeParams): UseWidgetN
});
noticeTimerRef.current = setTimeout(() => {
void invoke("hide_widget_notice");
noticeTimerRef.current = null;
hideNotice();
}, NOTICE_TIMEOUT_MS);
},
[stateRef],
[hideNotice, stateRef],
);
useEffect(() => {
return () => {
if (noticeTimerRef.current) {
clearTimeout(noticeTimerRef.current);
}
void invoke("hide_widget_notice");
hideNotice();
};
}, []);
}, [hideNotice]);
return {
showNotice,
hideNotice,
};
}

View file

@ -11,7 +11,6 @@ import {
MIN_RECORDING_DURATION_MS,
RECORDING_WIDGET_HEIGHT,
RECORDING_WIDGET_WIDTH,
WidgetNoticeTone,
} from "../widgetConstants";
import { createRecordingRuntimeController } from "../services/recordingRuntime";
import { processRecordingBlob } from "../services/transcriptionPipeline";
@ -24,7 +23,7 @@ interface UseWidgetRecordingParams {
setStream: Dispatch<SetStateAction<MediaStream | null>>;
resizeWidget: (width: number, height: number) => Promise<void>;
showError: (message: string) => void;
showNotice: (message: string, tone?: WidgetNoticeTone) => void;
hideNotice: () => void;
stopAndProcessRef: MutableRefObject<() => Promise<void>>;
}
@ -77,7 +76,7 @@ export function useWidgetRecording({
setStream,
resizeWidget,
showError,
showNotice,
hideNotice,
stopAndProcessRef,
}: UseWidgetRecordingParams): UseWidgetRecordingResult {
const runtimeRef = useRef(createRecordingRuntimeController());
@ -89,6 +88,7 @@ export function useWidgetRecording({
// ── Start recording ─────────────────────────────────────────────────────
const startRecording = useCallback(async () => {
logInfo("RECORDING", "startRecording called");
hideNotice();
if (!settings) {
logError("RECORDING", "Settings not loaded");
@ -172,7 +172,7 @@ export function useWidgetRecording({
`Ошибка запуска записи: ${error instanceof Error ? error.message : "Неизвестная ошибка"}`,
);
}
}, [dispatch, machineRef, resizeWidget, setStream, settings, showError]);
}, [dispatch, hideNotice, machineRef, resizeWidget, setStream, settings, showError]);
// ── Stop and process ────────────────────────────────────────────────────
const stopAndProcess = useCallback(async () => {
@ -235,10 +235,6 @@ export function useWidgetRecording({
runtimeRef.current.reset();
dispatch({ type: "PROCESSING_COMPLETE" });
await resizeWidget(IDLE_WIDGET_WIDTH, IDLE_WIDGET_HEIGHT);
if (pipelineResult.pasteErrorMessage) {
showNotice(pipelineResult.pasteErrorMessage, "info");
}
} catch (error) {
const errorMessage = formatErrorMessage(error);
logError("API", `Processing error: ${errorMessage}`);
@ -248,7 +244,7 @@ export function useWidgetRecording({
runtimeRef.current.reset();
showError(message);
}
}, [dispatch, machineRef, resizeWidget, setStream, settings, showError, showNotice]);
}, [dispatch, machineRef, resizeWidget, setStream, settings, showError]);
// ── Keep stopAndProcessRef current ──────────────────────────────────────
useEffect(() => {

View file

@ -15,7 +15,6 @@ export interface ProcessRecordingBlobParams {
export interface ProcessRecordingBlobResult {
durationSeconds: number;
hasTranscription: boolean;
pasteErrorMessage?: string;
}
export interface RetryHistoryEntryResult {
@ -105,10 +104,6 @@ function toUserFacingErrorMessage(error: unknown): string {
return "Не удалось обработать запись. Попробуйте отправить ее повторно.";
}
function toUserFacingPasteErrorMessage(): string {
return "Текст распознан, но вставить его не удалось. Скопируйте его из истории.";
}
const PROXY_BASE_URL = "https://proxy.talkis.ru";
async function transcribeViaProxy({
@ -213,7 +208,7 @@ async function transcribeAudio({
async function pasteCleanedText(text: string): Promise<void> {
logInfo("PASTE", "Sending cleaned text to paste_text");
await invoke("paste_text", { text });
logInfo("PASTE", "paste_text finished successfully");
logInfo("PASTE", "paste_text command finished; target app insertion cannot be confirmed reliably");
}
async function saveAndEmitHistoryEntry(entry: HistoryEntry, mode: "add" | "update"): Promise<void> {
@ -268,18 +263,17 @@ export async function processRecordingBlob({
await saveAndEmitHistoryEntry(historyEntry, "add");
let pasteFailed = false;
try {
await pasteCleanedText(result.cleaned);
} catch (pasteError) {
pasteFailed = true;
logError("PASTE", `Paste failed after successful transcription: ${formatErrorMessage(pasteError)}`);
return {
durationSeconds,
hasTranscription: true,
pasteErrorMessage: toUserFacingPasteErrorMessage(),
};
}
logInfo("PASTE", pasteFailed
? "Automatic paste command failed; latest text remains copyable from the idle widget"
: "Automatic paste command completed without OS-level errors");
return { durationSeconds, hasTranscription: true };
} catch (error) {
const rawErrorMessage = error instanceof Error

View file

@ -71,6 +71,8 @@ export type WidgetAction =
| { type: "RELEASE_STOP_TIMER_FIRED" }
// State transitions
| { type: "SET_PROCESSING" }
| { type: "MANUAL_RECORDING_START" }
| { type: "MANUAL_RECORDING_STOP" }
// Error
| { type: "ERROR"; message: string }
// Direct state transitions for hotkey capture interference
@ -106,6 +108,10 @@ export function widgetReducer(
return handleReleaseStopTimerFired(state);
case "SET_PROCESSING":
return handleSetProcessing(state);
case "MANUAL_RECORDING_START":
return handleManualRecordingStart(state);
case "MANUAL_RECORDING_STOP":
return handleManualRecordingStop(state);
case "ERROR":
return handleError(state, action.message);
case "RESET_HOTKEY_STATE":
@ -184,8 +190,14 @@ function handleRecordingStarted(state: WidgetMachineState, timestamp: number): R
recordingStartTimestamp: timestamp,
};
// If user released while we were starting up → immediate stop
if ((!state.hotkeyHeld || state.pendingStopAfterStart) && !state.lockedRecording) {
// If user requested stop while recorder was starting up → immediate stop.
if (state.pendingStopAfterStart) {
next.pendingStopAfterStart = false;
return result(next, [{ type: "stop_and_process" }]);
}
// If user released while we were starting up → immediate stop.
if (!state.hotkeyHeld && !state.lockedRecording) {
next.pendingStopAfterStart = false;
return result(next, [{ type: "stop_and_process" }]);
}
@ -229,6 +241,41 @@ function handleSetProcessing(state: WidgetMachineState): ReducerResult {
}, []);
}
function handleManualRecordingStart(state: WidgetMachineState): ReducerResult {
if (state.widgetState !== "idle") {
return result(state, []);
}
return result({
...state,
widgetState: "recording",
hotkeyHeld: false,
lockedRecording: true,
recordingActive: false,
pendingStopAfterStart: false,
releaseStopTimerActive: false,
suppressNextRelease: false,
}, [
{ type: "set_locked_recording_ui", value: true },
{ type: "start_recording" },
]);
}
function handleManualRecordingStop(state: WidgetMachineState): ReducerResult {
if (state.widgetState !== "recording") {
return result(state, []);
}
if (!state.recordingActive) {
return result({
...state,
pendingStopAfterStart: true,
}, []);
}
return result(state, [{ type: "stop_and_process" }]);
}
function handleReleaseStopTimerFired(state: WidgetMachineState): ReducerResult {
const next = { ...state, releaseStopTimerActive: false };

View file

@ -9,12 +9,17 @@ export interface WidgetNoticeState {
export const MIN_RECORDING_DURATION_MS = 500;
export const MIN_AUDIO_BLOB_BYTES = 1024;
export const NOTICE_TIMEOUT_MS = 5000;
export const WIDGET_SHELL_WIDTH = 50;
export const WIDGET_SHELL_HEIGHT = 18;
export const IDLE_WIDGET_WIDTH = WIDGET_SHELL_WIDTH;
export const IDLE_WIDGET_HEIGHT = WIDGET_SHELL_HEIGHT;
export const RECORDING_WIDGET_WIDTH = WIDGET_SHELL_WIDTH;
export const RECORDING_WIDGET_HEIGHT = WIDGET_SHELL_HEIGHT;
export const WIDGET_SHELL_WIDTH = 44;
export const WIDGET_SHELL_HEIGHT = 16;
export const IDLE_HOVER_SCALE = 1.5;
export const ACTIVE_WIDGET_SHELL_WIDTH = WIDGET_SHELL_WIDTH * IDLE_HOVER_SCALE;
export const ACTIVE_WIDGET_SHELL_HEIGHT = WIDGET_SHELL_HEIGHT * IDLE_HOVER_SCALE;
export const IDLE_HOVER_WIDGET_WIDTH = ACTIVE_WIDGET_SHELL_WIDTH + 12;
export const IDLE_HOVER_WIDGET_HEIGHT = ACTIVE_WIDGET_SHELL_HEIGHT + 8;
export const IDLE_WIDGET_WIDTH = IDLE_HOVER_WIDGET_WIDTH;
export const IDLE_WIDGET_HEIGHT = IDLE_HOVER_WIDGET_HEIGHT;
export const RECORDING_WIDGET_WIDTH = IDLE_HOVER_WIDGET_WIDTH;
export const RECORDING_WIDGET_HEIGHT = IDLE_HOVER_WIDGET_HEIGHT;
export const NOTICE_WIDGET_WIDTH = 212;
export const NOTICE_AREA_HEIGHT = 52;
/** Must match NOTICE_GAP in src-tauri/src/lib.rs (logical pixels). */

View file

@ -88,6 +88,7 @@
| 5.4 | Вставка в терминал | Текст в терминал | Cmd+V работает |
| 5.5 | Быстрая двойная запись (2 подряд) | Оба текста вставлены последовательно | Нет потери второго |
| 5.6 | Буфер обмена восстановлен | Скопировать текст → записать → вставить (Cmd+V ещё раз) | Исходный буфер восстановился |
| 5.7 | Быстрое копирование последней записи | Навести курсор на неактивный виджет после успешной транскрибации | Внутри виджета появляется иконка копирования; клик копирует последний обработанный текст и кратко показывает состояние успеха |
---