Skip to content

Commit eea08bf

Browse files
authored
fix(studio): six review fixes for the beginner shell (#4764)
1 parent 3e8a0b3 commit eea08bf

8 files changed

Lines changed: 135 additions & 19 deletions

web/src/hooks/storyboard/useStoryboardServerSync.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ export const useStoryboardServerSync = (boardId: string): void => {
7070
// other reference in the store means unsaved local edits.
7171
const syncedRef = useRef<StoryboardBoard | null>(null);
7272
const inFlightRef = useRef(false);
73+
// Set when unmount catches a save mid-flight: the finally block runs one
74+
// more flush save so the pending edit isn't lost with the timer.
75+
const flushAfterSaveRef = useRef(false);
7376
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
7477
const utilsRef = useRef(utils);
7578
utilsRef.current = utils;
@@ -112,13 +115,18 @@ export const useStoryboardServerSync = (boardId: string): void => {
112115
}
113116
};
114117

115-
const save = async (): Promise<void> => {
116-
if (disposed || inFlightRef.current) return;
118+
const save = async (flush = false): Promise<void> => {
119+
if (inFlightRef.current) {
120+
if (flush) flushAfterSaveRef.current = true;
121+
return;
122+
}
123+
if (disposed && !flush) return;
117124
const board = store.getState().boards[boardId];
118125
const revision = store.getState().serverRevisions[boardId];
119126
if (!board || !revision || board === syncedRef.current) return;
120127

121128
inFlightRef.current = true;
129+
let saved = false;
122130
try {
123131
const updated = await trpcClient.storyboards.update.mutate({
124132
id: boardId,
@@ -127,17 +135,24 @@ export const useStoryboardServerSync = (boardId: string): void => {
127135
document: boardToDocument(board),
128136
timelineId: board.timelineId
129137
});
130-
if (disposed) return;
131138
store.getState().setServerRevision(boardId, updated.updatedAt);
132139
syncedRef.current = board;
140+
saved = true;
133141
void utilsRef.current.storyboards.list.invalidate();
134-
// Edits landed while the save was in flight — go again.
142+
// Edits landed while the save was in flight — go again (via the
143+
// flush chain when the hook is already unmounted).
135144
if (store.getState().boards[boardId] !== syncedRef.current) {
136-
schedule();
145+
if (disposed || flushAfterSaveRef.current) {
146+
flushAfterSaveRef.current = true;
147+
} else {
148+
schedule();
149+
}
137150
}
138151
} catch (error) {
139-
if (disposed) return;
140152
console.error("Storyboard autosave failed", error);
153+
// Unmounted mid-flush: no live hook remains to retry or reload; the
154+
// next mount reconciles against the server copy.
155+
if (disposed) return;
141156
if (/modified since last read/i.test(getErrorMessage(error))) {
142157
// CAS conflict: the server copy wins.
143158
await load();
@@ -147,6 +162,10 @@ export const useStoryboardServerSync = (boardId: string): void => {
147162
}
148163
} finally {
149164
inFlightRef.current = false;
165+
if (saved && flushAfterSaveRef.current) {
166+
flushAfterSaveRef.current = false;
167+
void save(true);
168+
}
150169
}
151170
};
152171

@@ -171,6 +190,10 @@ export const useStoryboardServerSync = (boardId: string): void => {
171190
disposed = true;
172191
unsubscribe();
173192
if (timerRef.current) clearTimeout(timerRef.current);
193+
// Flush any pending debounced edit instead of dropping it with the
194+
// timer — leaving the page must not lose the last keystrokes.
195+
if (inFlightRef.current) flushAfterSaveRef.current = true;
196+
else void save(true);
174197
};
175198
}, [boardId]);
176199
};

web/src/studio/StudioAccountPage.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const TOPUP_CREDITS = 1_000;
2828

2929
const StudioAccountPage = () => {
3030
const theme = useTheme();
31-
const { status, loading } = useStudioCredits();
31+
const { status, loading, unavailable, refetch } = useStudioCredits();
3232
const utils = trpc.useUtils();
3333
const [error, setError] = useState<string | null>(null);
3434

@@ -50,6 +50,14 @@ const StudioAccountPage = () => {
5050
sx={{ flex: 1, minHeight: 0, overflowY: "auto", p: SPACING.xl }}
5151
>
5252
{loading && <LoadingSpinner />}
53+
{unavailable && (
54+
<AlertBanner severity="warning">
55+
Couldn&apos;t load the credit balance — it is unknown, not empty.{" "}
56+
<EditorButton size="small" onClick={refetch}>
57+
Retry
58+
</EditorButton>
59+
</AlertBanner>
60+
)}
5361
{error && (
5462
<AlertBanner severity="error" onClose={() => setError(null)}>
5563
{error}

web/src/studio/StudioHome.tsx

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* editor, which is the one finishing surface the product has.
77
*/
88

9-
import { useCallback, useMemo, useState } from "react";
9+
import { useCallback, useMemo, useRef, useState } from "react";
1010
import { useNavigate } from "react-router-dom";
1111
import { useTheme } from "@mui/material/styles";
1212
import TheatersRoundedIcon from "@mui/icons-material/TheatersRounded";
@@ -56,13 +56,15 @@ const PathCard = ({
5656
description,
5757
cta,
5858
busy,
59+
disabled,
5960
onStart
6061
}: {
6162
icon: React.ReactNode;
6263
title: string;
6364
description: string;
6465
cta: string;
6566
busy: boolean;
67+
disabled: boolean;
6668
onStart: () => void;
6769
}) => (
6870
<Card
@@ -78,7 +80,7 @@ const PathCard = ({
7880
<Text size="small" color="secondary">
7981
{description}
8082
</Text>
81-
<EditorButton variant="contained" onClick={onStart} disabled={busy}>
83+
<EditorButton variant="contained" onClick={onStart} disabled={disabled}>
8284
{busy ? "Creating…" : cta}
8385
</EditorButton>
8486
</FlexColumn>
@@ -94,7 +96,9 @@ const StudioHome = () => {
9496

9597
const storyboards = useStoryboards();
9698
const scripts = useScripts();
97-
const timelines = useTimelines("default");
99+
// No project filter: storyboards and scripts list all the user's documents,
100+
// so the merged recent list scopes timelines the same way.
101+
const timelines = useTimelines();
98102

99103
const recent = useMemo<RecentProject[]>(() => {
100104
const rows: RecentProject[] = [
@@ -122,20 +126,34 @@ const StudioHome = () => {
122126
.slice(0, 12);
123127
}, [storyboards.data, scripts.data, timelines.data]);
124128

129+
// One creation at a time: both cards disable while either runs, and the
130+
// handlers guard on a ref as well so a double-activation can't create two
131+
// projects or race the navigation.
132+
const creatingRef = useRef(false);
125133
const startStoryboard = useCallback(() => {
134+
if (creatingRef.current) return;
135+
creatingRef.current = true;
126136
setCreating("storyboard");
127137
createStoryboard
128138
.mutateAsync({ name: "Untitled storyboard", projectId: "default" })
129139
.then((created) => navigate(`/studio/storyboard/${created.id}`))
130-
.finally(() => setCreating(null));
140+
.finally(() => {
141+
creatingRef.current = false;
142+
setCreating(null);
143+
});
131144
}, [createStoryboard, navigate]);
132145

133146
const startScript = useCallback(() => {
147+
if (creatingRef.current) return;
148+
creatingRef.current = true;
134149
setCreating("script");
135150
createScript
136151
.mutateAsync({ name: "Untitled script", projectId: "default" })
137152
.then((created) => navigate(`/studio/script/${created.id}`))
138-
.finally(() => setCreating(null));
153+
.finally(() => {
154+
creatingRef.current = false;
155+
setCreating(null);
156+
});
139157
}, [createScript, navigate]);
140158

141159
return (
@@ -161,6 +179,7 @@ const StudioHome = () => {
161179
description="Describe your idea. The director agent breaks it into shots, renders stills, animates them into clips, and cuts them together."
162180
cta="New storyboard"
163181
busy={creating === "storyboard"}
182+
disabled={creating !== null}
164183
onStart={startStoryboard}
165184
/>
166185
<PathCard
@@ -169,6 +188,7 @@ const StudioHome = () => {
169188
description="Write or generate a script, cast a voice for every speaker, and turn the voiced lines into a video with captions."
170189
cta="New script"
171190
busy={creating === "script"}
191+
disabled={creating !== null}
172192
onStart={startScript}
173193
/>
174194
</FlexRow>

web/src/studio/StudioScriptPage.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* navigates to `/studio/timeline/:id`.
77
*/
88

9-
import { useEffect, useMemo, useState } from "react";
9+
import { useCallback, useEffect, useMemo, useState } from "react";
1010
import { useNavigate, useParams } from "react-router-dom";
1111
import { useTheme } from "@mui/material/styles";
1212
import GroupsIcon from "@mui/icons-material/Groups";
@@ -28,6 +28,7 @@ import {
2828
useScriptTitle
2929
} from "../stores/script/ScriptStore";
3030
import { useScriptServerSync } from "../hooks/script/useScriptServerSync";
31+
import { useDocumentUndoShortcuts } from "../hooks/useDocumentUndoShortcuts";
3132
import { useScriptAgentBridge } from "../hooks/script/useScriptAgentBridge";
3233
import { useAssembleScriptTimeline } from "../hooks/script/useAssembleScriptTimeline";
3334
import StudioShell from "./StudioShell";
@@ -50,6 +51,15 @@ const StudioScriptPage = () => {
5051
useScriptServerSync(scriptId);
5152
useScriptAgentBridge(scriptId);
5253

54+
const undo = useScriptStore((state) => state.undo);
55+
const redo = useScriptStore((state) => state.redo);
56+
useDocumentUndoShortcuts({
57+
active: true,
58+
enabled: true,
59+
onUndo: useCallback(() => undo(scriptId), [undo, scriptId]),
60+
onRedo: useCallback(() => redo(scriptId), [redo, scriptId])
61+
});
62+
5363
const { assemble, assembling, error: assembleError } =
5464
useAssembleScriptTimeline();
5565

web/src/studio/StudioShell.tsx

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,23 +21,33 @@ import {
2121
Tooltip
2222
} from "../components/ui_primitives";
2323
import { useStudioCredits } from "./useStudioCredits";
24+
import { useStudioAssistantModel } from "./useStudioAssistantModel";
2425

2526
const CreditsChip = () => {
2627
const navigate = useNavigate();
27-
const { status, remaining, loading } = useStudioCredits();
28-
const title = status
29-
? `${status.plan.name} plan — ${status.spentCredits} of ${status.grantedCredits} credits used. 1 credit = 1¢ of generation. Click to manage.`
30-
: "Plan & credits";
28+
const { status, remaining, loading, unavailable } = useStudioCredits();
29+
const title = unavailable
30+
? "Couldn't load the credit balance. Click to retry from the account page."
31+
: status
32+
? `${status.plan.name} plan — ${status.spentCredits} of ${status.grantedCredits} credits used. 1 credit = 1¢ of generation. Click to manage.`
33+
: "Plan & credits";
34+
const label = loading
35+
? "credits…"
36+
: unavailable
37+
? "credits unavailable"
38+
: `${remaining} credits`;
3139
return (
3240
<Tooltip title={title}>
3341
<span>
3442
<Chip
3543
compact
3644
clickable
3745
onClick={() => navigate("/studio/account")}
38-
color={remaining > 0 ? "primary" : "error"}
46+
color={
47+
unavailable ? "default" : remaining > 0 ? "primary" : "error"
48+
}
3949
icon={<BoltRoundedIcon />}
40-
label={loading ? "credits…" : `${remaining} credits`}
50+
label={label}
4151
/>
4252
</span>
4353
</Tooltip>
@@ -62,6 +72,7 @@ const StudioShell = ({
6272
}: StudioShellProps) => {
6373
const theme = useTheme();
6474
const navigate = useNavigate();
75+
useStudioAssistantModel();
6576
return (
6677
<FlexColumn fullHeight sx={{ width: "100%", minHeight: 0 }}>
6778
<FlexRow

web/src/studio/StudioStoryboardPage.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import StoryboardQueueOverlay from "../components/storyboard/StoryboardQueueOver
2020
import { useStoryboardStore } from "../stores/storyboard/StoryboardStore";
2121
import { useStoryboardGenerationSubscriptions } from "../stores/storyboard/StoryboardGenerationStore";
2222
import { useStoryboardServerSync } from "../hooks/storyboard/useStoryboardServerSync";
23+
import { useDocumentUndoShortcuts } from "../hooks/useDocumentUndoShortcuts";
2324
import { useStoryboardAgentBridge } from "../hooks/storyboard/useStoryboardAgentBridge";
2425
import { useDirectScreenplay } from "../hooks/storyboard/useDirectScreenplay";
2526
import { useAssembleTimeline } from "../hooks/storyboard/useAssembleTimeline";
@@ -66,6 +67,17 @@ const StudioStoryboardPage = () => {
6667
useStoryboardGenerationSubscriptions();
6768
useStudioModelPolicy(boardId);
6869

70+
// The board's undo buttons advertise ⌘Z; the page is the only surface, so
71+
// it is always the active one.
72+
const undo = useStoryboardStore((state) => state.undo);
73+
const redo = useStoryboardStore((state) => state.redo);
74+
useDocumentUndoShortcuts({
75+
active: true,
76+
enabled: true,
77+
onUndo: useCallback(() => undo(boardId), [undo, boardId]),
78+
onRedo: useCallback(() => redo(boardId), [redo, boardId])
79+
});
80+
6981
const { direct, directing, error: directError } = useDirectScreenplay();
7082
const handleDirect = useCallback(
7183
(shotCount: number) => direct(boardId, shotCount),
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* Pin the Studio assistants to the curated director model. Every editor's
3+
* agent panel reads `GlobalChatStore.selectedModel`, so inside the Studio
4+
* shell that selection is forced to the curated model (the model picker the
5+
* panels render then shows it, and turns run on it — metered like all
6+
* `nodetool`-provider calls). The user's own selection is restored when the
7+
* shell unmounts, so workspace chat is untouched.
8+
*/
9+
10+
import { useEffect } from "react";
11+
import useGlobalChatStore from "../stores/GlobalChatStore";
12+
import { STUDIO_DIRECTOR_MODEL } from "./curatedModels";
13+
14+
export function useStudioAssistantModel(): void {
15+
useEffect(() => {
16+
const store = useGlobalChatStore.getState();
17+
const previous = store.selectedModel;
18+
if (previous.id !== STUDIO_DIRECTOR_MODEL.id) {
19+
store.setSelectedModel({ ...STUDIO_DIRECTOR_MODEL });
20+
}
21+
return () => {
22+
// Route swaps unmount the old shell before the next one mounts, so a
23+
// studio-to-studio navigation restores here and re-pins immediately.
24+
useGlobalChatStore.getState().setSelectedModel(previous);
25+
};
26+
}, []);
27+
}
28+
29+
export default useStudioAssistantModel;

web/src/studio/useStudioCredits.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export interface StudioCredits {
1414
status: CreditStatusOutput | null;
1515
remaining: number;
1616
loading: boolean;
17+
/** The balance request failed — the balance is unknown, not full or empty. */
18+
unavailable: boolean;
1719
refetch: () => void;
1820
}
1921

@@ -27,6 +29,7 @@ export function useStudioCredits(): StudioCredits {
2729
status: query.data ?? null,
2830
remaining: query.data?.balanceCredits ?? 0,
2931
loading: query.isLoading,
32+
unavailable: query.isError,
3033
refetch: () => void query.refetch()
3134
};
3235
}

0 commit comments

Comments
 (0)