feat(audit): Batch 11 — UX behavior gaps (toast surface, sidebar badge)

A new in-app toast surface is the batch's spine, making the silent-failure and
global-status gaps fixable:

- Toast infra: store/toasts.ts (useToasts + toast(); auto-dismiss) + ui/Toaster.tsx
  (non-portal bottom-center stack, avoids the Fluent-portal GPU flicker). Unit-
  tested (test/toasts.test.ts). Mounted at the app root beside LiveRegion.
- UX6: shared renderer reveal.ts revealFile('open'|'folder') awaits the IPC result
  and toasts the main-process reason instead of silently no-oping on a moved/typed-
  out file. safeShowInFolder upgraded to return an error string like safeOpenPath
  (preload/mock/Api types updated).
- UX9 / UI25: a Fluent CounterBadge on the sidebar Downloads nav item shows the
  active (downloading + queued) count from any tab, via a count-only App selector
  that doesn't re-render on progress ticks.
- UX15: cancelAll store action + a "Cancel all (N)" header button.
- UX24: the disabled "Check watched" button uses disabledFocusable + a Hint that
  explains it needs a watched source.
- UX12: the Terminal gate gains an inline "Enable custom commands" button, so it's
  no longer a dead-end pointing at another screen.
- L144: the DownloadBar duplicate guard now also checks history — a URL downloaded
  earlier warns "Already downloaded: …" (dupInHistory) vs "Already in your queue: …".

Deferred with rationale (CODE-AUDIT.md): UX7/UX8 (Settings IA redesign — visual),
UX18/UX26 (subjective/visual polish), L131 (arguably by-design, needs live taskbar),
L142 (history/templates/sources IPC-return reconciliation — a focused own PR).

