From d3c65fc6c2982a4eb0266fed00d088d534ee62a4 Mon Sep 17 00:00:00 2001
From: Jamie Pine
Date: Fri, 30 Jan 2026 16:16:05 -0800
Subject: [PATCH 1/5] Enhance HistoryTable Component with Infinite Scroll and
Cache Management
- Updated HistoryTable to implement infinite scrolling for loading history items dynamically.
- Introduced state management for accumulated history and total item count.
- Added Intersection Observer for triggering additional data fetches when scrolling.
- Implemented cache clearing functionality in the backend to manage voice prompt caches effectively.
- Improved loading indicators and user feedback for data fetching states.
- Refactored code for better readability and maintainability.
---
app/src/components/History/HistoryTable.tsx | 110 +++++++++++++-----
.../components/VoiceProfiles/SampleList.tsx | 21 +++-
backend/backends/mlx_backend.py | 15 ++-
backend/backends/pytorch_backend.py | 2 +
backend/main.py | 14 +++
backend/profiles.py | 37 +++---
backend/utils/cache.py | 25 ++++
tauri/src-tauri/gen/Assets.car | Bin 3847048 -> 3847048 bytes
8 files changed, 173 insertions(+), 51 deletions(-)
diff --git a/app/src/components/History/HistoryTable.tsx b/app/src/components/History/HistoryTable.tsx
index bfb27edd..67abe80d 100644
--- a/app/src/components/History/HistoryTable.tsx
+++ b/app/src/components/History/HistoryTable.tsx
@@ -1,5 +1,6 @@
-import { AudioWaveform, Download, FileArchive, MoreHorizontal, Play, Trash2 } from 'lucide-react';
+import { AudioWaveform, Download, FileArchive, Loader2, MoreHorizontal, Play, Trash2 } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
+import type { HistoryResponse } from '@/lib/api/types';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -33,18 +34,21 @@ import { usePlayerStore } from '@/stores/playerStore';
// OLD TABLE-BASED COMPONENT - REMOVED (can be found in git history)
// This is the new alternate history view with fixed height rows
-// NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS
+// NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS WITH INFINITE SCROLL
export function HistoryTable() {
- const [page, _setPage] = useState(0);
+ const [page, setPage] = useState(0);
+ const [allHistory, setAllHistory] = useState([]);
+ const [total, setTotal] = useState(0);
const [isScrolled, setIsScrolled] = useState(false);
const scrollRef = useRef(null);
+ const loadMoreRef = useRef(null);
const fileInputRef = useRef(null);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState(null);
const limit = 20;
const { toast } = useToast();
- const { data: historyData, isLoading } = useHistory({
+ const { data: historyData, isLoading, isFetching } = useHistory({
limit,
offset: page * limit,
});
@@ -60,6 +64,56 @@ export function HistoryTable() {
const audioUrl = usePlayerStore((state) => state.audioUrl);
const isPlayerVisible = !!audioUrl;
+ // Update accumulated history when new data arrives
+ useEffect(() => {
+ if (historyData?.items) {
+ setTotal(historyData.total);
+ if (page === 0) {
+ // Reset to first page
+ setAllHistory(historyData.items);
+ } else {
+ // Append new items, avoiding duplicates
+ setAllHistory((prev) => {
+ const existingIds = new Set(prev.map((item) => item.id));
+ const newItems = historyData.items.filter((item) => !existingIds.has(item.id));
+ return [...prev, ...newItems];
+ });
+ }
+ }
+ }, [historyData, page]);
+
+ // Reset to page 0 when deletions or imports occur
+ useEffect(() => {
+ if (deleteGeneration.isSuccess || importGeneration.isSuccess) {
+ setPage(0);
+ setAllHistory([]);
+ }
+ }, [deleteGeneration.isSuccess, importGeneration.isSuccess]);
+
+ // Intersection Observer for infinite scroll
+ useEffect(() => {
+ const loadMoreEl = loadMoreRef.current;
+ if (!loadMoreEl) return;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ const target = entries[0];
+ if (target.isIntersecting && !isFetching && allHistory.length < total) {
+ setPage((prev) => prev + 1);
+ }
+ },
+ {
+ root: scrollRef.current,
+ rootMargin: '100px',
+ threshold: 0.1,
+ },
+ );
+
+ observer.observe(loadMoreEl);
+ return () => observer.disconnect();
+ }, [isFetching, allHistory.length, total]);
+
+ // Track scroll position for gradient effect
useEffect(() => {
const scrollEl = scrollRef.current;
if (!scrollEl) return;
@@ -113,27 +167,6 @@ export function HistoryTable() {
);
};
- const _handleImportClick = () => {
- file_handleImportClickk.click();
- };
-
- const _handleFileChange = (_e: React.ChangeEvent) => {
- cons_handleFileChangeet.files?.[0];
- if (file) {
- // Validate file extension
- if (!file.name.endsWith('.voicebox.zip')) {
- toast({
- title: 'Invalid file type',
- description: 'Please select a valid .voicebox.zip file',
- variant: 'destructive',
- });
- return;
- }
- setSelectedFile(file);
- setImportDialogOpen(true);
- }
- };
-
const handleImportConfirm = () => {
if (selectedFile) {
importGeneration.mutate(selectedFile, {
@@ -159,13 +192,16 @@ export function HistoryTable() {
}
};
- if (isLoading) {
- return null;
+ if (isLoading && page === 0) {
+ return (
+
+
+
+ );
}
- const history = historyData?.items || [];
- const total = historyData?.total || 0;
- const _hasMore = history.length === limit && (page + 1) * limit < total;
+ const history = allHistory;
+ const hasMore = allHistory.length < total;
return (
@@ -284,6 +320,20 @@ export function HistoryTable() {
);
})}
+
+ {/* Load more trigger element */}
+ {hasMore && (
+
+ {isFetching && }
+
+ )}
+
+ {/* End of list indicator */}
+ {!hasMore && history.length > 0 && (
+
+ You've reached the end
+
+ )}
>
)}
diff --git a/app/src/components/VoiceProfiles/SampleList.tsx b/app/src/components/VoiceProfiles/SampleList.tsx
index de848737..b63dfe07 100644
--- a/app/src/components/VoiceProfiles/SampleList.tsx
+++ b/app/src/components/VoiceProfiles/SampleList.tsx
@@ -2,8 +2,8 @@ import { Plus, Trash2, Play, Edit, Check, X, Volume2, Pause } from 'lucide-react
import { useEffect, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import { CircleButton } from '@/components/ui/circle-button';
-import { Textarea } from '@/components/ui/textarea';
import { Slider } from '@/components/ui/slider';
+import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import { useDeleteSample, useProfileSamples, useUpdateSample } from '@/lib/hooks/useProfiles';
@@ -194,7 +194,9 @@ export function SampleList({ profileId }: SampleListProps) {
No samples yet
-
Add your first audio sample to get started
+
+ Add your first audio sample to get started
+
) : (
@@ -206,7 +208,7 @@ export function SampleList({ profileId }: SampleListProps) {
key={sample.id}
className={cn(
'group relative rounded-lg border bg-card transition-all duration-200',
- isEditing ? 'ring-2 ring-primary/20' : 'hover:border-primary/30'
+ isEditing ? 'ring-2 ring-primary/20' : 'hover:border-primary/30',
)}
>
{isEditing ? (
@@ -287,11 +289,22 @@ export function SampleList({ profileId }: SampleListProps) {
)}
-
+
+
);
}
From e5f4606a6c36b9fdaf5277146d73442f9288a6df Mon Sep 17 00:00:00 2001
From: Jamie Pine
Date: Fri, 30 Jan 2026 17:07:35 -0800
Subject: [PATCH 5/5] Update CircleButton component to include default button
type
- Added a default `type` prop set to 'button' in the CircleButton component to ensure proper button behavior.
- Enhanced the component's flexibility by allowing the type to be overridden through props.
---
app/src/components/ui/circle-button.tsx | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/app/src/components/ui/circle-button.tsx b/app/src/components/ui/circle-button.tsx
index 268843ed..88394399 100644
--- a/app/src/components/ui/circle-button.tsx
+++ b/app/src/components/ui/circle-button.tsx
@@ -6,10 +6,11 @@ export interface CircleButtonProps extends React.ButtonHTMLAttributes(
- ({ className, icon: Icon, ...props }, ref) => {
+ ({ className, icon: Icon, type = 'button', ...props }, ref) => {
return (