@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.
- package/dist/assets/index-B7notge7.css +1 -0
- package/dist/assets/index-BBE6Ta9L.js +52 -0
- package/dist/index.html +13 -0
- package/package.json +45 -0
- package/src/api.ts +289 -0
- package/src/app/App.tsx +394 -0
- package/src/audio/index.tsx +74 -0
- package/src/audio/preview.ts +364 -0
- package/src/bridge.ts +45 -0
- package/src/bridgeTypes.ts +582 -0
- package/src/channels.ts +40 -0
- package/src/main.tsx +15 -0
- package/src/panels/ResizeHandle.tsx +113 -0
- package/src/panels/agent/AgentPanel.tsx +478 -0
- package/src/panels/agent/agent.css +322 -0
- package/src/panels/export/ExportDialog.tsx +284 -0
- package/src/panels/files/FilePanel.tsx +564 -0
- package/src/panels/files/McpSection.tsx +66 -0
- package/src/panels/files/icons.tsx +118 -0
- package/src/panels/header/DocSwitcher.tsx +85 -0
- package/src/panels/header/Header.tsx +132 -0
- package/src/panels/header/HistoryControls.tsx +20 -0
- package/src/panels/header/StatusBadges.tsx +49 -0
- package/src/panels/header/TransportBar.tsx +86 -0
- package/src/panels/header/ZoomControls.tsx +38 -0
- package/src/panels/help/HelpSection.tsx +190 -0
- package/src/panels/inspector/FramingRows.tsx +69 -0
- package/src/panels/inspector/InspectorPanel.tsx +178 -0
- package/src/panels/inspector/MarkerSection.tsx +63 -0
- package/src/panels/inspector/ParamRow.tsx +71 -0
- package/src/panels/inspector/animate/BlockTiming.tsx +341 -0
- package/src/panels/inspector/animate/DuringBlocks.tsx +85 -0
- package/src/panels/inspector/animate/InOutPresets.tsx +57 -0
- package/src/panels/inspector/animate/ResolvedChannels.tsx +150 -0
- package/src/panels/inspector/animate/ShotSection.tsx +126 -0
- package/src/panels/inspector/animate/useBlocks.ts +43 -0
- package/src/panels/inspector/design/AlignRow.tsx +63 -0
- package/src/panels/inspector/design/AppearanceSection.tsx +49 -0
- package/src/panels/inspector/design/ClonerSection.tsx +142 -0
- package/src/panels/inspector/design/ComponentSection.tsx +94 -0
- package/src/panels/inspector/design/CornerSection.tsx +52 -0
- package/src/panels/inspector/design/FillSection.tsx +89 -0
- package/src/panels/inspector/design/HtmlSection.tsx +50 -0
- package/src/panels/inspector/design/InstanceSection.tsx +302 -0
- package/src/panels/inspector/design/MediaSection.tsx +251 -0
- package/src/panels/inspector/design/MeshSection.tsx +172 -0
- package/src/panels/inspector/design/ShadowSection.tsx +73 -0
- package/src/panels/inspector/design/StrokeSection.tsx +85 -0
- package/src/panels/inspector/design/TransformSection.tsx +91 -0
- package/src/panels/inspector/design/TypographySection.tsx +170 -0
- package/src/panels/inspector/scene/CameraSection.tsx +59 -0
- package/src/panels/inspector/scene/EffectsSection.tsx +123 -0
- package/src/panels/inspector/scene/SceneSection.tsx +71 -0
- package/src/panels/inspector/scene/TransitionSection.tsx +136 -0
- package/src/panels/inspector/useDocWrite.ts +268 -0
- package/src/panels/material/MaterialPicker.tsx +172 -0
- package/src/panels/stage/StageMenu.tsx +38 -0
- package/src/panels/stage/StagePanel.tsx +131 -0
- package/src/panels/timeline/AudioLane.tsx +340 -0
- package/src/panels/timeline/BlockRow.tsx +242 -0
- package/src/panels/timeline/ChannelRow.tsx +266 -0
- package/src/panels/timeline/LifespanRow.tsx +165 -0
- package/src/panels/timeline/MarkerLane.tsx +146 -0
- package/src/panels/timeline/SceneStrip.tsx +145 -0
- package/src/panels/timeline/TimelineDock.tsx +488 -0
- package/src/panels/timeline/model.ts +641 -0
- package/src/panels/timeline/snap.ts +191 -0
- package/src/panels/toolbar/AddMenu.tsx +230 -0
- package/src/panels/toolbar/CameraViewBar.tsx +70 -0
- package/src/panels/toolbar/ShadingBar.tsx +42 -0
- package/src/panels/toolbar/ToolDockBar.tsx +110 -0
- package/src/panels/toolbar/ViewModeBar.tsx +49 -0
- package/src/shotTokens.css +131 -0
- package/src/stage/FrustumGizmo.tsx +184 -0
- package/src/stage/GridPlane.tsx +274 -0
- package/src/stage/MoveGizmo.tsx +411 -0
- package/src/stage/PerfHud.tsx +206 -0
- package/src/stage/SplitRenderer.tsx +511 -0
- package/src/stage/StageApp.tsx +3423 -0
- package/src/stage/StageTools.tsx +581 -0
- package/src/stage/bridgeClient.ts +278 -0
- package/src/stage/feed.ts +243 -0
- package/src/stage/frustumGeometry.ts +172 -0
- package/src/stage/gizmoMath.ts +494 -0
- package/src/stage/handles.ts +151 -0
- package/src/stage/instanceModules.ts +152 -0
- package/src/stage/interactions/move.ts +172 -0
- package/src/stage/interactions/panZoom.ts +60 -0
- package/src/stage/isolation.ts +155 -0
- package/src/stage/main.tsx +110 -0
- package/src/stage/modalState.ts +427 -0
- package/src/stage/overlays/Overlays.tsx +1046 -0
- package/src/stage/panes.ts +121 -0
- package/src/stage/penState.ts +112 -0
- package/src/stage/project.ts +402 -0
- package/src/stage/shading.ts +55 -0
- package/src/stage/tokenColor.ts +26 -0
- package/src/stage/view.ts +182 -0
- package/src/stage/workingView.ts +636 -0
- package/src/state/agentActions.ts +203 -0
- package/src/state/clientId.ts +25 -0
- package/src/state/context.tsx +33 -0
- package/src/state/insert.ts +507 -0
- package/src/state/keymap.ts +560 -0
- package/src/state/menu.ts +141 -0
- package/src/state/panelSize.ts +26 -0
- package/src/state/renderEvents.ts +100 -0
- package/src/state/store.ts +4094 -0
- package/src/state/triangles.ts +70 -0
- package/src/state/types.ts +384 -0
- package/src/styles.css +2948 -0
- 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
|
+
}
|