feat(stories): regenerate action on clips and the chat list dropdown

The track editor's clip toolbar now has a regenerate icon next to Delete; clicking it kicks a fresh take of the selected clip's underlying generation through the same /generate/{id}/regenerate path the History table uses, and pushes the id into the global pending set so the SSE watcher picks it up. The chat list's per-item dropdown gets the same action between Play-from-here and Remove. Translation keys added under storyContent.itemActions / storyContent.toast across all four locales.
This commit is contained in:
Jamie Pine
2026-04-25 01:48:00 -07:00
parent fe14df5fda
commit 2a937983c5
7 changed files with 64 additions and 5 deletions
@@ -1,6 +1,6 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, Mic, MoreHorizontal, Play, Trash2 } from 'lucide-react';
import { GripVertical, Mic, MoreHorizontal, Play, RotateCcw, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
@@ -21,6 +21,7 @@ interface StoryChatItemProps {
storyId: string;
index: number;
onRemove: () => void;
onRegenerate?: () => void;
currentTimeMs: number;
isPlaying: boolean;
dragHandleProps?: React.HTMLAttributes<HTMLButtonElement>;
@@ -30,6 +31,7 @@ interface StoryChatItemProps {
export function StoryChatItem({
item,
onRemove,
onRegenerate,
currentTimeMs,
isPlaying,
dragHandleProps,
@@ -134,6 +136,12 @@ export function StoryChatItem({
<Play className="mr-2 h-4 w-4" />
{t('storyContent.itemActions.playFromHere')}
</DropdownMenuItem>
{onRegenerate && (
<DropdownMenuItem onClick={onRegenerate}>
<RotateCcw className="mr-2 h-4 w-4" />
{t('storyContent.itemActions.regenerate')}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={onRemove}
className="text-destructive focus:text-destructive"
@@ -23,6 +23,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import { useHistory } from '@/lib/hooks/useHistory';
import {
useAddStoryItem,
@@ -47,6 +48,7 @@ export function StoryContent() {
const { toast } = useToast();
const scrollRef = useRef<HTMLDivElement>(null);
const pendingCount = useGenerationStore((s) => s.pendingGenerationIds.size);
const addPendingGeneration = useGenerationStore((s) => s.addPendingGeneration);
// Add generation popover state
const [searchQuery, setSearchQuery] = useState('');
@@ -142,6 +144,19 @@ export function StoryContent() {
}
}, [isPlaying]);
const handleRegenerate = async (generationId: string) => {
try {
await apiClient.regenerateGeneration(generationId);
addPendingGeneration(generationId);
} catch (error) {
toast({
title: t('storyContent.toast.regenerateFailed'),
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
});
}
};
const handleRemoveItem = (itemId: string) => {
if (!story) return;
@@ -399,6 +414,7 @@ export function StoryContent() {
storyId={story.id}
index={index}
onRemove={() => handleRemoveItem(item.id)}
onRegenerate={() => handleRegenerate(item.generation_id)}
currentTimeMs={currentTimeMs}
isPlaying={isPlaying && playbackStoryId === story.id}
/>
@@ -7,6 +7,7 @@ import {
Pause,
Play,
Plus,
RotateCcw,
Scissors,
Square,
Trash2,
@@ -32,6 +33,7 @@ import {
useTrimStoryItem,
} from '@/lib/hooks/useStories';
import { cn } from '@/lib/utils/cn';
import { useGenerationStore } from '@/stores/generationStore';
import { useStoryStore } from '@/stores/storyStore';
// Clip waveform component with trim support
@@ -152,6 +154,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
const removeItem = useRemoveStoryItem();
const setItemVersion = useSetStoryItemVersion();
const { toast } = useToast();
const addPendingGeneration = useGenerationStore((s) => s.addPendingGeneration);
// Selection state
const selectedClipId = useStoryStore((state) => state.selectedClipId);
@@ -630,6 +633,20 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
);
}, [selectedClipId, storyId, removeItem, toast, setSelectedClipId]);
const handleRegenerate = useCallback(async () => {
if (!selectedItem) return;
try {
await apiClient.regenerateGeneration(selectedItem.generation_id);
addPendingGeneration(selectedItem.generation_id);
} catch (error) {
toast({
title: 'Failed to regenerate',
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
});
}
}, [selectedItem, addPendingGeneration, toast]);
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -999,6 +1016,16 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
>
<Trash2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleRegenerate}
title="Regenerate"
aria-label="Regenerate clip"
>
<RotateCcw className="h-4 w-4" />
</Button>
{hasMultipleVersions && (
<>
<div className="w-px h-4 bg-border mx-1" />
+3 -1
View File
@@ -629,13 +629,15 @@
},
"itemActions": {
"playFromHere": "Play from here",
"regenerate": "Regenerate",
"removeFromStory": "Remove from Story"
},
"toast": {
"removeFailed": "Failed to remove item",
"reorderFailed": "Failed to reorder items",
"exportFailed": "Failed to export audio",
"addFailed": "Failed to add generation"
"addFailed": "Failed to add generation",
"regenerateFailed": "Failed to regenerate"
}
},
"history": {
+3 -1
View File
@@ -629,13 +629,15 @@
},
"itemActions": {
"playFromHere": "ここから再生",
"regenerate": "再生成",
"removeFromStory": "ストーリーから削除"
},
"toast": {
"removeFailed": "項目の削除に失敗しました",
"reorderFailed": "項目の並び替えに失敗しました",
"exportFailed": "オーディオのエクスポートに失敗しました",
"addFailed": "生成の追加に失敗しました"
"addFailed": "生成の追加に失敗しました",
"regenerateFailed": "再生成に失敗しました"
}
},
"history": {
+3 -1
View File
@@ -629,13 +629,15 @@
},
"itemActions": {
"playFromHere": "从此处播放",
"regenerate": "重新生成",
"removeFromStory": "从故事中移除"
},
"toast": {
"removeFailed": "移除项目失败",
"reorderFailed": "重新排序项目失败",
"exportFailed": "导出音频失败",
"addFailed": "添加生成失败"
"addFailed": "添加生成失败",
"regenerateFailed": "重新生成失败"
}
},
"history": {
+3 -1
View File
@@ -629,13 +629,15 @@
},
"itemActions": {
"playFromHere": "從此處播放",
"regenerate": "重新生成",
"removeFromStory": "從故事中移除"
},
"toast": {
"removeFailed": "移除項目失敗",
"reorderFailed": "重新排序項目失敗",
"exportFailed": "匯出音訊失敗",
"addFailed": "新增生成失敗"
"addFailed": "新增生成失敗",
"regenerateFailed": "重新生成失敗"
}
},
"history": {