* fix(ui): keep scaled desktop shell responsive * fix(linux): support desktop microphone capture * fix(ui): update the centered VoiceStudio brand * fix(audio): fall back when recorder start is unsupported * fix(desktop): use the app header as titlebar * feat(audio): add live microphone input controls * fix(dub): recover from missing transcription models * fix(dub): make pipeline stages actionable * fix(asr): recover low-memory transcription * docs: record desktop reliability fixes * fix(ui): use semantic error banner border * fix(dub): harden recovery and recording fallbacks
506 lines
20 KiB
React
506 lines
20 KiB
React
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { createPortal } from 'react-dom';
|
|
import {
|
|
Globe,
|
|
Fingerprint,
|
|
Wand2,
|
|
Film,
|
|
FolderOpen,
|
|
RefreshCw,
|
|
Settings2,
|
|
ChevronRight,
|
|
ChevronDown,
|
|
Zap,
|
|
Building2,
|
|
Library,
|
|
FileText,
|
|
Trash2,
|
|
Minus,
|
|
Square,
|
|
X,
|
|
} from 'lucide-react';
|
|
import { Button, Badge } from '../ui';
|
|
import NotificationPanel from './NotificationPanel';
|
|
import TitleTabs from './TitleTabs';
|
|
import { useAppStore } from '../store';
|
|
import { useSysinfo } from '../api/hooks';
|
|
|
|
const VIEW_META = {
|
|
launchpad: {
|
|
labelKey: 'header.label_launchpad',
|
|
Icon: Globe,
|
|
accent: '#f3a5b6',
|
|
kickerKey: 'header.kicker_studio',
|
|
},
|
|
studio: {
|
|
labelKey: 'nav.voice',
|
|
Icon: Fingerprint,
|
|
accent: '#d3869b',
|
|
kickerKey: 'header.kicker_studio',
|
|
},
|
|
// Legacy ids — kept so a not-yet-shimmed persisted 'clone'/'design' mode
|
|
// still renders a sensible header (voice-studio-unification P4).
|
|
clone: {
|
|
labelKey: 'header.label_clone',
|
|
Icon: Fingerprint,
|
|
accent: '#d3869b',
|
|
kickerKey: 'header.kicker_studio',
|
|
},
|
|
design: {
|
|
labelKey: 'header.label_design',
|
|
Icon: Wand2,
|
|
accent: '#8ec07c',
|
|
kickerKey: 'header.kicker_studio',
|
|
},
|
|
dub: {
|
|
labelKey: 'header.label_dub',
|
|
Icon: Film,
|
|
accent: '#fe8019',
|
|
kickerKey: 'header.kicker_studio',
|
|
},
|
|
projects: {
|
|
labelKey: 'header.label_projects',
|
|
Icon: FolderOpen,
|
|
accent: '#83a598',
|
|
kickerKey: 'header.kicker_library',
|
|
},
|
|
gallery: {
|
|
labelKey: 'header.label_gallery',
|
|
Icon: Library,
|
|
accent: '#b8bb26',
|
|
kickerKey: 'header.kicker_library',
|
|
},
|
|
transcriptions: {
|
|
labelKey: 'header.label_transcriptions',
|
|
Icon: FileText,
|
|
accent: '#d3869b',
|
|
kickerKey: 'header.kicker_library',
|
|
},
|
|
settings: {
|
|
labelKey: 'header.label_settings',
|
|
Icon: Settings2,
|
|
accent: '#fabd2f',
|
|
kickerKey: 'header.kicker_preferences',
|
|
},
|
|
enterprise: {
|
|
labelKey: 'header.label_enterprise',
|
|
Icon: Building2,
|
|
accent: '#fe8019',
|
|
kickerKey: 'header.kicker_licensing',
|
|
},
|
|
};
|
|
|
|
function WaveBars({ color = '#f3a5b6', active }) {
|
|
const heights = [4, 9, 5, 11, 6, 10, 5, 8];
|
|
return (
|
|
<div
|
|
className={`inline-flex items-center gap-[2px] h-[16px] px-[6px] transition-opacity duration-200 max-[1351px]:hidden ${active ? 'opacity-100' : 'opacity-[0.35]'}`}
|
|
aria-hidden="true"
|
|
>
|
|
{heights.map((h, i) => (
|
|
<span
|
|
key={i}
|
|
className={`inline-block w-[2.5px] rounded-[2px] transition-opacity duration-200 ${active ? '[animation:hqBounce_1.3s_ease-in-out_infinite]' : ''}`}
|
|
style={{
|
|
// Height + color are per-instance; animation-delay is per-bar.
|
|
// These three are genuinely dynamic so stay inline.
|
|
height: h,
|
|
background: color,
|
|
animationDelay: `${i * 0.08}s`,
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
async function runWindowAction(action) {
|
|
try {
|
|
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
|
const appWindow = getCurrentWindow();
|
|
if (action === 'minimize') await appWindow.minimize();
|
|
else if (action === 'maximize') await appWindow.toggleMaximize();
|
|
else if (action === 'close') await appWindow.close();
|
|
} catch {
|
|
console.warn('Window control action failed');
|
|
}
|
|
}
|
|
|
|
export default function Header({
|
|
mode,
|
|
setMode,
|
|
navStyle = 'rail',
|
|
modelStatus,
|
|
doubleClickMaximize,
|
|
activeProjectName,
|
|
onFlushMemory,
|
|
}) {
|
|
// Titlebar-tabs mode puts the workspace switcher in this row, where the
|
|
// breadcrumb + wordmark normally sit — the tabs already say where you are,
|
|
// and two answers to that question in one bar is one too many.
|
|
const tabsInTitlebar = navStyle === 'tabs';
|
|
const showWindowControls = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
|
const { t } = useTranslation();
|
|
// Sysinfo is subscribed here (not in App via useAppData) so the 5s poll
|
|
// only re-renders the header chrome, not the whole App tree.
|
|
const sysQuery = useSysinfo();
|
|
const sysStats = sysQuery.data ?? null;
|
|
// Default OFF — chrome shouldn't double as a resource monitor. Power users
|
|
// flip this on via Settings → Performance. Idle/Ready/Loading badge +
|
|
// Flush button stay visible regardless (action-relevant).
|
|
const showLiveStats = useAppStore((s) => s.showHeaderLiveStats);
|
|
const [flushing, setFlushing] = useState(false);
|
|
const [flushOpen, setFlushOpen] = useState(false);
|
|
const [loadedModels, setLoadedModels] = useState([]);
|
|
const [unloading, setUnloading] = useState(null);
|
|
const flushRef = useRef(null);
|
|
const flushBtnRef = useRef(null);
|
|
const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0 });
|
|
|
|
// Dynamically compute dropdown position from button rect
|
|
const computePos = useCallback(() => {
|
|
if (!flushBtnRef.current) return;
|
|
const rect = flushBtnRef.current.getBoundingClientRect();
|
|
const dropW = 260;
|
|
const dropH = 220; // approximate max height
|
|
const pad = 6;
|
|
|
|
// Default: below button, right-aligned
|
|
let top = rect.bottom + pad;
|
|
let left = rect.right - dropW;
|
|
|
|
// Flip up if too close to bottom
|
|
if (top + dropH > window.innerHeight - 10) {
|
|
top = rect.top - dropH - pad;
|
|
}
|
|
// Clamp left so it doesn't go off-screen
|
|
if (left < 8) left = 8;
|
|
if (left + dropW > window.innerWidth - 8) left = window.innerWidth - dropW - 8;
|
|
|
|
setDropdownPos({ top, left });
|
|
}, []);
|
|
|
|
// Recompute on open, resize, and scroll
|
|
useEffect(() => {
|
|
if (!flushOpen) return;
|
|
computePos();
|
|
window.addEventListener('resize', computePos);
|
|
window.addEventListener('scroll', computePos, true);
|
|
return () => {
|
|
window.removeEventListener('resize', computePos);
|
|
window.removeEventListener('scroll', computePos, true);
|
|
};
|
|
}, [flushOpen, computePos]);
|
|
const view = VIEW_META[mode] || VIEW_META.launchpad;
|
|
const ViewIcon = view.Icon;
|
|
|
|
// Fetch loaded models when dropdown opens
|
|
useEffect(() => {
|
|
if (!flushOpen) return;
|
|
const fetchModels = async () => {
|
|
try {
|
|
const { apiFetch } = await import('../api/client');
|
|
const res = await apiFetch('/model/loaded');
|
|
const data = await res.json();
|
|
setLoadedModels(data.models || []);
|
|
} catch {}
|
|
};
|
|
fetchModels();
|
|
}, [flushOpen]);
|
|
|
|
// Click outside to close (must check both the button wrapper AND the portal dropdown)
|
|
const dropdownRef = useRef(null);
|
|
useEffect(() => {
|
|
if (!flushOpen) return;
|
|
const handler = (e) => {
|
|
const inBtn = flushRef.current && flushRef.current.contains(e.target);
|
|
const inDrop = dropdownRef.current && dropdownRef.current.contains(e.target);
|
|
if (!inBtn && !inDrop) setFlushOpen(false);
|
|
};
|
|
document.addEventListener('mousedown', handler);
|
|
return () => document.removeEventListener('mousedown', handler);
|
|
}, [flushOpen]);
|
|
|
|
const unloadModel = async (modelId) => {
|
|
setUnloading(modelId);
|
|
try {
|
|
const { apiFetch } = await import('../api/client');
|
|
await apiFetch(`/model/unload/${modelId}`, { method: 'POST' });
|
|
setLoadedModels((prev) => prev.filter((m) => m.id !== modelId));
|
|
} catch {
|
|
} finally {
|
|
setUnloading(null);
|
|
}
|
|
};
|
|
// Dynamic accent color must stay inline — it's driven by the current view.
|
|
const dotStyle = { background: view.accent, boxShadow: `0 0 10px ${view.accent}90` };
|
|
const labelStyle = { color: view.accent };
|
|
return (
|
|
<div
|
|
className={`header-area ${tabsInTitlebar ? 'header-area--tabs' : ''}`}
|
|
data-tauri-drag-region
|
|
onDoubleClick={doubleClickMaximize}
|
|
>
|
|
{tabsInTitlebar ? (
|
|
<div className="header-area__tabs min-w-0">
|
|
<TitleTabs mode={mode} setMode={setMode} />
|
|
</div>
|
|
) : (
|
|
/* Left: view title + breadcrumb */
|
|
<div className="flex items-center gap-[14px] justify-self-start min-w-0">
|
|
<div className="inline-flex items-center gap-[6px] h-[var(--chrome-pill-h)] [font-family:var(--font-sans)] max-[961px]:gap-[5px]">
|
|
<span
|
|
className="w-[7px] h-[7px] rounded-full shrink-0 [animation:hqPulse_2.4s_ease-in-out_infinite] max-[821px]:hidden"
|
|
style={dotStyle}
|
|
/>
|
|
<span className="text-[length:var(--chrome-label-size)] font-semibold tracking-[var(--chrome-label-track)] uppercase text-[var(--chrome-fg-muted)] max-[1501px]:hidden">
|
|
{t(view.kickerKey)}
|
|
</span>
|
|
<ChevronRight size={10} color="#504945" className="mx-[2px] max-[1501px]:hidden" />
|
|
<span
|
|
className="inline-flex items-center gap-1 [font-family:var(--font-sans)] text-[0.72rem] font-semibold tracking-[0.02em] max-[961px]:text-[0.78rem]"
|
|
style={labelStyle}
|
|
>
|
|
<ViewIcon size={12} className="mr-1 align-[-1px]" />
|
|
{t(view.labelKey)}
|
|
</span>
|
|
{activeProjectName ? (
|
|
<>
|
|
<ChevronRight size={10} color="#504945" className="mx-[2px]" />
|
|
<span
|
|
className="[font-family:var(--font-sans)] text-[0.68rem] font-medium text-[var(--chrome-fg-muted)] max-w-[180px] overflow-hidden text-ellipsis whitespace-nowrap max-[1201px]:hidden"
|
|
title={activeProjectName}
|
|
>
|
|
{activeProjectName}
|
|
</span>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
{import.meta.env.DEV && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
title={t('common.reload')}
|
|
onClick={() => window.location.reload()}
|
|
leading={<RefreshCw size={9} />}
|
|
className="shrink-0"
|
|
>
|
|
{t('common.reload')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Center: logo — the tabs take this room in titlebar-tabs mode. */}
|
|
{!tabsInTitlebar && (
|
|
<div className="flex items-center gap-2 justify-self-center pointer-events-none whitespace-nowrap">
|
|
<svg
|
|
viewBox="0 0 32 32"
|
|
fill="none"
|
|
aria-hidden="true"
|
|
data-testid="voice-studio-logo"
|
|
className="size-6 overflow-visible"
|
|
>
|
|
<path
|
|
d="M2 16c2.2 0 2.5-4 4.2-4 2.1 0 2.2 8 4.1 8 2.1 0 2.2-16 4.3-16 2.3 0 2.1 24 4.3 24 2.1 0 2.2-19 4.2-19 2.2 0 2.1 12 4 12 1.7 0 2-5 3.9-5"
|
|
stroke="var(--chrome-accent)"
|
|
strokeWidth="2.8"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
<span className="text-[0.92rem] font-semibold text-[var(--chrome-fg)] tracking-[0.02em] [font-family:var(--font-sans)] not-italic">
|
|
Voice<span className="text-[var(--chrome-accent)]">Studio</span>
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Right: wave + sys stats. UI scale (S/M/L) lives in the bottom
|
|
LogsFooter bar so all app-wide chrome sits together. */}
|
|
<div className="flex items-center justify-end gap-3 justify-self-end min-w-0 overflow-visible">
|
|
<NotificationPanel onNavigate={setMode} />
|
|
<WaveBars
|
|
color={view.accent}
|
|
active={modelStatus === 'ready' || modelStatus === 'loading'}
|
|
/>
|
|
{sysStats && (
|
|
<div className="flex items-center gap-[10px] [font-family:var(--chrome-font-mono)] text-[10.5px] text-[var(--chrome-fg-dim)] bg-transparent h-[var(--chrome-pill-h)] whitespace-nowrap shrink overflow-hidden tabular-nums slashed-zero max-[851px]:hidden!">
|
|
{showLiveStats && (
|
|
<>
|
|
<span className="max-[1081px]:hidden">
|
|
<b className="text-[var(--chrome-fg-muted)] font-semibold">RAM</b>{' '}
|
|
{sysStats.ram.toFixed(1)}/{sysStats.total_ram.toFixed(0)}G
|
|
</span>
|
|
<span className="max-[1081px]:hidden">
|
|
<b className="text-[var(--chrome-fg-muted)] font-semibold">CPU</b>{' '}
|
|
{sysStats.cpu.toFixed(0)}%
|
|
</span>
|
|
<span
|
|
className="[border-left:1px_solid_var(--chrome-border)] pl-[6px]"
|
|
aria-label={`VRAM usage: ${sysStats.vram.toFixed(1)} gigabytes`}
|
|
>
|
|
<b
|
|
className={`font-semibold ${sysStats.gpu_active ? 'text-[var(--chrome-severity-ok)]' : 'text-[var(--chrome-fg-muted)]'}`}
|
|
>
|
|
VRAM
|
|
</b>{' '}
|
|
{sysStats.vram.toFixed(1)}G
|
|
</span>
|
|
</>
|
|
)}
|
|
<span className="[border-left:1px_solid_var(--chrome-border)] pl-[6px] flex items-center gap-1">
|
|
<Badge
|
|
tone={
|
|
modelStatus === 'ready'
|
|
? 'success'
|
|
: modelStatus === 'loading'
|
|
? 'warn'
|
|
: 'neutral'
|
|
}
|
|
size="xs"
|
|
dot
|
|
className={`[border:none]! bg-transparent! p-0! normal-case! tracking-normal! font-semibold! ${modelStatus === 'loading' ? 'ui-badge--pulse' : ''}`}
|
|
>
|
|
{modelStatus === 'ready'
|
|
? t('header.status_ready')
|
|
: modelStatus === 'loading'
|
|
? t('header.status_loading')
|
|
: t('header.status_idle')}
|
|
</Badge>
|
|
</span>
|
|
{onFlushMemory && (
|
|
<div ref={flushRef} style={{ position: 'relative' }}>
|
|
<Button
|
|
ref={flushBtnRef}
|
|
variant="subtle"
|
|
size="sm"
|
|
title={t('header.memory_management')}
|
|
loading={flushing}
|
|
leading={!flushing && <Zap size={8} />}
|
|
trailing={<ChevronDown size={8} />}
|
|
onClick={() => setFlushOpen((o) => !o)}
|
|
className="ml-[2px]"
|
|
>
|
|
{t('header.flush')}
|
|
</Button>
|
|
{flushOpen &&
|
|
createPortal(
|
|
<div
|
|
className="fixed w-[260px] bg-[var(--color-bg-elev-1)] [border:1px_solid_var(--color-border)] rounded-[var(--radius-lg)] [box-shadow:0_8px_24px_rgba(0,0,0,0.5)] z-[9999] py-[4px] [animation:flush-slide_0.12s_ease-out]"
|
|
style={{ top: dropdownPos.top, left: dropdownPos.left }}
|
|
ref={dropdownRef}
|
|
>
|
|
<div className="text-[10px] font-semibold text-[var(--color-fg-subtle)] uppercase tracking-[0.5px] pt-[6px] px-[12px] pb-[4px]">
|
|
{t('header.loaded_models')}
|
|
</div>
|
|
{loadedModels.length === 0 ? (
|
|
<div className="p-[12px] text-[11px] text-[var(--color-fg-muted)] text-center">
|
|
{t('header.no_models')}
|
|
</div>
|
|
) : (
|
|
loadedModels.map((m) => (
|
|
<div
|
|
key={m.id}
|
|
className="flex items-center justify-between py-[6px] px-[12px] gap-2 hover:bg-[rgba(255,255,255,0.03)]"
|
|
>
|
|
<div className="flex flex-col gap-[1px] min-w-0">
|
|
<span className="text-[12px] text-[var(--color-fg)] font-medium">
|
|
{m.name}
|
|
{/* Resident-but-not-routed engine (e.g. VoiceStudio still in
|
|
VRAM after switching to another backend) — say so. */}
|
|
{m.is_active_engine === false && (
|
|
<span className="ml-[6px] text-[10px] font-normal text-[var(--color-fg-subtle)] [font-family:var(--font-mono)]">
|
|
{t('header.model_not_active')}
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span className="text-[10px] text-[var(--color-fg-subtle)] [font-family:var(--font-mono)]">
|
|
{m.device} {m.vram_mb > 0 ? `· ${m.vram_mb.toFixed(0)} MB` : ''}
|
|
</span>
|
|
</div>
|
|
{m.unloadable && (
|
|
<button
|
|
className="text-[10px] font-semibold text-[var(--color-brand)] bg-[rgba(211,134,155,0.1)] [border:1px_solid_rgba(211,134,155,0.2)] rounded-[var(--radius-pill)] py-[2px] px-[8px] cursor-pointer shrink-0 hover:bg-[rgba(211,134,155,0.2)]"
|
|
onClick={() => unloadModel(m.id)}
|
|
disabled={unloading === m.id}
|
|
aria-label={`Unload ${m.name}`}
|
|
>
|
|
{unloading === m.id ? '…' : t('header.unload')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
))
|
|
)}
|
|
<div className="h-[1px] bg-[var(--color-border)] my-[4px]" />
|
|
<button
|
|
className="flex items-center gap-[6px] w-full py-[6px] px-[12px] text-[12px] text-[var(--color-fg)] bg-transparent border-none cursor-pointer text-left hover:bg-[rgba(255,255,255,0.04)]"
|
|
onClick={async () => {
|
|
setFlushing(true);
|
|
setFlushOpen(false);
|
|
try {
|
|
await onFlushMemory(false);
|
|
} finally {
|
|
setFlushing(false);
|
|
}
|
|
}}
|
|
>
|
|
<Zap size={10} /> {t('header.flush_caches')}
|
|
</button>
|
|
<button
|
|
className="flex items-center gap-[6px] w-full py-[6px] px-[12px] text-[12px] text-[#fb4934] bg-transparent border-none cursor-pointer text-left hover:bg-[rgba(251,73,52,0.08)]"
|
|
onClick={async () => {
|
|
setFlushing(true);
|
|
setFlushOpen(false);
|
|
try {
|
|
await onFlushMemory(true);
|
|
} finally {
|
|
setFlushing(false);
|
|
}
|
|
}}
|
|
>
|
|
<Trash2 size={10} /> {t('header.unload_all_flush')}
|
|
</button>
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{showWindowControls && (
|
|
<div className="ml-1 flex h-full shrink-0 items-stretch" data-testid="window-controls">
|
|
<button
|
|
type="button"
|
|
className="flex h-7 w-9 items-center justify-center border-0 bg-transparent text-[var(--chrome-fg-muted)] hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--chrome-fg)]"
|
|
aria-label={t('common.minimize_window')}
|
|
title={t('common.minimize_window')}
|
|
onClick={() => void runWindowAction('minimize')}
|
|
>
|
|
<Minus size={13} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="flex h-7 w-9 items-center justify-center border-0 bg-transparent text-[var(--chrome-fg-muted)] hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--chrome-fg)]"
|
|
aria-label={t('common.maximize_restore_window')}
|
|
title={t('common.maximize_restore_window')}
|
|
onClick={() => void runWindowAction('maximize')}
|
|
>
|
|
<Square size={10} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="flex h-7 w-9 items-center justify-center border-0 bg-transparent text-[var(--chrome-fg-muted)] hover:bg-[#c42b1c] hover:text-white"
|
|
aria-label={t('common.close_window')}
|
|
title={t('common.close_window')}
|
|
onClick={() => void runWindowAction('close')}
|
|
>
|
|
<X size={13} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|