feat(ui): shared ListPane primitive + misc polish

ListPane is a compound component (Header / TitleRow / Title / Actions /
Search / Scroll) that owns the relative wrapper, faded right divider
(50px top fade), top scroll mask, and absolute-positioned header used by
every list-detail tab. Wires up CapturesTab, StoryList, and EffectsList.
EffectsTab gets -mx-8 / pr-8 to match the edge-to-edge layout used
elsewhere.

Other changes:
- MCPPage: native <select> → shadcn <Select> for default voice and
  per-binding voice pickers
- Button outline variant: add hover:border-accent
- Drop hover:text-destructive from trailing delete buttons
  (HistoryTable, GpuAcceleration, GpuPage, EffectsChainEditor,
  EffectsDetail)
- HistoryTable empty state moved behind t('history.empty')
- StoryContent scroll padding pt-14 → pt-16
- backend health reports the captures dir
- landing CapturesMockup: "Send to" → "Export" with Download icon
- CHANGELOG: drop [Unreleased] personality section
This commit is contained in:
Jamie Pine
2026-04-24 17:23:14 -07:00
parent 736a661059
commit b97c565a45
21 changed files with 616 additions and 238 deletions
+199 -51
View File
@@ -1,16 +1,19 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { save } from '@tauri-apps/plugin-dialog';
import { writeFile, writeTextFile } from '@tauri-apps/plugin-fs';
import {
Captions,
Check,
ChevronDown,
CircleDot,
Copy,
Download,
FileAudio,
FileText,
Loader2,
Mic,
Send,
Settings2,
Sparkles,
Square,
@@ -23,6 +26,16 @@ import { useTranslation } from 'react-i18next';
import { CapturePill } from '@/components/CapturePill/CapturePill';
import { CaptureInlinePlayer } from '@/components/CapturesTab/CaptureInlinePlayer';
import { DictationReadinessChecklist } from '@/components/CapturesTab/DictationReadinessChecklist';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@@ -33,8 +46,15 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
ListPane,
ListPaneHeader,
ListPaneScroll,
ListPaneSearch,
ListPaneTitle,
ListPaneTitleRow,
} from '@/components/ListPane';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import type {
@@ -126,6 +146,7 @@ export function CapturesTab() {
const [showRefined, setShowRefined] = useState(true);
const [playAsVoiceId, setPlayAsVoiceId] = useState<string | null>(null);
const [playbackState, setPlaybackState] = useState<PlaybackState>('idle');
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const setAudioWithAutoPlay = usePlayerStore((s) => s.setAudioWithAutoPlay);
const audioUrl = usePlayerStore((s) => s.audioUrl);
@@ -222,6 +243,7 @@ export function CapturesTab() {
const deleteMutation = useMutation({
mutationFn: async (captureId: string) => apiClient.deleteCapture(captureId),
onSuccess: () => {
setDeleteDialogOpen(false);
queryClient.invalidateQueries({ queryKey: ['captures'] });
},
onError: (err: Error) => {
@@ -293,6 +315,96 @@ export function CapturesTab() {
}
};
const exportToastSuccess = (path: string) => {
const name = path.split(/[\\/]/).pop() ?? path;
toast({ title: t('captures.toast.exportSuccess', { path: name }) });
};
const exportToastError = (err: unknown) => {
toast({
title: t('captures.toast.exportFailed'),
description: err instanceof Error ? err.message : String(err),
variant: 'destructive',
});
};
const handleExportAudio = async () => {
if (!selected) return;
try {
const dest = await save({
defaultPath: `capture_${selected.id.slice(0, 8)}.wav`,
filters: [{ name: 'Audio', extensions: ['wav'] }],
});
if (!dest) return;
const res = await fetch(apiClient.getCaptureAudioUrl(selected.id));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buf = new Uint8Array(await res.arrayBuffer());
await writeFile(dest, buf);
exportToastSuccess(dest);
} catch (err) {
exportToastError(err);
}
};
const handleExportTranscript = async () => {
if (!selected) return;
const text = (selected.transcript_refined || selected.transcript_raw || '').trim();
if (!text) {
toast({ title: t('captures.toast.exportEmpty'), variant: 'destructive' });
return;
}
try {
const dest = await save({
defaultPath: `capture_${selected.id.slice(0, 8)}.txt`,
filters: [{ name: 'Text', extensions: ['txt'] }],
});
if (!dest) return;
await writeTextFile(dest, text);
exportToastSuccess(dest);
} catch (err) {
exportToastError(err);
}
};
const buildCaptureMarkdown = (capture: CaptureResponse): string => {
const lines: string[] = [];
lines.push(`# Capture ${capture.id}`, '');
lines.push(`- **Source:** ${capture.source}`);
lines.push(`- **Created:** ${capture.created_at}`);
if (capture.duration_ms != null) lines.push(`- **Duration:** ${formatDuration(capture.duration_ms)}`);
if (capture.language) lines.push(`- **Language:** ${capture.language}`);
if (capture.stt_model) lines.push(`- **STT model:** ${capture.stt_model}`);
if (capture.llm_model) lines.push(`- **LLM model:** ${capture.llm_model}`);
lines.push('');
if (capture.transcript_refined?.trim()) {
lines.push('## Refined transcript', '', capture.transcript_refined.trim(), '');
}
if (capture.transcript_raw?.trim()) {
lines.push('## Raw transcript', '', capture.transcript_raw.trim(), '');
}
return lines.join('\n');
};
const handleExportMarkdown = async () => {
if (!selected) return;
const hasContent = (selected.transcript_refined || selected.transcript_raw || '').trim();
if (!hasContent) {
toast({ title: t('captures.toast.exportEmpty'), variant: 'destructive' });
return;
}
try {
const dest = await save({
defaultPath: `capture_${selected.id.slice(0, 8)}.md`,
filters: [{ name: 'Markdown', extensions: ['md'] }],
});
if (!dest) return;
await writeTextFile(dest, buildCaptureMarkdown(selected));
exportToastSuccess(dest);
} catch (err) {
exportToastError(err);
}
};
const handlePlayAs = (voice?: VoiceProfileResponse) => {
if (!selected) return;
const target = voice ?? playAsVoice;
@@ -327,50 +439,41 @@ export function CapturesTab() {
/>
{/* Left: capture list */}
<div className="w-[340px] shrink-0 flex flex-col relative overflow-hidden border-r border-border">
<div className="absolute top-0 left-0 right-0 h-20 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
<div className="absolute top-0 left-0 right-0 z-20 pl-4 pr-4">
<div className="flex items-center mb-3">
<h1 className="text-2xl px-4 font-bold">{t('captures.title')}</h1>
<Badge
variant="secondary"
className="h-5 px-1.5 -ml-2 text-[10px] font-medium text-accent bg-accent/10 border border-accent/20"
>
{t('captures.beta')}
</Badge>
</div>
<div className="relative">
<Input
placeholder={t('captures.searchPlaceholder')}
<div className="w-[340px] shrink-0">
<ListPane>
<ListPaneHeader>
<ListPaneTitleRow>
<ListPaneTitle>{t('captures.title')}</ListPaneTitle>
<Badge
variant="secondary"
className="h-5 px-1.5 -ml-2 text-[10px] font-medium text-accent bg-accent/10 border border-accent/20"
>
{t('captures.beta')}
</Badge>
</ListPaneTitleRow>
<ListPaneSearch
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-9 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0"
onChange={setSearch}
placeholder={t('captures.searchPlaceholder')}
/>
</div>
</div>
</ListPaneHeader>
<div
className={cn(
'flex-1 overflow-y-auto overflow-x-hidden pt-24',
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
)}
>
<div className="px-4 pb-6 space-y-1">
{capturesLoading ? (
<div className="px-4 py-12 flex items-center justify-center text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : filtered.length === 0 ? (
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
{search ? (
<p>{t('captures.empty.noMatches', { query: search })}</p>
) : (
<p>{t('captures.empty.none')}</p>
)}
</div>
) : (
filtered.map((capture) => {
<ListPaneScroll className={cn(isPlayerVisible && BOTTOM_SAFE_AREA_PADDING)}>
<div className="px-4 pb-6 space-y-1">
{capturesLoading ? (
<div className="px-4 py-12 flex items-center justify-center text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : filtered.length === 0 ? (
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
{search ? (
<p>{t('captures.empty.noMatches', { query: search })}</p>
) : (
<p>{t('captures.empty.none')}</p>
)}
</div>
) : (
filtered.map((capture) => {
const isActive = selectedId === capture.id;
const refined = !!capture.transcript_refined;
return (
@@ -413,8 +516,9 @@ export function CapturesTab() {
);
})
)}
</div>
</div>
</div>
</ListPaneScroll>
</ListPane>
</div>
{/* Right: capture detail */}
@@ -670,17 +774,40 @@ export function CapturesTab() {
? t('captures.actions.reRefine')
: t('captures.actions.refine')}
</Button>
<Button variant="outline" size="sm" disabled>
<Send className="h-3.5 w-3.5 mr-1.5" />
{t('captures.actions.sendTo')}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Download className="h-3.5 w-3.5 mr-1.5" />
{t('captures.actions.export')}
<ChevronDown className="h-3.5 w-3.5 ml-1 opacity-70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
{t('captures.actions.exportDropdownLabel')}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleExportAudio}>
<FileAudio className="h-3.5 w-3.5 mr-2 text-muted-foreground" />
{t('captures.actions.exportAudio')}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExportTranscript}>
<Captions className="h-3.5 w-3.5 mr-2 text-muted-foreground" />
{t('captures.actions.exportTranscript')}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExportMarkdown}>
<FileText className="h-3.5 w-3.5 mr-2 text-muted-foreground" />
{t('captures.actions.exportMarkdown')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex-1" />
<Button
variant="ghost"
size="sm"
onClick={() => deleteMutation.mutate(selected.id)}
onClick={() => setDeleteDialogOpen(true)}
disabled={deleteMutation.isPending}
className="text-muted-foreground hover:text-destructive"
className="text-muted-foreground "
>
{deleteMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
@@ -744,6 +871,27 @@ export function CapturesTab() {
</div>
)}
</div>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('captures.deleteDialog.title')}</AlertDialogTitle>
<AlertDialogDescription>{t('captures.deleteDialog.description')}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
onClick={() => selected && deleteMutation.mutate(selected.id)}
disabled={deleteMutation.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteMutation.isPending ? t('captures.deleteDialog.deleting') : t('common.delete')}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -350,7 +350,7 @@ function SortableEffectItem({
<button
type="button"
className="p-0.5 text-muted-foreground hover:text-destructive"
className="p-0.5 text-muted-foreground "
onClick={onRemove}
title={t('effects.chain.remove')}
>
@@ -279,7 +279,7 @@ export function EffectsDetail() {
<Button
variant="ghost"
size="sm"
className="h-8 text-destructive hover:text-destructive gap-1.5"
className="h-8 text-destructive gap-1.5"
onClick={handleDelete}
disabled={deleting}
>
+73 -64
View File
@@ -1,6 +1,14 @@
import { useQuery } from '@tanstack/react-query';
import { Loader2, Plus, Sparkles, Wand2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
ListPane,
ListPaneActions,
ListPaneHeader,
ListPaneScroll,
ListPaneTitle,
ListPaneTitleRow,
} from '@/components/ListPane';
import { Button } from '@/components/ui/button';
import { apiClient } from '@/lib/api/client';
import type { EffectPresetResponse } from '@/lib/api/types';
@@ -43,73 +51,74 @@ export function EffectsList() {
}
return (
<div className="flex flex-col h-full min-h-0">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">{t('effects.title')}</h2>
<Button variant="outline" size="sm" className="h-8 gap-1.5" onClick={handleCreateNew}>
<Plus className="h-3.5 w-3.5" />
{t('effects.newPreset')}
</Button>
</div>
<ListPane>
<ListPaneHeader>
<ListPaneTitleRow>
<ListPaneTitle>{t('effects.title')}</ListPaneTitle>
<ListPaneActions>
<Button onClick={handleCreateNew} size="sm">
<Plus className="mr-2 h-4 w-4" />
{t('effects.newPreset')}
</Button>
</ListPaneActions>
</ListPaneTitleRow>
</ListPaneHeader>
{/* Scrollable list */}
<div className="flex-1 min-h-0 overflow-y-auto space-y-4">
{/* Built-in presets */}
{builtIn.length > 0 && (
<div>
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
{t('effects.sections.builtin')}
</div>
<div className="space-y-1.5">
{builtIn.map((preset) => (
<PresetCard
key={preset.id}
preset={preset}
isSelected={selectedPresetId === preset.id && !isCreatingNew}
onSelect={() => handleSelect(preset)}
/>
))}
</div>
</div>
)}
{/* User presets */}
{userPresets.length > 0 && (
<div>
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
{t('effects.sections.custom')}
</div>
<div className="space-y-1.5">
{userPresets.map((preset) => (
<PresetCard
key={preset.id}
preset={preset}
isSelected={selectedPresetId === preset.id && !isCreatingNew}
onSelect={() => handleSelect(preset)}
/>
))}
</div>
</div>
)}
{/* New preset placeholder */}
{isCreatingNew && (
<div>
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
{t('effects.sections.new')}
</div>
<div className="rounded-xl border-2 border-accent/40 bg-accent/5 p-3">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-accent" />
<span className="text-sm font-medium">{t('effects.unsaved.title')}</span>
<ListPaneScroll className="pt-16">
<div className="px-4 pb-6 space-y-4">
{builtIn.length > 0 && (
<div>
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
{t('effects.sections.builtin')}
</div>
<div className="space-y-1.5">
{builtIn.map((preset) => (
<PresetCard
key={preset.id}
preset={preset}
isSelected={selectedPresetId === preset.id && !isCreatingNew}
onSelect={() => handleSelect(preset)}
/>
))}
</div>
<p className="text-xs text-muted-foreground mt-1">{t('effects.unsaved.hint')}</p>
</div>
</div>
)}
</div>
</div>
)}
{userPresets.length > 0 && (
<div>
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
{t('effects.sections.custom')}
</div>
<div className="space-y-1.5">
{userPresets.map((preset) => (
<PresetCard
key={preset.id}
preset={preset}
isSelected={selectedPresetId === preset.id && !isCreatingNew}
onSelect={() => handleSelect(preset)}
/>
))}
</div>
</div>
)}
{isCreatingNew && (
<div>
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
{t('effects.sections.new')}
</div>
<div className="rounded-xl border-2 border-accent/40 bg-accent/5 p-3">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-accent" />
<span className="text-sm font-medium">{t('effects.unsaved.title')}</span>
</div>
<p className="text-xs text-muted-foreground mt-1">{t('effects.unsaved.hint')}</p>
</div>
</div>
)}
</div>
</ListPaneScroll>
</ListPane>
);
}
+2 -2
View File
@@ -3,7 +3,7 @@ import { EffectsList } from './EffectsList';
export function EffectsTab() {
return (
<div className="flex flex-col h-full min-h-0 overflow-hidden">
<div className="flex flex-col h-full min-h-0 overflow-hidden -mx-8">
<div className="flex-1 min-h-0 flex gap-6 overflow-hidden">
{/* Left - Presets list */}
<div className="w-full max-w-[360px] shrink-0 flex flex-col min-h-0">
@@ -11,7 +11,7 @@ export function EffectsTab() {
</div>
{/* Right - Detail / editor */}
<div className="flex-1 min-h-0 flex flex-col">
<div className="flex-1 min-h-0 flex flex-col pr-8">
<EffectsDetail />
</div>
</div>
+2 -2
View File
@@ -462,7 +462,7 @@ export function HistoryTable() {
<div className="flex flex-col h-full min-h-0 relative">
{history.length === 0 ? (
<div className="text-center py-12 px-5 border-2 border-dashed mb-5 border-muted rounded-md text-muted-foreground flex-1 flex items-center justify-center">
No voice generations, yet...
{t('history.empty')}
</div>
) : (
<>
@@ -474,7 +474,7 @@ export function HistoryTable() {
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground hover:text-destructive"
className="h-7 text-xs text-muted-foreground"
onClick={() => setClearFailedDialogOpen(true)}
disabled={clearFailed.isPending}
>
+99
View File
@@ -0,0 +1,99 @@
import type { CSSProperties, ReactNode } from 'react';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils/cn';
interface ListPaneProps {
className?: string;
children: ReactNode;
}
export function ListPane({ className, children }: ListPaneProps) {
return (
<div className={cn('h-full flex flex-col relative overflow-hidden', className)}>
<div
className="absolute top-0 right-0 bottom-0 w-px bg-border pointer-events-none z-30"
style={{
maskImage: 'linear-gradient(to bottom, transparent 0, black 50px)',
WebkitMaskImage: 'linear-gradient(to bottom, transparent 0, black 50px)',
}}
/>
<div className="absolute top-0 left-0 right-0 h-20 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
{children}
</div>
);
}
interface ListPaneHeaderProps {
className?: string;
children: ReactNode;
}
export function ListPaneHeader({ className, children }: ListPaneHeaderProps) {
return (
<div className={cn('absolute top-0 left-0 right-0 z-20 px-4', className)}>{children}</div>
);
}
interface ListPaneTitleRowProps {
className?: string;
children: ReactNode;
}
export function ListPaneTitleRow({ className, children }: ListPaneTitleRowProps) {
return <div className={cn('flex items-center mb-2', className)}>{children}</div>;
}
interface ListPaneTitleProps {
className?: string;
children: ReactNode;
}
export function ListPaneTitle({ className, children }: ListPaneTitleProps) {
return <h2 className={cn('text-2xl px-4 font-bold truncate', className)}>{children}</h2>;
}
interface ListPaneActionsProps {
className?: string;
children: ReactNode;
}
export function ListPaneActions({ className, children }: ListPaneActionsProps) {
return <div className={cn('ml-auto flex items-center gap-2', className)}>{children}</div>;
}
interface ListPaneSearchProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
}
export function ListPaneSearch({ value, onChange, placeholder, className }: ListPaneSearchProps) {
return (
<div className={cn('relative', className)}>
<Input
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-9 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0"
/>
</div>
);
}
interface ListPaneScrollProps {
className?: string;
style?: CSSProperties;
children: ReactNode;
}
export function ListPaneScroll({ className, style, children }: ListPaneScrollProps) {
return (
<div
className={cn('flex-1 overflow-y-auto overflow-x-hidden pt-24', className)}
style={style}
>
{children}
</div>
);
}
@@ -366,7 +366,7 @@ export function GpuAcceleration() {
<Button
onClick={handleDelete}
variant="ghost"
className="w-full text-muted-foreground hover:text-destructive"
className="w-full text-muted-foreground "
size="sm"
>
<Trash2 className="h-4 w-4 mr-2" />
+38 -7
View File
@@ -1,5 +1,5 @@
import { Check, ChevronDown, Keyboard, Laptop, Lock, Trash2, Volume2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Check, ChevronDown, FolderOpen, Keyboard, Laptop, Lock, Volume2 } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AccessibilityNotice } from '@/components/AccessibilityGate/AccessibilityGate';
import { InputMonitoringNotice } from '@/components/InputMonitoringGate/InputMonitoringGate';
@@ -27,6 +27,8 @@ import { useToast } from '@/components/ui/use-toast';
import { useDictationReadiness } from '@/lib/hooks/useDictationReadiness';
import { useCaptureSettings } from '@/lib/hooks/useSettings';
import { useProfiles } from '@/lib/hooks/useProfiles';
import { usePlatform } from '@/platform/PlatformContext';
import { useServerStore } from '@/stores/serverStore';
import { cn } from '@/lib/utils/cn';
import { displayLabelForKey, modifierSideHint } from '@/lib/utils/keyCodes';
import type { Qwen3ModelSize, VoiceProfileResponse, WhisperModelSize } from '@/lib/api/types';
@@ -136,6 +138,8 @@ function HotkeyPillPreview({ enabled }: { enabled: boolean }) {
export function CapturesPage() {
const { t } = useTranslation();
const platform = usePlatform();
const serverUrl = useServerStore((state) => state.serverUrl);
const { settings, update } = useCaptureSettings();
const { data: profiles } = useProfiles();
const { toast } = useToast();
@@ -159,6 +163,32 @@ export function CapturesPage() {
const [copyToClipboard, setCopyToClipboard] = useState(true);
const [retention, setRetention] = useState('forever');
const [chordEditor, setChordEditor] = useState<'push' | 'toggle' | null>(null);
const [opening, setOpening] = useState(false);
const [capturesPath, setCapturesPath] = useState<string | null>(null);
useEffect(() => {
fetch(`${serverUrl}/health/filesystem`)
.then((res) => res.json())
.then((data) => {
const dir = data.directories?.find((d: { path: string }) =>
d.path.includes('captures'),
);
if (dir?.path) setCapturesPath(dir.path);
})
.catch(() => {});
}, [serverUrl]);
const openCapturesFolder = useCallback(async () => {
if (!capturesPath) return;
setOpening(true);
try {
await platform.filesystem.openPath(capturesPath);
} catch (e) {
console.error('Failed to open captures folder:', e);
} finally {
setOpening(false);
}
}, [platform, capturesPath]);
const voices: VoiceProfileResponse[] = profiles ?? [];
const defaultVoice =
@@ -561,16 +591,17 @@ export function CapturesPage() {
/>
<SettingRow
title={t('settings.captures.storage.clearAll.title')}
description={t('settings.captures.storage.clearAll.description')}
title={t('settings.captures.storage.folder.title')}
description={capturesPath ?? t('settings.captures.storage.folder.description')}
action={
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive hover:bg-destructive/10 border-destructive/30"
onClick={openCapturesFolder}
disabled={opening || !capturesPath}
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
{t('settings.captures.storage.clearAll.action')}
<FolderOpen className="h-3.5 w-3.5 mr-1.5" />
{t('settings.captures.storage.folder.open')}
</Button>
}
/>
+1 -1
View File
@@ -388,7 +388,7 @@ export function GpuPage() {
onClick={handleDelete}
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-destructive"
className="text-muted-foreground "
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.remove.button')}
+60 -35
View File
@@ -2,6 +2,13 @@ import { Check, Copy, Plug, Trash2, Waypoints } from 'lucide-react';
import { useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useMCPBindings } from '@/lib/hooks/useMCPBindings';
import { useProfiles } from '@/lib/hooks/useProfiles';
import { useCaptureSettings } from '@/lib/hooks/useSettings';
@@ -101,22 +108,28 @@ export function MCPPage() {
title={t('settings.mcp.defaultVoice.label')}
description={t('settings.mcp.defaultVoice.labelHint')}
action={
<select
value={defaultProfileId}
onChange={(e) =>
<Select
value={defaultProfileId || '__default__'}
onValueChange={(v) =>
updateCapture({
default_playback_voice_id: e.target.value || null,
default_playback_voice_id: v === '__default__' ? null : v,
})
}
className="h-8 px-2 rounded-md border bg-background text-sm min-w-[180px]"
>
<option value="">{t('settings.mcp.defaultVoice.none')}</option>
{(profiles ?? []).map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<SelectTrigger className="w-[220px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">
{t('settings.mcp.defaultVoice.none')}
</SelectItem>
{(profiles ?? []).map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
}
/>
</SettingSection>
@@ -153,24 +166,30 @@ export function MCPPage() {
)}
</div>
</div>
<select
value={b.profile_id ?? ''}
onChange={(e) =>
<Select
value={b.profile_id ?? '__default__'}
onValueChange={(v) =>
upsertAsync({
client_id: b.client_id,
label: b.label,
profile_id: e.target.value || null,
profile_id: v === '__default__' ? null : v,
})
}
className="h-8 px-2 rounded-md border bg-background text-sm min-w-[160px]"
>
<option value="">{t('settings.mcp.bindings.defaultOption')}</option>
{(profiles ?? []).map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">
{t('settings.mcp.bindings.defaultOption')}
</SelectItem>
{(profiles ?? []).map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="icon"
variant="ghost"
@@ -201,18 +220,24 @@ export function MCPPage() {
onChange={(e) => setNewLabel(e.target.value)}
className="h-9 px-3 rounded-md border bg-background text-sm"
/>
<select
value={newProfileId}
onChange={(e) => setNewProfileId(e.target.value)}
className="h-9 px-2 rounded-md border bg-background text-sm min-w-[140px]"
<Select
value={newProfileId || '__default__'}
onValueChange={(v) => setNewProfileId(v === '__default__' ? '' : v)}
>
<option value="">{t('settings.mcp.bindings.defaultOption')}</option>
{(profiles ?? []).map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<SelectTrigger className="h-9 min-w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">
{t('settings.mcp.bindings.defaultOption')}
</SelectItem>
{(profiles ?? []).map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
size="sm"
@@ -364,7 +364,7 @@ export function StoryContent() {
{/* Content */}
<div
ref={scrollRef}
className="flex-1 min-h-0 overflow-y-auto space-y-3 pt-14 scroll-pt-14 relative z-0"
className="flex-1 min-h-0 overflow-y-auto space-y-3 pt-16 scroll-pt-16 relative z-0"
style={{ paddingBottom: bottomPadding > 0 ? `${bottomPadding}px` : undefined }}
>
{sortedItems.length === 0 ? (
+37 -29
View File
@@ -29,6 +29,15 @@ import {
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
ListPane,
ListPaneActions,
ListPaneHeader,
ListPaneScroll,
ListPaneSearch,
ListPaneTitle,
ListPaneTitleRow,
} from '@/components/ListPane';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import {
@@ -202,32 +211,25 @@ export function StoryList() {
}
return (
<div className="h-full flex flex-col relative overflow-hidden border-r border-border">
{/* Scroll Mask */}
<div className="absolute top-0 left-0 right-0 h-20 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
<ListPane>
<ListPaneHeader>
<ListPaneTitleRow>
<ListPaneTitle>{t('stories.title')}</ListPaneTitle>
<ListPaneActions>
<Button onClick={() => setCreateDialogOpen(true)} size="sm">
<Plus className="mr-2 h-4 w-4" />
{t('stories.newStory')}
</Button>
</ListPaneActions>
</ListPaneTitleRow>
<ListPaneSearch
value={search}
onChange={setSearch}
placeholder={t('stories.searchPlaceholder')}
/>
</ListPaneHeader>
{/* Fixed Header */}
<div className="absolute top-0 left-0 right-0 z-20 pl-4 pr-4">
<div className="flex items-center justify-between mb-2">
<h2 className="text-2xl px-4 font-bold">{t('stories.title')}</h2>
<Button onClick={() => setCreateDialogOpen(true)} size="sm">
<Plus className="mr-2 h-4 w-4" />
{t('stories.newStory')}
</Button>
</div>
<div className="relative">
<Input
placeholder={t('stories.searchPlaceholder')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-9 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0"
/>
</div>
</div>
{/* Scrollable Story List */}
<div
className="flex-1 overflow-y-auto pt-24 relative z-0"
<ListPaneScroll
style={{ paddingBottom: hasTrackEditor ? `${trackEditorHeight + 140}px` : '170px' }}
>
{storyList.length === 0 ? (
@@ -268,8 +270,14 @@ export function StoryList() {
</span>
<div className="flex-1" />
</div>
<div className="text-[13px] text-foreground/90 line-clamp-2 leading-snug mb-2">
{story.name}
<div className="text-[13px] line-clamp-2 leading-snug mb-2">
<span className="text-foreground font-medium">{story.name}</span>
{story.description ? (
<>
<span className="mx-1.5 text-muted-foreground/50">·</span>
<span className="text-muted-foreground">{story.description}</span>
</>
) : null}
</div>
<div className="flex items-center gap-1.5 flex-wrap">
<Badge
@@ -311,7 +319,7 @@ export function StoryList() {
})}
</div>
)}
</div>
</ListPaneScroll>
{/* Create Story Dialog */}
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
@@ -422,6 +430,6 @@ export function StoryList() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</ListPane>
);
}
+7 -3
View File
@@ -3,14 +3,18 @@ import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
const buttonVariants = cva([
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm',
'font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2',
'focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
'[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0'
],
{
variants: {
variant: {
default: 'bg-accent text-accent-foreground hover:bg-accent/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
outline: 'border border-input bg-background hover:bg-accent hover:border-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-accent underline-offset-4 hover:underline',
+18 -5
View File
@@ -53,7 +53,11 @@
"copy": "Copy",
"refine": "Refine",
"reRefine": "Re-refine",
"sendTo": "Send to",
"export": "Export",
"exportDropdownLabel": "Export capture as",
"exportAudio": "Audio (WAV)",
"exportTranscript": "Transcript (TXT)",
"exportMarkdown": "Markdown (MD)",
"delete": "Delete",
"playAs": "Play as {{name}}",
"playAsFallback": "Play as…",
@@ -73,6 +77,11 @@
"turnOnShortcut": "Turn on the global shortcut to dictate from anywhere — or click Dictate above for an in-app capture.",
"openSettings": "Open Captures settings"
},
"deleteDialog": {
"title": "Delete capture",
"description": "This will permanently delete the capture, its audio, and its transcript. This cannot be undone.",
"deleting": "Deleting…"
},
"toast": {
"deleteFailed": "Delete failed",
"playAsFailed": "Play-as failed",
@@ -80,6 +89,9 @@
"noVoiceDescription": "Create a voice profile before using Play as.",
"transcriptCopied": "Transcript copied",
"copyFailed": "Copy failed",
"exportSuccess": "Exported to {{path}}",
"exportFailed": "Export failed",
"exportEmpty": "Nothing to export",
"shortcutNotArmed": "Shortcut on, but not yet armed",
"shortcutNotArmedDescription_one": "{{names}} still needs to download. Open the Captures tab to start.",
"shortcutNotArmedDescription_other": "{{names}} still need to download. Open the Captures tab to start."
@@ -628,6 +640,7 @@
}
},
"history": {
"empty": "No voice generations, yet…",
"actions": {
"menu": "Actions",
"play": "Play",
@@ -983,10 +996,10 @@
"d30": "30 days",
"d7": "7 days"
},
"clearAll": {
"title": "Clear all captures",
"description": "Permanently delete every capture and its audio. This cannot be undone.",
"action": "Clear captures"
"folder": {
"title": "Captures folder",
"description": "Where capture audio and transcripts are stored on disk.",
"open": "Open"
}
},
"sidebar": {
+18 -5
View File
@@ -53,7 +53,11 @@
"copy": "コピー",
"refine": "整形",
"reRefine": "再整形",
"sendTo": "送信先",
"export": "エクスポート",
"exportDropdownLabel": "形式を選択",
"exportAudio": "音声 (WAV)",
"exportTranscript": "文字起こし (TXT)",
"exportMarkdown": "Markdown (MD)",
"delete": "削除",
"playAs": "{{name}} で再生",
"playAsFallback": "ボイスで再生…",
@@ -73,6 +77,11 @@
"turnOnShortcut": "グローバルショートカットを有効にしてどこからでもディクテーション — または上の「ディクテーション」をクリックしてアプリ内でキャプチャします。",
"openSettings": "キャプチャ設定を開く"
},
"deleteDialog": {
"title": "キャプチャを削除",
"description": "このキャプチャと、その音声・文字起こしを完全に削除します。元に戻せません。",
"deleting": "削除中…"
},
"toast": {
"deleteFailed": "削除に失敗しました",
"playAsFailed": "ボイスでの再生に失敗しました",
@@ -80,6 +89,9 @@
"noVoiceDescription": "「ボイスで再生」を使う前にボイスプロファイルを作成してください。",
"transcriptCopied": "文字起こしをコピーしました",
"copyFailed": "コピーに失敗しました",
"exportSuccess": "{{path}} に書き出しました",
"exportFailed": "書き出しに失敗しました",
"exportEmpty": "書き出す内容がありません",
"shortcutNotArmed": "ショートカットは有効ですが、まだ準備が完了していません",
"shortcutNotArmedDescription_one": "{{names}} のダウンロードがまだ必要です。キャプチャタブを開いて開始してください。",
"shortcutNotArmedDescription_other": "{{names}} のダウンロードがまだ必要です。キャプチャタブを開いて開始してください。"
@@ -628,6 +640,7 @@
}
},
"history": {
"empty": "音声生成はまだありません…",
"actions": {
"menu": "操作",
"play": "再生",
@@ -983,10 +996,10 @@
"d30": "30 日",
"d7": "7 日"
},
"clearAll": {
"title": "すべてのキャプチャをクリア",
"description": "すべてのキャプチャ音声を完全に削除します。元に戻せません。",
"action": "キャプチャをクリア"
"folder": {
"title": "キャプチャフォルダ",
"description": "キャプチャ音声と文字起こしをディスクに保存する場所。",
"open": "開く"
}
},
"sidebar": {
+18 -5
View File
@@ -53,7 +53,11 @@
"copy": "复制",
"refine": "精修",
"reRefine": "重新精修",
"sendTo": "发送到",
"export": "导出",
"exportDropdownLabel": "导出格式",
"exportAudio": "音频 (WAV)",
"exportTranscript": "文字稿 (TXT)",
"exportMarkdown": "Markdown (MD)",
"delete": "删除",
"playAs": "以 {{name}} 播放",
"playAsFallback": "播放为……",
@@ -73,6 +77,11 @@
"turnOnShortcut": "开启全局快捷键以在任何位置进行听写——或点击上方的「听写」在应用内进行捕获。",
"openSettings": "打开「捕获」设置"
},
"deleteDialog": {
"title": "删除捕获",
"description": "这将永久删除该捕获及其音频和转录。此操作不可撤销。",
"deleting": "删除中…"
},
"toast": {
"deleteFailed": "删除失败",
"playAsFailed": "播放失败",
@@ -80,6 +89,9 @@
"noVoiceDescription": "使用「播放为」之前请先创建声音档案。",
"transcriptCopied": "转录已复制",
"copyFailed": "复制失败",
"exportSuccess": "已导出到 {{path}}",
"exportFailed": "导出失败",
"exportEmpty": "无可导出的内容",
"shortcutNotArmed": "快捷键已开启,但尚未就绪",
"shortcutNotArmedDescription_one": "{{names}} 仍需下载。打开「捕获」标签页开始下载。",
"shortcutNotArmedDescription_other": "{{names}} 仍需下载。打开「捕获」标签页开始下载。"
@@ -628,6 +640,7 @@
}
},
"history": {
"empty": "暂无语音生成…",
"actions": {
"menu": "操作",
"play": "播放",
@@ -983,10 +996,10 @@
"d30": "30 天",
"d7": "7 天"
},
"clearAll": {
"title": "清除所有捕获",
"description": "永久删除所有捕获及其音频。此操作不可撤销。",
"action": "清除捕获"
"folder": {
"title": "捕获文件夹",
"description": "捕获的音频和转录在磁盘上的存储位置。",
"open": "打开"
}
},
"sidebar": {
+18 -5
View File
@@ -53,7 +53,11 @@
"copy": "複製",
"refine": "精修",
"reRefine": "重新精修",
"sendTo": "傳送至",
"export": "匯出",
"exportDropdownLabel": "匯出格式",
"exportAudio": "音訊 (WAV)",
"exportTranscript": "文字稿 (TXT)",
"exportMarkdown": "Markdown (MD)",
"delete": "刪除",
"playAs": "以 {{name}} 播放",
"playAsFallback": "以聲音播放……",
@@ -73,6 +77,11 @@
"turnOnShortcut": "開啟全域快捷鍵以從任何地方口述——或點選上方的「口述」進行 App 內擷取。",
"openSettings": "開啟擷取設定"
},
"deleteDialog": {
"title": "刪除擷取",
"description": "這將永久刪除該擷取及其音訊與轉錄。此操作無法復原。",
"deleting": "刪除中…"
},
"toast": {
"deleteFailed": "刪除失敗",
"playAsFailed": "以聲音播放失敗",
@@ -80,6 +89,9 @@
"noVoiceDescription": "使用「以聲音播放」前請先建立聲音檔案。",
"transcriptCopied": "已複製轉錄文字",
"copyFailed": "複製失敗",
"exportSuccess": "已匯出至 {{path}}",
"exportFailed": "匯出失敗",
"exportEmpty": "沒有可匯出的內容",
"shortcutNotArmed": "快捷鍵已開啟,但尚未就緒",
"shortcutNotArmedDescription_one": "{{names}} 仍需下載。請開啟「擷取」分頁開始下載。",
"shortcutNotArmedDescription_other": "{{names}} 仍需下載。請開啟「擷取」分頁開始下載。"
@@ -628,6 +640,7 @@
}
},
"history": {
"empty": "尚無語音生成…",
"actions": {
"menu": "操作",
"play": "播放",
@@ -983,10 +996,10 @@
"d30": "30 天",
"d7": "7 天"
},
"clearAll": {
"title": "清除所有擷取",
"description": "永久刪除每一筆擷取與其音訊。此操作無法復原。",
"action": "清除擷取"
"folder": {
"title": "擷取資料夾",
"description": "擷取的音訊與轉錄在磁碟上的儲存位置。",
"open": "開啟"
}
},
"sidebar": {