Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 33 additions & 29 deletions app/classroom/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export default function ClassroomDetailPage() {

const generationStartedRef = useRef(false);

const { generateRemaining, retrySingleOutline, stop } = useSceneGenerator({
const { generateRemaining, retrySingleOutline, stop, regenerateScene } = useSceneGenerator({
onComplete: () => {
log.info('[Classroom] All scenes generated');
},
Expand Down Expand Up @@ -140,37 +140,41 @@ export default function ClassroomDetailPage() {
const completedOrders = new Set(scenes.map((s) => s.order));
const hasPending = outlines.some((o) => !completedOrders.has(o.order));

if (hasPending && stage) {
generationStartedRef.current = true;

// Load generation params from sessionStorage (stored by generation-preview before navigating)
const genParamsStr = sessionStorage.getItem('generationParams');
const params = genParamsStr ? JSON.parse(genParamsStr) : {};

// Reconstruct imageMapping from IndexedDB using pdfImages storageIds
const storageIds = (params.pdfImages || [])
.map((img: { storageId?: string }) => img.storageId)
.filter(Boolean);

loadImageMapping(storageIds).then((imageMapping) => {
generateRemaining({
pdfImages: params.pdfImages,
imageMapping,
stageInfo: {
name: stage.name || '',
description: stage.description,
language: stage.language,
style: stage.style,
},
agents: params.agents,
userProfile: params.userProfile,
});
if (!stage) return;

generationStartedRef.current = true;

// Load generation params from sessionStorage (stored by generation-preview before navigating)
const genParamsStr = sessionStorage.getItem('generationParams');
const params = genParamsStr ? JSON.parse(genParamsStr) : {};

// Reconstruct imageMapping from IndexedDB using pdfImages storageIds
const storageIds = (params.pdfImages || [])
.map((img: { storageId?: string }) => img.storageId)
.filter(Boolean);

// Always call generateRemaining to set lastParamsRef (needed for regenerateScene)
// It will early-return if there's nothing to generate
loadImageMapping(storageIds).then((imageMapping) => {
generateRemaining({
pdfImages: params.pdfImages,
imageMapping,
stageInfo: {
name: stage.name || '',
description: stage.description,
language: stage.language,
style: stage.style,
},
agents: params.agents,
userProfile: params.userProfile,
});
} else if (outlines.length > 0 && stage) {
});

// If no pending outlines, also resume media generation in background
if (!hasPending && outlines.length > 0) {
// All scenes are generated, but some media may not have finished.
// Resume media generation for any tasks not yet in IndexedDB.
// generateMediaForOutlines skips already-completed tasks automatically.
generationStartedRef.current = true;
generateMediaForOutlines(outlines, stage.id).catch((err) => {
log.warn('[Classroom] Media generation resume error:', err);
});
Expand Down Expand Up @@ -204,7 +208,7 @@ export default function ClassroomDetailPage() {
</div>
</div>
) : (
<Stage onRetryOutline={retrySingleOutline} />
<Stage onRetryOutline={retrySingleOutline} onRegenerateScene={regenerateScene} />
)}
</div>
</MediaStageProvider>
Expand Down
7 changes: 7 additions & 0 deletions components/stage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { PENDING_SCENE_ID } from '@/lib/store/stage';
import { useCanvasStore } from '@/lib/store/canvas';
import { useSettingsStore } from '@/lib/store/settings';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useSceneGenerator } from '@/lib/hooks/use-scene-generator';
import { SceneSidebar } from './stage/scene-sidebar';
import { Header } from './header';
import { CanvasArea } from '@/components/canvas/canvas-area';
Expand Down Expand Up @@ -42,8 +43,10 @@ import { VisuallyHidden } from 'radix-ui';
*/
export function Stage({
onRetryOutline,
onRegenerateScene,
}: {
onRetryOutline?: (outlineId: string) => Promise<void>;
onRegenerateScene?: (sceneId: string) => Promise<void>;
}) {
const { t } = useI18n();
const { mode, getCurrentScene, scenes, currentSceneId, setCurrentSceneId, generatingOutlines } =
Expand Down Expand Up @@ -138,6 +141,9 @@ export function Stage({
},
});

// Scene regeneration hook
const { regenerateScene } = useSceneGenerator();

// Pick a student agent for discussion trigger (prioritize student > non-teacher > fallback)
const pickStudentAgent = useCallback((): string => {
const registry = useAgentRegistry.getState();
Expand Down Expand Up @@ -936,6 +942,7 @@ export function Stage({
onCollapseChange={setSidebarCollapsed}
onSceneSelect={gatedSceneSwitch}
onRetryOutline={onRetryOutline}
onRegenerateScene={onRegenerateScene ?? regenerateScene}
/>

{/* Main Content Area */}
Expand Down
65 changes: 65 additions & 0 deletions components/stage/scene-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ interface SceneSidebarProps {
readonly onCollapseChange: (collapsed: boolean) => void;
readonly onSceneSelect?: (sceneId: string) => void;
readonly onRetryOutline?: (outlineId: string) => Promise<void>;
readonly onRegenerateScene?: (sceneId: string) => Promise<void>;
readonly regeneratingSceneId?: string | null;
}

const DEFAULT_WIDTH = 220;
Expand All @@ -35,6 +37,8 @@ export function SceneSidebar({
onCollapseChange,
onSceneSelect,
onRetryOutline,
onRegenerateScene,
regeneratingSceneId,
}: SceneSidebarProps) {
const { t } = useI18n();
const router = useRouter();
Expand All @@ -56,6 +60,43 @@ export function SceneSidebar({
}
};

const [regeneratingSceneIdState, setRegeneratingSceneIdState] = useState<string | null>(null);

const handleRegenerateScene = useCallback(
async (sceneId: string) => {
if (!onRegenerateScene) return;
setRegeneratingSceneIdState(sceneId);
try {
await onRegenerateScene(sceneId);
} finally {
setRegeneratingSceneIdState(null);
}
},
[onRegenerateScene],
);

// Determines whether a scene is actively regenerating.
// Checks the local button-click state AND whether the scene's outline
// is currently in generatingOutlines (meaning the operation is running).
// This ensures the animation stays visible when the operation is queued
// (local state is cleared in finally but generatingOutlines is updated
// when the operation actually starts executing).
const isSceneRegenerating = useCallback(
(sceneId: string) => {
// Respect external prop if provided
if (regeneratingSceneId !== undefined && regeneratingSceneId !== null) {
return regeneratingSceneId === sceneId;
}
if (regeneratingSceneIdState === sceneId) return true;
const scene = scenes.find((s) => s.id === sceneId);
if (!scene) return false;
// Cross-reference: if the outline with matching order is in generatingOutlines,
// this scene is being regenerated (even if local state was cleared after queueing).
return generatingOutlines.some((o) => o.order === scene.order);
},
[regeneratingSceneId, regeneratingSceneIdState, scenes, generatingOutlines],
);

const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_WIDTH);
const isDraggingRef = useRef(false);

Expand Down Expand Up @@ -190,6 +231,30 @@ export function SceneSidebar({
{scene.title}
</span>
</div>
{/* Regenerate Button */}
{onRegenerateScene && (
<button
type="button"
data-testid="regenerate-button"
onClick={(e) => {
e.stopPropagation();
handleRegenerateScene(scene.id);
}}
disabled={isSceneRegenerating(scene.id)}
className={cn(
'opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-all active:scale-90',
isSceneRegenerating(scene.id) && 'opacity-100',
)}
title={t('generation.regenerateScene')}
>
<RefreshCw
className={cn(
'w-3 h-3 text-gray-400 dark:text-gray-500',
isSceneRegenerating(scene.id) && 'animate-spin',
)}
/>
</button>
)}
</div>

{/* Thumbnail */}
Expand Down
Loading
Loading