defer story add until TTS completes, add generating pill to story editor, fix item placement per-track

This commit is contained in:
Jamie Pine
2026-03-13 10:28:20 -07:00
parent 655a60ca81
commit 81f8be1a94
5 changed files with 108 additions and 42 deletions
@@ -12,12 +12,12 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages'; import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages';
import { useGenerationForm } from '@/lib/hooks/useGenerationForm'; import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
import { useProfile, useProfiles } from '@/lib/hooks/useProfiles'; import { useProfile, useProfiles } from '@/lib/hooks/useProfiles';
import { useAddStoryItem, useStory } from '@/lib/hooks/useStories'; import { useStory } from '@/lib/hooks/useStories';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { useGenerationStore } from '@/stores/generationStore';
import { useStoryStore } from '@/stores/storyStore'; import { useStoryStore } from '@/stores/storyStore';
import { useUIStore } from '@/stores/uiStore'; import { useUIStore } from '@/stores/uiStore';
import { ParalinguisticInput } from './ParalinguisticInput'; import { ParalinguisticInput } from './ParalinguisticInput';
@@ -44,8 +44,7 @@ export function FloatingGenerateBox({
const selectedStoryId = useStoryStore((state) => state.selectedStoryId); const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight); const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
const { data: currentStory } = useStory(selectedStoryId); const { data: currentStory } = useStory(selectedStoryId);
const addStoryItem = useAddStoryItem(); const addPendingStoryAdd = useGenerationStore((s) => s.addPendingStoryAdd);
const { toast } = useToast();
// Calculate if track editor is visible (on stories route with items) // Calculate if track editor is visible (on stories route with items)
const hasTrackEditor = isStoriesRoute && currentStory && currentStory.items.length > 0; const hasTrackEditor = isStoriesRoute && currentStory && currentStory.items.length > 0;
@@ -53,25 +52,9 @@ export function FloatingGenerateBox({
const { form, handleSubmit, isPending } = useGenerationForm({ const { form, handleSubmit, isPending } = useGenerationForm({
onSuccess: async (generationId) => { onSuccess: async (generationId) => {
setIsExpanded(false); setIsExpanded(false);
// If on stories route and a story is selected, add generation to story // Defer the story add until TTS completes — useGenerationProgress handles it
if (isStoriesRoute && selectedStoryId && generationId) { if (isStoriesRoute && selectedStoryId && generationId) {
try { addPendingStoryAdd(generationId, selectedStoryId);
await addStoryItem.mutateAsync({
storyId: selectedStoryId,
data: { generation_id: generationId },
});
toast({
title: 'Added to story',
description: `Generation added to "${currentStory?.name || 'story'}"`,
});
} catch (error) {
toast({
title: 'Failed to add to story',
description:
error instanceof Error ? error.message : 'Could not add generation to story',
variant: 'destructive',
});
}
} }
}, },
}); });
+32 -6
View File
@@ -13,8 +13,11 @@ import {
sortableKeyboardCoordinates, sortableKeyboardCoordinates,
verticalListSortingStrategy, verticalListSortingStrategy,
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { Link } from '@tanstack/react-router';
import { AnimatePresence, motion } from 'framer-motion';
import { Download, Plus } from 'lucide-react'; import { Download, Plus } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import Loader from 'react-loaders';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
@@ -28,6 +31,7 @@ import {
useStory, useStory,
} from '@/lib/hooks/useStories'; } from '@/lib/hooks/useStories';
import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback'; import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback';
import { useGenerationStore } from '@/stores/generationStore';
import { useStoryStore } from '@/stores/storyStore'; import { useStoryStore } from '@/stores/storyStore';
import { SortableStoryChatItem } from './StoryChatItem'; import { SortableStoryChatItem } from './StoryChatItem';
@@ -40,6 +44,7 @@ export function StoryContent() {
const addStoryItem = useAddStoryItem(); const addStoryItem = useAddStoryItem();
const { toast } = useToast(); const { toast } = useToast();
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const pendingCount = useGenerationStore((s) => s.pendingGenerationIds.size);
// Add generation popover state // Add generation popover state
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
@@ -54,8 +59,7 @@ export function StoryContent() {
return historyData.items.filter( return historyData.items.filter(
(gen) => (gen) =>
!storyGenerationIds.has(gen.id) && !storyGenerationIds.has(gen.id) &&
(gen.text.toLowerCase().includes(query) || (gen.text.toLowerCase().includes(query) || gen.profile_name.toLowerCase().includes(query)),
gen.profile_name.toLowerCase().includes(query)),
); );
}, [historyData, story, searchQuery]); }, [historyData, story, searchQuery]);
@@ -267,7 +271,31 @@ export function StoryContent() {
<p className="text-sm text-muted-foreground mt-1">{story.description}</p> <p className="text-sm text-muted-foreground mt-1">{story.description}</p>
)} )}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2 items-center">
<AnimatePresence>
{pendingCount > 0 && (
<motion.div
initial={{ opacity: 0, scale: 0.9, width: 0 }}
animate={{ opacity: 1, scale: 1, width: 'auto' }}
exit={{ opacity: 0, scale: 0.9, width: 0 }}
transition={{ duration: 0.2 }}
>
<Link
to="/"
className="flex items-center gap-2 h-8 pl-1.5 pr-3 rounded-full bg-card border border-border hover:bg-muted/50 transition-all duration-200 cursor-pointer"
>
<div className="shrink-0 w-10 h-5 overflow-hidden flex items-center justify-center">
<div className="scale-[0.45]">
<Loader type="line-scale" active />
</div>
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">
Generating {pendingCount} {pendingCount === 1 ? 'audio' : 'audios'}
</span>
</Link>
</motion.div>
)}
</AnimatePresence>
<Popover open={isAddOpen} onOpenChange={setIsAddOpen}> <Popover open={isAddOpen} onOpenChange={setIsAddOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
@@ -287,9 +315,7 @@ export function StoryContent() {
<div className="max-h-60 overflow-y-auto"> <div className="max-h-60 overflow-y-auto">
{availableGenerations.length === 0 ? ( {availableGenerations.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground"> <div className="p-4 text-center text-sm text-muted-foreground">
{searchQuery {searchQuery ? 'No matching generations found' : 'No available generations'}
? 'No matching generations found'
: 'No available generations'}
</div> </div>
) : ( ) : (
availableGenerations.map((gen) => ( availableGenerations.map((gen) => (
+40 -7
View File
@@ -23,6 +23,7 @@ export function useGenerationProgress() {
const { toast } = useToast(); const { toast } = useToast();
const pendingIds = useGenerationStore((s) => s.pendingGenerationIds); const pendingIds = useGenerationStore((s) => s.pendingGenerationIds);
const removePendingGeneration = useGenerationStore((s) => s.removePendingGeneration); const removePendingGeneration = useGenerationStore((s) => s.removePendingGeneration);
const removePendingStoryAdd = useGenerationStore((s) => s.removePendingStoryAdd);
const isPlaying = usePlayerStore((s) => s.isPlaying); const isPlaying = usePlayerStore((s) => s.isPlaying);
const setAudioWithAutoPlay = usePlayerStore((s) => s.setAudioWithAutoPlay); const setAudioWithAutoPlay = usePlayerStore((s) => s.setAudioWithAutoPlay);
const autoplayOnGenerate = useServerStore((s) => s.autoplayOnGenerate); const autoplayOnGenerate = useServerStore((s) => s.autoplayOnGenerate);
@@ -66,12 +67,36 @@ export function useGenerationProgress() {
// Refresh history to pick up the completed generation // Refresh history to pick up the completed generation
queryClient.invalidateQueries({ queryKey: ['history'] }); queryClient.invalidateQueries({ queryKey: ['history'] });
toast({ // If this generation was queued for a story, add it now
title: 'Generation complete!', const storyId = removePendingStoryAdd(id);
description: data.duration if (storyId) {
? `Audio generated (${data.duration.toFixed(2)}s)` apiClient
: 'Audio generated', .addStoryItem(storyId, { generation_id: id })
}); .then(() => {
queryClient.invalidateQueries({ queryKey: ['stories'] });
queryClient.invalidateQueries({ queryKey: ['stories', storyId] });
toast({
title: 'Added to story',
description: data.duration
? `Audio generated (${data.duration.toFixed(2)}s) and added to story`
: 'Audio generated and added to story',
});
})
.catch(() => {
toast({
title: 'Generation complete',
description: 'Audio generated but failed to add to story',
variant: 'destructive',
});
});
} else {
toast({
title: 'Generation complete!',
description: data.duration
? `Audio generated (${data.duration.toFixed(2)}s)`
: 'Audio generated',
});
}
// Auto-play if enabled and nothing is currently playing // Auto-play if enabled and nothing is currently playing
if (autoplayRef.current && !isPlayingRef.current) { if (autoplayRef.current && !isPlayingRef.current) {
@@ -82,6 +107,7 @@ export function useGenerationProgress() {
source.close(); source.close();
currentSources.delete(id); currentSources.delete(id);
removePendingGeneration(id); removePendingGeneration(id);
removePendingStoryAdd(id);
queryClient.invalidateQueries({ queryKey: ['history'] }); queryClient.invalidateQueries({ queryKey: ['history'] });
@@ -114,5 +140,12 @@ export function useGenerationProgress() {
} }
currentSources.clear(); currentSources.clear();
}; };
}, [pendingIds, removePendingGeneration, queryClient, toast, setAudioWithAutoPlay]); }, [
pendingIds,
removePendingGeneration,
removePendingStoryAdd,
queryClient,
toast,
setAudioWithAutoPlay,
]);
} }
+25 -1
View File
@@ -5,18 +5,23 @@ interface GenerationState {
pendingGenerationIds: Set<string>; pendingGenerationIds: Set<string>;
/** Whether any generation is in progress (derived convenience) */ /** Whether any generation is in progress (derived convenience) */
isGenerating: boolean; isGenerating: boolean;
/** Map of generationId → storyId for deferred story additions */
pendingStoryAdds: Map<string, string>;
addPendingGeneration: (id: string) => void; addPendingGeneration: (id: string) => void;
removePendingGeneration: (id: string) => void; removePendingGeneration: (id: string) => void;
addPendingStoryAdd: (generationId: string, storyId: string) => void;
removePendingStoryAdd: (generationId: string) => string | undefined;
/** Legacy setter for backward compat with useRestoreActiveTasks */ /** Legacy setter for backward compat with useRestoreActiveTasks */
setIsGenerating: (generating: boolean) => void; setIsGenerating: (generating: boolean) => void;
setActiveGenerationId: (id: string | null) => void; setActiveGenerationId: (id: string | null) => void;
activeGenerationId: string | null; activeGenerationId: string | null;
} }
export const useGenerationStore = create<GenerationState>((set) => ({ export const useGenerationStore = create<GenerationState>((set, get) => ({
pendingGenerationIds: new Set(), pendingGenerationIds: new Set(),
isGenerating: false, isGenerating: false,
activeGenerationId: null, activeGenerationId: null,
pendingStoryAdds: new Map(),
addPendingGeneration: (id) => addPendingGeneration: (id) =>
set((state) => { set((state) => {
@@ -32,6 +37,25 @@ export const useGenerationStore = create<GenerationState>((set) => ({
return { pendingGenerationIds: next, isGenerating: next.size > 0 }; return { pendingGenerationIds: next, isGenerating: next.size > 0 };
}), }),
addPendingStoryAdd: (generationId, storyId) =>
set((state) => {
const next = new Map(state.pendingStoryAdds);
next.set(generationId, storyId);
return { pendingStoryAdds: next };
}),
removePendingStoryAdd: (generationId) => {
const storyId = get().pendingStoryAdds.get(generationId);
if (storyId) {
set((state) => {
const next = new Map(state.pendingStoryAdds);
next.delete(generationId);
return { pendingStoryAdds: next };
});
}
return storyId;
},
setIsGenerating: (generating) => set({ isGenerating: generating }), setIsGenerating: (generating) => set({ isGenerating: generating }),
setActiveGenerationId: (id) => set({ activeGenerationId: id }), setActiveGenerationId: (id) => set({ activeGenerationId: id }),
})); }));
+6 -6
View File
@@ -270,11 +270,14 @@ async def add_item_to_story(
generation_created_at=generation.created_at, generation_created_at=generation.created_at,
) )
# Get track from data or default to 0
track = data.track if data.track is not None else 0
# Calculate start_time_ms if not provided # Calculate start_time_ms if not provided
if data.start_time_ms is not None: if data.start_time_ms is not None:
start_time_ms = data.start_time_ms start_time_ms = data.start_time_ms
else: else:
# Find the maximum end time (start_time_ms + duration_ms) of existing items # Find the maximum end time on the target track only
existing_items = db.query( existing_items = db.query(
DBStoryItem, DBStoryItem,
DBGeneration DBGeneration
@@ -282,11 +285,11 @@ async def add_item_to_story(
DBGeneration, DBGeneration,
DBStoryItem.generation_id == DBGeneration.id DBStoryItem.generation_id == DBGeneration.id
).filter( ).filter(
DBStoryItem.story_id == story_id DBStoryItem.story_id == story_id,
DBStoryItem.track == track,
).all() ).all()
if not existing_items: if not existing_items:
# First item starts at 0
start_time_ms = 0 start_time_ms = 0
else: else:
max_end_time_ms = 0 max_end_time_ms = 0
@@ -297,9 +300,6 @@ async def add_item_to_story(
# Add 200ms gap after the last item # Add 200ms gap after the last item
start_time_ms = max_end_time_ms + 200 start_time_ms = max_end_time_ms + 200
# Get track from data or default to 0
track = data.track if data.track is not None else 0
# Create item # Create item
item = DBStoryItem( item = DBStoryItem(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),