diff --git a/agent.md b/agent.md index 692d3f5..7c721ec 100644 --- a/agent.md +++ b/agent.md @@ -263,7 +263,9 @@ its strongest line (played first when it comes later in the moment), and makes each one **its own video in the project**: 1080×1920 with captions on, framed on the person speaking. The video the request was sent to is not changed. The finished message, and the job while it runs, list them in `made: [{ id, name }]`; open -one at `/edits/{id}`. "Frame it for vertical" frames the video it was sent to the +one at `/edits/{id}`. With `together`, the moments become **one** video instead, in order, opening +on one 3 second hook. When a request could mean either ("5 cut downs with a +strong hook"), `/instruct` asks which before doing anything. "Frame it for vertical" frames the video it was sent to the same way, in its 9:16 version (added when missing). Shots the framing is unsure of keep the whole picture, and the message says how many: a person checks them in the editor's AI reframe. Both need the video on the media service (an diff --git a/src/client/assistant.tsx b/src/client/assistant.tsx index 70b9921..d31a0f5 100644 --- a/src/client/assistant.tsx +++ b/src/client/assistant.tsx @@ -172,86 +172,77 @@ function MadeLinks({ made, onOpen }: { made: Made[]; onOpen: (id: string) => voi } /** - * The background work, when the conversation is the dashboard's chat: what a - * job is doing, its controls, and the clips it made. Nothing shows while - * nothing needs the person. + * The background work, when the conversation is the dashboard's chat: one + * short line beside the Videos menu. What a job is doing while it runs, its + * controls when it needs the person, and "6 new videos" for a few seconds when + * it made some. Nothing when nothing is happening: it never takes room from + * the player. */ -export function JobStrip({ ask, onOpen }: { ask: Ask; onOpen: (id: string) => void }) { - const { jobs, messages, notTaken, act } = ask; - const [acting, setActing] = useState(null); +export function JobStatus({ ask }: { ask: Ask }) { + const { jobs, notTaken, act } = ask; + const [acting, setActing] = useState(false); const [err, setErr] = useState(""); - const [dismissed, setDismissed] = useState>(new Set()); - const doAct = async (id: string, action: "run" | "cancel" | "apply") => { - setActing(`${id}:${action}`); + const [done, setDone] = useState(null); + const was = useRef(new Map()); + // A job seen running here that finished with videos: say so, briefly. + useEffect(() => { + for (const j of jobs) { + const before = was.current.get(j.id); + if (before && ACTIVE.includes(before) && !ACTIVE.includes(j.status) && j.status === "applied") { + const n = ask.messages?.filter((m) => m.job_id === j.id).reduce((k, m) => k + (m.made?.length ?? 0), 0) ?? 0; + setDone(n ? `${n} new video${n === 1 ? "" : "s"}` : "Done"); + } + was.current.set(j.id, j.status); + } + }, [jobs, ask.messages]); + useEffect(() => { + if (!done) return; + const t = setTimeout(() => setDone(null), 8000); + return () => clearTimeout(t); + }, [done]); + const run = async (id: string, action: "run" | "cancel" | "apply") => { + setActing(true); setErr(""); try { await act(id, action); } catch (e) { setErr(String((e as Error).message)); } finally { - setActing(null); + setActing(false); } }; - // The newest message of each finished job that made clips, until dismissed. - const madeBy = new Map(); - for (const m of messages ?? []) if (m.job_id && m.made?.length) madeBy.set(m.job_id, m.made); - const shown = jobs.filter( - (j) => - !dismissed.has(j.id) && - (j.status === "proposed" || ACTIVE.includes(j.status) || j.status === "stale" || notTaken.has(j.id) || (j.status === "applied" && madeBy.has(j.id))), - ); - if (!shown.length && !err) return null; - const close = (id: string) => setDismissed((d) => new Set(d).add(id)); + const job = jobs.find((j) => j.status === "proposed" || ACTIVE.includes(j.status) || j.status === "stale" || notTaken.has(j.id)); + const small = `${btnGhost} h-7 px-1.5 text-fine`; + if (err) return {err}; + if (!job) return done ? {done} : null; return ( -
- {shown.map((j) => { - const busy = acting !== null; - return ( -
-
- {ACTIVE.includes(j.status) ? : } - - {j.status === "proposed" - ? `Waiting for a yes: ${j.instruction}` - : j.status === "queued" - ? "Starting in the background…" - : j.status === "running" - ? `${j.doing ?? "Working"}…` - : j.status === "applied" - ? `Made ${madeBy.get(j.id)!.length} clips` - : "A change from the chat is waiting: the cut changed while it ran"} - - {j.status === "proposed" && ( - <> - - - - )} - {ACTIVE.includes(j.status) && ( - - )} - {(j.status === "stale" || notTaken.has(j.id)) && ( - <> - - {j.status === "stale" && ( - - )} - - )} - {j.status === "applied" && ( - - )} -
- {(j.status === "applied" ? madeBy.get(j.id) : j.made)?.length ? ( - - ) : null} -
- ); - })} - {err &&

{err}

} -
+ + {ACTIVE.includes(job.status) && } + + {job.status === "proposed" + ? "Run in the background?" + : job.status === "queued" + ? "Starting…" + : job.status === "running" + ? (job.doing ?? "Working…") + : "Kept aside: the cut changed"} + + {job.status === "proposed" && ( + <> + + + + )} + {ACTIVE.includes(job.status) && ( + + )} + {(job.status === "stale" || notTaken.has(job.id)) && ( + <> + + {job.status === "stale" && } + + )} + ); } diff --git a/src/client/edit.tsx b/src/client/edit.tsx index 71ac6d5..2f26aee 100644 --- a/src/client/edit.tsx +++ b/src/client/edit.tsx @@ -1,5 +1,5 @@ import { ReframeDialog } from "./reframe"; -import { AssistantPanel, JobStrip, canonical, useAsk } from "./assistant"; +import { AssistantPanel, JobStatus, canonical, useAsk } from "./assistant"; import { VideoSwitcher, useProjectVideos } from "./videos"; import { embedded, openChat, useChatCommands, useChatContext, useHostChanges } from "@clawnify/app/client"; // Footage edit projects — the timeline editor over EDL documents. @@ -1574,6 +1574,7 @@ export function EditEditor({ {saveState === "saved" ? "Saved" : saveState === "saving" ? "Saving…" : saveState} + {embedded && }