chore(lint): adopt oxlint as the linter + CI gate; fix the bugs it surfaced (#761)

ESLint was misconfigured (only globals.browser → 47 false no-undef) and run
NOWHERE in CI, so 259 errors had accumulated unnoticed. Replace it with oxlint
(Rust, ~50-100x faster) as the primary linter AND a real CI gate so lint debt
can't silently pile up again.

Tooling:
- frontend/.oxlintrc.json — correctness=error; no-unused-vars with the existing
  ^[A-Z_] convention; node/vitest env overrides + AudioWorklet/__APP_VERSION__
  globals (kills the false no-undef class); max-lines:500 (warn).
- package.json: `lint` → oxlint, `lint:fix`, `lint:hooks` (advisory eslint).
- eslint.config.js stripped to ONLY the React-Compiler rule family oxlint can't
  do yet (set-state-in-effect etc.), run via `lint:hooks`, NOT gated. Drop once
  oxlint's JS-plugin support leaves alpha.
- ci.yml: new "Frontend lint (oxlint)" step in the Tests job — the gate.

Real bugs oxlint caught (were buried in ESLint's noise):
- GlossaryPanel: <X/> close-icon used but never imported → the edit-row cancel
  button threw ReferenceError at render. Imported X.
- Two use*-named NON-hooks (useEngine action, useArchetypeAsProfile API call)
  tripped rules-of-hooks; suppressed with documented disables (renaming these
  misleading names is a worthwhile follow-up).

Cleanup to reach a 0-error gate: removed 52 genuinely-dead vars/imports across
18 files (heavy in App.jsx — stale useState left over from prior refactors) and
4 behavior-preserving autofixes (no-useless-fallback-in-spread / no-useless-escape).

Verified: oxlint 0 errors; bun install --frozen-lockfile in sync (Docker rule);
vite build passes; full suite 638/638.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-06-30 05:08:52 +05:30
committed by GitHub
co-authored by mergetest Claude Opus 4.8
parent b9707e0d8f
commit 8765f766e1
31 changed files with 138 additions and 76 deletions
+7
View File
@@ -105,6 +105,13 @@ jobs:
working-directory: frontend
run: bun run typecheck:ci
# oxlint gate — fast Rust linter, blocks on errors so lint debt can't
# re-accumulate (warnings, incl. the react-compiler advisories in
# `lint:hooks`, are non-blocking). See frontend/.oxlintrc.json.
- name: Frontend lint (oxlint)
working-directory: frontend
run: bun run lint
- name: Run Vitest (frontend)
working-directory: frontend
run: bunx vitest run
+42 -1
View File
@@ -15,7 +15,7 @@
},
"frontend": {
"name": "omnivoice-studio",
"version": "0.3.5",
"version": "0.3.8",
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/source-serif-4": "^5.2.9",
@@ -67,6 +67,7 @@
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"oxlint": "^1.71.0",
"playwright-core": "1.61.0",
"typescript": "^6.0.3",
"vite": "^8.0.16",
@@ -205,6 +206,44 @@
"@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="],
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.71.0", "", { "os": "android", "cpu": "arm" }, "sha512-ImGmd1njEg4FEJH03jhRnveEegtO3czCtfptvaHivKAZQIYATbVFBrrzbaYMYv0oJioTnxZAZVSyV+oL7W8S2g=="],
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.71.0", "", { "os": "android", "cpu": "arm64" }, "sha512-4A5BEexBrwY1YFF8Kiq/lp/wQPRG79G3BWIE1FuWaM5MvmpYSd+7ZySVcKkHdwo0UDzdQGddp6pD9mpctMqLnw=="],
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.71.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9wJA9GJulLwS2usU3CEisI/ESDO1n1z9eyTCvApMDrAkbJ1ve0mORgTMjcWWsKxkzkeZ2N/Gpra5IQE7x8tYgQ=="],
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.71.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-PlLCjS06V0PeJMAJwzjrExw1sYNW9Gch3JtNlcwwZDXGlTYDuwHNN89zYH8LTXFfgkVtsYvs2nv0FqrzyuFDzg=="],
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.71.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Lhil7bWre0ncxbUoDoxfS0JzpTz17BRQKW7iwoAUY8GJ66+WwJEfYPCFJ1P0WgVZR5/O/b3Q2pENlHOjeXLOGQ=="],
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.71.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Oo9/L58PYD3RC0x05d2upAPLllHytTjHQGsnC06P6Ynn7jKkp5mdImQxXdJ3+FnBaKspNpGogzgVsi6g872LiA=="],
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.71.0", "", { "os": "linux", "cpu": "arm" }, "sha512-mSHfyfgJrEbyIR29ejaeS50BdPk+GoNPlC1dckpDiUZbJAIel68sjSMdOt4WY0/gva+ECC7FNITQkxMJU+vSBw=="],
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.71.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-n9yY4M2tiy3aij4AqtlnspzpfdpeT5JQfK2/w2d8oyp5W0FRwOb1dIeX99nORNcxGr08iD9bH8N5XFz3I2iy8w=="],
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.71.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ=="],
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.71.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA=="],
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.71.0", "", { "os": "linux", "cpu": "none" }, "sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg=="],
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.71.0", "", { "os": "linux", "cpu": "none" }, "sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA=="],
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.71.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg=="],
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.71.0", "", { "os": "linux", "cpu": "x64" }, "sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g=="],
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.71.0", "", { "os": "linux", "cpu": "x64" }, "sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA=="],
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.71.0", "", { "os": "none", "cpu": "arm64" }, "sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw=="],
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.71.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-bd5kI8spYwTm3BILDtGhi73zoup5dw8MlPQNT8YB3BD5UIsjNe3K9/4ctrzQMX4SZMoK5HgzVLkLJzacEXB7fA=="],
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.71.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-W4HvOHGzVLHcrmFu+bMrJlho+/yrlX5ZNdJZqGe8MEldkQG+RHYhxxad9P4jvWAYFmIqUA5i9DQ8QsJqSU9GIw=="],
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.71.0", "", { "os": "win32", "cpu": "x64" }, "sha512-D2kyEIPHk/G/wiZLnwTVC/sVst+T/lKldVOjAFpgTIBUAOlry72e5OiapDbDBF4LfJLkN5ypJb/8Eu6yJzkveQ=="],
"@playwright/test": ["@playwright/test@1.61.0", "", { "dependencies": { "playwright": "1.61.0" }, "bin": { "playwright": "cli.js" } }, "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA=="],
"@radix-ui/number": ["@radix-ui/number@1.1.2", "", {}, "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig=="],
@@ -773,6 +812,8 @@
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"oxlint": ["oxlint@1.71.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.71.0", "@oxlint/binding-android-arm64": "1.71.0", "@oxlint/binding-darwin-arm64": "1.71.0", "@oxlint/binding-darwin-x64": "1.71.0", "@oxlint/binding-freebsd-x64": "1.71.0", "@oxlint/binding-linux-arm-gnueabihf": "1.71.0", "@oxlint/binding-linux-arm-musleabihf": "1.71.0", "@oxlint/binding-linux-arm64-gnu": "1.71.0", "@oxlint/binding-linux-arm64-musl": "1.71.0", "@oxlint/binding-linux-ppc64-gnu": "1.71.0", "@oxlint/binding-linux-riscv64-gnu": "1.71.0", "@oxlint/binding-linux-riscv64-musl": "1.71.0", "@oxlint/binding-linux-s390x-gnu": "1.71.0", "@oxlint/binding-linux-x64-gnu": "1.71.0", "@oxlint/binding-linux-x64-musl": "1.71.0", "@oxlint/binding-openharmony-arm64": "1.71.0", "@oxlint/binding-win32-arm64-msvc": "1.71.0", "@oxlint/binding-win32-ia32-msvc": "1.71.0", "@oxlint/binding-win32-x64-msvc": "1.71.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-U1m1X+C0vDj7DC1e13IoZULzEcPczE7UOMTs8VlZGHUEIUaSTZKo5qkPsQEfzpgnQ29Pea/w3Xntk62UCecxZw=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
+38
View File
@@ -0,0 +1,38 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"env": {
"browser": true,
"es2024": true
},
"globals": {
"__APP_VERSION__": "readonly",
"AudioWorklet": "readonly",
"AudioWorkletNode": "readonly",
"AudioWorkletProcessor": "readonly",
"registerProcessor": "readonly"
},
"plugins": ["react", "import", "unicorn"],
"ignorePatterns": ["dist/", "src-tauri/", "node_modules/"],
"categories": {
"correctness": "error"
},
"rules": {
"no-unused-vars": ["error", { "varsIgnorePattern": "^[A-Z_]", "argsIgnorePattern": "^_", "caughtErrors": "none" }],
"no-empty": ["warn", { "allowEmptyCatch": true }],
"no-unused-expressions": ["error", { "allowShortCircuit": true, "allowTernary": true }],
"react/exhaustive-deps": "warn",
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }],
"max-lines": ["warn", { "max": 500, "skipBlankLines": true, "skipComments": true }]
},
"overrides": [
{
"files": ["vite.config.js", "*.config.js", "eslint.config.js"],
"env": { "node": true }
},
{
"files": ["**/*.test.{js,jsx}", "**/*.spec.{js,jsx}", "src/test/**"],
"env": { "node": true, "vitest": true }
}
]
}
+15 -14
View File
@@ -1,32 +1,33 @@
import js from '@eslint/js'
import globals from 'globals'
// Advisory-only lint for the React Compiler rule family that oxlint does not yet
// implement natively (e.g. react-hooks/set-state-in-effect, immutability,
// preserve-manual-memoization). Run locally via `bun run lint:hooks`.
//
// This is NOT a CI gate. oxlint (`bun run lint`, see .oxlintrc.json) is the gate.
// Everything oxlint already covers (no-unused-vars, exhaustive-deps,
// rules-of-hooks, no-undef, max-lines, react-refresh) is turned OFF here so the
// two tools don't double-report. Drop this file once oxlint's JS-plugin support
// graduates from alpha and can run eslint-plugin-react-hooks directly.
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import globals from 'globals'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
extends: [reactHooks.configs.flat.recommended],
languageOptions: {
ecmaVersion: 2020,
ecmaVersion: 'latest',
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
// Modularization guardrail (warn-only so CI stays green). See
// CONTRIBUTING.md → "Frontend file structure & size limits".
'max-lines': ['warn', { max: 500, skipBlankLines: true, skipComments: true }],
// Owned by oxlint — disabled here to avoid duplicate diagnostics.
'react-hooks/exhaustive-deps': 'off',
'react-hooks/rules-of-hooks': 'off',
},
},
])
+4 -1
View File
@@ -8,7 +8,9 @@
"dev": "vite",
"desktop": "tauri dev",
"build": "vite build",
"lint": "eslint .",
"lint": "oxlint",
"lint:fix": "oxlint --fix",
"lint:hooks": "eslint .",
"typecheck": "tsc --noEmit",
"typecheck:ci": "tsc --noEmit --checkJs false",
"test": "vitest run",
@@ -69,6 +71,7 @@
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"oxlint": "^1.71.0",
"playwright-core": "1.61.0",
"typescript": "^6.0.3",
"vite": "^8.0.16",
+6 -23
View File
@@ -39,7 +39,6 @@ import FloatingPill from './components/FloatingPill';
// main studio return below. Do not re-wrap here — double-gating renders two
// PIN dialogs.
import useRealtimeEvents from './hooks/useRealtimeEvents';
import { BootstrapSplash, useBootstrapStage } from './components/BootstrapSplash';
import './components/Misc.css';
@@ -60,13 +59,12 @@ import {
POPULAR_LANGS, POPULAR_ISO, TAGS, CATEGORIES, PRESETS, CLONE_MAX_SECONDS,
} from './utils/constants';
import { LANG_CODES } from './utils/languages';
import { formatTime } from './utils/format';
import { API, apiPost } from './api/client';
import { API } from './api/client';
import { flushMemory as apiFlushMemory } from './api/system';
import { saveProject as apiSaveProject, loadProject as apiLoadProject, deleteProject as apiDeleteProject, renameProject as apiRenameProject } from './api/projects';
import { exportAction, exportReveal, exportRecord } from './api/exports';
import { isTauri, doubleClickMaximize, fileToMediaUrl, playBlobAudio, playPing } from './utils/media';
import { isTauri, doubleClickMaximize, fileToMediaUrl, playBlobAudio } from './utils/media';
import { browserDownload } from './utils/download';
import { checkForUpdate, fetchAppVersion } from './utils/updater';
import { syncChannel } from './utils/channelControl';
@@ -83,7 +81,6 @@ function App() {
// Mode + uiScale + sidebar-collapsed persist across reloads automatically
// via the store's `partialize`; active project / voice ids stay transient.
const uiScale = useAppStore(s => s.uiScale);
const setUiScale = useAppStore(s => s.setUiScale);
// Responsive shell breakpoints driven off the app-container's OWN width, not
// the viewport. The shell is sized `width: calc(100vw / --ui-scale)` then
@@ -338,42 +335,28 @@ function App() {
const setDubDialect = useAppStore(s => s.setDubDialect);
const dubInstruct = useAppStore(s => s.dubInstruct);
const setDubInstruct = useAppStore(s => s.setDubInstruct);
const dubProgress = useAppStore(s => s.dubProgress);
const setDubProgress = useAppStore(s => s.setDubProgress);
const dubFilename = useAppStore(s => s.dubFilename);
const setDubFilename = useAppStore(s => s.setDubFilename);
const dubDuration = useAppStore(s => s.dubDuration);
const setDubDuration = useAppStore(s => s.setDubDuration);
const dubError = useAppStore(s => s.dubError);
const setDubError = useAppStore(s => s.setDubError);
const dubTracks = useAppStore(s => s.dubTracks);
const setDubTracks = useAppStore(s => s.setDubTracks);
const dubTranscript = useAppStore(s => s.dubTranscript);
const setDubTranscript = useAppStore(s => s.setDubTranscript);
const isTranslating = useAppStore(s => s.isTranslating);
const setIsTranslating = useAppStore(s => s.setIsTranslating);
const preserveBg = useAppStore(s => s.preserveBg);
const setPreserveBg = useAppStore(s => s.setPreserveBg);
const defaultTrack = useAppStore(s => s.defaultTrack);
const setDefaultTrack = useAppStore(s => s.setDefaultTrack);
const exportTracks = useAppStore(s => s.exportTracks);
const setExportTracks = useAppStore(s => s.setExportTracks);
const previewSegIds = useAppStore(s => s.previewSegIds);
const setPreviewSegIds = useAppStore(s => s.setPreviewSegIds);
const speakerClones = useAppStore(s => s.speakerClones);
const setSpeakerClones = useAppStore(s => s.setSpeakerClones);
const dubTaskId = useAppStore(s => s.dubTaskId);
const setDubTaskId = useAppStore(s => s.setDubTaskId);
const dubPrepStage = useAppStore(s => s.dubPrepStage);
const setDubPrepStage = useAppStore(s => s.setDubPrepStage);
const translateQuality = useAppStore(s => s.translateQuality);
const setTranslateQuality = useAppStore(s => s.setTranslateQuality);
const glossaryTerms = useAppStore(s => s.glossaryTerms);
const setGlossaryTerms = useAppStore(s => s.setGlossaryTerms);
const dualSubs = useAppStore(s => s.dualSubs);
const burnSubs = useAppStore(s => s.burnSubs);
const setDualSubs = useAppStore(s => s.setDualSubs);
// ── UNDO / REDO + SEGMENT EDITING ──
// Must come before useDubWorkflow because the dub generate handler needs
@@ -383,12 +366,12 @@ function App() {
segmentEditField, segmentDelete, segmentRestoreOriginal,
segmentSplit, segmentMerge, segmentMoveResize,
timelineSelSegId, setTimelineSelSegId,
selectedSegIds, setSelectedSegIds,
selectedSegIds,
toggleSegSelect, selectAllSegs, clearSegSelection,
bulkApplyToSelected, bulkDeleteSelected,
directionSegId, openDirection, closeDirection, saveDirection,
lastGenFingerprints, setLastGenFingerprints,
incrementalPlan, setIncrementalPlan,
setLastGenFingerprints,
incrementalPlan,
recomputeIncremental,
} = useSegmentEditing();
@@ -397,7 +380,7 @@ function App() {
const {
translateProvider, setTranslateProvider,
showTranscript, setShowTranscript,
previewAudios, setPreviewAudios,
setPreviewAudios,
transcribeElapsed,
handleDubUpload: _handleDubUpload, handleDubIngestUrl,
handleDubAbort, handleDubRetryTranscribe,
+2 -2
View File
@@ -115,7 +115,7 @@ export async function apiFetch(path: string, opts: RequestInit = {}): Promise<Re
if (pin) extra['X-OmniVoice-Pin'] = pin;
if (key) extra['Authorization'] = `Bearer ${key}`;
const finalOpts: RequestInit = Object.keys(extra).length
? { ...opts, headers: { ...(opts.headers as Record<string, string> || {}), ...extra } }
? { ...opts, headers: { ...(opts.headers as Record<string, string>), ...extra } }
: opts;
const signal = finalOpts.signal as AbortSignal | null | undefined;
let lastDetail = '';
@@ -171,7 +171,7 @@ export async function apiPost<T = unknown>(
if (body instanceof FormData) {
init.body = body;
} else if (body !== undefined) {
init.headers = { 'Content-Type': 'application/json', ...(opts.headers as Record<string, string> || {}) };
init.headers = { 'Content-Type': 'application/json', ...(opts.headers as Record<string, string>) };
init.body = JSON.stringify(body);
}
return apiJson<T>(path, init);
+1 -4
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { Scissors, Play, Pause, Check, ZoomIn, ZoomOut, Maximize2, Repeat } from 'lucide-react';
import {
clamp, encodeWav, computePeaksFromChannel, computePeaksAsync, pickTickInterval,
@@ -12,9 +12,6 @@ import './AudioTrimmer.css';
const EDGE_GRAB_PX = 10;
const computePeaks = (buffer, buckets = DEFAULT_PEAK_BUCKETS) =>
computePeaksFromChannel(buffer.getChannelData(0), buckets);
function fmtSec(t, precision = 2) {
if (!isFinite(t)) return '0.00s';
return `${t.toFixed(precision)}s`;
+2 -2
View File
@@ -136,8 +136,8 @@ export default function CaptureWidget({ onDismiss }) {
const [captureMode] = useState(() =>
localStorage.getItem(LS_CAPTURE_MODE) || 'fast'
);
const [lastEngine, setLastEngine] = useState('');
const [lastTime, setLastTime] = useState(0);
const [, setLastEngine] = useState('');
const [, setLastTime] = useState(0);
const [partialText, setPartialText] = useState('');
// Live-dictation prefs (mirrored from the backend dictation.* namespace).
@@ -125,7 +125,7 @@ export default function EngineCompatibilityMatrix({
const [activeFamily, setActiveFamily] = useState(family);
// Phase 3 Plan 03-01 / TTS-05: which engine has its license dialog
// currently open, or null. Only one dialog is ever open at a time.
const [licenseDialogFor, setLicenseDialogFor] = useState(null);
const [, setLicenseDialogFor] = useState(null);
// health state keyed by engine id:
// { [id]: { inflight: boolean, ok?: boolean, message?: string,
+1 -2
View File
@@ -35,7 +35,7 @@ export default function ExportModal({
burnSubs, setBurnSubs,
API,
triggerDownload,
handleDubDownload, handleDubAudioDownload, handleAudioExport,
handleDubDownload, handleAudioExport,
segmentCount = 0,
timingStrategy = '',
onEnterprise,
@@ -80,7 +80,6 @@ export default function ExportModal({
return out;
}, [dubTracks, t]);
const dubOnlyTracks = useMemo(() => allTracks.filter(t => t.kind === 'dub'), [allTracks]);
const selectedTracks = allTracks.filter(t => exportTracks[t.code] !== false);
const selectedDubs = selectedTracks.filter(t => t.kind === 'dub');
+1 -1
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, Trash2, BookOpen, Sparkles, Check, ChevronDown } from 'lucide-react';
import { Plus, Trash2, BookOpen, Sparkles, Check, ChevronDown, X } from 'lucide-react';
import { toast } from 'react-hot-toast';
import { Panel, Button, Input, Badge } from '../ui';
import {
+1 -3
View File
@@ -7,7 +7,7 @@ import {
import toast from 'react-hot-toast';
import { clearSystemLogs, clearTauriLogs } from '../api/system';
import { useSystemLogs, useTauriLogs, useNotifications, useClearLogs, useClearTauriLogs } from '../api/hooks';
import { useSystemLogs, useTauriLogs, useNotifications } from '../api/hooks';
import { getFrontendLogs, clearFrontendLogs } from '../utils/consoleBuffer';
import { useTranslation } from 'react-i18next';
import { useAppStore } from '../store';
@@ -160,7 +160,6 @@ export default function LogsFooter() {
// comes from the in-process ring buffer in consoleBuffer.js.
const [lines, setLines] = useState({ backend: [], frontend: [], tauri: [] });
const [loading, setLoading] = useState(false);
const [hfInput, setHfInput] = useState('');
const scrollRef = useRef(null);
useEffect(() => localStorage.setItem(LS_HEIGHT, String(height)), [height]);
@@ -344,7 +343,6 @@ export default function LogsFooter() {
// ── Render ──────────────────────────────────────────────────────────
const current = lines[active] || [];
const notifCounts = { error: 0, warn: notifications.filter(n => n.level === 'warn').length + notifications.filter(n => n.level === 'error').length, total: notifications.length };
return (
<div className={['logs-footer', collapsed ? 'logs-footer--collapsed' : 'logs-footer--open'].join(' ')}
@@ -87,7 +87,6 @@ export default function ReadinessChecklist({ compact = false, showWhenAllPass =
// Determine if all critical checks pass
const allPass = checks.every(c => c.status === 'pass' || c.status === 'warn');
const anyFail = checks.some(c => c.status === 'fail');
const criticalFails = checks.filter(c => c.status === 'fail');
// Hide when everything is fine (unless explicitly asked to show)
if (!showWhenAllPass && allPass && !isLoading) return null;
+1 -1
View File
@@ -48,7 +48,7 @@ export default function WaveformPlayer({
useEffect(() => { autoPlayRef.current = autoPlay; }, [autoPlay]);
const [resolvedUrl, setResolvedUrl] = useState(null);
const [ready, setReady] = useState(false);
const [, setReady] = useState(false);
const [failed, setFailed] = useState(false); // WaveSurfer unavailable → native fallback
const [missing, setMissing] = useState(false); // source 404s (stale history) → inert notice
const [isPlaying, setIsPlaying] = useState(false);
@@ -288,6 +288,7 @@ export default function WizardLibrary() {
type="button"
className="frs-btn frs-btn--quiet swiz-lib__act"
disabled={switching === b.id}
// eslint-disable-next-line react-hooks/rules-of-hooks -- useEngine is an action fn, not a React hook
onClick={() => useEngine(b.id)}
>
{t('firstrun.lib_use', 'Use')}
+1 -3
View File
@@ -4,12 +4,10 @@ import { listProfiles } from '../api/profiles';
import { listHistory } from '../api/generate';
import { listProjects } from '../api/projects';
import { listDubHistory } from '../api/dub';
import { listExportHistory, exportAction, exportReveal, exportRecord } from '../api/exports';
import { listExportHistory } from '../api/exports';
import { modelStatus as apiModelStatus } from '../api/system';
import { useModelStatus } from '../api/hooks';
import useRealtimeEvents from './useRealtimeEvents';
import { isTauri, fileToMediaUrl } from '../utils/media';
import { toast } from 'react-hot-toast';
/**
* Encapsulates all data-loading effects, localStorage persistence,
+1 -1
View File
@@ -9,7 +9,7 @@ import { dialectMatchesLang } from '../api/dialects';
import { segmentGenInputs, applySpeakerCloneDefaults } from '../utils/segments';
import { apiPost } from '../api/client';
import { API } from '../api/client';
import { playPing, isTauri } from '../utils/media';
import { playPing } from '../utils/media';
import { toast } from 'react-hot-toast';
import { toastErrorWithReport } from '../utils/errorToast';
import { addBreadcrumb } from '../utils/breadcrumbs';
+1 -1
View File
@@ -1,6 +1,6 @@
import { useState, useRef, useCallback } from 'react';
import { useAppStore } from '../store';
import { generateSpeech, audioUrlWithCacheBust } from '../api/generate';
import { generateSpeech } from '../api/generate';
import { pickDesignSeed } from '../utils/seed';
import { playBlobAudio, playPing } from '../utils/media';
import { probeAudioDuration } from '../utils/format';
+3 -3
View File
@@ -128,7 +128,7 @@ export default function AudiobookTab({ profiles = [] }) {
const onPreviewChapter = useCallback(async (i) => {
setError('');
setChapterPrev((p) => ({ ...p, [i]: { ...(p[i] || {}), loading: true } }));
setChapterPrev((p) => ({ ...p, [i]: { ...p[i], loading: true } }));
try {
const lexicon = lexDict();
const r = await audiobookPreviewChapter({
@@ -137,7 +137,7 @@ export default function AudiobookTab({ profiles = [] }) {
});
setChapterPrev((p) => ({ ...p, [i]: { url: audioUrl(r.output), loading: false } }));
} catch (e) {
setChapterPrev((p) => ({ ...p, [i]: { ...(p[i] || {}), loading: false } }));
setChapterPrev((p) => ({ ...p, [i]: { ...p[i], loading: false } }));
setError(e?.message || String(e));
}
}, [text, defaultVoice, lex]);
@@ -174,7 +174,7 @@ export default function AudiobookTab({ profiles = [] }) {
} else if (evt.type === 'chapter') {
setProgress({ current: evt.index + 1, total: evt.total, title: evt.title });
} else if (evt.type === 'assembling') {
setProgress((p) => ({ ...(p || {}), assembling: true }));
setProgress((p) => ({ ...p, assembling: true }));
} else if (evt.type === 'chapter_error') {
setProgress({ current: evt.index + 1, total: evt.total, title: evt.title });
} else if (evt.type === 'done') {
+1 -1
View File
@@ -256,7 +256,7 @@ function JobCard({ job, onCancel, onDelete, t }) {
{/* Output downloads for done jobs */}
{job.status === 'done' && job.outputs && Object.keys(job.outputs).length > 0 && (
<div className="batch-queue__card-outputs">
{Object.entries(job.outputs).map(([lang, path]) => (
{Object.entries(job.outputs).map(([lang]) => (
<a
key={lang}
className="batch-queue__card-dl"
+1 -1
View File
@@ -59,7 +59,7 @@ function ActionCard({ hue, Icon, title, count, onClick, children }) {
}
export default function Launchpad({
profiles, studioProjects, dubHistory, exportHistory = [],
profiles, studioProjects, exportHistory = [],
setMode, setIsCompareModalOpen, handleSelectProfile, loadProject,
}) {
const { t } = useTranslation();
-3
View File
@@ -231,9 +231,6 @@ export default function Settings() {
}
}, [updateChannel, t]);
// refreshInfo polling replaced by TanStack Query (useSystemInfo + useModelStatus)
const refreshInfo = useCallback(() => {}, []);
const refreshLogs = useCallback(async () => {
setLoadingLogs(true);
try {
-1
View File
@@ -8,7 +8,6 @@
* page updates in realtime without requiring a shared store.
*/
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { copyText } from "../utils/copyText";
import { useTranslation } from 'react-i18next';
import { Mic, Copy, Trash2, Search, Clock, Languages, FileText, Download } from 'lucide-react';
import { Button } from '../ui';
+1
View File
@@ -163,6 +163,7 @@ export default function VoiceGallery() {
onPreview={(a) => playUrl(archetypePreviewUrl(a.id), a.id)}
onUse={async (a) => {
try {
// eslint-disable-next-line react-hooks/rules-of-hooks -- useArchetypeAsProfile is an API call, not a React hook
const r = await useArchetypeAsProfile(a.id, a.name);
// Hand the new profile to the synthesis view and jump there so the
// user lands ready-to-generate instead of hunting for it in the list.
+1 -1
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fireEvent, render, screen, within } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import VoiceSelector from '../components/VoiceSelector';
+1 -1
View File
@@ -1,4 +1,4 @@
import React, { useCallback, isValidElement } from 'react';
import React, { isValidElement } from 'react';
import * as RadixMenu from '@radix-ui/react-dropdown-menu';
import { ChevronRight } from 'lucide-react';
import './Menu.css';
+1 -1
View File
@@ -18,7 +18,7 @@ export const PAUSE_MAX_MS = 10000;
// body. Moved-equivalent of audiobook.py _HEADING_RE. Global+multiline.
const HEADING_RE = /^[ \t]*#[ \t]+(\S.*)$/gm;
// [voice:NAME] — content excludes BOTH brackets (mirrors _VOICE_RE).
const VOICE_RE = /\[voice:([^\]\[]*)\]/g;
const VOICE_RE = /\[voice:([^\][]*)\]/g;
// Pause dialect mirroring omnivoice.utils.text._PAUSE_RE. JS has no atomic
// group; the unit's own `(?:\s*(ms|s))?` is zero-width when no unit follows, so
// the trailing `\s*` is the ONLY consumer of trailing whitespace — no two `\s*`
+1 -1
View File
@@ -65,7 +65,7 @@ export function describeMicError(err, platform = detectPlatform()) {
/** Convenience: resolve the description to a final translated string. */
export function micErrorMessage(t, err, platform = detectPlatform()) {
const d = describeMicError(err, platform);
const params = { ...(d.params || {}) };
const params = { ...d.params };
if (d.hintKey) params.hint = t(d.hintKey);
return t(d.key, params);
}
+1 -1
View File
@@ -26,7 +26,7 @@ import { roundHalfToEven, PAUSE_DEFAULT_MS, PAUSE_MAX_MS } from './longformParse
// [voice:[^\]\[]*] (empty [voice:] → default; a nested `[` → no match).
// ReDoS-safe: the unit's `(?:\s*(ms|s))?` is zero-width without a unit, so no
// two `\s*` overlap on the same whitespace run (mirrors PAUSE_RE).
const TOKEN_RE = /\[(?:\s*pause(?:\s+(\d+(?:\.\d+)?)(?:\s*(ms|s))?)?\s*|voice:\s*([^\]\[]*))\]/gi;
const TOKEN_RE = /\[(?:\s*pause(?:\s+(\d+(?:\.\d+)?)(?:\s*(ms|s))?)?\s*|voice:\s*([^\][]*))\]/gi;
// Resolve a parsed (number, unit) pause to clamped ms — mirrors text.py:_pause_ms
// (banker's rounding via the shared roundHalfToEven so highlight == render).
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect } from 'vitest';
// Stub Tauri globals before import so isTauri is false
delete globalThis.window?.__TAURI_INTERNALS__;