refactor(studio): update layout, config, and remove agent activity tracking (#64)

## Summary
- **NLELayout**: Add toolbar slot, composition breadcrumb navigation, improved responsive layout
- **Vite config**: Add full project API (preview, thumbnail, render, file CRUD) for standalone dev mode
- Remove AgentActivityTrack component (replaced by timeline clips)
- Add HTML editor utilities for composition source editing
- Guard setInterval cleanup to dev-only to prevent `vite build` from hanging in CI

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Miguel Ángel
2026-03-28 20:45:00 +01:00
committed by GitHub
parent 0769678f46
commit 8c1ae77697
12 changed files with 674 additions and 187 deletions
@@ -26,7 +26,9 @@ function getLanguageExtension(language: string) {
case "js":
case "typescript":
case "ts":
return javascript({ typescript: language === "typescript" || language === "ts" });
return javascript({
typescript: language === "typescript" || language === "ts",
});
default:
return html();
}
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef, memo, type ReactNode } from "react";
import { useState, useCallback, useRef, memo, type ReactNode } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player";
import type { TimelineElement } from "../../player";
import { NLEPreview } from "./NLEPreview";
@@ -9,7 +10,9 @@ interface NLELayoutProps {
portrait?: boolean;
/** Slot for overlays rendered on top of the preview (cursors, highlights, etc.) */
previewOverlay?: ReactNode;
/** Slot rendered below the timeline tracks (e.g., agent activity swim lanes) */
/** Slot rendered above the timeline tracks (toolbar with split, delete, zoom) */
timelineToolbar?: ReactNode;
/** Slot rendered below the timeline tracks */
timelineFooter?: ReactNode;
/** Increment to force the preview to reload (e.g., after file writes) */
refreshKey?: number;
@@ -17,6 +20,15 @@ interface NLELayoutProps {
activeCompositionPath?: string | null;
/** Callback to expose the iframe ref (for element picker, etc.) */
onIframeRef?: (iframe: HTMLIFrameElement | null) => void;
/** Callback when the viewed composition changes (drill-down/back) */
onCompositionChange?: (compositionPath: string | null) => void;
/** Custom clip content renderer for timeline (thumbnails, waveforms, etc.) */
renderClipContent?: (
element: TimelineElement,
style: { clip: string; label: string },
) => ReactNode;
/** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
onCompIdToSrcChange?: (map: Map<string, string>) => void;
}
const MIN_TIMELINE_H = 100;
@@ -27,10 +39,14 @@ export const NLELayout = memo(function NLELayout({
projectId,
portrait,
previewOverlay,
timelineToolbar,
timelineFooter,
refreshKey,
activeCompositionPath,
onIframeRef,
onCompositionChange,
renderClipContent,
onCompIdToSrcChange,
}: NLELayoutProps) {
const {
iframeRef,
@@ -38,8 +54,17 @@ export const NLELayout = memo(function NLELayout({
seek,
onIframeLoad: baseOnIframeLoad,
saveSeekPosition,
resetPlayer,
} = useTimelinePlayer();
// Reset timeline state when the project changes to prevent stale data from a
// previous project leaking into the new one.
const prevProjectIdRef = useRef<string | null>(null);
if (prevProjectIdRef.current !== projectId) {
prevProjectIdRef.current = projectId;
resetPlayer();
}
// Preserve seek position when refreshKey changes (iframe will remount via key prop).
const prevRefreshKeyRef = useRef(refreshKey);
if (refreshKey !== prevRefreshKeyRef.current) {
@@ -55,7 +80,7 @@ export const NLELayout = memo(function NLELayout({
// Composition ID → actual file path mapping, built from the raw index.html
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
useEffect(() => {
useMountEffect(() => {
fetch(`/api/projects/${projectId}/files/index.html`)
.then((r) => r.json())
.then((data: { content?: string }) => {
@@ -70,15 +95,28 @@ export const NLELayout = memo(function NLELayout({
if (id && src) map.set(id, src);
}
setCompIdToSrc(map);
onCompIdToSrcChange?.(map);
})
.catch(() => {});
}, [projectId]);
});
// Composition drill-down stack
const [compositionStack, setCompositionStack] = useState<CompositionLevel[]>([
{ id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
]);
// Wrap setCompositionStack to auto-notify parent on composition change
const onCompositionChangeRef = useRef(onCompositionChange);
onCompositionChangeRef.current = onCompositionChange;
const updateCompositionStack: typeof setCompositionStack = useCallback((action) => {
setCompositionStack((prev) => {
const next = typeof action === "function" ? action(prev) : action;
const id = next[next.length - 1]?.id;
queueMicrotask(() => onCompositionChangeRef.current?.(id === "master" ? null : id));
return next;
});
}, []);
// Resizable timeline height
const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
const isDragging = useRef(false);
@@ -126,7 +164,7 @@ export const NLELayout = memo(function NLELayout({
usePlayerStore.getState().setElements([]);
// Toggle: if already viewing this composition, go back to parent (like Premiere)
setCompositionStack((prev) => {
updateCompositionStack((prev) => {
const currentId = prev[prev.length - 1].id;
if (currentId === resolvedPath && prev.length > 1) {
return prev.slice(0, -1);
@@ -141,14 +179,15 @@ export const NLELayout = memo(function NLELayout({
return [...prev, { id: resolvedPath, label, previewUrl }];
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- iframeRef_ is a stable ref; .current mutates and should not be a dep
// eslint-disable-next-line react-hooks/exhaustive-deps
[projectId, compIdToSrc],
);
// Navigate back to a specific breadcrumb level
const handleNavigateComposition = useCallback((index: number) => {
usePlayerStore.getState().setElements([]);
setCompositionStack((prev) => prev.slice(0, index + 1));
updateCompositionStack((prev) => prev.slice(0, index + 1));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Navigate to a composition when activeCompositionPath changes
@@ -157,11 +196,11 @@ export const NLELayout = memo(function NLELayout({
prevActiveCompRef.current = activeCompositionPath;
queueMicrotask(() => usePlayerStore.getState().setElements([]));
if (activeCompositionPath === "index.html") {
setCompositionStack((prev) => (prev.length > 1 ? [prev[0]] : prev));
updateCompositionStack((prev) => (prev.length > 1 ? [prev[0]] : prev));
} else if (activeCompositionPath.startsWith("compositions/")) {
const label = activeCompositionPath.replace(/^compositions\//, "").replace(/\.html$/, "");
const previewUrl = `/api/projects/${projectId}/preview/comp/${activeCompositionPath}`;
setCompositionStack((prev) => {
updateCompositionStack((prev) => {
if (prev[prev.length - 1].id === activeCompositionPath) return prev;
return [
{ id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
@@ -201,9 +240,10 @@ export const NLELayout = memo(function NLELayout({
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape" && compositionStack.length > 1) {
setCompositionStack((prev) => prev.slice(0, -1));
updateCompositionStack((prev) => prev.slice(0, -1));
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[compositionStack.length],
);
@@ -255,11 +295,16 @@ export const NLELayout = memo(function NLELayout({
onDoubleClick={(e) => {
if ((e.target as HTMLElement).closest("[data-clip]")) return;
if (compositionStack.length > 1) {
setCompositionStack((prev) => prev.slice(0, -1));
updateCompositionStack((prev) => prev.slice(0, -1));
}
}}
>
<Timeline onSeek={seek} onDrillDown={handleDrillDown} />
{timelineToolbar}
<Timeline
onSeek={seek}
onDrillDown={handleDrillDown}
renderClipContent={renderClipContent}
/>
{timelineFooter}
</div>
</div>