From 6360092b6a7e793324673f1cb19fb9c1b72bbe55 Mon Sep 17 00:00:00 2001 From: Slava Yultyyev Date: Thu, 18 Jun 2026 11:42:10 -0700 Subject: [PATCH 01/18] feat(ui): render the CompositionDoc as a timeline (ruler, lanes, clips, playhead) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser UI was built entirely on the legacy session/output-file model — it never read the CompositionDoc the Phase 0 engine maintains (`grep api/timeline src/ui/web/src` was zero hits), so it looked like an op log, not an editor. That is the real source of "sketchy": architectural, not cosmetic. Add the human window onto the doc, reusing endpoints that already exist: - useTimeline() polls GET /api/timeline (mirrors useSession). - lib/composition.ts mirrors the doc geometry helpers (clipDuration / clipEndSec / compositionDuration) so clips position by their document extent. - DocTimeline renders a seconds ruler, one lane per track, clips as blocks (startSec → clipDuration), transitions as boundary markers, clip selection, and an accessible range-input playhead. Wired as the primary surface above the (now demoted) legacy outputs/render-queue strip. - .app switches to a flex column so stacked strips size to content and `main` flexes to fill. Read-only for now (no doc mutation): editing via the existing verb layer and the composited-frame preview are the next increments. Verified in a real browser — a 2-clip + transition composition renders with correct lanes/ruler/marker, clip select highlights, and the playhead scrubs. --- src/ui/web/src/App.tsx | 9 + src/ui/web/src/components/DocTimeline.tsx | 143 ++++++++++++++ src/ui/web/src/hooks/useTimeline.ts | 51 +++++ src/ui/web/src/lib/composition.ts | 31 +++ src/ui/web/src/styles.css | 220 +++++++++++++++++++++- src/ui/web/src/types.ts | 56 ++++++ 6 files changed, 508 insertions(+), 2 deletions(-) create mode 100644 src/ui/web/src/components/DocTimeline.tsx create mode 100644 src/ui/web/src/hooks/useTimeline.ts create mode 100644 src/ui/web/src/lib/composition.ts diff --git a/src/ui/web/src/App.tsx b/src/ui/web/src/App.tsx index d93f44f..82b5668 100644 --- a/src/ui/web/src/App.tsx +++ b/src/ui/web/src/App.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { ChatPanel } from './components/ChatPanel.js'; import { DetailPane } from './components/DetailPane.js'; +import { DocTimeline } from './components/DocTimeline.js'; import { AddCaptionsForm } from './components/forms/AddCaptionsForm.js'; import { AddTextForm } from './components/forms/AddTextForm.js'; import { AddTitleCardForm } from './components/forms/AddTitleCardForm.js'; @@ -20,11 +21,14 @@ import { ToolPickerModal } from './components/ToolPickerModal.js'; import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts.js'; import { useSession } from './hooks/useSession.js'; import { useSessionSafety } from './hooks/useSessionSafety.js'; +import { useTimeline } from './hooks/useTimeline.js'; export function App() { const { session, loading, error, refresh } = useSession(); + const { composition } = useTimeline(); const safety = useSessionSafety(); const [selectedId, setSelectedId] = useState(null); + const [selectedClipId, setSelectedClipId] = useState(null); const [pickerOpen, setPickerOpen] = useState(false); const [activeTool, setActiveTool] = useState(null); const [safetyError, setSafetyError] = useState(null); @@ -120,6 +124,11 @@ export function App() { /> {safetyError ?
{safetyError}
: null} + void; +}) { + const [playheadSec, setPlayheadSec] = useState(0); + + const total = compositionDuration(composition); + const spanSec = Math.max(total, MIN_SPAN_SEC); + const laneWidth = spanSec * PX_PER_SEC; + const ticks = Array.from({ length: Math.floor(spanSec) + 1 }, (_, i) => i); + const hasClips = composition.tracks.some((t) => t.clips.length > 0); + const playhead = Math.min(playheadSec, spanSec); + + return ( +
+
+ Timeline + + {total.toFixed(2)}s · {composition.width}×{composition.height} · {composition.fps}fps · + rev {composition.rev} + + {hasClips ? ( + setPlayheadSec(Number(e.target.value))} + aria-label="Playhead position (seconds)" + /> + ) : null} + ▶ {playhead.toFixed(2)}s +
+ + {hasClips ? ( +
+
+
+
+
+ {ticks.map((t) => ( +
+ {t}s +
+ ))} +
+
+ + {composition.tracks.map((track) => ( +
+
+ {track.id} + {track.kind} +
+
+ {track.clips.map((clip) => ( + + ))} + {track.transitions.map((tr) => { + const after = track.clips.find((c) => c.id === tr.afterClipId); + if (!after) return null; + return ( +
+ {tr.kind} +
+ ); + })} +
+
+ ))} + + +
+ ) : ( +
+ No clips yet — drop in media or add a clip to start building the timeline. +
+ )} +
+ ); +} + +function ClipBlock({ + clip, + selected, + onSelect, +}: { + clip: Clip; + selected: boolean; + onSelect: (clipId: string | null) => void; +}) { + const left = clip.startSec * PX_PER_SEC; + const width = clipDuration(clip) * PX_PER_SEC; + return ( + + ); +} diff --git a/src/ui/web/src/hooks/useTimeline.ts b/src/ui/web/src/hooks/useTimeline.ts new file mode 100644 index 0000000..e237fb6 --- /dev/null +++ b/src/ui/web/src/hooks/useTimeline.ts @@ -0,0 +1,51 @@ +import { useCallback, useEffect, useState } from 'react'; +import type { Composition } from '../types.js'; + +const EMPTY: Composition = { + version: 1, + rev: 0, + width: 1920, + height: 1080, + fps: 30, + background: 'black', + tracks: [], +}; + +/** + * Polls `/api/timeline` (the CompositionDoc — the source of truth for assembled + * edits) every `intervalMs`. Mirrors `useSession`: returns the latest doc plus + * loading/error state and a `refresh()` for callers that just mutated it (e.g. + * after a verb POST) and want the view to update without waiting for the poll. + */ +export function useTimeline(intervalMs = 2000): { + composition: Composition; + loading: boolean; + error: string | null; + refresh: () => Promise; +} { + const [composition, setComposition] = useState(EMPTY); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + try { + const res = await fetch('/api/timeline'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json = (await res.json()) as { document: Composition }; + setComposition(json.document); + setError(null); + setLoading(false); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + const timer = window.setInterval(load, intervalMs); + return () => window.clearInterval(timer); + }, [intervalMs, load]); + + return { composition, loading, error, refresh: load }; +} diff --git a/src/ui/web/src/lib/composition.ts b/src/ui/web/src/lib/composition.ts new file mode 100644 index 0000000..a12a1c3 --- /dev/null +++ b/src/ui/web/src/lib/composition.ts @@ -0,0 +1,31 @@ +import type { Clip, Composition } from '../types.js'; + +// Frontend mirror of the doc geometry helpers in src/timeline/composition.ts. +// The timeline view positions clips by their DOCUMENT extent (startSec + +// clipDuration), exactly as the schema/reducer model them. + +/** On-timeline duration of a clip in seconds (source window for media, explicit + * duration for text/color). */ +export function clipDuration(clip: Clip): number { + return clip.kind === 'media' ? clip.sourceOutSec - clip.sourceInSec : clip.durationSec; +} + +export function clipEndSec(clip: Clip): number { + return clip.startSec + clipDuration(clip); +} + +/** Latest clip end across all tracks — the composition's overall length. */ +export function compositionDuration(comp: Composition): number { + let end = 0; + for (const track of comp.tracks) { + for (const clip of track.clips) end = Math.max(end, clipEndSec(clip)); + } + return end; +} + +/** Short, human-readable label for a clip on the timeline. */ +export function clipLabel(clip: Clip): string { + if (clip.kind === 'media') return clip.mediaId; + if (clip.kind === 'text') return `“${clip.text.slice(0, 20)}”`; + return `color · ${clip.color}`; +} diff --git a/src/ui/web/src/styles.css b/src/ui/web/src/styles.css index d862060..045801f 100644 --- a/src/ui/web/src/styles.css +++ b/src/ui/web/src/styles.css @@ -16,8 +16,12 @@ body, } .app { - display: grid; - grid-template-rows: auto auto auto 1fr; + /* Flex column (not a fixed-row grid) so stacked strips — header, import, + timeline, the legacy outputs row, an optional error bar — each take their + content height and `main` flexes to fill, without the row count being + hard-coded. */ + display: flex; + flex-direction: column; height: 100%; } @@ -47,6 +51,8 @@ body, display: grid; grid-template-columns: 320px 1fr; overflow: hidden; + flex: 1 1 auto; + min-height: 0; } .main-with-chat { @@ -769,6 +775,216 @@ body, gap: 0.4rem; } +/* ─── Doc timeline (the CompositionDoc, the source of truth) ──────────── */ + +.doc-timeline { + background: #fff; + border-bottom: 1px solid #e5e5e5; +} + +.doc-tl-head { + display: flex; + align-items: baseline; + gap: 0.75rem; + padding: 0.5rem 1.25rem; + border-bottom: 1px solid #f0f0f0; +} + +.doc-tl-title { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #555; +} + +.doc-tl-meta { + font-family: ui-monospace, SFMono-Regular, monospace; + font-size: 0.75rem; + color: #999; +} + +.doc-tl-scrub { + margin-left: auto; + width: 220px; + accent-color: #d23030; +} + +.doc-tl-playtime { + font-family: ui-monospace, SFMono-Regular, monospace; + font-size: 0.75rem; + color: #d23030; + min-width: 4.5rem; + text-align: right; +} + +.doc-tl-scroll { + overflow-x: auto; + padding: 0 1.25rem 0.75rem; +} + +.doc-tl-inner { + position: relative; +} + +.doc-tl-ruler-row, +.doc-tl-lane { + display: flex; + align-items: stretch; +} + +.doc-tl-gutter, +.doc-tl-lane-label { + flex: 0 0 128px; + width: 128px; +} + +.doc-tl-lane-label { + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.1rem; + padding-right: 0.75rem; +} + +.doc-tl-track-id { + font-family: ui-monospace, SFMono-Regular, monospace; + font-size: 0.78rem; + color: #333; +} + +.doc-tl-track-kind { + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #aaa; +} + +.doc-tl-ruler { + position: relative; + height: 22px; + border-bottom: 1px solid #eee; +} + +.doc-tl-tick { + position: absolute; + top: 0; + bottom: 0; + border-left: 1px solid #ededed; +} + +.doc-tl-tick-label { + position: absolute; + top: 4px; + left: 3px; + font-size: 0.62rem; + color: #b0b0b0; + font-variant-numeric: tabular-nums; +} + +.doc-tl-clips { + position: relative; + height: 52px; + margin: 4px 0; + background: repeating-linear-gradient(90deg, #fafafa 0 89px, #f2f2f2 89px 90px); + border-radius: 4px; +} + +.doc-tl-clip { + position: absolute; + top: 4px; + bottom: 4px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; + padding: 0 0.5rem; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 4px; + overflow: hidden; + text-align: left; + cursor: pointer; + font: inherit; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); +} + +.doc-tl-clip-media { + background: #dbeafe; +} +.doc-tl-clip-text { + background: #ede9fe; +} +.doc-tl-clip-color { + background: #e5e7eb; +} + +.doc-tl-clip.selected { + border-color: #2563eb; + box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.4); +} + +.doc-tl-clip-label { + font-size: 0.72rem; + font-weight: 500; + color: #1a1a1a; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.doc-tl-clip-dur { + font-size: 0.62rem; + color: #6b7280; + font-variant-numeric: tabular-nums; +} + +.doc-tl-transition { + position: absolute; + top: 4px; + bottom: 4px; + width: 0; + display: flex; + align-items: flex-start; + transform: translateX(-50%); + pointer-events: none; +} + +.doc-tl-transition-label { + font-size: 0.58rem; + color: #fff; + background: #f59e0b; + border-radius: 3px; + padding: 1px 4px; + white-space: nowrap; + transform: translateX(-50%); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +} + +.doc-tl-playhead { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + background: #d23030; + pointer-events: none; + z-index: 2; +} + +.doc-tl-playhead::before { + content: ""; + position: absolute; + top: 0; + left: -4px; + border: 5px solid transparent; + border-top-color: #d23030; +} + +.doc-tl-empty { + padding: 1.5rem 1.25rem; + color: #999; + font-size: 0.85rem; +} + .row-arrow { color: #888; font-size: 0.85rem; diff --git a/src/ui/web/src/types.ts b/src/ui/web/src/types.ts index bcace23..beedf0a 100644 --- a/src/ui/web/src/types.ts +++ b/src/ui/web/src/types.ts @@ -14,3 +14,59 @@ export interface Session { version: 1; entries: SessionEntry[]; } + +// ─── Composition document ──────────────────────────────────────────────────── +// Mirrors the shape returned by /api/timeline (src/timeline/composition.ts), +// kept in sync manually like Session above. Only the fields the UI reads. + +export type ClipKind = 'media' | 'text' | 'color'; + +interface ClipBase { + id: string; + startSec: number; + effects?: unknown[]; + transform?: unknown; +} +export interface MediaClip extends ClipBase { + kind: 'media'; + mediaId: string; + sourceInSec: number; + sourceOutSec: number; +} +export interface TextClip extends ClipBase { + kind: 'text'; + text: string; + durationSec: number; +} +export interface ColorClip extends ClipBase { + kind: 'color'; + color: string; + durationSec: number; +} +export type Clip = MediaClip | TextClip | ColorClip; + +export interface Transition { + afterClipId: string; + kind: string; + durationSec: number; +} + +export type TrackKind = 'video' | 'audio'; + +export interface Track { + id: string; + kind: TrackKind; + clips: Clip[]; + transitions: Transition[]; + muted?: boolean; +} + +export interface Composition { + version: number; + rev: number; + width: number; + height: number; + fps: number; + background: string; + tracks: Track[]; +} From 4e09783d0a544bd77b62f58b97f4bbfd95e13e0a Mon Sep 17 00:00:00 2001 From: Slava Yultyyev Date: Thu, 18 Jun 2026 12:07:20 -0700 Subject: [PATCH 02/18] refactor(ui): remove the in-app chat sidebar (the only key-requiring surface) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser chat panel called Anthropic directly (POST /api/chat → streamText) — the sole thing needing ANTHROPIC_API_KEY and the sole network call in `clip ui`. The agent-editing story is better served by Claude Code (skill → verbs, no key) and, next, an MCP server for Claude Desktop — both drive the same verb layer, and the timeline view live-updates via polling regardless of which agent edits. The in-app chat returns later for the hosted/web product. Remove: ChatPanel, the /api/chat routes + chat.json persistence + buildSystemPrompt, buildTimelineTools/buildChatTools, the Chat header button, the chat CSS, and the @ai-sdk/* + ai dependencies. Keep makeVerbContext + summarizeComposition (used by the verbs route, reusable by the MCP server). Result: `clip ui` makes zero network calls and needs no key; the JS bundle drops 437 kB → 235 kB (gzip 120 → 71). Verified in-browser: no Chat button, the timeline and ops intact, no console errors. 539 tests pass. --- package.json | 3 - pnpm-lock.yaml | 137 +----------- src/ui/chat-tools.ts | 74 ------- src/ui/server.ts | 167 +------------- src/ui/timeline-tools.ts | 87 +------- src/ui/web/src/App.tsx | 9 +- src/ui/web/src/components/ChatPanel.tsx | 267 ----------------------- src/ui/web/src/components/Header.tsx | 13 -- src/ui/web/src/styles.css | 278 ------------------------ 9 files changed, 8 insertions(+), 1027 deletions(-) delete mode 100644 src/ui/chat-tools.ts delete mode 100644 src/ui/web/src/components/ChatPanel.tsx diff --git a/package.json b/package.json index 6a78ded..7b4ea9d 100644 --- a/package.json +++ b/package.json @@ -54,10 +54,7 @@ "prepublishOnly": "pnpm build" }, "dependencies": { - "@ai-sdk/anthropic": "^3.0.80", - "@ai-sdk/react": "^3.0.193", "@hono/node-server": "^2.0.4", - "ai": "^6.0.191", "execa": "^9.6.1", "ffmpeg-static": "^5.3.0", "get-port": "^7.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eaad549..26622af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,18 +8,9 @@ importers: .: dependencies: - '@ai-sdk/anthropic': - specifier: ^3.0.80 - version: 3.0.80(zod@4.4.3) - '@ai-sdk/react': - specifier: ^3.0.193 - version: 3.0.193(react@19.2.6)(zod@4.4.3) '@hono/node-server': specifier: ^2.0.4 version: 2.0.4(hono@4.12.23) - ai: - specifier: ^6.0.191 - version: 6.0.191(zod@4.4.3) execa: specifier: ^9.6.1 version: 9.6.1 @@ -111,34 +102,6 @@ packages: '@actions/io@3.0.2': resolution: {integrity: sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==} - '@ai-sdk/anthropic@3.0.80': - resolution: {integrity: sha512-lT8flzmQe7brMXcj+HxIRqC5/P8N2spHj88n7fdY84K8Ay5TI5hbeic3P2T668d9UmKZtIUcefLwgGW4xzfVkA==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - - '@ai-sdk/gateway@3.0.120': - resolution: {integrity: sha512-MYKAeD2q7/sa1ZdqtL2tw0Me0B8Tok6Q/fhkJDhJl39dG8u+VBlWO9yk9lcdm784bM418o1EKObo4aOxs6+18Q==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - - '@ai-sdk/provider-utils@4.0.27': - resolution: {integrity: sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - - '@ai-sdk/provider@3.0.10': - resolution: {integrity: sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==} - engines: {node: '>=18'} - - '@ai-sdk/react@3.0.193': - resolution: {integrity: sha512-El0jUZ/B7mvBHAD5rfSDqOAhWxutVTq7BCNhfGuwfDPT9SO0TMHybh2bMkieJQI7YOfl+qNBoWrRAOHHaFb99Q==} - engines: {node: '>=18'} - peerDependencies: - react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1 - '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -955,10 +918,6 @@ packages: '@types/react@19.2.15': resolution: {integrity: sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==} - '@vercel/oidc@3.2.0': - resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} - engines: {node: '>= 20'} - '@vitejs/plugin-react@6.0.2': resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1022,12 +981,6 @@ packages: resolution: {integrity: sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==} engines: {node: '>=18'} - ai@6.0.191: - resolution: {integrity: sha512-zAxvjKebQE7YkSyyNIl0OM7i6/zygnKeF+yNUjD4nWOelYrG+LpDd6RnH6mjySI4zUpZ7o4wbnmAy8jc6u98vQ==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - ansi-escapes@7.3.0: resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} engines: {node: '>=18'} @@ -1261,10 +1214,6 @@ packages: resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} engines: {node: '>=12'} - dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} - engines: {node: '>=6'} - detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1332,10 +1281,6 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - eventsource-parser@3.1.0: - resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} - engines: {node: '>=18.0.0'} - execa@8.0.1: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} engines: {node: '>=16.17'} @@ -1598,9 +1543,6 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - json-schema@0.4.0: - resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} - json-with-bigint@3.5.8: resolution: {integrity: sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==} @@ -2266,11 +2208,6 @@ packages: resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} engines: {node: '>=14.18'} - swr@2.4.1: - resolution: {integrity: sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==} - peerDependencies: - react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -2290,10 +2227,6 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} - throttleit@2.1.0: - resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==} - engines: {node: '>=18'} - through2@2.0.5: resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} @@ -2439,11 +2372,6 @@ packages: resolution: {integrity: sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - use-sync-external-store@1.6.0: - resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -2611,40 +2539,6 @@ snapshots: '@actions/io@3.0.2': {} - '@ai-sdk/anthropic@3.0.80(zod@4.4.3)': - dependencies: - '@ai-sdk/provider': 3.0.10 - '@ai-sdk/provider-utils': 4.0.27(zod@4.4.3) - zod: 4.4.3 - - '@ai-sdk/gateway@3.0.120(zod@4.4.3)': - dependencies: - '@ai-sdk/provider': 3.0.10 - '@ai-sdk/provider-utils': 4.0.27(zod@4.4.3) - '@vercel/oidc': 3.2.0 - zod: 4.4.3 - - '@ai-sdk/provider-utils@4.0.27(zod@4.4.3)': - dependencies: - '@ai-sdk/provider': 3.0.10 - '@standard-schema/spec': 1.1.0 - eventsource-parser: 3.1.0 - zod: 4.4.3 - - '@ai-sdk/provider@3.0.10': - dependencies: - json-schema: 0.4.0 - - '@ai-sdk/react@3.0.193(react@19.2.6)(zod@4.4.3)': - dependencies: - '@ai-sdk/provider-utils': 4.0.27(zod@4.4.3) - ai: 6.0.191(zod@4.4.3) - react: 19.2.6 - swr: 2.4.1(react@19.2.6) - throttleit: 2.1.0 - transitivePeerDependencies: - - zod - '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -2955,7 +2849,8 @@ snapshots: dependencies: '@octokit/openapi-types': 27.0.0 - '@opentelemetry/api@1.9.1': {} + '@opentelemetry/api@1.9.1': + optional: true '@oxc-project/types@0.132.0': {} @@ -3233,8 +3128,6 @@ snapshots: dependencies: csstype: 3.2.3 - '@vercel/oidc@3.2.0': {} - '@vitejs/plugin-react@6.0.2(vite@8.0.14(@types/node@24.12.4)(esbuild@0.27.7)(tsx@4.22.3))': dependencies: '@rolldown/pluginutils': 1.0.1 @@ -3301,14 +3194,6 @@ snapshots: clean-stack: 5.3.0 indent-string: 5.0.0 - ai@6.0.191(zod@4.4.3): - dependencies: - '@ai-sdk/gateway': 3.0.120(zod@4.4.3) - '@ai-sdk/provider': 3.0.10 - '@ai-sdk/provider-utils': 4.0.27(zod@4.4.3) - '@opentelemetry/api': 1.9.1 - zod: 4.4.3 - ansi-escapes@7.3.0: dependencies: environment: 1.1.0 @@ -3514,8 +3399,6 @@ snapshots: define-lazy-prop@3.0.0: {} - dequal@2.0.3: {} - detect-libc@2.1.2: {} dir-glob@3.0.1: @@ -3619,8 +3502,6 @@ snapshots: dependencies: '@types/estree': 1.0.9 - eventsource-parser@3.1.0: {} - execa@8.0.1: dependencies: cross-spawn: 7.0.6 @@ -3865,8 +3746,6 @@ snapshots: json-parse-even-better-errors@2.3.1: {} - json-schema@0.4.0: {} - json-with-bigint@3.5.8: {} jsonfile@6.2.1: @@ -4472,12 +4351,6 @@ snapshots: has-flag: 4.0.0 supports-color: 7.2.0 - swr@2.4.1(react@19.2.6): - dependencies: - dequal: 2.0.3 - react: 19.2.6 - use-sync-external-store: 1.6.0(react@19.2.6) - tagged-tag@1.0.0: {} temp-dir@3.0.0: {} @@ -4497,8 +4370,6 @@ snapshots: dependencies: any-promise: 1.3.0 - throttleit@2.1.0: {} - through2@2.0.5: dependencies: readable-stream: 2.3.8 @@ -4613,10 +4484,6 @@ snapshots: url-join@5.0.0: {} - use-sync-external-store@1.6.0(react@19.2.6): - dependencies: - react: 19.2.6 - util-deprecate@1.0.2: {} validate-npm-package-license@3.0.4: diff --git a/src/ui/chat-tools.ts b/src/ui/chat-tools.ts deleted file mode 100644 index 0108fc2..0000000 --- a/src/ui/chat-tools.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { type Tool, tool } from 'ai'; -import { appendOp } from '../session/store.js'; -import { TOOL_REGISTRY } from './tool-registry.js'; - -/** - * One-line descriptions surfaced to the agent for each tool. These are - * what the model reads when deciding which tool to call — keep them - * actionable and free of UI-speak. The full Zod schema (with per-field - * `.describe()` text) gives the agent the parameter-level details. - */ -const TOOL_DESCRIPTIONS: Record = { - ingest: 'Probe and register a media file so other tools can reference it by path.', - trim: 'Cut a clip between two timecodes (HH:MM:SS, MM:SS, or seconds). Stream-copy, no re-encode.', - split: 'Divide a clip at a timecode into before + after halves.', - concat: 'Stitch two or more video files back-to-back in order.', - add_text: 'Burn a text overlay at a position for a time range.', - add_audio: - 'Add or mix music / voiceover / sfx into a video — optional fade and sidechain ducking.', - add_title_card: 'Prepend a colored card with centered text to a clip.', - add_captions: 'Burn multiple timed caption cues into a video.', - transition: 'Crossfade, slide, or wipe between two clips of matching dimensions.', - render: 'Re-encode to mp4 / mov / webm with quality + size controls.', - preview: 'Extract a single JPEG frame at a timecode (for the agent to inspect).', - adjust: 'Adjust brightness, contrast, saturation, and/or audio volume in one pass.', - speed: 'Slow down, speed up, or reverse a clip (audio stays in sync).', - overlay: 'Picture-in-picture an overlay clip onto a base clip at a position.', - zoom_pan: 'Ken Burns zoom / pan over a clip — animate scale and focus point.', - stabilize: 'Two-pass vidstab stabilization. Requires an ffmpeg build with vidstab.', - chroma_key: 'Key out a flat color from the foreground and composite over a background.', - silence_remove: 'Detect and cut silent stretches from audio in a video.', - highlight_reel: - 'Extract several time ranges from one source and stitch them with optional transitions.', -}; - -/** - * Build the set of tools the chat-side agent can call. Each tool wraps a - * `TOOL_REGISTRY` entry: validates with the Zod schema, runs the handler, - * appends a session entry so agent-driven changes show up in session.json - * exactly like UI-driven ones. The UI's session poll picks them up. - * - * Errors are returned as structured objects rather than thrown — the agent - * can read a tool failure and try a different approach (e.g. fix arguments) - * instead of the whole turn aborting. - */ -export function buildChatTools(): Record { - const tools: Record = {}; - for (const [name, entry] of Object.entries(TOOL_REGISTRY)) { - const description = TOOL_DESCRIPTIONS[name] ?? `Run the ${name} editor tool.`; - tools[name] = tool({ - description, - // The registry stores `z.ZodType` to be heterogeneous-friendly; - // AI SDK takes any Standard Schema (which Zod implements). - // biome-ignore lint/suspicious/noExplicitAny: same any-on-purpose as TOOL_REGISTRY - inputSchema: entry.schema as any, - execute: async (input: unknown) => { - try { - const result = await entry.fn(input); - await appendOp({ - tool: name, - args: input as Record, - result: result as Record, - }); - return result; - } catch (err) { - // Return-as-data so the model can react. Throwing would abort - // the whole streaming turn. - const message = err instanceof Error ? err.message : String(err); - return { error: message, tool: name }; - } - }, - }); - } - return tools; -} diff --git a/src/ui/server.ts b/src/ui/server.ts index 2254e59..0fc41c8 100644 --- a/src/ui/server.ts +++ b/src/ui/server.ts @@ -1,14 +1,12 @@ import { randomBytes } from 'node:crypto'; import { createWriteStream, existsSync } from 'node:fs'; -import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { mkdir, readdir, stat } from 'node:fs/promises'; import { basename, dirname, resolve } from 'node:path'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { fileURLToPath } from 'node:url'; -import { anthropic } from '@ai-sdk/anthropic'; import { serve } from '@hono/node-server'; import { serveStatic } from '@hono/node-server/serve-static'; -import { convertToModelMessages, stepCountIs, streamText, type UIMessage } from 'ai'; import getPort from 'get-port'; import { Hono } from 'hono'; import open from 'open'; @@ -28,8 +26,7 @@ import { preview } from '../tools/preview.js'; import { snapshot } from '../tools/snapshot.js'; import { undo } from '../tools/undo.js'; import { ensureWorkspace, getWorkspace, WorkspaceBoundaryError } from '../workspace.js'; -import { buildChatTools } from './chat-tools.js'; -import { buildTimelineTools, makeVerbContext, summarizeComposition } from './timeline-tools.js'; +import { makeVerbContext } from './timeline-tools.js'; import { isRegisteredTool, TOOL_REGISTRY } from './tool-registry.js'; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -268,166 +265,6 @@ export async function startUiServer(options: UiServerOptions = {}): Promise { - try { - const raw = await readFile(chatPath(), 'utf-8'); - const parsed = JSON.parse(raw); - if (Array.isArray(parsed)) return parsed as UIMessage[]; - return []; - } catch (err: unknown) { - if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') return []; - // Corrupt history → start fresh; better than blocking chat outright. - return []; - } - } - - async function writeChatHistory(messages: UIMessage[]): Promise { - await ensureWorkspace(); - await writeFile(chatPath(), `${JSON.stringify(messages, null, 2)}\n`); - } - - function summarizeEntryForChat(e: { - id: string; - tool: string; - args: Record; - result: Record; - }): string { - const path = - typeof e.result.path === 'string' - ? e.result.path - : typeof (e.result.ref as { path?: unknown })?.path === 'string' - ? (e.result.ref as { path: string }).path - : '(no playable path)'; - return `- ${e.id} ${e.tool} → ${path}`; - } - - function buildSystemPrompt(): Promise { - return Promise.all([readSession(), readComposition().catch(() => null)]).then( - ([session, comp]) => { - const workspace = getWorkspace(); - const recent = session.entries.slice(-12).map(summarizeEntryForChat).join('\n'); - return [ - 'You are the assistant inside MakeMyClip Editor — a local, FFmpeg-backed video editor.', - '', - 'You build a video by editing a TIMELINE DOCUMENT: a non-destructive list of tracks and clips. Every edit is recorded and undoable.', - '', - 'Primary tools — prefer these:', - '- `timeline_edit` — apply editing verbs (add_media, add_text, add_color, trim, move, split, remove, transition, set_transform) to the document. Batch related verbs into ONE call; they apply atomically as a single undoable change.', - '- `timeline_show` — read the current document (tracks, clips, timings). Call it to ground yourself BEFORE editing and to VERIFY AFTER an edit.', - '- `timeline_undo` / `timeline_redo` / `timeline_history` — move through the edit history.', - '', - 'Workflow: timeline_show → timeline_edit → timeline_show to confirm. Refer to clips by their `id` (from timeline_show); refer to media by absolute or workspace-relative path.', - '', - 'The legacy file-tools (chroma_key, stabilize, overlay, render, …) operate on standalone files, NOT the document. Use them only for operations the timeline engine does not cover yet — e.g. final render, chroma key, stabilization.', - '', - `Workspace: ${workspace}`, - comp - ? `Current document: ${JSON.stringify(summarizeComposition(comp))}` - : 'Current document: unreadable (corrupt?) — start over with `timeline_edit` after the user removes composition.json, or guide them to `clip timeline new`.', - recent ? `Recent file-tool ops:\n${recent}` : '', - ] - .filter(Boolean) - .join('\n'); - }, - ); - } - - /** - * GET /api/chat — persisted history (UIMessage[]). Returns empty array - * for a fresh workspace. - */ - app.get('/api/chat', async (c) => { - const messages = await readChatHistory(); - return c.json({ messages }); - }); - - /** - * DELETE /api/chat — clear history. - */ - app.delete('/api/chat', async (c) => { - await writeChatHistory([]); - return c.json({ messages: [] }); - }); - - /** - * POST /api/chat — `useChat` transport target. Body is `{ messages: - * UIMessage[] }`. Returns a UI message stream the @ai-sdk/react hook - * consumes. Each agent turn can fire several tool calls (multi-step) - * up to `stopWhen` — tools mutate session.json, the UI's session poll - * picks them up. - */ - app.post('/api/chat', async (c) => { - if (!process.env.ANTHROPIC_API_KEY) { - return c.json( - { - error: 'ANTHROPIC_API_KEY is not set. Add it to your environment and restart `clip ui`.', - }, - 400, - ); - } - let body: { messages?: UIMessage[] }; - try { - body = (await c.req.json()) as { messages?: UIMessage[] }; - } catch { - return c.json({ error: 'Body must be JSON with a `messages` array.' }, 400); - } - const messages = Array.isArray(body.messages) ? body.messages : []; - - let system: string; - try { - // buildSystemPrompt reads the session; a corrupt file used to brick every - // chat turn with a raw 500. Degrade to an actionable message instead. - system = await buildSystemPrompt(); - } catch (err) { - if (err instanceof SessionCorruptError) { - return c.json( - { - error: `Session file is corrupt: ${err.path}. Recover with \`clip undo \` or remove it, then retry.`, - }, - 409, - ); - } - throw err; - } - const modelMessages = await convertToModelMessages(messages); - const result = streamText({ - // Sonnet 4.6 is the sweet spot for tool-use latency / cost; the user - // can swap models later via env / a settings panel. - model: anthropic('claude-sonnet-4-6'), - system, - messages: modelMessages, - // Op-aware timeline tools first (the primary editing path); the legacy - // file-tools remain for operations the document engine doesn't cover yet. - tools: { ...buildTimelineTools(), ...buildChatTools() }, - // Up to 8 chained tool calls per turn — enough for "trim three clips - // then concat" without runaway loops. - stopWhen: stepCountIs(8), - }); - - return result.toUIMessageStreamResponse({ - // Persist the full updated conversation when the stream finishes so - // a page reload restores the chat. - onFinish: async ({ messages: finalMessages }) => { - try { - await writeChatHistory(finalMessages); - } catch (err) { - // Don't block the response on persistence — log it server-side. - console.warn('Could not persist chat history:', err); - } - }, - }); - }); - // ─── Duration probe (Timeline scrub slider) ─────────────────────── // In-memory cache: session entries are append-only and clip files don't diff --git a/src/ui/timeline-tools.ts b/src/ui/timeline-tools.ts index f7aad81..0918b3b 100644 --- a/src/ui/timeline-tools.ts +++ b/src/ui/timeline-tools.ts @@ -1,20 +1,12 @@ -import { type Tool, tool } from 'ai'; -import { z } from 'zod'; import { appendOp } from '../session/store.js'; import { type Composition, clipEndSec, compositionDuration } from '../timeline/composition.js'; -import { - applyVerbs, - readComposition, - readDocOpLog, - redoDocOp, - undoLastDocOp, -} from '../timeline/document-store.js'; -import { CompositionVerbSchema, DEFAULT_VERB_TRACK, type VerbContext } from '../timeline/verbs.js'; +import { DEFAULT_VERB_TRACK, type VerbContext } from '../timeline/verbs.js'; import { ingest } from '../tools/ingest.js'; import { resolveInWorkspace } from '../workspace.js'; /** A VerbContext wired to the real ingest tool — resolves the path, registers the - * media, and logs the ingest to the session so it shows up like a CLI ingest. */ + * media, and logs the ingest to the session so it shows up like a CLI ingest. + * Shared by the `clip ui` verbs route and (next) the MCP server. */ export function makeVerbContext(): VerbContext { return { defaultTrack: DEFAULT_VERB_TRACK, @@ -50,76 +42,3 @@ export function summarizeComposition(comp: Composition): unknown { })), }; } - -/** - * The op-aware timeline toolset for the chat agent: edits go through the verb - * layer → `mutateComposition`, so every agent edit lands on the SAME non- - * destructive, undoable document the human and CLI edit — not the legacy file - * tools that produced orphaned output files. Errors return as data so a failed - * call doesn't abort the streaming turn. - */ -export function buildTimelineTools(): Record { - const ctx = makeVerbContext(); - const asData = (fn: () => Promise) => async () => { - try { - return await fn(); - } catch (err) { - return { error: err instanceof Error ? err.message : String(err) }; - } - }; - - return { - timeline_edit: tool({ - description: - 'Edit the timeline document with one or more verbs (applied atomically as a single undoable change). This is the primary way to build a video: add clips, trim, move, split, transition, etc. Returns the updated document summary.', - inputSchema: z.object({ - verbs: z.array(CompositionVerbSchema).min(1).describe('Editing verbs to apply in order.'), - }), - execute: async ({ verbs }) => - asData(async () => { - const { doc, ops } = await applyVerbs(verbs, ctx); - return { applied: ops.length, document: summarizeComposition(doc) }; - })(), - }), - timeline_show: tool({ - description: - 'Read the current timeline document — tracks, clips, and timings. Call this to ground yourself before editing or to verify a change.', - inputSchema: z.object({}), - execute: async () => asData(async () => summarizeComposition(await readComposition()))(), - }), - timeline_undo: tool({ - description: 'Undo the most recent timeline edit.', - inputSchema: z.object({}), - execute: async () => - asData(async () => { - const { undone, label } = await undoLastDocOp(); - return undone ? { undone: true, label } : { undone: false, message: 'Nothing to undo.' }; - })(), - }), - timeline_redo: tool({ - description: 'Redo the most recently undone timeline edit.', - inputSchema: z.object({}), - execute: async () => - asData(async () => { - const { redone, label } = await redoDocOp(); - return redone ? { redone: true, label } : { redone: false, message: 'Nothing to redo.' }; - })(), - }), - timeline_history: tool({ - description: 'List the timeline edit history (what can be undone / redone).', - inputSchema: z.object({}), - execute: async () => - asData(async () => { - const log = await readDocOpLog(); - return { - canUndo: log.cursor > 0, - canRedo: log.cursor < log.entries.length, - entries: log.entries.map((e, i) => ({ - label: e.label, - state: i < log.cursor ? 'applied' : 'undone', - })), - }; - })(), - }), - }; -} diff --git a/src/ui/web/src/App.tsx b/src/ui/web/src/App.tsx index 82b5668..3cdf259 100644 --- a/src/ui/web/src/App.tsx +++ b/src/ui/web/src/App.tsx @@ -1,5 +1,4 @@ import { useState } from 'react'; -import { ChatPanel } from './components/ChatPanel.js'; import { DetailPane } from './components/DetailPane.js'; import { DocTimeline } from './components/DocTimeline.js'; import { AddCaptionsForm } from './components/forms/AddCaptionsForm.js'; @@ -32,7 +31,6 @@ export function App() { const [pickerOpen, setPickerOpen] = useState(false); const [activeTool, setActiveTool] = useState(null); const [safetyError, setSafetyError] = useState(null); - const [chatOpen, setChatOpen] = useState(false); const selectedEntry = selectedId === null ? null : (session.entries.find((e) => e.id === selectedId) ?? null); @@ -119,8 +117,6 @@ export function App() { snapshots={safety.snapshots} onRestore={(label) => void handleRestore(label)} safetyLoading={safety.loading} - chatOpen={chatOpen} - onToggleChat={() => setChatOpen((v) => !v)} /> {safetyError ?
{safetyError}
: null} @@ -138,7 +134,7 @@ export function App() { }} onConcatSuccess={refresh} /> -
+
)} - {chatOpen ? ( - setChatOpen(false)} /> - ) : null}
void; - onClose: () => void; -}) { - // useChat must mount with the final initial-messages list to avoid a - // double-render that would create a fresh empty chat first. We load - // history once, then mount the inner component with the real seed. - const [initial, setInitial] = useState(null); - - useEffect(() => { - fetch('/api/chat') - .then((r) => r.json() as Promise<{ messages: UIMessage[] }>) - .then((d) => setInitial(d.messages ?? [])) - .catch(() => setInitial([])); - }, []); - - return ( - - ); -} - -function ChatPanelInner({ - initial, - onAgentTurnComplete, -}: { - initial: UIMessage[]; - onAgentTurnComplete: () => void; -}) { - const { messages, sendMessage, status, error, setMessages } = useChat({ - messages: initial, - transport: new DefaultChatTransport({ api: '/api/chat' }), - onFinish: () => { - // Trigger a session refresh after every agent turn so tool calls - // surface in the OpList / Timeline immediately. - onAgentTurnComplete(); - }, - }); - - const [input, setInput] = useState(''); - const scrollEndRef = useRef(null); - - // Auto-scroll to bottom on every messages / status change. The deps are - // listed for their trigger-on-change effect, not to read in the body — - // biome flags this as "unused" but removing them breaks auto-scroll. - // biome-ignore lint/correctness/useExhaustiveDependencies: deps drive re-run on change - useEffect(() => { - scrollEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }); - }, [messages, status]); - - async function handleSubmit(e: FormEvent) { - e.preventDefault(); - const text = input.trim(); - if (!text || status === 'streaming' || status === 'submitted') return; - setInput(''); - void sendMessage({ text }); - } - - async function handleClear() { - if (!window.confirm('Clear chat history?')) return; - try { - await fetch('/api/chat', { method: 'DELETE' }); - } catch { - // Network failures are non-blocking — local state is the source of truth - // for the next render either way. - } - setMessages([]); - } - - const busy = status === 'streaming' || status === 'submitted'; - - return ( - <> -
- {messages.length > 0 ? ( - - ) : null} -
-
- {messages.length === 0 ? ( -
-

Ask the agent to do editing work.

-
    -
  • "Trim the first ingested clip to seconds 5–20."
  • -
  • "Concat the last three trims with a 0.4s fade."
  • -
  • "Add a black title card saying 'Demo' before clip 1."
  • -
-
- ) : ( - messages.map((msg) => ) - )} - {busy ?
Thinking…
: null} -
-
- {error ? : null} -
-