@dailies-studio/shot-editor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/dist/assets/index-B7notge7.css +1 -0
  2. package/dist/assets/index-BBE6Ta9L.js +52 -0
  3. package/dist/index.html +13 -0
  4. package/package.json +45 -0
  5. package/src/api.ts +289 -0
  6. package/src/app/App.tsx +394 -0
  7. package/src/audio/index.tsx +74 -0
  8. package/src/audio/preview.ts +364 -0
  9. package/src/bridge.ts +45 -0
  10. package/src/bridgeTypes.ts +582 -0
  11. package/src/channels.ts +40 -0
  12. package/src/main.tsx +15 -0
  13. package/src/panels/ResizeHandle.tsx +113 -0
  14. package/src/panels/agent/AgentPanel.tsx +478 -0
  15. package/src/panels/agent/agent.css +322 -0
  16. package/src/panels/export/ExportDialog.tsx +284 -0
  17. package/src/panels/files/FilePanel.tsx +564 -0
  18. package/src/panels/files/McpSection.tsx +66 -0
  19. package/src/panels/files/icons.tsx +118 -0
  20. package/src/panels/header/DocSwitcher.tsx +85 -0
  21. package/src/panels/header/Header.tsx +132 -0
  22. package/src/panels/header/HistoryControls.tsx +20 -0
  23. package/src/panels/header/StatusBadges.tsx +49 -0
  24. package/src/panels/header/TransportBar.tsx +86 -0
  25. package/src/panels/header/ZoomControls.tsx +38 -0
  26. package/src/panels/help/HelpSection.tsx +190 -0
  27. package/src/panels/inspector/FramingRows.tsx +69 -0
  28. package/src/panels/inspector/InspectorPanel.tsx +178 -0
  29. package/src/panels/inspector/MarkerSection.tsx +63 -0
  30. package/src/panels/inspector/ParamRow.tsx +71 -0
  31. package/src/panels/inspector/animate/BlockTiming.tsx +341 -0
  32. package/src/panels/inspector/animate/DuringBlocks.tsx +85 -0
  33. package/src/panels/inspector/animate/InOutPresets.tsx +57 -0
  34. package/src/panels/inspector/animate/ResolvedChannels.tsx +150 -0
  35. package/src/panels/inspector/animate/ShotSection.tsx +126 -0
  36. package/src/panels/inspector/animate/useBlocks.ts +43 -0
  37. package/src/panels/inspector/design/AlignRow.tsx +63 -0
  38. package/src/panels/inspector/design/AppearanceSection.tsx +49 -0
  39. package/src/panels/inspector/design/ClonerSection.tsx +142 -0
  40. package/src/panels/inspector/design/ComponentSection.tsx +94 -0
  41. package/src/panels/inspector/design/CornerSection.tsx +52 -0
  42. package/src/panels/inspector/design/FillSection.tsx +89 -0
  43. package/src/panels/inspector/design/HtmlSection.tsx +50 -0
  44. package/src/panels/inspector/design/InstanceSection.tsx +302 -0
  45. package/src/panels/inspector/design/MediaSection.tsx +251 -0
  46. package/src/panels/inspector/design/MeshSection.tsx +172 -0
  47. package/src/panels/inspector/design/ShadowSection.tsx +73 -0
  48. package/src/panels/inspector/design/StrokeSection.tsx +85 -0
  49. package/src/panels/inspector/design/TransformSection.tsx +91 -0
  50. package/src/panels/inspector/design/TypographySection.tsx +170 -0
  51. package/src/panels/inspector/scene/CameraSection.tsx +59 -0
  52. package/src/panels/inspector/scene/EffectsSection.tsx +123 -0
  53. package/src/panels/inspector/scene/SceneSection.tsx +71 -0
  54. package/src/panels/inspector/scene/TransitionSection.tsx +136 -0
  55. package/src/panels/inspector/useDocWrite.ts +268 -0
  56. package/src/panels/material/MaterialPicker.tsx +172 -0
  57. package/src/panels/stage/StageMenu.tsx +38 -0
  58. package/src/panels/stage/StagePanel.tsx +131 -0
  59. package/src/panels/timeline/AudioLane.tsx +340 -0
  60. package/src/panels/timeline/BlockRow.tsx +242 -0
  61. package/src/panels/timeline/ChannelRow.tsx +266 -0
  62. package/src/panels/timeline/LifespanRow.tsx +165 -0
  63. package/src/panels/timeline/MarkerLane.tsx +146 -0
  64. package/src/panels/timeline/SceneStrip.tsx +145 -0
  65. package/src/panels/timeline/TimelineDock.tsx +488 -0
  66. package/src/panels/timeline/model.ts +641 -0
  67. package/src/panels/timeline/snap.ts +191 -0
  68. package/src/panels/toolbar/AddMenu.tsx +230 -0
  69. package/src/panels/toolbar/CameraViewBar.tsx +70 -0
  70. package/src/panels/toolbar/ShadingBar.tsx +42 -0
  71. package/src/panels/toolbar/ToolDockBar.tsx +110 -0
  72. package/src/panels/toolbar/ViewModeBar.tsx +49 -0
  73. package/src/shotTokens.css +131 -0
  74. package/src/stage/FrustumGizmo.tsx +184 -0
  75. package/src/stage/GridPlane.tsx +274 -0
  76. package/src/stage/MoveGizmo.tsx +411 -0
  77. package/src/stage/PerfHud.tsx +206 -0
  78. package/src/stage/SplitRenderer.tsx +511 -0
  79. package/src/stage/StageApp.tsx +3423 -0
  80. package/src/stage/StageTools.tsx +581 -0
  81. package/src/stage/bridgeClient.ts +278 -0
  82. package/src/stage/feed.ts +243 -0
  83. package/src/stage/frustumGeometry.ts +172 -0
  84. package/src/stage/gizmoMath.ts +494 -0
  85. package/src/stage/handles.ts +151 -0
  86. package/src/stage/instanceModules.ts +152 -0
  87. package/src/stage/interactions/move.ts +172 -0
  88. package/src/stage/interactions/panZoom.ts +60 -0
  89. package/src/stage/isolation.ts +155 -0
  90. package/src/stage/main.tsx +110 -0
  91. package/src/stage/modalState.ts +427 -0
  92. package/src/stage/overlays/Overlays.tsx +1046 -0
  93. package/src/stage/panes.ts +121 -0
  94. package/src/stage/penState.ts +112 -0
  95. package/src/stage/project.ts +402 -0
  96. package/src/stage/shading.ts +55 -0
  97. package/src/stage/tokenColor.ts +26 -0
  98. package/src/stage/view.ts +182 -0
  99. package/src/stage/workingView.ts +636 -0
  100. package/src/state/agentActions.ts +203 -0
  101. package/src/state/clientId.ts +25 -0
  102. package/src/state/context.tsx +33 -0
  103. package/src/state/insert.ts +507 -0
  104. package/src/state/keymap.ts +560 -0
  105. package/src/state/menu.ts +141 -0
  106. package/src/state/panelSize.ts +26 -0
  107. package/src/state/renderEvents.ts +100 -0
  108. package/src/state/store.ts +4094 -0
  109. package/src/state/triangles.ts +70 -0
  110. package/src/state/types.ts +384 -0
  111. package/src/styles.css +2948 -0
  112. package/src/tools.ts +181 -0
