From a4cfc49af2cbd9237a40c000dc60fda1d59c3b5f Mon Sep 17 00:00:00 2001 From: Palash Debnath <4178343+debpalash@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:46:33 +0530 Subject: [PATCH 01/15] fix(electron): polish Linux onboarding and workspace sidebar controls --- CHANGELOG.md | 2 + bun.lock | 2 + docs/electron-runtime.md | 6 +- .../components/app-shell/sidebar-toggle.tsx | 22 +++ .../app-shell/use-workspace-sidebar.test.tsx | 54 ++++++++ .../app-shell/use-workspace-sidebar.ts | 67 +++++++++ .../components/app-shell/workspace-header.tsx | 2 + .../app-shell/workspace-sidebar.tsx | 106 ++++---------- .../src/components/setup-gate.test.tsx | 22 ++- .../renderer/src/components/setup-gate.tsx | 130 +++++++++++------- .../src/features/clone/clone-page.tsx | 20 +-- .../renderer/src/features/home/home-page.tsx | 26 ++-- .../src/features/settings/model-library.tsx | 48 ++++--- .../src/renderer/src/i18n/locales/ar.json | 1 + .../src/renderer/src/i18n/locales/de.json | 1 + .../src/renderer/src/i18n/locales/en.json | 1 + .../src/renderer/src/i18n/locales/es.json | 1 + .../src/renderer/src/i18n/locales/fr.json | 1 + .../src/renderer/src/i18n/locales/hi.json | 1 + .../src/renderer/src/i18n/locales/id.json | 1 + .../src/renderer/src/i18n/locales/it.json | 1 + .../src/renderer/src/i18n/locales/ja.json | 1 + .../src/renderer/src/i18n/locales/ko.json | 1 + .../src/renderer/src/i18n/locales/nl.json | 1 + .../src/renderer/src/i18n/locales/pl.json | 1 + .../src/renderer/src/i18n/locales/pt.json | 1 + .../src/renderer/src/i18n/locales/ru.json | 1 + .../src/renderer/src/i18n/locales/sv.json | 1 + .../src/renderer/src/i18n/locales/th.json | 1 + .../src/renderer/src/i18n/locales/tr.json | 1 + .../src/renderer/src/i18n/locales/uk.json | 1 + .../src/renderer/src/i18n/locales/vi.json | 1 + .../src/renderer/src/i18n/locales/zh-CN.json | 1 + .../src/renderer/src/i18n/locales/zh-TW.json | 1 + .../src/renderer/src/lib/store/workspace.ts | 1 + electron/tests/setup-gate-smoke.mjs | 39 +++++- frontend/src/i18n/locales/ar.json | 1 + frontend/src/i18n/locales/de.json | 1 + frontend/src/i18n/locales/en.json | 1 + frontend/src/i18n/locales/es.json | 1 + frontend/src/i18n/locales/fr.json | 1 + frontend/src/i18n/locales/hi.json | 1 + frontend/src/i18n/locales/id.json | 1 + frontend/src/i18n/locales/it.json | 1 + frontend/src/i18n/locales/ja.json | 1 + frontend/src/i18n/locales/ko.json | 1 + frontend/src/i18n/locales/nl.json | 1 + frontend/src/i18n/locales/pl.json | 1 + frontend/src/i18n/locales/pt.json | 1 + frontend/src/i18n/locales/ru.json | 1 + frontend/src/i18n/locales/sv.json | 1 + frontend/src/i18n/locales/th.json | 1 + frontend/src/i18n/locales/tr.json | 1 + frontend/src/i18n/locales/uk.json | 1 + frontend/src/i18n/locales/vi.json | 1 + frontend/src/i18n/locales/zh-CN.json | 1 + frontend/src/i18n/locales/zh-TW.json | 1 + 57 files changed, 408 insertions(+), 181 deletions(-) create mode 100644 electron/src/renderer/src/components/app-shell/sidebar-toggle.tsx create mode 100644 electron/src/renderer/src/components/app-shell/use-workspace-sidebar.test.tsx create mode 100644 electron/src/renderer/src/components/app-shell/use-workspace-sidebar.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 77e2dd83..d86d34a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ the frozen-backend fallback mirror it for their toolchains. ### Changed +- Electron first run uses four simple steps with model packs, optional advanced controls and skippable dictation setup (#1823) + - Model Catalogue is one page: a setup summary (speech, transcription, dictation, language model) on top, one TTS / ASR / LLM switch, and each family's downloadable weights listed under its engines; the separate Models pane and the Settings → Voice → Engines / Models signposts are gone, the models directory and voice previews moved to Settings → Storage and the HF mirror to Network (#2013) - The engine list is one line per engine (engine, device it runs on, status, one action) with a detail panel for everything else; each engine's weights install from its panel, so the separate weights list and recommendation card are gone (#2020) - CosyVoice 3 installs patched protobuf and transformers releases, clearing five security advisories (#2030, #2031) diff --git a/bun.lock b/bun.lock index 621ce781..7dcf89bb 100644 --- a/bun.lock +++ b/bun.lock @@ -2800,6 +2800,8 @@ "@types/keyv/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + "@types/react-dom/@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], + "@types/responselike/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], "@vitest/browser/pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], diff --git a/docs/electron-runtime.md b/docs/electron-runtime.md index 8871a8da..1cddbca8 100644 --- a/docs/electron-runtime.md +++ b/docs/electron-runtime.md @@ -19,7 +19,7 @@ Cancel stops the active process tree or download and returns to setup. Failure p Verification: runtime regression tests cover consent gating, dependency changes, incomplete environments, failed repair, cancellation before download, and source replacement. `node electron/tests/packaged-smoke.mjs --setup` verifies the first-run view without initiating an installation. `--install` performs the explicit isolated runtime installation and verifies the packaged renderer's same-origin connection to its managed backend. -The first-run browser gate also survives a partial preflight payload without blanking, keeps navigation disabled until a complete passing report arrives, and presents required, installed and curated recommended models before the optional catalogue. Its compact 640 px layout leaves the model cards usable without horizontal overflow. +First run has four steps: System check, Model packs, Privacy, and Enter studio. Model packs reuse the existing performance tiers and install only missing supported models after an explicit click. Installed state, remaining download size, disk checks and installation progress appear in one place. Advanced mode reveals individual models, engine tuning, privacy settings and recovery tools; failed system checks still expose recovery without enabling Advanced. Dictation and its permissions are optional on the final step and remain available in Settings. Navigation requires passing preflight, required models and the privacy choice; entering the studio opens the first-voice demo. On narrow windows the step navigation moves above the content and the footer stays visible. A subsequent packaged launch also reused that installed runtime and started a managed backend. Electron discovers compatible Tauri default, custom and portable runtime locations and can reuse them without a download when the interpreter and both frozen dependency manifests match. Its location record distinguishes reused environments from custom runtimes Electron creates. Uninstall includes only an Electron-owned custom runtime; it never claims or removes a reused Tauri runtime. @@ -73,3 +73,7 @@ Setup reserves its active operation before asynchronous compatibility checks. Re If the renderer remains empty after three bounded reloads, Electron paints an asset-independent localized recovery page. Its retry clears only Chromium's display cache, schedules a relaunch, and still shuts the managed backend down cleanly; voices, projects, models and preferences remain untouched. Branding remains in a fixed native title row while onboarding status loads, installation runs or recovery needs retry. Runtime details scroll independently below it; installer phases wrap into two columns on narrow windows, so a growing progress/log surface cannot clip the wordmark or window controls. + +On Windows and Linux, the collapsed workspace sidebar shows the VoiceStudio icon at the top. Use the toggle beside the page title to expand the sidebar. macOS retains its existing sidebar control. + +Every main workspace header exposes the same sidebar toggle, including pages that automatically collapse the voice library at narrow widths. The control reflects the visible sidebar state and explicitly expands it for the current workspace. diff --git a/electron/src/renderer/src/components/app-shell/sidebar-toggle.tsx b/electron/src/renderer/src/components/app-shell/sidebar-toggle.tsx new file mode 100644 index 00000000..2764bb2e --- /dev/null +++ b/electron/src/renderer/src/components/app-shell/sidebar-toggle.tsx @@ -0,0 +1,22 @@ +import { PanelLeftCloseIcon, PanelLeftOpenIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; +import { useWorkspaceSidebarState } from './use-workspace-sidebar'; + +export function SidebarToggle() { + const { t } = useTranslation(); + const { compact, setOpen } = useWorkspaceSidebarState(); + return ( + + ); +} diff --git a/electron/src/renderer/src/components/app-shell/use-workspace-sidebar.test.tsx b/electron/src/renderer/src/components/app-shell/use-workspace-sidebar.test.tsx new file mode 100644 index 00000000..49a99e7e --- /dev/null +++ b/electron/src/renderer/src/components/app-shell/use-workspace-sidebar.test.tsx @@ -0,0 +1,54 @@ +import { renderHook, act } from '@testing-library/react'; +import { beforeEach, expect, it, vi } from 'vitest'; +const state = vi.hoisted(() => ({ + path: '/stories', + narrow: true, + layout: { libraryOpen: true, expandedLibraryContext: null as string | null }, +})); +vi.mock('@tanstack/react-router', () => ({ + useRouterState: ({ select }: any) => select({ location: { pathname: state.path } }), +})); +vi.mock('@/lib/store/workspace', () => ({ + useWorkspace: () => state.layout, + setWorkspace: (patch: object) => { + Object.assign(state.layout, patch); + }, +})); +import { useWorkspaceSidebarState } from './use-workspace-sidebar'; +beforeEach(() => { + state.path = '/stories'; + state.narrow = true; + state.layout = { libraryOpen: true, expandedLibraryContext: null }; + vi.stubGlobal('matchMedia', () => ({ + matches: state.narrow, + addEventListener() {}, + removeEventListener() {}, + })); +}); +it('expands an automatically collapsed sidebar even when libraryOpen is already true', () => { + const { result, rerender } = renderHook(useWorkspaceSidebarState); + expect(result.current.compact).toBe(true); + act(() => result.current.setOpen(true)); + rerender(); + expect(result.current.compact).toBe(false); + act(() => result.current.setOpen(false)); + rerender(); + expect(result.current.compact).toBe(true); +}); +it('does not carry a forced expansion to another workspace', () => { + const { result, rerender } = renderHook(useWorkspaceSidebarState); + act(() => result.current.setOpen(true)); + rerender(); + state.path = '/gallery'; + rerender(); + expect(result.current.compact).toBe(true); +}); +it('keeps the clone workspace toggle in sync with explicit collapse', () => { + state.path = '/clone'; + state.narrow = false; + const { result, rerender } = renderHook(useWorkspaceSidebarState); + expect(result.current.compact).toBe(false); + act(() => result.current.setOpen(false)); + rerender(); + expect(result.current.compact).toBe(true); +}); diff --git a/electron/src/renderer/src/components/app-shell/use-workspace-sidebar.ts b/electron/src/renderer/src/components/app-shell/use-workspace-sidebar.ts new file mode 100644 index 00000000..51d2ffeb --- /dev/null +++ b/electron/src/renderer/src/components/app-shell/use-workspace-sidebar.ts @@ -0,0 +1,67 @@ +import { useSyncExternalStore } from 'react'; +import { useRouterState } from '@tanstack/react-router'; +import { setWorkspace, useWorkspace } from '@/lib/store/workspace'; + +const SECONDARY_ROUTES = new Set([ + '/stories', + '/audiobook', + '/tools', + '/batch', + '/gallery', + '/personas', + '/projects', + '/dub', + '/design', + '/transcriptions', +]); +// A local-controls pane needs enough room for the actual workspace. At the +// default desktop window, preserve navigation as a rail and restore the full +// voice library automatically once both it and a local-controls pane leave a +// useful editing canvas. Browser zoom and Windows display scaling are included +// in the CSS viewport width, so this threshold also covers high-DPI layouts. +const COMPACT_QUERY = '(max-width: 1680px)'; + +function routeHasSecondarySidebar(pathname: string): boolean { + const normalized = pathname.replace(/\/+$/, '') || '/'; + return [...SECONDARY_ROUTES].some( + (route) => normalized === route || normalized.startsWith(`${route}/`), + ); +} + +function routeOwnsVoiceLibrary(pathname: string): boolean { + const normalized = pathname.replace(/\/+$/, '') || '/'; + return normalized === '/personas' || normalized.startsWith('/personas/'); +} + +function useCompactViewport(): boolean { + return useSyncExternalStore( + (notify) => { + const query = window.matchMedia(COMPACT_QUERY); + query.addEventListener('change', notify); + return () => query.removeEventListener('change', notify); + }, + () => window.matchMedia(COMPACT_QUERY).matches, + () => false, + ); +} + +export function useWorkspaceSidebarState() { + const { libraryOpen, expandedLibraryContext } = useWorkspace(); + const pathname = useRouterState({ select: (state) => state.location.pathname }); + const compactViewport = useCompactViewport(); + const compactContext = `${pathname}:${compactViewport}`; + const forceExpanded = expandedLibraryContext === compactContext; + const compact = + !libraryOpen || + ((routeOwnsVoiceLibrary(pathname) || (compactViewport && routeHasSecondarySidebar(pathname))) && + !forceExpanded); + const setOpen = (open: boolean) => + setWorkspace({ libraryOpen: open, expandedLibraryContext: open ? compactContext : null }); + return { + compact, + compactViewport, + forceExpanded, + secondaryWorkspace: routeHasSecondarySidebar(pathname), + setOpen, + }; +} diff --git a/electron/src/renderer/src/components/app-shell/workspace-header.tsx b/electron/src/renderer/src/components/app-shell/workspace-header.tsx index bc656b75..4b576dce 100644 --- a/electron/src/renderer/src/components/app-shell/workspace-header.tsx +++ b/electron/src/renderer/src/components/app-shell/workspace-header.tsx @@ -1,3 +1,4 @@ +import { SidebarToggle } from './sidebar-toggle'; import type { ReactNode } from 'react'; import { SearchIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; @@ -14,6 +15,7 @@ export function WorkspaceHeader({ children }: { children: ReactNode }) { !isMac() && 'native-controls-right', )} > + {!isMac() && } {children} + {showCompactBrand ? ( +
+ {t('app.name')} +
+ ) : ( + + )}
@@ -153,8 +106,7 @@ export function WorkspaceSidebar() { size="icon-sm" aria-label={t('common.close')} onClick={() => { - setExpandedContext(null); - setLibraryOpen(false); + setOpen(false); }} > diff --git a/electron/src/renderer/src/components/setup-gate.test.tsx b/electron/src/renderer/src/components/setup-gate.test.tsx index c091f330..1105df01 100644 --- a/electron/src/renderer/src/components/setup-gate.test.tsx +++ b/electron/src/renderer/src/components/setup-gate.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; const mock = vi.hoisted(() => ({ ready: true, preflight: { ok: false } })); @@ -19,7 +19,7 @@ vi.mock('@/features/settings/system-preflight', () => ({ })); vi.mock('@/features/settings/model-library', () => ({ ModelLibrary: () =>
Models
, - SystemRecommendations: () => null, + PerformanceModelPacks: () =>
Model packs
, })); vi.mock('@/features/settings/model-settings', () => ({ ModelSettings: () =>
Engine settings
, @@ -73,13 +73,17 @@ it('requires passing preflight and installed models before completion', async () // without blanking the entire first-run experience. await waitFor(() => expect(client.getQueryData(['setup-preflight'])).toEqual({ ok: false })); expect(screen.getByRole('button', { name: 'setup.continue_ok' })).toBeDisabled(); - expect(screen.getByRole('button', { name: '4.engineSidebar.dictation' })).toBeDisabled(); + expect(screen.getByRole('button', { name: '4.setup.enter_studio' })).toBeDisabled(); client.setQueryData(['setup-preflight'], { ok: true, checks: [] }); await waitFor(() => expect(screen.getByRole('button', { name: 'setup.continue_ok' })).toBeEnabled(), ); fireEvent.click(screen.getByRole('button', { name: 'setup.continue_ok' })); - await screen.findByText('Models'); + await screen.findByText('Model packs'); + expect(screen.queryByText('Models')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'dub.advanced' })); + expect(screen.getByText('Models')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'dub.advanced' })); expect(screen.getByRole('button', { name: 'setup.continue_ok' })).toBeDisabled(); mock.ready = true; await client.invalidateQueries({ queryKey: ['setup-status'] }); @@ -87,13 +91,19 @@ it('requires passing preflight and installed models before completion', async () expect(screen.getByRole('button', { name: 'setup.continue_ok' })).toBeEnabled(), ); fireEvent.click(screen.getByRole('button', { name: 'setup.continue_ok' })); - await screen.findByText('Privacy'); + await screen.findByRole('button', { name: 'Choose privacy' }); + expect(screen.queryByText('Privacy')).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Choose privacy' })); await waitFor(() => expect(screen.getByRole('button', { name: 'setup.continue_ok' })).toBeEnabled(), ); fireEvent.click(screen.getByRole('button', { name: 'setup.continue_ok' })); - await screen.findByText('Shortcuts'); + await screen.findByText('setup.ready_desc'); + expect(screen.queryByText('Shortcuts')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /demo.dictation_title/ })); + expect(screen.getByText('Shortcuts')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /demo.dictation_title/ })); + expect(screen.queryByText('Shortcuts')).not.toBeInTheDocument(); let finishCatalogue!: () => void; const catalogueReady = new Promise((resolve) => { finishCatalogue = resolve; diff --git a/electron/src/renderer/src/components/setup-gate.tsx b/electron/src/renderer/src/components/setup-gate.tsx index b0bfd63b..b3162a00 100644 --- a/electron/src/renderer/src/components/setup-gate.tsx +++ b/electron/src/renderer/src/components/setup-gate.tsx @@ -1,4 +1,4 @@ -import { ModelLibrary, SystemRecommendations } from '@/features/settings/model-library'; +import { ModelLibrary, PerformanceModelPacks } from '@/features/settings/model-library'; import { AnalyticsConsent } from './analytics-consent'; import { SetupRecovery } from './setup-recovery'; import { useEffect, useState, type ReactNode } from 'react'; @@ -24,7 +24,7 @@ import { setupWasCompleted, setupWasStarted, } from '@/lib/setup-progress'; -import { AudioLinesIcon, CpuIcon, MicIcon, ShieldCheckIcon } from 'lucide-react'; +import { AudioLinesIcon, CpuIcon, SparklesIcon, ShieldCheckIcon } from 'lucide-react'; interface SetupStatus { models_ready: boolean; @@ -32,9 +32,9 @@ interface SetupStatus { } const steps = [ { label: 'setup.system_check', icon: CpuIcon }, - { label: 'setup.install_models', icon: AudioLinesIcon }, + { label: 'models.pack_title', icon: AudioLinesIcon }, { label: 'settings.privacy', icon: ShieldCheckIcon }, - { label: 'engineSidebar.dictation', icon: MicIcon }, + { label: 'setup.enter_studio', icon: SparklesIcon }, ] as const; export function SetupGate({ children }: { children: ReactNode }) { @@ -44,6 +44,8 @@ export function SetupGate({ children }: { children: ReactNode }) { const [needed, setNeeded] = useState(null); const [setupInProgress, setSetupInProgress] = useState(setupWasStarted); const [step, setStep] = useState(0); + const [advanced, setAdvanced] = useState(false); + const [dictationSetup, setDictationSetup] = useState(false); const [family, setFamily] = useState('tts'); const [consentRequired, setConsentRequired] = useState(true); const [enteringStudio, setEnteringStudio] = useState(false); @@ -98,28 +100,31 @@ export function SetupGate({ children }: { children: ReactNode }) {

{t('app.name')}

-
- {t('preferences.ui_scale')} - {appearanceScales.map((scale) => ( - - ))} -
+ {advanced && ( +
+ {t('preferences.ui_scale')} + {appearanceScales.map((scale) => ( + + ))} +
+ )}
-
-