Verified: typecheck (node+web) + 279 tests + eslint + production build green;
touched files prettier-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 17:05:26 -04:00
parent 814ecac287
commit 661a4e7572
18 changed files with 420 additions and 35 deletions
+6 -3
View File
@@ -53,8 +53,11 @@ export async function safeOpenPath(p: unknown): Promise<string> {
return shell.openPath(p)
}
/** Reveal a path in the OS file manager. No-op for missing/invalid paths. */
export function safeShowInFolder(p: unknown): void {
if (typeof p !== 'string' || !isAbsolute(p) || !existsSync(p)) return
/** Reveal a path in the OS file manager. Returns '' on success, or an error
* string (mirroring safeOpenPath) so the renderer can surface it (UX6). */
export function safeShowInFolder(p: unknown): string {
if (typeof p !== 'string' || !isAbsolute(p)) return 'Invalid path.'
if (!existsSync(p)) return 'File not found — it may have been moved or deleted.'
shell.showItemInFolder(p)
return ''
}
+2 -1
View File
@@ -80,7 +80,8 @@ const api = {
openUrl: (url: string): Promise<void> => ipcRenderer.invoke(IpcChannels.openUrl, url),
showInFolder: (path: string): Promise<void> => ipcRenderer.invoke(IpcChannels.showInFolder, path),
showInFolder: (path: string): Promise<string> =>
ipcRenderer.invoke(IpcChannels.showInFolder, path),
readClipboard: (): Promise<string> => ipcRenderer.invoke(IpcChannels.clipboardRead),
+9
View File
@@ -9,6 +9,7 @@ import { SettingsView } from './components/SettingsView'
import { Onboarding } from './components/Onboarding'
import { CommandPalette, type PaletteAction } from './components/CommandPalette'
import { LiveRegion } from './components/LiveRegion'
import { Toaster } from './components/ui/Toaster'
import { getTheme, pageBackground } from './theme'
import { SPACE } from './components/ui/tokens'
import { useSettings } from './store/settings'
@@ -55,6 +56,12 @@ function App(): React.JSX.Element {
const isDark = useResolvedDark()
const tab = useNav((s) => s.tab)
const setTab = useNav((s) => s.setTab)
// Active-download count for the sidebar Downloads badge (UX9/UI25), so a run in
// progress is visible from any tab. A number selector only re-renders when the
// count actually changes -- not on every progress tick.
const activeCount = useDownloads(
(s) => s.items.filter((i) => i.status === 'downloading' || i.status === 'queued').length
)
const [paletteOpen, setPaletteOpen] = useState(false)
// Track the element that was focused before the palette opened so we can restore it on close.
const prePaletteRef = useRef<Element | null>(null)
@@ -225,6 +232,7 @@ function App(): React.JSX.Element {
collapsed={collapsed}
onToggleCollapsed={toggleCollapsed}
showTerminal={showTerminal}
downloadCount={activeCount}
/>
<main className={styles.content}>
@@ -248,6 +256,7 @@ function App(): React.JSX.Element {
</>
)}
<LiveRegion />
<Toaster />
</div>
</FluentProvider>
)
+4 -1
View File
@@ -52,6 +52,7 @@ export function DownloadBar(): React.JSX.Element {
scheduleAt,
dragActive,
dup,
dupInHistory,
showAdvanced,
downloadOptions,
incognito,
@@ -170,7 +171,9 @@ export function DownloadBar(): React.JSX.Element {
</>
}
>
<Caption1>Already in your queue: "{dup}".</Caption1>
<Caption1>
{dupInHistory ? 'Already downloaded' : 'Already in your queue'}: "{dup}".
</Caption1>
</Banner>
)}
+10 -1
View File
@@ -8,7 +8,7 @@
tokens,
shorthands
} from '@fluentui/react-components'
import { ArrowDownloadRegular, ArrowClockwiseRegular } from '@fluentui/react-icons'
import { ArrowDownloadRegular, ArrowClockwiseRegular, DismissRegular } from '@fluentui/react-icons'
import { useDownloads, type DownloadItem } from '../store/downloads'
import { queueSummaryOf } from '../store/queueStats'
import { DownloadBar } from './DownloadBar'
@@ -75,6 +75,7 @@ export function DownloadsView(): React.JSX.Element {
const items = useDownloads((s) => s.items)
const clearFinished = useDownloads((s) => s.clearFinished)
const retryAll = useDownloads((s) => s.retryAll)
const cancelAll = useDownloads((s) => s.cancelAll)
const summary = queueSummaryOf(items)
const hasFinished = items.some(
@@ -89,6 +90,9 @@ export function DownloadsView(): React.JSX.Element {
i.status === 'paused' ||
i.status === 'saved'
).length
// Cancelable = the items with a live/pending run (downloading + queued); this is
// exactly what "Cancel all" acts on (UX15).
const cancelable = items.filter((i) => i.status === 'downloading' || i.status === 'queued').length
return (
<div className={mergeClasses(styles.root, screen.width)}>
@@ -126,6 +130,11 @@ export function DownloadsView(): React.JSX.Element {
Retry all failed ({summary.failed})
</Button>
)}
{cancelable > 0 && (
<Button size="small" appearance="subtle" icon={<DismissRegular />} onClick={cancelAll}>
Cancel all ({cancelable})
</Button>
)}
{hasFinished && (
<Button size="small" appearance="subtle" onClick={clearFinished}>
Clear finished
+22 -8
View File
@@ -36,6 +36,7 @@ import { thumbUrl } from '../thumb'
import { relTime } from '../datetime'
import { MediaThumb } from './MediaThumb'
import { VirtualList } from './VirtualList'
import { Hint } from './Hint'
import { ScreenHeader, useScreenStyles } from './ui/Screen'
import { StatusChip } from './ui/StatusChip'
import { SegmentedControl } from './ui/SegmentedControl'
@@ -536,15 +537,28 @@ export function LibraryView(): React.JSX.Element {
)}
<div className={styles.toolbar}>
<Button
size="small"
appearance="subtle"
icon={syncing ? <Spinner size="tiny" /> : <ArrowSyncRegular />}
onClick={onCheckNew}
disabled={syncing || watchedCount === 0}
<Hint
label={
watchedCount === 0
? 'Turn on "Watch" for a channel or playlist below first, then this checks them for new uploads.'
: 'Check your watched sources for new uploads'
}
placement="top"
align="start"
>
Check {watchedCount > 0 ? `${watchedCount} watched` : 'watched'} for new
</Button>
<Button
size="small"
appearance="subtle"
icon={syncing ? <Spinner size="tiny" /> : <ArrowSyncRegular />}
onClick={onCheckNew}
// disabledFocusable (not disabled) when nothing is watched, so the
// button still shows its explanatory tooltip on hover/focus (UX24).
disabled={syncing}
disabledFocusable={watchedCount === 0}
>
Check {watchedCount > 0 ? `${watchedCount} watched` : 'watched'} for new
</Button>
</Hint>
{syncNote && <Caption1 className={text.muted}>{syncNote}</Caption1>}
<div className={styles.toolbarSpacer} />
<span className={styles.switchRow}>
+38 -3
View File
@@ -1,4 +1,11 @@
import { Caption1, makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
import {
Caption1,
CounterBadge,
makeStyles,
mergeClasses,
tokens,
shorthands
} from '@fluentui/react-components'
import {
ArrowDownloadFilled,
ArrowDownloadRegular,
@@ -88,6 +95,8 @@ const useStyles = makeStyles({
alignSelf: 'stretch'
},
navItem: {
// relative so the collapsed count badge can pin to the icon's corner.
position: 'relative',
display: 'flex',
alignItems: 'center',
gap: '11px',
@@ -105,6 +114,16 @@ const useStyles = makeStyles({
backgroundColor: tokens.colorNeutralBackground1Hover
}
},
// Expanded: the active-download count sits at the far right of the nav row.
navBadge: {
marginLeft: 'auto'
},
// Collapsed: pin the count to the top-right corner of the centered icon.
navBadgeCollapsed: {
position: 'absolute',
top: '4px',
right: '8px'
},
navItemCollapsed: {
justifyContent: 'center',
padding: '9px 0'
@@ -155,6 +174,8 @@ interface SidebarProps {
onToggleCollapsed: () => void
/** Show the Terminal nav item (only when custom commands are enabled) */
showTerminal: boolean
/** Active (downloading + queued) count, badged on the Downloads nav item (UX9). */
downloadCount: number
}
export function Sidebar({
@@ -166,7 +187,8 @@ export function Sidebar({
version,
collapsed,
onToggleCollapsed,
showTerminal
showTerminal,
downloadCount
}: SidebarProps): React.JSX.Element {
const styles = useStyles()
const focus = useFocusStyles()
@@ -238,10 +260,23 @@ export function Sidebar({
}
onClick={() => onTabChange(n.value)}
aria-current={active ? 'page' : undefined}
aria-label={n.label}
aria-label={
n.value === 'downloads' && downloadCount > 0
? `${n.label}, ${downloadCount} active`
: n.label
}
>
<span className={styles.navIcon}>{n.icon}</span>
{!collapsed && n.label}
{n.value === 'downloads' && downloadCount > 0 && (
<CounterBadge
className={collapsed ? styles.navBadgeCollapsed : styles.navBadge}
count={downloadCount}
size="small"
color="brand"
aria-hidden
/>
)}
</button>
)
return collapsed ? (
+18 -4
View File
@@ -33,6 +33,10 @@ const MAX_LOG_LINES = 2000
const useStyles = makeStyles({
root: { display: 'flex', flexDirection: 'column', gap: SPACE.section, height: '100%' },
gate: {
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: '8px',
padding: '12px 14px',
backgroundColor: tokens.colorStatusWarningBackground1,
color: tokens.colorStatusWarningForeground1,
@@ -75,6 +79,7 @@ export function TerminalView(): React.JSX.Element {
const styles = useStyles()
const screen = useScreenStyles()
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
const updateSettings = useSettings((s) => s.update)
const [args, setArgs] = useState('')
const [lines, setLines] = useState<Line[]>([])
@@ -158,10 +163,19 @@ export function TerminalView(): React.JSX.Element {
/>
{!customCommandEnabled && (
<Body1 className={styles.gate}>
The terminal is part of custom commands. Turn on "Run custom commands" in Settings
Custom commands to use it.
</Body1>
<div className={styles.gate}>
<Body1>
The terminal runs the bundled yt-dlp with your own arguments part of custom commands,
which can pass arbitrary yt-dlp flags. Turn it on to use the terminal.
</Body1>
<Button
appearance="primary"
size="small"
onClick={() => updateSettings({ customCommandEnabled: true })}
>
Enable custom commands
</Button>
</div>
)}
<div className={styles.inputRow}>
@@ -9,6 +9,7 @@ import {
type CommandPreviewResult
} from '@shared/ipc'
import { useDownloads, type MediaKind } from '../../store/downloads'
import { useHistory } from '../../store/history'
import { QUALITY_OPTIONS } from '../../qualityOptions'
import { sameVideo } from '../../store/queueStats'
import { useSettings } from '../../store/settings'
@@ -55,6 +56,8 @@ export interface DownloadBarController {
setDragActive: Dispatch<SetStateAction<boolean>>
dup: string | null
setDup: Dispatch<SetStateAction<string | null>>
/** true when the duplicate was found in history (already downloaded) vs the live queue (L144). */
dupInHistory: boolean
// advanced / preview
showAdvanced: boolean
setShowAdvanced: Dispatch<SetStateAction<boolean>>
@@ -141,6 +144,9 @@ export function useDownloadBar(): DownloadBarController {
// Duplicate guard: warn before enqueuing a URL already in the active queue.
const [dup, setDup] = useState<string | null>(null)
// Whether the duplicate matched a history entry (already downloaded) rather than
// a live queue item (L144) — the banner wording differs.
const [dupInHistory, setDupInHistory] = useState(false)
const confirmDup = useRef(false)
// Per-download options (M5/M6/UX1): advanced features.
@@ -431,6 +437,15 @@ export function useDownloadBar(): DownloadBarController {
existing.title.endsWith(' download') ||
existing.title === 'New download'
setDup(isPlaceholder ? existing.url : existing.title)
setDupInHistory(false)
return
}
// Not in the queue, but maybe already downloaded earlier (L144): warn so a
// re-download is a deliberate choice, not an accidental duplicate.
const downloaded = useHistory.getState().entries.find((h) => sameVideo(h.url, trimmed))
if (downloaded) {
setDup(downloaded.title || downloaded.url)
setDupInHistory(true)
return
}
}
@@ -513,6 +528,7 @@ export function useDownloadBar(): DownloadBarController {
scheduleAt,
setScheduleAt,
// drag / dup
dupInHistory,
dragActive,
setDragActive,
dup,
@@ -0,0 +1,92 @@
import { Caption1, Button, makeStyles, tokens, shorthands } from '@fluentui/react-components'
import {
DismissRegular,
ErrorCircleFilled,
CheckmarkCircleFilled,
InfoFilled
} from '@fluentui/react-icons'
import { useToasts, type ToastTone } from '../../store/toasts'
import { Z, ELEVATION, RADIUS, ICON } from './tokens'
/**
* Renders the transient-toast stack (UX6/UX9) bottom-center. Deliberately NOT a
* Fluent Toaster/portal this app avoids portal-based overlays (they blank/flicker
* on the dev GPU; see Select.tsx / CommandPalette.tsx) so it's a plain fixed
* stack. No enter/exit animation, matching the app's conservative motion policy on
* this machine.
*/
const useStyles = makeStyles({
stack: {
position: 'fixed',
left: '50%',
bottom: '24px',
transform: 'translateX(-50%)',
zIndex: Z.tooltip,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '8px',
// Let clicks fall through the gaps between toasts to the app underneath.
pointerEvents: 'none'
},
toast: {
pointerEvents: 'auto',
display: 'flex',
alignItems: 'center',
gap: '10px',
maxWidth: 'min(520px, 90vw)',
padding: '10px 8px 10px 12px',
backgroundColor: tokens.colorNeutralBackground1,
border: `1px solid ${tokens.colorNeutralStroke2}`,
...shorthands.borderRadius(RADIUS.surface),
boxShadow: ELEVATION.overlay
},
icon: {
flexShrink: 0,
fontSize: `${ICON.control}px`,
display: 'flex'
},
info: { color: tokens.colorNeutralForeground3 },
error: { color: tokens.colorStatusDangerForeground1 },
success: { color: tokens.colorStatusSuccessForeground1 },
message: {
flexGrow: 1,
minWidth: 0
}
})
const ICONS: Record<ToastTone, React.JSX.Element> = {
info: <InfoFilled />,
error: <ErrorCircleFilled />,
success: <CheckmarkCircleFilled />
}
export function Toaster(): React.JSX.Element | null {
const styles = useStyles()
const toasts = useToasts((s) => s.toasts)
const dismiss = useToasts((s) => s.dismiss)
const toneClass: Record<ToastTone, string> = {
info: styles.info,
error: styles.error,
success: styles.success
}
if (toasts.length === 0) return null
return (
<div className={styles.stack} role="status" aria-live="polite">
{toasts.map((t) => (
<div key={t.id} className={styles.toast}>
<span className={`${styles.icon} ${toneClass[t.tone]}`}>{ICONS[t.tone]}</span>
<Caption1 className={styles.message}>{t.message}</Caption1>
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={() => dismiss(t.id)}
aria-label="Dismiss notification"
/>
</div>
))}
</div>
)
}
+1 -1
View File
@@ -135,7 +135,7 @@ export const mockApi: Api = {
chooseFolder: async () => null,
openPath: async () => '',
openUrl: async () => {},
showInFolder: async () => {},
showInFolder: async () => '',
readClipboard: async () => '',
logError: () => {},
getSettings: async () => MOCK_SETTINGS,
+19
View File
@@ -0,0 +1,19 @@
import { toast } from './store/toasts'
import { logError } from './reportError'
/**
* Open a downloaded file, or reveal it in its folder, surfacing any failure as a
* toast (UX6) instead of silently doing nothing. Clicking "Open file" on a
* download that was later moved or deleted used to no-op with no message; now the
* main-process reason ("File not found — it may have been moved…", "Refusing to
* open this file type.") is shown. Both IPC calls resolve to '' on success or an
* error string; a rejected promise (a bridge failure) is logged.
*/
export function revealFile(action: 'open' | 'folder', filePath: string): void {
const call = action === 'open' ? window.api.openPath(filePath) : window.api.showInFolder(filePath)
call
.then((err) => {
if (err) toast(err, 'error')
})
.catch(logError(action === 'open' ? 'openPath' : 'showInFolder'))
}
+2
View File
@@ -116,6 +116,8 @@ export interface DownloadState {
/** internal: promote any 'saved' item whose scheduledFor time has arrived */
promoteDueScheduled: () => void
cancel: (id: string) => void
/** cancel every active (downloading + queued) item at once (UX15) */
cancelAll: () => void
remove: (id: string) => void
clearFinished: () => void
openFile: (id: string) => void
+22 -2
View File
@@ -2,6 +2,7 @@ import { create } from 'zustand'
import { type MediaKind } from '@shared/ipc'
import { isPreview as PREVIEW } from '../isPreview'
import { logError } from '../reportError'
import { revealFile } from '../reveal'
import { useSettings } from './settings'
import { useHistory } from './history'
import { emit, on } from './coordinator'
@@ -335,6 +336,25 @@ export const useDownloads = create<DownloadState>((set, get) => {
pump()
},
cancelAll: () => {
const active = get().items.filter((i) => i.status === 'downloading' || i.status === 'queued')
if (active.length === 0) return
// Kill the live processes for the running ones; queued items never started.
if (!PREVIEW) {
for (const i of active) {
if (i.status === 'downloading') window.api.cancelDownload(i.id).catch(() => {})
}
}
set((s) => ({
items: s.items.map((i) =>
i.status === 'downloading' || i.status === 'queued'
? { ...i, status: 'canceled', speedBytesPerSec: undefined, etaSeconds: undefined }
: i
)
}))
pump()
},
remove: (id) => {
set((s) => ({ items: s.items.filter((i) => i.id !== id) }))
pump()
@@ -353,12 +373,12 @@ export const useDownloads = create<DownloadState>((set, get) => {
openFile: (id) => {
const item = get().items.find((i) => i.id === id)
if (!PREVIEW && item?.filePath) window.api.openPath(item.filePath)
if (!PREVIEW && item?.filePath) revealFile('open', item.filePath)
},
showInFolder: (id) => {
const item = get().items.find((i) => i.id === id)
if (!PREVIEW && item?.filePath) window.api.showInFolder(item.filePath)
if (!PREVIEW && item?.filePath) revealFile('folder', item.filePath)
},
applyEvent: (ev) => {
+3 -2
View File
@@ -2,6 +2,7 @@ import { create } from 'zustand'
import { HISTORY_MAX_ENTRIES, type HistoryEntry } from '@shared/ipc'
import { isPreview as PREVIEW } from '../isPreview'
import { logError } from '../reportError'
import { revealFile } from '../reveal'
// Mock history so the History tab has content during UI design.
const seed: HistoryEntry[] = PREVIEW
@@ -87,12 +88,12 @@ export const useHistory = create<HistoryState>((set, get) => ({
openFile: (id) => {
const e = get().entries.find((x) => x.id === id)
if (!PREVIEW && e?.filePath) window.api.openPath(e.filePath)
if (!PREVIEW && e?.filePath) revealFile('open', e.filePath)
},
showInFolder: (id) => {
const e = get().entries.find((x) => x.id === id)
if (!PREVIEW && e?.filePath) window.api.showInFolder(e.filePath)
if (!PREVIEW && e?.filePath) revealFile('folder', e.filePath)
}
}))
+45
View File
@@ -0,0 +1,45 @@
import { create } from 'zustand'
import { newId } from '../id'
/**
* A tiny transient-notification store (audit UX6/UX9). The app had no way to tell
* the user about a background failure a swallowed `openPath` error, a file that
* moved so those actions silently did nothing. This backs a lightweight,
* non-blocking toast stack rendered by `Toaster`; any store or component can raise
* one via {@link toast}.
*/
export type ToastTone = 'info' | 'error' | 'success'
export interface Toast {
id: string
message: string
tone: ToastTone
}
interface ToastState {
toasts: Toast[]
show: (message: string, tone?: ToastTone) => void
dismiss: (id: string) => void
}
/** How long a toast lingers before auto-dismissing. */
const AUTO_DISMISS_MS = 5000
export const useToasts = create<ToastState>((set, get) => ({
toasts: [],
show: (message, tone = 'info') => {
const id = newId('toast')
set((s) => ({ toasts: [...s.toasts, { id, message, tone }] }))
// Auto-dismiss so the stack self-clears; the user can also dismiss early.
setTimeout(() => get().dismiss(id), AUTO_DISMISS_MS)
},
dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }))
}))
/**
* Imperative shortcut for non-component callers (the Zustand stores), so a store
* action can surface an error without wiring the hook through a component.
*/
export function toast(message: string, tone?: ToastTone): void {
useToasts.getState().show(message, tone)
}