@@ -0,0 +1,113 @@
1
+ // A draggable panel edge.
2
+ //
3
+ // ── THE SIZE IS MEASURED FROM THE PRESS, NEVER ACCUMULATED ──────────────
4
+ //
5
+ // Every move computes `base + (now - origin)`, where both `base` and `origin`
6
+ // were captured on pointerdown. The tempting alternative — add each move's
7
+ // delta to the current size — DRIFTS the moment a size clamps: the pointer
8
+ // keeps travelling past the limit, the panel stops growing, and on the way
9
+ // back the panel starts moving again immediately instead of when the pointer
10
+ // returns to the edge. Same reason `panBy` measures from the press.
11
+ //
12
+ // ── THE BASE IS READ AT THE PRESS, NOT SUBSCRIBED ────────────────────────
13
+ //
14
+ // `getValue` instead of a `value` prop, deliberately: a subscribed value means
15
+ // the HOST re-renders on every write this handle makes, and both hosts are the
16
+ // heaviest panels in the chrome (the shell, the dock). The handle needs the
17
+ // size exactly once per gesture — at the press — so it asks for it then, and
18
+ // the hosts subscribe to nothing on its behalf.
19
+ //
20
+ // ── WRITES ARE COALESCED TO THE FRAME ────────────────────────────────────
21
+ //
22
+ // A pointer reports far above display rate; each `onChange` is a store write
23
+ // and a full listener sweep. One write per frame, last position wins, the
24
+ // release flushes whatever is pending so the final size always lands.
25
+ //
26
+ // Pointer capture, so a fast drag that leaves the 8px strip keeps resizing
27
+ // rather than dropping the gesture, and so the release always lands here.
28
+ import { useEffect, useRef } from "react";
29
+
30
+ export interface ResizeHandleProps {
31
+ /** "x" drags left-right (a side rail), "y" up-down (a bottom dock). */
32
+ axis: "x" | "y";
33
+ /** The size at the moment of the press, in px — the drag's base. */
34
+ getValue(): number;
35
+ /** +1 when moving the pointer along the axis GROWS the panel, -1 when it
36
+ * shrinks it. A bottom dock is -1: dragging its top edge upward, which is
37
+ * a decreasing clientY, makes it taller. */
38
+ sign: 1 | -1;
39
+ onChange(next: number): void;
40
+ /** Double-click. Every resizable edge needs a way back to the default that
41
+ * is not "drag until it looks right again". */
42
+ onReset(): void;
43
+ className: string;
44
+ label: string;
45
+ }
46
+
47
+ export function ResizeHandle({
48
+ axis,
49
+ getValue,
50
+ sign,
51
+ onChange,
52
+ onReset,
53
+ className,
54
+ label,
55
+ }: ResizeHandleProps) {
56
+ const drag = useRef<{ base: number; origin: number } | null>(null);
57
+ const pending = useRef<number | null>(null);
58
+ const raf = useRef(0);
59
+ useEffect(
60
+ () => () => {
61
+ if (raf.current !== 0) cancelAnimationFrame(raf.current);
62
+ },
63
+ [],
64
+ );
65
+ const posOf = (e: { clientX: number; clientY: number }): number =>
66
+ axis === "x" ? e.clientX : e.clientY;
67
+ const flush = (): void => {
68
+ raf.current = 0;
69
+ if (pending.current === null) return;
70
+ const next = pending.current;
71
+ pending.current = null;
72
+ onChange(next);
73
+ };
74
+
75
+ return (
76
+ <div
77
+ className={className}
78
+ // A separator is what this IS to a screen reader, and the orientation is
79
+ // the axis it separates along — which is the opposite of the axis it
80
+ // moves in: a vertical separator slides left and right.
81
+ role="separator"
82
+ aria-orientation={axis === "x" ? "vertical" : "horizontal"}
83
+ aria-label={label}
84
+ title={`${label} — drag to resize, double-click to reset`}
85
+ onDoubleClick={onReset}
86
+ onPointerDown={(e) => {
87
+ // Or the browser starts a text selection across the panels the drag
88
+ // passes over, and the whole chrome flashes blue mid-resize.
89
+ e.preventDefault();
90
+ e.currentTarget.setPointerCapture(e.pointerId);
91
+ drag.current = { base: getValue(), origin: posOf(e) };
92
+ }}
93
+ onPointerMove={(e) => {
94
+ const d = drag.current;
95
+ if (d === null) return;
96
+ pending.current = d.base + (posOf(e) - d.origin) * sign;
97
+ if (raf.current === 0) raf.current = requestAnimationFrame(flush);
98
+ }}
99
+ onPointerUp={() => {
100
+ drag.current = null;
101
+ // The release's position always lands, even mid-frame.
102
+ flush();
103
+ }}
104
+ // Pen and touch can have the gesture cancelled out from under them; the
105
+ // pointerup that would clear this never arrives, and without this the
106
+ // next hover over the strip would resize with a stale base.
107
+ onPointerCancel={() => {
108
+ drag.current = null;
109
+ pending.current = null;
110
+ }}
111
+ />
112
+ );
113
+ }
@@ -0,0 +1,478 @@
1
+ // The shot editor's agent drawer: prompt, streamed transcript, tool chips, and
2
+ // review-before-apply cards. The transport, the event vocabulary and the
3
+ // transcript reducer are `@dailies-studio/agent-client`'s; only the rendering is
4
+ // here.
5
+ //
6
+ // ── THE CARD IS THE PHASE ───────────────────────────────────────────────
7
+ //
8
+ // A shot proposal is not a diff — a diff of a Doc3D JSON is unreadable and,
9
+ // worse, a lie about what the change IS. What an author has to judge is how
10
+ // LONG the video is now and what moves in it, so that is the card: the intent
11
+ // as the title, the duration and the block/camera-move counts as chips, and the
12
+ // scene/layer outline the server already rendered.
13
+ //
14
+ // Keydown stops at the panel root so typing a prompt never reaches the global
15
+ // shot keymap — "delete" in a sentence must not delete the selected layer, and
16
+ // this chrome's keymap is single-letter tool verbs all the way down.
17
+ import "./agent.css";
18
+ import { useEffect, useRef, useState } from "react";
19
+ import type { KeyboardEvent as ReactKeyboardEvent } from "react";
20
+ import { isDocProposalItem } from "@dailies-studio/agent-client";
21
+ import type {
22
+ AgentFileProposalItem,
23
+ AgentProposalItem,
24
+ AgentShotProposalItem,
25
+ AgentTranscriptItem,
26
+ } from "@dailies-studio/agent-client";
27
+ import { useShotEditor, useStore } from "../../state/context.js";
28
+ import {
29
+ applyAgentProposal,
30
+ askAgent,
31
+ cancelAgent,
32
+ clearAgentKey,
33
+ refreshAgentCredentials,
34
+ saveAgentKey,
35
+ } from "../../state/agentActions.js";
36
+
37
+ const MODEL_LABELS: Record<string, string> = {
38
+ "claude-sonnet-5": "Sonnet 5",
39
+ "claude-opus-4-8": "Opus 4.8",
40
+ };
41
+
42
+ function modelLabel(model: string): string {
43
+ return MODEL_LABELS[model] ?? model;
44
+ }
45
+
46
+ /** Apply / status, shared by every card kind. */
47
+ function CardActions(props: { item: AgentProposalItem }) {
48
+ const store = useStore();
49
+ const { item } = props;
50
+ if (item.status === "pending") {
51
+ return (
52
+ <button
53
+ type="button"
54
+ className="btn agent-apply"
55
+ onClick={() => void applyAgentProposal(store, item.id)}
56
+ >
57
+ Apply
58
+ </button>
59
+ );
60
+ }
61
+ return (
62
+ <span className={`agent-card-status is-${item.status}`}>
63
+ {item.status === "applying" ? "Applying…" : item.status}
64
+ </span>
65
+ );
66
+ }
67
+
68
+ /**
69
+ * The scene/layer outline, exactly as the server rendered it.
70
+ *
71
+ * Deliberately NOT re-derived chrome-side: the proposal has not been applied,
72
+ * so this editor does not hold the document it describes, and inventing an
73
+ * outline from a doc we do not have is the coupling the summary exists to
74
+ * avoid.
75
+ */
76
+ function Outline(props: { lines: readonly string[] }) {
77
+ if (props.lines.length === 0) {
78
+ return <div className="agent-card-note">No shots reported.</div>;
79
+ }
80
+ return (
81
+ <pre className="agent-tree">
82
+ {props.lines.map((line, i) => (
83
+ <div key={i} className="agent-tree-line">
84
+ {line}
85
+ </div>
86
+ ))}
87
+ </pre>
88
+ );
89
+ }
90
+
91
+ function ShotCard(props: { item: AgentShotProposalItem }) {
92
+ const { item } = props;
93
+ const openName = useShotEditor((s) => s.name);
94
+ const { summary } = item;
95
+ // Three cases, one of which needs a warning. Same shot: the SSE bus adopts
96
+ // it. A CREATE of another name: applying opens it. An EDIT of another shot:
97
+ // it lands on disk but this editor stays where it is — say so rather than
98
+ // letting the card imply the author is looking at the result.
99
+ const editsElsewhere = openName !== null && openName !== item.docName && item.baseRev !== null;
100
+ return (
101
+ <div className={`agent-card agent-card-shot agent-card-${item.status}`}>
102
+ <div className="agent-card-head">
103
+ <span className="agent-card-path" title={item.intent}>
104
+ {item.intent}
105
+ </span>
106
+ <CardActions item={item} />
107
+ </div>
108
+ <div className="agent-card-meta">
109
+ <span className="agent-card-doc" title={`scene "${summary.shotName}"`}>
110
+ {summary.shotName}
111
+ </span>
112
+ {/* DURATION first: a shot document is a video, and "how long is it now"
113
+ is the first thing a reviewer asks. */}
114
+ <span className="agent-card-count">{summary.durationFrames}f</span>
115
+ <span className="agent-card-count">
116
+ {summary.sceneCount} {summary.sceneCount === 1 ? "shot" : "shots"}
117
+ </span>
118
+ <span className="agent-card-count">
119
+ {summary.layerCount} {summary.layerCount === 1 ? "layer" : "layers"}
120
+ </span>
121
+ {/* The MOTION half. Silent at zero — a chip reading "0 blocks" on a
122
+ static card is noise, and this lane's whole animation system is
123
+ blocks, so their absence is itself the information. */}
124
+ {summary.blockCount > 0 && (
125
+ <span className="agent-card-count">
126
+ {summary.blockCount} {summary.blockCount === 1 ? "block" : "blocks"}
127
+ </span>
128
+ )}
129
+ {summary.shotCount > 0 && (
130
+ <span className="agent-card-count">
131
+ {summary.shotCount} {summary.shotCount === 1 ? "camera move" : "camera moves"}
132
+ </span>
133
+ )}
134
+ </div>
135
+ <Outline lines={summary.outline} />
136
+ {item.error !== null && <div className="error-strip agent-card-error">{item.error}</div>}
137
+ {/* APPLIED WITH WARNINGS is its own state: the document was written, but
138
+ codegen refused (a hand-edited generated file) — so the component that
139
+ actually renders was not updated. A plain green "Applied" there is the
140
+ one place a half-landed change reads as a landed one. */}
141
+ {item.status === "applied" && item.warning !== null && (
142
+ <div className="agent-card-note agent-card-warning">
143
+ Applied with warnings — the scene document was saved, but: {item.warning}
144
+ </div>
145
+ )}
146
+ {item.status === "applied" && item.error === null && item.warning === null && (
147
+ <div className="agent-card-note">
148
+ Applied — the change is live in the editor. Ctrl+Z takes it back.
149
+ </div>
150
+ )}
151
+ {item.status === "stale" && (
152
+ <div className="agent-card-note">
153
+ The scene changed after this was proposed — ask the agent again so it can re-read the
154
+ document and re-propose.
155
+ </div>
156
+ )}
157
+ {editsElsewhere && item.status === "pending" && (
158
+ <div className="agent-card-note">
159
+ Edits scene “{item.docName}”, not the open one — applying writes it, but this editor stays
160
+ on “{openName}”.
161
+ </div>
162
+ )}
163
+ </div>
164
+ );
165
+ }
166
+
167
+ function diffLineClass(line: string): string {
168
+ // The +++/--- file headers are headers, not adds/dels.
169
+ if (line.startsWith("+++") || line.startsWith("---") || line.startsWith("@@")) {
170
+ return "agent-diff-hunk";
171
+ }
172
+ if (line.startsWith("+")) return "agent-diff-add";
173
+ if (line.startsWith("-")) return "agent-diff-del";
174
+ return "agent-diff-ctx";
175
+ }
176
+
177
+ /** A source-file proposal. The shot agent works through shot tools, but a
178
+ * session can still touch ordinary source, and a transcript that drops cards
179
+ * it did not expect is worse than one that shows a plain diff. */
180
+ function FileCard(props: { item: AgentFileProposalItem }) {
181
+ const { item } = props;
182
+ const lines = item.diff.replace(/\n$/, "").split("\n");
183
+ return (
184
+ <div className={`agent-card agent-card-${item.status}`}>
185
+ <div className="agent-card-head">
186
+ <span className="agent-card-path" title={item.path}>
187
+ {item.proposalKind === "create" ? `new file ${item.path}` : item.path}
188
+ </span>
189
+ <CardActions item={item} />
190
+ </div>
191
+ <pre className="agent-diff">
192
+ {lines.map((line, i) => (
193
+ <div key={i} className={diffLineClass(line)}>
194
+ {line}
195
+ </div>
196
+ ))}
197
+ </pre>
198
+ {item.error !== null && <div className="error-strip agent-card-error">{item.error}</div>}
199
+ {item.status === "stale" && (
200
+ <div className="agent-card-note">
201
+ The file changed on disk after this was proposed — ask the agent again to re-read and
202
+ re-propose.
203
+ </div>
204
+ )}
205
+ </div>
206
+ );
207
+ }
208
+
209
+ function TranscriptItem(props: { item: AgentTranscriptItem }) {
210
+ const { item } = props;
211
+ if (item.kind === "proposal") {
212
+ if (item.proposalKind === "shot") return <ShotCard item={item} />;
213
+ // A canvas, motion or scene card can reach THIS chrome only if the session
214
+ // was opened here, which the surface gating makes impossible today.
215
+ // Rendered read-only rather than swallowed: a transcript that silently
216
+ // drops cards is worse than one that shows a card with no button.
217
+ // Through the shared PREDICATE, not an inline negation: TypeScript will
218
+ // not eliminate a union member whose own discriminant is a union
219
+ // ("edit" | "create") from the else branch of a disjunction, so the
220
+ // document arms would lose their fields. See isFileProposalEvent.
221
+ if (isDocProposalItem(item)) {
222
+ return (
223
+ <div className={`agent-card agent-card-${item.status}`}>
224
+ <div className="agent-card-head">
225
+ <span className="agent-card-path" title={item.intent}>
226
+ {item.intent}
227
+ </span>
228
+ <span className={`agent-card-status is-${item.status}`}>{item.status}</span>
229
+ </div>
230
+ <div className="agent-card-note">
231
+ A {item.proposalKind} document — “{item.docName}”. Open that editor to review and apply
232
+ it.
233
+ </div>
234
+ </div>
235
+ );
236
+ }
237
+ return <FileCard item={item} />;
238
+ }
239
+ if (item.kind === "tool") return <div className="agent-tool">{item.text}</div>;
240
+ return <div className={`agent-msg agent-msg-${item.kind}`}>{item.text}</div>;
241
+ }
242
+
243
+ function KeySetup(props: { compact: boolean; onDone?: () => void }) {
244
+ const store = useStore();
245
+ const credentials = useShotEditor((s) => s.agentCredentials);
246
+ const credentialsError = useShotEditor((s) => s.agentCredentialsError);
247
+ const [key, setKey] = useState("");
248
+ const [saving, setSaving] = useState(false);
249
+
250
+ const save = async (): Promise<void> => {
251
+ if (key.trim() === "" || saving) return;
252
+ setSaving(true);
253
+ await saveAgentKey(store, key.trim());
254
+ setSaving(false);
255
+ if (store.getState().agentCredentials?.source === "stored") {
256
+ setKey("");
257
+ props.onDone?.();
258
+ }
259
+ };
260
+
261
+ return (
262
+ <div className="agent-setup">
263
+ {!props.compact && (
264
+ <p className="agent-setup-blurb">
265
+ The agent builds scenes through the studio's validated document API and needs an Anthropic
266
+ API key. Bring your own: it is stored in <code>.studio/agent/</code> (kept out of git) on
267
+ this machine, sent only to Anthropic, and never shown again.
268
+ </p>
269
+ )}
270
+ {credentials?.source === "env" && (
271
+ <p className="agent-setup-blurb">
272
+ Using <code>ANTHROPIC_API_KEY</code> from the environment. Paste a key to override it.
273
+ </p>
274
+ )}
275
+ {credentials?.source === "stored" && (
276
+ <p className="agent-setup-blurb">
277
+ Using your stored key (…{credentials.last4}).{" "}
278
+ <button type="button" className="btn is-danger" onClick={() => void clearAgentKey(store)}>
279
+ Remove key
280
+ </button>
281
+ </p>
282
+ )}
283
+ <div className="agent-setup-row">
284
+ <input
285
+ type="password"
286
+ className="agent-key-input"
287
+ placeholder="sk-ant-…"
288
+ value={key}
289
+ autoComplete="off"
290
+ onChange={(e) => setKey(e.target.value)}
291
+ onKeyDown={(e) => {
292
+ // isComposing: the Enter that commits an IME candidate must not save.
293
+ if (e.key === "Enter" && !e.nativeEvent.isComposing) void save();
294
+ }}
295
+ />
296
+ <button
297
+ type="button"
298
+ className="btn"
299
+ disabled={key.trim() === "" || saving}
300
+ onClick={() => void save()}
301
+ >
302
+ {saving ? "Saving…" : "Save key"}
303
+ </button>
304
+ </div>
305
+ {credentialsError !== null && <div className="error-strip">{credentialsError}</div>}
306
+ </div>
307
+ );
308
+ }
309
+
310
+ export function AgentPanel() {
311
+ const store = useStore();
312
+ const credentials = useShotEditor((s) => s.agentCredentials);
313
+ const credentialsError = useShotEditor((s) => s.agentCredentialsError);
314
+ const items = useShotEditor((s) => s.agentItems);
315
+ const running = useShotEditor((s) => s.agentRunning);
316
+ const agentError = useShotEditor((s) => s.agentError);
317
+ const agentModel = useShotEditor((s) => s.agentModel);
318
+ const costUsd = useShotEditor((s) => s.agentCostUsd);
319
+ const name = useShotEditor((s) => s.name);
320
+ const [prompt, setPrompt] = useState("");
321
+ const [keyOpen, setKeyOpen] = useState(false);
322
+ const listRef = useRef<HTMLDivElement | null>(null);
323
+
324
+ // One credentials probe per opening; a saved/removed key refreshes through
325
+ // the actions themselves.
326
+ useEffect(() => {
327
+ if (store.getState().agentCredentials === null) void refreshAgentCredentials(store);
328
+ }, [store]);
329
+
330
+ // Keep the transcript pinned to the newest item while streaming.
331
+ useEffect(() => {
332
+ const el = listRef.current;
333
+ if (el) el.scrollTop = el.scrollHeight;
334
+ }, [items]);
335
+
336
+ const submit = (): void => {
337
+ const text = prompt.trim();
338
+ if (text === "" || running || credentials?.configured !== true) return;
339
+ setPrompt("");
340
+ void askAgent(store, text);
341
+ };
342
+
343
+ const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>): void => {
344
+ // The shot keymap (V/M/T tool keys, Delete, ⇧A, Ctrl+Z) must never see
345
+ // these — every one of them is a single letter.
346
+ e.stopPropagation();
347
+ };
348
+
349
+ const model = agentModel ?? credentials?.model ?? "";
350
+
351
+ return (
352
+ <div className="agent-panel" data-testid="agent-panel" onKeyDown={onKeyDown}>
353
+ <div className="panel-title agent-title">
354
+ <span className="agent-title-label">Agent</span>
355
+ {credentials !== null && credentials.configured && (
356
+ <div className="agent-models" role="tablist">
357
+ {credentials.models.map((m) => (
358
+ <button
359
+ key={m}
360
+ type="button"
361
+ role="tab"
362
+ aria-selected={m === model}
363
+ className={`btn${m === model ? " is-active" : ""}`}
364
+ title={m}
365
+ onClick={() => store.setAgentModel(m === credentials.model ? null : m)}
366
+ >
367
+ {modelLabel(m)}
368
+ </button>
369
+ ))}
370
+ </div>
371
+ )}
372
+ <button
373
+ type="button"
374
+ className={`btn${keyOpen ? " is-active" : ""}`}
375
+ title="API key (bring your own)"
376
+ onClick={() => setKeyOpen((v) => !v)}
377
+ >
378
+ Key
379
+ </button>
380
+ <button
381
+ type="button"
382
+ className="btn"
383
+ disabled={items.length === 0 || running}
384
+ title="Start a new chat"
385
+ onClick={() => store.clearAgentTranscript()}
386
+ >
387
+ New
388
+ </button>
389
+ <button
390
+ type="button"
391
+ className="btn is-icon"
392
+ aria-label="Close agent panel"
393
+ onClick={() => store.setAgentOpen(false)}
394
+ >
395
+ ×
396
+ </button>
397
+ </div>
398
+
399
+ {credentials !== null && !credentials.configured ? (
400
+ <KeySetup compact={false} />
401
+ ) : keyOpen ? (
402
+ <KeySetup compact onDone={() => setKeyOpen(false)} />
403
+ ) : null}
404
+
405
+ {/* The probe itself failed (older server, transient network): the
406
+ composer is gated, so this strip is the only way out — say so. */}
407
+ {credentials === null && credentialsError !== null && (
408
+ <div className="error-strip agent-strip">
409
+ <span className="agent-strip-msg">{credentialsError}</span>
410
+ <button type="button" className="btn" onClick={() => void refreshAgentCredentials(store)}>
411
+ Retry
412
+ </button>
413
+ </div>
414
+ )}
415
+
416
+ <div className="agent-transcript" ref={listRef}>
417
+ {items.length === 0 ? (
418
+ <div className="panel-empty">
419
+ Ask for a scene — “a product video: the screenshot pushes in while the headline fades
420
+ up”. You get real layers at real depths; nothing lands until you apply it.
421
+ </div>
422
+ ) : (
423
+ items.map((item) => <TranscriptItem key={item.id} item={item} />)
424
+ )}
425
+ {running && <div className="agent-msg agent-msg-info agent-thinking">Working…</div>}
426
+ </div>
427
+
428
+ {agentError !== null && <div className="error-strip agent-strip">{agentError}</div>}
429
+ {costUsd !== null && !running && (
430
+ <div className="agent-cost">last turn ≈ ${costUsd.toFixed(costUsd < 0.1 ? 3 : 2)}</div>
431
+ )}
432
+
433
+ <div className="agent-composer">
434
+ {name !== null && (
435
+ <span className="agent-pin" title="Sent as context with your next ask">
436
+ ◎ {name}
437
+ </span>
438
+ )}
439
+ <textarea
440
+ className="agent-input"
441
+ rows={2}
442
+ placeholder={
443
+ credentials === null
444
+ ? "Waiting for the agent backend…"
445
+ : !credentials.configured
446
+ ? "Add an API key above to start"
447
+ : "Ask the agent… (Enter to send, Shift+Enter for a new line)"
448
+ }
449
+ value={prompt}
450
+ disabled={credentials === null || !credentials.configured}
451
+ onChange={(e) => setPrompt(e.target.value)}
452
+ onKeyDown={(e) => {
453
+ // isComposing: committing an IME candidate is not "send" — that
454
+ // would start a paid turn on a half-composed prompt.
455
+ if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
456
+ e.preventDefault();
457
+ submit();
458
+ }
459
+ }}
460
+ />
461
+ {running ? (
462
+ <button type="button" className="btn is-danger" onClick={() => void cancelAgent(store)}>
463
+ Stop
464
+ </button>
465
+ ) : (
466
+ <button
467
+ type="button"
468
+ className="btn agent-send"
469
+ disabled={prompt.trim() === "" || credentials?.configured !== true}
470
+ onClick={submit}
471
+ >
472
+ Ask
473
+ </button>
474
+ )}
475
+ </div>
476
+ </div>
477
+ );
478
+ }