diff --git a/apps/api/app/api/routes/agent.py b/apps/api/app/api/routes/agent.py index df85f18..4907131 100644 --- a/apps/api/app/api/routes/agent.py +++ b/apps/api/app/api/routes/agent.py @@ -10,11 +10,13 @@ import asyncio import json import logging +import re import uuid from collections.abc import AsyncGenerator +from dataclasses import dataclass from typing import Any, Literal -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends from fastapi.responses import StreamingResponse from openai import AsyncOpenAI from pydantic import BaseModel @@ -34,6 +36,80 @@ log = logging.getLogger("fiebatt.agent") router = APIRouter(prefix="/agent", tags=["agent"]) + +_TOOL_MARKUP_START = re.compile( + r"<\s*(?:(?:[||]\s*)?DSML(?:\s*[||])?\s*)?" + r"(?:function[\s_]?calls?|tool[\s_]?calls?|invoke\b|parameter\b)", + re.IGNORECASE, +) +_DSML_INVOKE = re.compile( + r'<\s*(?:(?:[||]\s*)?DSML(?:\s*[||])?\s*)?invoke\s+name="([^"]+)"[^>]*>' + r"(.*?)" + r"", + re.IGNORECASE | re.DOTALL, +) +_DSML_PARAMETER = re.compile( + r'<\s*(?:(?:[||]\s*)?DSML(?:\s*[||])?\s*)?parameter\s+' + r'name="([^"]+)"(?:\s+string="(true|false)")?[^>]*>' + r"(.*?)" + r"", + re.IGNORECASE | re.DOTALL, +) + + +@dataclass(slots=True) +class _ParsedFunction: + name: str + arguments: str + + +@dataclass(slots=True) +class _ParsedToolCall: + id: str + function: _ParsedFunction + + +def _clean_agent_text(text: str) -> str: + """Return user-facing prose before any leaked tool protocol block.""" + marker = _TOOL_MARKUP_START.search(text) + visible = text[:marker.start()] if marker else text + return visible.strip() + + +def _parse_dsml_tool_calls(text: str) -> list[_ParsedToolCall]: + """Recover DeepSeek-style DSML calls leaked into message content.""" + parsed: list[_ParsedToolCall] = [] + for index, invoke in enumerate(_DSML_INVOKE.finditer(text)): + name, body = invoke.groups() + arguments: dict[str, Any] = {} + for parameter in _DSML_PARAMETER.finditer(body): + key, is_string, raw_value = parameter.groups() + value = raw_value.strip() + if (is_string or "false").lower() != "true": + try: + arguments[key] = json.loads(value) + except json.JSONDecodeError: + arguments[key] = value + else: + arguments[key] = value + if not arguments: + try: + direct = json.loads(body.strip()) + if isinstance(direct, dict): + arguments = direct + except json.JSONDecodeError: + pass + parsed.append( + _ParsedToolCall( + id=f"dsml-{uuid.uuid4().hex[:12]}-{index}", + function=_ParsedFunction( + name=name, + arguments=json.dumps(arguments), + ), + ) + ) + return parsed + # ---- request schema ---- class BBoxParam(BaseModel): @@ -761,9 +837,9 @@ async def _bridge_plan_events( The ``generate_job`` worker publishes a rich trail of stage events on the per-job event bus (plan_start, plan_done, gen_start, variant_done…). - The Pro-mode reveal panel consumes these directly via - ``/api/jobs/{id}/stream``, but the Vibe-mode agent chat only hears the - agent SSE stream — so by default the user never sees that the model is + The detailed generation panel consumes these directly via + ``/api/jobs/{id}/stream``, while agent chat only hears the agent SSE + stream — so by default the user never sees that the model is quietly rewriting their one-line prompt into a video generation brief. This helper bridges the gap for exactly the events that are interesting @@ -887,7 +963,6 @@ def _build_messages( async def _agent_stream( *, - request: Request, body: AgentChatRequest, session_id: str, runner: Any, @@ -988,18 +1063,17 @@ async def _agent_stream( ) choice = response.choices[0] msg = choice.message - text = msg.content or "" - tool_calls = msg.tool_calls or [] + raw_text = msg.content or "" + tool_calls: list[Any] = list(msg.tool_calls or []) + if not tool_calls: + tool_calls = _parse_dsml_tool_calls(raw_text) + text = _clean_agent_text(raw_text) except Exception as exc: log.exception("[agent.chat] req=%s %s API call failed on turn %d", req_id, provider_name, turn) yield sse_event("error", {"message": f"{provider_name} API error: {exc}"}) yield sse_event("done", {}) return - if await request.is_disconnected(): - log.info("Client disconnected during agent loop") - return - if text: _agent_text_parts.append(text) yield sse_event("token", {"text": text}) @@ -1036,6 +1110,10 @@ async def _agent_stream( tool_args = json.loads(tc.function.arguments) if tc.function.arguments else {} if tool_name == "create_edit_plan" and body.selection_id: tool_args.setdefault("selection_id", body.selection_id) + if tool_name == "generate_edit": + # Keep attribution separate from the model-authored generation + # prompt. The worker emits both for the before/refined card. + tool_args["user_prompt"] = body.message if tool_name in {"create_edit_plan", "generate_edit"} and body.video_gen_provider: tool_args["video_gen_provider"] = body.video_gen_provider @@ -1186,13 +1264,47 @@ async def _agent_stream( yield sse_event("done", {}) +# Keep strong references so Python cannot collect detached agent turns while +# Qwen is planning or queueing their generation job. +_agent_turn_tasks: set[asyncio.Task[None]] = set() + + +async def _detached_agent_relay( + source: AsyncGenerator[str, None], +) -> AsyncGenerator[str, None]: + """Relay SSE while letting agent orchestration survive client navigation.""" + queue: asyncio.Queue[str | None] = asyncio.Queue() + + async def consume() -> None: + try: + async for event in source: + await queue.put(event) + except Exception as exc: + log.exception("detached agent turn failed") + await queue.put(sse_event("error", {"message": str(exc)})) + await queue.put(sse_event("done", {})) + finally: + await queue.put(None) + + task = asyncio.create_task(consume(), name=f"agent-turn:{uuid.uuid4().hex[:8]}") + _agent_turn_tasks.add(task) + task.add_done_callback(_agent_turn_tasks.discard) + + # Closing this relay only drops browser delivery. The consumer keeps + # running, persists its conversation, and starts any requested video job. + while True: + event = await queue.get() + if event is None: + return + yield event + + # ---- route ---- @router.post("/chat") async def agent_chat( body: AgentChatRequest, - request: Request, session: SessionModel = Depends(get_session), runner=Depends(get_runner), ) -> StreamingResponse: @@ -1205,11 +1317,12 @@ async def agent_chat( needs them instead. """ return StreamingResponse( - _agent_stream( - request=request, - body=body, - session_id=session.id, - runner=runner, + _detached_agent_relay( + _agent_stream( + body=body, + session_id=session.id, + runner=runner, + ), ), media_type="text/event-stream", headers={ diff --git a/apps/api/app/api/routes/jobs.py b/apps/api/app/api/routes/jobs.py index 22ae821..7da13bd 100644 --- a/apps/api/app/api/routes/jobs.py +++ b/apps/api/app/api/routes/jobs.py @@ -1,7 +1,7 @@ import asyncio import json -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi.responses import StreamingResponse from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -18,31 +18,15 @@ router = APIRouter(tags=["jobs"]) -@router.get("/jobs/{job_id}", response_model=JobOut) -async def get_job( - job_id: str, - session: SessionModel = Depends(get_session), - db: AsyncSession = Depends(get_db), -): - job = ( - await db.execute( - select(Job).where(Job.id == job_id).options(selectinload(Job.variants)) - ) - ).scalar_one_or_none() - if job is None: - raise HTTPException(status_code=404, detail="job not found") - - # enforce session ownership through the project - proj = await db.get(Project, job.project_id) - if proj is None or proj.session_id != session.id: - raise HTTPException(status_code=404, detail="job not found") - +def _job_out(job: Job) -> JobOut: payload = job.payload or {} return JobOut( job_id=job.id, kind=job.kind, status=job.status, # type: ignore[arg-type] error=job.error, + created_at=job.created_at, + accepted=bool(payload.get("latest_accepted_segment_id")), start_ts=job.start_ts, end_ts=job.end_ts, provider=payload.get("selected_provider"), @@ -74,6 +58,51 @@ async def get_job( ) +@router.get("/projects/{project_id}/generation-jobs", response_model=list[JobOut]) +async def list_generation_jobs( + project_id: str, + limit: int = Query(default=10, ge=1, le=20), + session: SessionModel = Depends(get_session), + db: AsyncSession = Depends(get_db), +): + """Recent generation jobs used to restore work after page navigation.""" + proj = await db.get(Project, project_id) + if proj is None or proj.session_id != session.id: + raise HTTPException(status_code=404, detail="project not found") + rows = ( + await db.execute( + select(Job) + .where(Job.project_id == project_id, Job.kind == "generate") + .options(selectinload(Job.variants)) + .order_by(Job.created_at.desc()) + .limit(limit) + ) + ).scalars().all() + return [_job_out(job) for job in rows] + + +@router.get("/jobs/{job_id}", response_model=JobOut) +async def get_job( + job_id: str, + session: SessionModel = Depends(get_session), + db: AsyncSession = Depends(get_db), +): + job = ( + await db.execute( + select(Job).where(Job.id == job_id).options(selectinload(Job.variants)) + ) + ).scalar_one_or_none() + if job is None: + raise HTTPException(status_code=404, detail="job not found") + + # enforce session ownership through the project + proj = await db.get(Project, job.project_id) + if proj is None or proj.session_id != session.id: + raise HTTPException(status_code=404, detail="job not found") + + return _job_out(job) + + @router.get("/jobs/{job_id}/stream") async def stream_job_events( job_id: str, diff --git a/apps/api/app/schemas/job.py b/apps/api/app/schemas/job.py index 5e6caa7..39e43e6 100644 --- a/apps/api/app/schemas/job.py +++ b/apps/api/app/schemas/job.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Any from pydantic import BaseModel, Field @@ -21,6 +22,8 @@ class JobOut(BaseModel): kind: str status: JobStatus error: str | None = None + created_at: datetime | None = None + accepted: bool = False variants: list[VariantOut] = [] # authoritative edit window used when accepting a generated replacement. start_ts: float | None = None diff --git a/apps/api/app/services/agent_tools.py b/apps/api/app/services/agent_tools.py index 0268bb4..d7bcf87 100644 --- a/apps/api/app/services/agent_tools.py +++ b/apps/api/app/services/agent_tools.py @@ -655,6 +655,7 @@ async def _generate_edit( reference_frame_ts=reference_frame_ts, payload={ "video_gen_provider": video_gen_provider or "auto", + "user_prompt": str(args.get("user_prompt") or prompt), "plan_id": plan.id if plan else None, "planned_context": plan.range_json if plan else None, "planned_intent": plan.intent_json if plan else None, diff --git a/apps/api/app/workers/generate_job.py b/apps/api/app/workers/generate_job.py index bf6a8de..4fcfda8 100644 --- a/apps/api/app/workers/generate_job.py +++ b/apps/api/app/workers/generate_job.py @@ -588,7 +588,7 @@ async def _do_extract_end_frame() -> tuple[Path, bool]: if planned_intent is not None and planned_intent.grounded_edit is not None else "asking Qwen to structure the edit plan" ), - user_prompt=prompt, + user_prompt=str(payload.get("user_prompt") or prompt), bbox=bbox, duration=segment_duration, ) diff --git a/apps/api/tests/test_agent.py b/apps/api/tests/test_agent.py index a4babca..f6b37a8 100644 --- a/apps/api/tests/test_agent.py +++ b/apps/api/tests/test_agent.py @@ -21,7 +21,13 @@ from app.main import app # noqa: E402 from app.db.init import create_all # noqa: E402 from app.workers.runner import JobRunner # noqa: E402 -from app.api.routes.agent import sse_event, _build_messages # noqa: E402 +from app.api.routes.agent import ( # noqa: E402 + _build_messages, + _clean_agent_text, + _detached_agent_relay, + _parse_dsml_tool_calls, + sse_event, +) from app.services.agent_tools import execute_tool # noqa: E402 from app.db.session import get_db # noqa: E402 from app.config.settings import get_settings # noqa: E402 @@ -175,6 +181,29 @@ def test_unicode(self): assert "\u2728" in parsed["text"] +def test_dsml_tool_markup_is_parsed_and_hidden(): + raw = """I will inspect it now. +<|DSML|function_calls> +<|DSML|invoke name="analyze_video"> +<|DSML|parameter name="project_id" string="true">project-1 +<|DSML|parameter name="fps" string="false">2 + +""" + + assert _clean_agent_text(raw) == "I will inspect it now." + calls = _parse_dsml_tool_calls(raw) + assert len(calls) == 1 + assert calls[0].function.name == "analyze_video" + assert json.loads(calls[0].function.arguments) == { + "project_id": "project-1", + "fps": 2, + } + + +def test_malformed_dsml_prefix_is_never_user_visible(): + assert _clean_agent_text("Starting +<|DSML|invoke name="analyze_video"> +<|DSML|parameter name="project_id" string="true">test-proj + +""" + mock_client = _mock_agent_client( + _make_mock_agent_response(raw_call), + _make_mock_agent_response("Inspection complete."), + ) + + with patch("app.api.routes.agent.AsyncOpenAI", return_value=mock_client), \ + patch("app.api.routes.agent.execute_tool", new_callable=AsyncMock) as mock_exec: + mock_exec.return_value = { + "status": "done", + "project_id": "test-proj", + "duration": 5.0, + } + res = await client.post( + "/api/agent/chat", + json={ + "project_id": "test-proj", + "message": "Inspect this video", + "conversation_id": "conv-dsml", + }, + headers=headers(), + ) + + events = _parse_sse_events(res.text) + visible_text = "".join( + event["data"]["text"] for event in events if event["event"] == "token" + ) + assert visible_text == "I will inspect it.Inspection complete." + assert "DSML" not in res.text + assert mock_exec.await_args.kwargs["args"] == {"project_id": "test-proj"} + + +@pytest.mark.asyncio +async def test_generate_tool_keeps_original_user_prompt(client: AsyncClient, live_agent_settings): + """Model-authored generation text must not replace user attribution.""" + response = _make_mock_agent_response( + "", + tool_calls=[_make_tool_call("generate_edit", { + "project_id": "test-proj", + "start_ts": 0, + "end_ts": 3, + "bbox": {"x": 0, "y": 0, "w": 1, "h": 1}, + "prompt": "A lime-green automobile with stable geometry.", + })], + ) + mock_client = _mock_agent_client(response) + + async def no_plan_events(*_args, **_kwargs): + if False: + yield "" + + with patch("app.api.routes.agent.AsyncOpenAI", return_value=mock_client), \ + patch("app.api.routes.agent.execute_tool", new_callable=AsyncMock) as mock_exec, \ + patch("app.api.routes.agent._bridge_plan_events", no_plan_events): + mock_exec.return_value = {"job_id": "job-1", "status": "pending"} + res = await client.post( + "/api/agent/chat", + json={ + "project_id": "test-proj", + "message": "make the car green", + "conversation_id": "conv-original-prompt", + }, + headers=headers(), + ) + + assert res.status_code == 200 + args = mock_exec.await_args.kwargs["args"] + assert args["prompt"] == "A lime-green automobile with stable geometry." + assert args["user_prompt"] == "make the car green" diff --git a/apps/web/src/app/editor/Studio.tsx b/apps/web/src/app/editor/Studio.tsx index 07f042a..9efe51d 100644 --- a/apps/web/src/app/editor/Studio.tsx +++ b/apps/web/src/app/editor/Studio.tsx @@ -19,7 +19,6 @@ import { } from "@/stores/edl"; import { Preview } from "@/components/editor-port/Preview"; import { CompareOverlay } from "@/components/editor-port/CompareOverlay"; -import { Inspector } from "@/components/editor-port/Inspector"; import { Timeline } from "@/components/editor-port/Timeline"; import { Library } from "@/components/editor-port/Library"; import { UploadDrop } from "@/components/editor-port/UploadDrop"; @@ -225,7 +224,6 @@ function StudioInner({ const [uploadError, setUploadError] = useState(null); const [hydrateError, setHydrateError] = useState(null); const [hydrateAttempt, setHydrateAttempt] = useState(0); - const [mode, setMode] = useState<'vibe' | 'pro'>('vibe'); const [showShortcuts, setShowShortcuts] = useState(false); const [showCompare, setShowCompare] = useState(false); const [hydratingProject, setHydratingProject] = useState(Boolean(initialProject)); @@ -270,9 +268,9 @@ function StudioInner({ // The "effective" project id is the one AgentChat actually sends to the // backend — it falls back to whatever source the user just uploaded in - // vibe mode so the chat keeps working even before we've persisted a + // focused layout so chat keeps working even before we've persisted a // reopen-able reel. The timeline-refresh listener MUST key on this same - // fallback chain, otherwise uploading-in-vibe-mode silently skips the + // fallback chain, otherwise a newly uploaded project silently skips the // listener attach and ``accept_variant`` lands on the server but the // preview never updates. (This was the "pressing apply does nothing" // bug — initialProject was undefined, so projectId was undefined, so @@ -380,7 +378,7 @@ function StudioInner({ // Prefer the saved initialProject snapshot (from ?reopen=), but // fall back to constructing one from the live EDL source asset — - // this is the vibe-mode upload path where the parent route never + // this is the upload path where the parent route never // provided an initialProject prop. Without this fallback the // handler aborted via the ref mismatch and the preview stayed // frozen on the original clip. @@ -659,7 +657,7 @@ function StudioInner({ : "Export"; return ( -
+
fileInputRef.current?.click()} onCompare={() => { dispatch({ type: "set_playing", playing: false }); @@ -927,16 +923,7 @@ function StudioInner({ /> diff --git a/apps/web/src/app/editor/studio.css b/apps/web/src/app/editor/studio.css index a14abcf..7f62f15 100644 --- a/apps/web/src/app/editor/studio.css +++ b/apps/web/src/app/editor/studio.css @@ -181,58 +181,6 @@ } .topbar__ghost:hover { border-color: var(--edge-2); color: var(--ink); } -/* vibe / pro toggle pill — subtle chrome rim in off state, fully filled - bevel in on state. mirrors the pill-nav aesthetic on the landing page. */ -.topbar__vibe { - position: relative; - padding: 4px 14px; - border-radius: 9999px; - font-family: var(--f-mono); - font-size: 10px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--ink-fade); - background: transparent; - border: 1px solid var(--edge); - transition: - color var(--dur-s) var(--ease), - border-color var(--dur-s) var(--ease), - background var(--dur-m) var(--ease), - box-shadow var(--dur-m) var(--ease); - isolation: isolate; -} -.topbar__vibe:hover { - color: var(--ink); - border-color: var(--edge-2); - box-shadow: 0 0 22px -8px rgba(232, 232, 232, 0.35); -} -.topbar__vibe--on { - color: #0a0a0a; - border-color: transparent; - background: - linear-gradient( - 180deg, - #f4f4f4 0%, - #d0d0d0 46%, - #9a9a9a 50%, - #bcbcbc 54%, - #e8e8e8 100% - ); - text-shadow: 0 1px 0 rgba(255, 255, 255, 0.6); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.7), - inset 0 -1px 0 rgba(0, 0, 0, 0.35), - 0 0 0 1px rgba(0, 0, 0, 0.45); -} -.topbar__vibe--on:hover { - filter: brightness(1.06); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.7), - inset 0 -1px 0 rgba(0, 0, 0, 0.35), - 0 0 0 1px rgba(0, 0, 0, 0.45), - 0 0 32px -10px rgba(190, 220, 255, 0.45); -} - .topbar__export { padding: 4px 16px; font-size: 11px; @@ -382,34 +330,34 @@ transform: translate(-50%, -50%); } -/* ── vibe mode ── */ +/* ── focused editor layout ── */ /* 60/40 split: video player left, agent chat right */ -/* hide left sidebar and timeline in vibe mode */ -.studio--vibe .studio__left, -.studio--vibe .studio__bottom { +/* hide low-level media panels from the main workflow */ +.studio--focused .studio__left, +.studio--focused .studio__bottom { display: none !important; } /* hide the left splitter and bottom splitter */ -.studio--vibe .splitter--vertical:first-of-type, -.studio--vibe .studio__h-splitter-wrap { +.studio--focused .splitter--vertical:first-of-type, +.studio--focused .studio__h-splitter-wrap { display: none !important; } /* 60/40 grid: center (video) | splitter | right (chat) */ -.studio--vibe .studio__body { +.studio--focused .studio__body { grid-template-columns: minmax(0, 3fr) var(--split-x) minmax(280px, 2fr); } -/* right sidebar is visible in vibe mode (agent chat fills it) */ -.studio--vibe .studio__right { +/* agent chat fills the right side */ +.studio--focused .studio__right { display: flex !important; flex-direction: column; overflow: hidden; } -.studio--vibe .agent-chat { +.studio--focused .agent-chat { height: 100%; min-height: 0; min-width: 0; @@ -417,27 +365,27 @@ } @media (max-width: 900px) { - .studio--vibe .studio__body { + .studio--focused .studio__body { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(260px, 1fr) minmax(300px, 46vh); } - .studio--vibe .splitter--vertical { + .studio--focused .splitter--vertical { display: none !important; } } /* collapse the timeline row */ -.studio--vibe { +.studio--focused { grid-template-rows: 40px 1fr; } /* hide the timeline toolbar row if it exists */ -.studio--vibe .tbar { +.studio--focused .tbar { display: none !important; } /* make the preview video fill the space */ -.studio--vibe .pv { +.studio--focused .pv { height: 100%; } diff --git a/apps/web/src/app/projects/projects-client.tsx b/apps/web/src/app/projects/projects-client.tsx index abe0003..feb9c61 100644 --- a/apps/web/src/app/projects/projects-client.tsx +++ b/apps/web/src/app/projects/projects-client.tsx @@ -11,6 +11,7 @@ import { listProjects, logout, me, + updateProject, type Me, type ProjectListItem, } from "@/lib/api"; @@ -70,6 +71,20 @@ export function ProjectsClient() { } } + async function handleRename(projectId: string, name: string) { + try { + const updated = await updateProject(projectId, name); + setItems((current) => + current?.map((item) => + item.project_id === projectId ? { ...item, name: updated.name } : item, + ) ?? current, + ); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + throw err; + } + } + return (
@@ -165,6 +180,7 @@ export function ProjectsClient() { lastEdited={formatRelative(project.created_at)} meta={`${formatDuration(project.duration)} · ${project.width}x${project.height}`} onDelete={() => handleDelete(project.project_id)} + onRename={(name) => handleRename(project.project_id, name)} title={project.name} videoUrl={project.video_url} /> diff --git a/apps/web/src/components/editor-port/AgentChat.tsx b/apps/web/src/components/editor-port/AgentChat.tsx index e1d7033..d0a475f 100644 --- a/apps/web/src/components/editor-port/AgentChat.tsx +++ b/apps/web/src/components/editor-port/AgentChat.tsx @@ -8,6 +8,7 @@ import { MessageContent, } from "@/components/ui/message"; import { useAgentStream } from "@/hooks/useAgentStream"; +import { accept } from "@/lib/api"; import { useEDL, totalDuration } from "@/stores/edl"; import { useAgent, @@ -37,6 +38,8 @@ export function AgentChat({ projectId }: AgentChatProps) { } = useAgentStream(projectId); const { state: edlState } = useEDL(); const { dispatch: agentDispatch } = useAgent(); + const [applyingVariant, setApplyingVariant] = useState(null); + const [appliedVariant, setAppliedVariant] = useState(null); const messagesRef = useRef(null); const lastMessage = messages[messages.length - 1]; const lastAgentText = lastMessage?.type === "agent" ? lastMessage.text : ""; @@ -77,23 +80,32 @@ export function AgentChat({ projectId }: AgentChatProps) { [agentDispatch], ); - // Variant card "apply" — user picked a specific finished variant. Ask - // the agent to call accept_variant with that index so the generated - // clip lands on the timeline. Studio listens for the completion event - // (fiebatt:timeline-refresh) and re-hydrates the EDL from the server. + // Applying a finished render is a deterministic editor action. Calling + // the API directly avoids a second model turn guessing whether it should + // invoke accept_variant. Studio consumes the returned authoritative EDL. const handleApplyVariant = useCallback( - (jobId: string, variantIndex: number) => { - if (!projectId) return; - void sendMessage({ - projectId, - message: `Accept variant ${variantIndex} for job ${jobId} and apply it to the timeline. After the tool succeeds, confirm it's applied in one sentence.`, - playheadTs: edlState.playhead, - duration: totalDuration(edlState.clips), - bbox: edlState.bbox ?? null, - selectionId: edlState.mask?.selectionId ?? null, - }); + async (jobId: string, variantIndex: number) => { + if (!projectId || applyingVariant) return; + const key = `${jobId}:${variantIndex}`; + setApplyingVariant(key); + try { + const accepted = await accept(jobId, variantIndex); + window.dispatchEvent( + new CustomEvent("fiebatt:timeline-refresh", { + detail: { tool: "accept_variant", timeline: accepted.timeline }, + }), + ); + setAppliedVariant(key); + } catch (error) { + agentDispatch({ + type: "add_error", + message: error instanceof Error ? error.message : "Could not apply this edit.", + }); + } finally { + setApplyingVariant(null); + } }, - [projectId, sendMessage, edlState.playhead, edlState.clips, edlState.bbox, edlState.mask?.selectionId], + [projectId, applyingVariant, agentDispatch], ); return ( @@ -255,6 +267,10 @@ export function AgentChat({ projectId }: AgentChatProps) { background: rgba(126, 231, 135, 0.15); border-color: rgba(126, 231, 135, 0.35); } + .suggestion-card__btn:disabled { + cursor: default; + opacity: 0.6; + } .suggestion-card__resolved { font-family: var(--f-mono); font-size: 9px; @@ -406,6 +422,8 @@ export function AgentChat({ projectId }: AgentChatProps) { message={msg} onDismissSuggestion={handleDismissSuggestion} onApplyVariant={handleApplyVariant} + applyingVariant={applyingVariant} + appliedVariant={appliedVariant} /> ))} @@ -429,10 +447,14 @@ function MessageRenderer({ message, onDismissSuggestion, onApplyVariant, + applyingVariant, + appliedVariant, }: { message: AgentMessage; onDismissSuggestion: (ts: number) => void; - onApplyVariant: (jobId: string, variantIndex: number) => void; + onApplyVariant: (jobId: string, variantIndex: number) => void | Promise; + applyingVariant: string | null; + appliedVariant: string | null; }) { switch (message.type) { case "user": @@ -481,6 +503,8 @@ function MessageRenderer({ jobId={message.jobId} variants={message.variants} onApply={onApplyVariant} + applyingVariant={applyingVariant} + appliedVariant={appliedVariant} /> ); @@ -597,10 +621,14 @@ function VariantPreviewCard({ jobId, variants, onApply, + applyingVariant, + appliedVariant, }: { jobId: string; variants: VariantPreview[]; - onApply: (jobId: string, variantIndex: number) => void; + onApply: (jobId: string, variantIndex: number) => void | Promise; + applyingVariant: string | null; + appliedVariant: string | null; }) { console.log( `[VariantPreviewCard] render job=${jobId} variants=${variants?.length ?? 0}`, @@ -612,41 +640,47 @@ function VariantPreviewCard({ {variants.length} variant{variants.length !== 1 ? "s" : ""} ready
- {variants.map((v) => ( -
-
- {v.url ? ( -
- ))} + ); + })}
); @@ -654,7 +688,7 @@ function VariantPreviewCard({ // ─── prompt plan card ──────────────────────────────────────────────── // -// Surfaces the prompt-rewriting layer so users can SEE what the AI +// Surfaces the prompt-rewriting layer so users can see what the system // turned their one-liner into before it gets dispatched for generation. // Without this the whole "intelligent prompt expansion" value prop is // invisible to the user and the edit feels like a black box. Shows the @@ -700,7 +734,7 @@ function PromptPlanCard({ )}
- qwen + refined {ready ? (

{plan.prompt || plan.prompt_for_veo || plan.description || "(no prompt returned)"} diff --git a/apps/web/src/components/editor-port/CompareOverlay.tsx b/apps/web/src/components/editor-port/CompareOverlay.tsx index 2c4df9a..06b22a6 100644 --- a/apps/web/src/components/editor-port/CompareOverlay.tsx +++ b/apps/web/src/components/editor-port/CompareOverlay.tsx @@ -14,11 +14,14 @@ import { export function CompareOverlay({ onClose }: { onClose: () => void }) { const { state } = useEDL(); const original = state.sources.find((asset) => asset.kind === "source") ?? state.sources[0] ?? null; - const total = totalDuration(state.clips); - const [playhead, setPlayhead] = useState(() => Math.min(state.playhead, total || state.playhead)); + const editedDuration = totalDuration(state.clips); + const comparisonDuration = Math.max(original?.duration ?? 0, editedDuration); + const [playhead, setPlayhead] = useState(() => + Math.min(state.playhead, comparisonDuration || state.playhead), + ); const [playing, setPlaying] = useState(false); const [singleView, setSingleView] = useState<"original" | "edited">("original"); - const [sideBySide, setSideBySide] = useState(false); + const [sideBySide, setSideBySide] = useState(true); const originalRef = useRef(null); const editedRef = useRef(null); const editedClipIdRef = useRef(null); @@ -90,7 +93,7 @@ export function CompareOverlay({ onClose }: { onClose: () => void }) { return; } - const restart = total > 0 && playhead >= total - 0.05; + const restart = comparisonDuration > 0 && playhead >= comparisonDuration - 0.05; const nextPlayhead = restart ? 0 : playhead; lastTickRef.current = null; setPlayhead(nextPlayhead); @@ -100,7 +103,7 @@ export function CompareOverlay({ onClose }: { onClose: () => void }) { void Promise.allSettled([originalVideo.play(), editedVideo.play()]); setPlaying(true); }); - }, [playhead, playing, syncVideos, total]); + }, [comparisonDuration, playhead, playing, syncVideos]); useEffect(() => { if (!playing) { @@ -116,10 +119,10 @@ export function CompareOverlay({ onClose }: { onClose: () => void }) { const delta = (now - lastTickRef.current) / 1000; lastTickRef.current = now; setPlayhead((current) => { - const next = Math.min(total, current + delta); - if (next >= total) { + const next = Math.min(comparisonDuration, current + delta); + if (next >= comparisonDuration) { setPlaying(false); - return total; + return comparisonDuration; } return next; }); @@ -130,7 +133,7 @@ export function CompareOverlay({ onClose }: { onClose: () => void }) { return () => { if (rafRef.current != null) cancelAnimationFrame(rafRef.current); }; - }, [playing, total]); + }, [comparisonDuration, playing]); useEffect(() => { const onKey = (event: KeyboardEvent) => { @@ -160,7 +163,7 @@ export function CompareOverlay({ onClose }: { onClose: () => void }) {

Compare
-
Original and edited timeline play together.
+
Original video and full edited timeline play together.
- {formatTime(playhead)} / {formatTime(total)} + {formatTime(playhead)} / {formatTime(comparisonDuration)} diff --git a/apps/web/src/components/project-card.tsx b/apps/web/src/components/project-card.tsx index c6057a2..e796ea3 100644 --- a/apps/web/src/components/project-card.tsx +++ b/apps/web/src/components/project-card.tsx @@ -1,6 +1,9 @@ +"use client"; + import Link from "next/link"; import Image from "next/image"; -import { ArrowRight, Trash2 } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { ArrowRight, Check, Pencil, Trash2, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -14,6 +17,7 @@ export type ProjectCardProps = { videoUrl?: string; imageUrl?: string; onDelete?: () => void; + onRename?: (name: string) => void | Promise; placeholderClassName?: string; }; @@ -26,8 +30,51 @@ export function ProjectCard({ videoUrl, imageUrl, onDelete, + onRename, placeholderClassName, }: ProjectCardProps) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(title); + const [saving, setSaving] = useState(false); + const [renameError, setRenameError] = useState(null); + const inputRef = useRef(null); + + useEffect(() => { + if (editing) inputRef.current?.select(); + }, [editing]); + + async function saveRename() { + const name = draft.trim(); + if (!onRename || saving) return; + if (!name) { + setDraft(title); + setRenameError("Name cannot be empty."); + return; + } + if (name === title) { + setEditing(false); + setRenameError(null); + return; + } + setSaving(true); + setRenameError(null); + try { + await onRename(name); + setEditing(false); + } catch { + setRenameError("Could not rename project."); + inputRef.current?.focus(); + } finally { + setSaving(false); + } + } + + function cancelRename() { + setDraft(title); + setRenameError(null); + setEditing(false); + } + return (
@@ -62,7 +109,48 @@ export function ProjectCard({
-

{title}

+ {editing ? ( +
+ setDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") void saveRename(); + if (event.key === "Escape") cancelRename(); + }} + value={draft} + /> + + +
+ ) : ( +

{title}

+ )} + {renameError ? ( +

{renameError}

+ ) : null}

{meta}

@@ -71,6 +159,18 @@ export function ProjectCard({

+ {onRename && !editing ? ( + + ) : null} setName(event.target.value)} + onChange={(event) => { + setName(event.target.value); + setError(false); + }} onBlur={() => void save()} onKeyDown={(event) => { if (event.key === "Enter") event.currentTarget.blur(); @@ -45,6 +56,7 @@ export function ProjectNameEditor({ } }} value={name} + title={error ? "Could not save project name" : saving ? "Saving project name" : "Click to rename"} /> ); } diff --git a/apps/web/src/hooks/useAgentStream.ts b/apps/web/src/hooks/useAgentStream.ts index d108b93..11fb609 100644 --- a/apps/web/src/hooks/useAgentStream.ts +++ b/apps/web/src/hooks/useAgentStream.ts @@ -14,10 +14,12 @@ import { getJob, getConversationMessages, getSessionId, + listGenerationJobs, listConversations, type ChatMessageResp, } from "@/lib/api"; import { redirectToLogin } from "@/lib/auth"; +import { cleanAgentText } from "@/lib/agent-text"; import { useAgent, type AgentAction, @@ -54,6 +56,41 @@ function agentChatUrl(): string { return "/api/agent/chat"; } +type PendingAgentTurn = { + startedAt: number; + prompt: string; + jobId?: string; +}; + +const PENDING_TURN_PREFIX = "fiebatt.pending-agent-turn."; +const PLAN_RECOVERY_TIMEOUT_MS = 7 * 60 * 1000; + +function pendingTurnKey(projectId: string): string { + return `${PENDING_TURN_PREFIX}${projectId}`; +} + +function readPendingTurn(projectId: string): PendingAgentTurn | null { + try { + const raw = localStorage.getItem(pendingTurnKey(projectId)); + if (!raw) return null; + const value = JSON.parse(raw) as Partial; + if (typeof value.startedAt !== "number" || typeof value.prompt !== "string") { + return null; + } + return value as PendingAgentTurn; + } catch { + return null; + } +} + +function writePendingTurn(projectId: string, turn: PendingAgentTurn): void { + localStorage.setItem(pendingTurnKey(projectId), JSON.stringify(turn)); +} + +function clearPendingTurn(projectId: string): void { + localStorage.removeItem(pendingTurnKey(projectId)); +} + // ─── hook ───────────────────────────────────────────────────────────── export function useAgentStream(projectId?: string | null) { @@ -61,10 +98,60 @@ export function useAgentStream(projectId?: string | null) { const abortRef = useRef(null); const inFlightRef = useRef(false); const generationWatchRef = useRef(null); + const watchedJobIdRef = useRef(null); const loadedRef = useRef(null); + const conversationHydrationRef = useRef | null>(null); const conversationIdRef = useRef(state.conversationId); conversationIdRef.current = state.conversationId; + const watchGeneration = useCallback(async (jobId: string) => { + if (watchedJobIdRef.current === jobId) return; + generationWatchRef.current?.abort(); + const watch = new AbortController(); + generationWatchRef.current = watch; + watchedJobIdRef.current = jobId; + dispatch({ type: "resume_job_watch", activity: "video job queued…" }); + try { + while (!watch.signal.aborted) { + const job = await getJob(jobId); + if (watch.signal.aborted) return; + const ready = job.variants.filter((variant) => variant.url); + const failed = job.variants.filter((variant) => variant.status === "error"); + if (job.status === "done" || job.status === "error") { + if (projectId) clearPendingTurn(projectId); + if (ready.length > 0) { + dispatch({ type: "add_variant_preview", jobId, variants: ready }); + dispatch({ type: "set_activity", activity: "preview ready — choose a variant" }); + } else { + dispatch({ + type: "add_error", + message: job.error || failed[0]?.error || "Video generation failed without a usable preview.", + }); + } + return; + } + dispatch({ + type: "set_activity", + activity: `${job.status === "processing" ? "rendering" : "queued"} · ${ready.length}/${job.variants.length || 1} previews ready…`, + }); + await new Promise((resolve) => window.setTimeout(resolve, 1500)); + } + } catch (error) { + if (!watch.signal.aborted) { + dispatch({ + type: "add_error", + message: `Could not read generation status: ${error instanceof Error ? error.message : String(error)}`, + }); + } + } finally { + if (generationWatchRef.current === watch) { + generationWatchRef.current = null; + watchedJobIdRef.current = null; + dispatch({ type: "end_stream" }); + } + } + }, [dispatch, projectId]); + // hydrate the most recent conversation for this project on mount useEffect(() => { if (!projectId) return; @@ -76,7 +163,7 @@ export function useAgentStream(projectId?: string | null) { } loadedRef.current = projectId; - (async () => { + const hydration = (async () => { try { const convos = await listConversations(projectId); if (convos.length === 0) { @@ -117,6 +204,7 @@ export function useAgentStream(projectId?: string | null) { console.warn("[agent] failed to load conversation:", err); } })(); + conversationHydrationRef.current = hydration; }, [projectId, dispatch]); const sendMessage = useCallback( @@ -135,6 +223,7 @@ export function useAgentStream(projectId?: string | null) { abortRef.current = controller; let timedOut = false; let sawDone = false; + let generationQueued = false; const timeout = window.setTimeout(() => { timedOut = true; dispatch({ @@ -144,51 +233,8 @@ export function useAgentStream(projectId?: string | null) { controller.abort(); }, 7 * 60 * 1000); - const watchGeneration = async (jobId: string) => { - generationWatchRef.current?.abort(); - const watch = new AbortController(); - generationWatchRef.current = watch; - dispatch({ type: "set_activity", activity: "video job queued…" }); - try { - while (!watch.signal.aborted) { - const job = await getJob(jobId); - if (watch.signal.aborted) return; - const ready = job.variants.filter((variant) => variant.url); - const failed = job.variants.filter((variant) => variant.status === "error"); - if (job.status === "done" || job.status === "error") { - if (ready.length > 0) { - dispatch({ type: "add_variant_preview", jobId, variants: ready }); - dispatch({ type: "set_activity", activity: "preview ready — choose a variant" }); - } else { - dispatch({ - type: "add_error", - message: job.error || failed[0]?.error || "Video generation failed without a usable preview.", - }); - } - return; - } - dispatch({ - type: "set_activity", - activity: `${job.status === "processing" ? "rendering" : "queued"} · ${ready.length}/${job.variants.length || 1} previews ready…`, - }); - await new Promise((resolve) => window.setTimeout(resolve, 1500)); - } - } catch (error) { - if (!watch.signal.aborted) { - dispatch({ - type: "add_error", - message: `Could not read generation status: ${error instanceof Error ? error.message : String(error)}`, - }); - } - } finally { - if (generationWatchRef.current === watch) { - generationWatchRef.current = null; - dispatch({ type: "end_stream" }); - } - } - }; - // Optimistic UI: show the user message immediately + writePendingTurn(projectId, { startedAt: Date.now(), prompt: message }); dispatch({ type: "add_user_message", text: message }); dispatch({ type: "start_stream" }); @@ -276,10 +322,19 @@ export function useAgentStream(projectId?: string | null) { const data = JSON.parse(dataStr) as Record; eventCount++; if (currentEvent === "done") sawDone = true; - handleSSEEvent(dispatch, currentEvent, data); + handleSSEEvent(dispatch, currentEvent, data, message); if (currentEvent === "suggestion") { const edit = data.edit as { job_id?: string } | undefined; - if (edit?.job_id) void watchGeneration(edit.job_id); + if (edit?.job_id) { + generationQueued = true; + const pending = readPendingTurn(projectId); + writePendingTurn(projectId, { + startedAt: pending?.startedAt ?? Date.now(), + prompt: pending?.prompt ?? message, + jobId: edit.job_id, + }); + void watchGeneration(edit.job_id); + } } } catch (err) { console.warn( @@ -317,6 +372,7 @@ export function useAgentStream(projectId?: string | null) { } } finally { window.clearTimeout(timeout); + if (sawDone && !generationQueued) clearPendingTurn(projectId); // Keep the input/status card alive while the detached generation // watcher is polling for a preview after the chat stream ends. if (!generationWatchRef.current) dispatch({ type: "end_stream" }); @@ -324,9 +380,72 @@ export function useAgentStream(projectId?: string | null) { inFlightRef.current = false; } }, - [state.messages, state.conversationId, dispatch], + [state.messages, state.conversationId, dispatch, watchGeneration], ); + // A page change only closes browser delivery. The backend agent turn and + // generation keep running. On return, find the job created for that turn + // and restore polling (including a preview that finished while away). + useEffect(() => { + if (!projectId) return; + const pending = readPendingTurn(projectId); + if (!pending) return; + + let cancelled = false; + const recover = async () => { + // Conversation hydration replaces message rows. Wait for it before + // adding a recovered preview so a slower history request cannot erase it. + await conversationHydrationRef.current; + if (cancelled) return; + dispatch({ + type: "resume_job_watch", + activity: pending.jobId ? "restoring video job…" : "finishing edit plan…", + }); + const deadline = pending.startedAt + PLAN_RECOVERY_TIMEOUT_MS; + while (!cancelled && Date.now() <= deadline) { + try { + if (pending.jobId) { + await watchGeneration(pending.jobId); + return; + } + const jobs = await listGenerationJobs(projectId, 10); + if (cancelled) return; + const earliest = pending.startedAt - 30_000; + const candidate = jobs.find((job) => { + if (job.accepted) return false; + const createdAt = job.created_at ? Date.parse(job.created_at) : 0; + return createdAt >= earliest; + }); + if (candidate) { + writePendingTurn(projectId, { ...pending, jobId: candidate.job_id }); + await watchGeneration(candidate.job_id); + return; + } + } catch { + // Network or planning may still be settling. Retry until deadline. + } + await new Promise((resolve) => window.setTimeout(resolve, 2000)); + } + if (!cancelled) { + clearPendingTurn(projectId); + dispatch({ + type: "add_error", + message: "Could not restore the edit job. Please try the edit again.", + }); + } + }; + + void recover(); + return () => { + cancelled = true; + }; + }, [dispatch, projectId, watchGeneration]); + + useEffect(() => () => { + abortRef.current?.abort(); + generationWatchRef.current?.abort(); + }, []); + const stopStream = useCallback(() => { abortRef.current?.abort(); generationWatchRef.current?.abort(); @@ -336,6 +455,7 @@ export function useAgentStream(projectId?: string | null) { const clearChat = useCallback(async () => { abortRef.current?.abort(); + if (projectId) clearPendingTurn(projectId); const oldId = state.conversationId; dispatch({ type: "clear_messages" }); @@ -379,7 +499,7 @@ function dbMessageToAgentMessage(msg: ChatMessageResp): AgentMessage | null { case "agent": return { type: "agent", - text: (content.text as string) ?? "", + text: cleanAgentText((content.text as string) ?? ""), ts, streaming: false, }; @@ -446,6 +566,7 @@ function handleSSEEvent( dispatch: React.Dispatch, event: string, data: Record, + currentUserPrompt = "", ): void { _logSSE(event, data); @@ -560,7 +681,7 @@ function handleSSEEvent( dispatch({ type: "prompt_plan_started", jobId, - userPrompt: (data.user_prompt as string | undefined) ?? "", + userPrompt: currentUserPrompt || (data.user_prompt as string | undefined) || "", }); break; } diff --git a/apps/web/src/lib/agent-text.ts b/apps/web/src/lib/agent-text.ts new file mode 100644 index 0000000..38ddd10 --- /dev/null +++ b/apps/web/src/lib/agent-text.ts @@ -0,0 +1,11 @@ +const TOOL_MARKUP_START = /<\s*(?:(?:[||]\s*)?DSML(?:\s*[||])?\s*)?(?:function[\s_]?calls?|tool[\s_]?calls?|invoke\b|parameter\b)/i; + +export function hasAgentToolMarkup(text: string): boolean { + return TOOL_MARKUP_START.test(text); +} + +/** Keep model prose while removing raw tool-protocol markup from the UI. */ +export function cleanAgentText(text: string): string { + const marker = TOOL_MARKUP_START.exec(text); + return marker ? text.slice(0, marker.index).trimEnd() : text; +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 70a9cd0..11c92ff 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -129,6 +129,8 @@ export type JobResp = { status: JobStatus; variants: Variant[]; error: string | null; + created_at?: string | null; + accepted?: boolean; /** authoritative edit window accepted by the backend */ start_ts: number | null; end_ts: number | null; @@ -542,6 +544,13 @@ export function getJob(id: string): Promise { return request(`/api/jobs/${id}`); } +export function listGenerationJobs( + projectId: string, + limit = 10, +): Promise { + return request(`/api/projects/${projectId}/generation-jobs?limit=${limit}`); +} + export function accept(job_id: string, variant_index: number): Promise { return request("/api/accept", { method: "POST", diff --git a/apps/web/src/stores/agent.tsx b/apps/web/src/stores/agent.tsx index aaae93e..fb4db42 100644 --- a/apps/web/src/stores/agent.tsx +++ b/apps/web/src/stores/agent.tsx @@ -14,6 +14,7 @@ import { type Dispatch, type ReactNode, } from "react"; +import { cleanAgentText, hasAgentToolMarkup } from "@/lib/agent-text"; // ─── types ──────────────────────────────────────────────────────────── @@ -71,7 +72,13 @@ export interface PromptPlan { export type AgentMessage = | { type: "user"; text: string; ts: number } - | { type: "agent"; text: string; ts: number; streaming?: boolean } + | { + type: "agent"; + text: string; + ts: number; + streaming?: boolean; + toolMarkupSuppressed?: boolean; + } | { type: "tool_call"; id: string; @@ -116,6 +123,7 @@ export interface AgentState { export type AgentAction = | { type: "add_user_message"; text: string } | { type: "start_stream" } + | { type: "resume_job_watch"; activity: string } | { type: "set_activity"; activity: string | null } | { type: "append_token"; text: string } | { type: "end_stream" } @@ -186,6 +194,13 @@ function agentReducer(state: AgentState, action: AgentAction): AgentState { ], }; + case "resume_job_watch": + return { + ...state, + streaming: true, + activity: action.activity, + }; + case "set_activity": return { ...state, activity: action.activity }; @@ -193,7 +208,13 @@ function agentReducer(state: AgentState, action: AgentAction): AgentState { const msgs = [...state.messages]; const last = msgs[msgs.length - 1]; if (last && last.type === "agent" && last.streaming) { - msgs[msgs.length - 1] = { ...last, text: last.text + action.text }; + if (last.toolMarkupSuppressed) return state; + const combined = last.text + action.text; + msgs[msgs.length - 1] = { + ...last, + text: cleanAgentText(combined), + toolMarkupSuppressed: hasAgentToolMarkup(combined), + }; } return { ...state, messages: msgs }; } @@ -333,7 +354,9 @@ function agentReducer(state: AgentState, action: AgentAction): AgentState { { type: "prompt_plan", jobId: action.jobId, - userPrompt: "", + userPrompt: [...state.messages] + .reverse() + .find((message) => message.type === "user")?.text ?? "", plan: action.plan, vendor: null, ts: now, diff --git a/apps/web/tests/e2e/frontend-readiness.spec.ts b/apps/web/tests/e2e/frontend-readiness.spec.ts index 77599a6..81282be 100644 --- a/apps/web/tests/e2e/frontend-readiness.spec.ts +++ b/apps/web/tests/e2e/frontend-readiness.spec.ts @@ -19,11 +19,33 @@ async function json(route: Route, body: unknown, status = 200) { }); } -async function installApi(page: Page, projects: typeof project[] = []) { +async function installApi( + page: Page, + projects: typeof project[] = [], + timeline?: unknown, + jobs?: { + agentSse: string; + current: () => unknown; + }, +) { await page.route("**/api/**", async (route) => { const request = route.request(); const path = new URL(request.url()).pathname; + if (path === "/api/agent/chat" && jobs) { + return route.fulfill({ + body: jobs.agentSse, + contentType: "text/event-stream", + status: 200, + }); + } + if (path === "/api/jobs/job-navigation" && jobs) { + return json(route, jobs.current()); + } + if (path === `/api/projects/${project.project_id}/generation-jobs` && jobs) { + return json(route, [jobs.current()]); + } + if (path === "/api/me") { return json(route, { session_id: "user:1", @@ -38,11 +60,16 @@ async function installApi(page: Page, projects: typeof project[] = []) { if (path === "/api/health") return json(route, { ok: true, features: {} }); if (path === "/api/upload") return json(route, project); if (path === `/api/projects/${project.project_id}`) { + if (request.method() === "PATCH") { + const body = request.postDataJSON() as { name: string }; + return json(route, { ...project, name: body.name }); + } return json(route, { ...project, segments: [], entities: [] }); } if (path === `/api/timeline/${project.project_id}`) { - return json(route, { + return json(route, timeline ?? { project_id: project.project_id, + duration: project.duration, segments: [{ start_ts: 0, end_ts: 4, @@ -67,6 +94,30 @@ test("project library shows only saved projects", async ({ page }) => { await expect(page.locator("article")).toHaveCount(0); }); +test("project can be renamed from the library", async ({ page }) => { + await installApi(page, [project]); + await page.goto("/projects"); + + await page.getByRole("button", { name: "Rename clip" }).click(); + await page.getByLabel("Rename clip").fill("Launch cut"); + await page.getByRole("button", { name: "Save project name" }).click(); + + await expect(page.getByText("Launch cut")).toBeVisible(); + await expect(page.getByRole("button", { name: "Rename Launch cut" })).toBeVisible(); +}); + +test("project can be renamed from the editor", async ({ page }) => { + await installApi(page, [project]); + await page.goto(`/editor?projectId=${project.project_id}`); + + const name = page.getByLabel("Project name"); + await name.fill("Final reel"); + await name.press("Enter"); + + await expect(name).toHaveValue("Final reel"); + await expect(name).toHaveAttribute("aria-invalid", "false"); +}); + test("settings use automatic platform routing", async ({ page }) => { await installApi(page); await page.goto("/settings"); @@ -89,6 +140,9 @@ test("upload opens the authoritative project", async ({ page }) => { await expect(page).toHaveURL(/\/editor\?projectId=project-123$/); await expect(page.getByLabel("Project name")).toHaveValue("clip"); + await expect(page.getByPlaceholder("describe an edit...")).toBeVisible(); + await expect(page.getByRole("tab", { name: "Vibe" })).toHaveCount(0); + await expect(page.getByRole("tab", { name: "Editor" })).toHaveCount(0); }); test("login rejects recursive return paths", async ({ page }) => { @@ -101,3 +155,196 @@ test("login rejects recursive return paths", async ({ page }) => { await expect(page).toHaveURL(/\/projects$/); }); + +test("compare opens original and complete edited timeline side by side", async ({ page }) => { + await installApi(page, [project], { + project_id: project.project_id, + duration: 4, + segments: [], + edl: { + updated_at: 1, + sources: [ + { + id: "source-1", + kind: "source", + url: "/test-video.mp4", + duration: 4, + fps: 24, + project_id: project.project_id, + label: "clip", + }, + { + id: "generated-1", + kind: "generated", + url: "/generated.mp4", + duration: 2, + fps: 24, + project_id: project.project_id, + label: "applied edit", + }, + ], + clips: [ + { + id: "clip-original", + kind: "source", + url: "/test-video.mp4", + source_start: 0, + source_end: 2, + media_duration: 4, + volume: 1, + label: "clip", + project_id: project.project_id, + source_asset_id: "source-1", + }, + { + id: "clip-generated", + kind: "generated", + url: "/generated.mp4", + source_start: 0, + source_end: 2, + media_duration: 2, + volume: 1, + label: "applied edit", + project_id: project.project_id, + source_asset_id: "generated-1", + }, + ], + }, + }); + await page.goto(`/editor?projectId=${project.project_id}`); + await expect(page.getByLabel("Project name")).toHaveValue("clip"); + + await page.getByRole("button", { name: "Compare" }).click(); + + await expect(page.getByLabel("Original video")).toBeVisible(); + await expect(page.getByLabel("Edited timeline preview")).toBeVisible(); + await expect(page.getByRole("button", { name: "Single view" })).toBeVisible(); + + await page.getByLabel("Compare playhead").fill("3"); + await expect(page.getByLabel("Edited timeline preview")).toHaveAttribute( + "src", + "/generated.mp4", + ); +}); + +test("generation preview returns after leaving and reopening editor", async ({ page }) => { + let finished = false; + const currentJob = () => ({ + job_id: "job-navigation", + kind: "generate", + status: finished ? "done" : "processing", + error: null, + created_at: new Date().toISOString(), + accepted: false, + start_ts: 0, + end_ts: 3, + variants: finished + ? [{ + id: "variant-navigation", + index: 0, + status: "done", + url: "/generated-navigation.mp4", + description: "finished while away", + visual_coherence: 8, + prompt_adherence: 8, + error: null, + }] + : [], + }); + const agentSse = [ + "event: suggestion", + `data: ${JSON.stringify({ edit: { + job_id: "job-navigation", + start_ts: 0, + end_ts: 3, + suggestion: "make the car green", + } })}`, + "", + "event: done", + "data: {}", + "", + "", + ].join("\n"); + + await installApi(page, [project], undefined, { + agentSse, + current: currentJob, + }); + await page.goto(`/editor?projectId=${project.project_id}`); + await page.getByPlaceholder("describe an edit...").fill("make the car green"); + await page.getByRole("button", { name: "send message" }).click(); + await expect(page.getByText(/rendering|queued/).first()).toBeVisible(); + await expect.poll(() => page.evaluate(() => + localStorage.getItem("fiebatt.pending-agent-turn.project-123"), + )).not.toBeNull(); + + await page.goto("/projects"); + finished = true; + await page.goto(`/editor?projectId=${project.project_id}`); + + await expect(page.getByText("finished while away")).toBeVisible(); + await expect(page.getByRole("button", { name: "apply" })).toBeVisible(); +}); + +test("prompt card separates user request from refined prompt", async ({ page }) => { + const agentSse = [ + "event: prompt_plan_started", + `data: ${JSON.stringify({ + job_id: "job-prompt", + user_prompt: "A polished emerald vehicle with exact color consistency.", + })}`, + "", + "event: prompt_plan", + `data: ${JSON.stringify({ + job_id: "job-prompt", + plan: { + prompt: "A polished emerald vehicle with exact color consistency.", + intent: "appearance change", + }, + })}`, + "", + "event: done", + "data: {}", + "", + "", + ].join("\n"); + await installApi(page, [project], undefined, { + agentSse, + current: () => ({}), + }); + await page.goto(`/editor?projectId=${project.project_id}`); + await page.getByPlaceholder("describe an edit...").fill("make the car green"); + await page.getByRole("button", { name: "send message" }).click(); + + await expect(page.locator(".prompt-plan__user")).toHaveText("make the car green"); + await expect(page.locator(".prompt-plan__content")).toHaveText( + "A polished emerald vehicle with exact color consistency.", + ); + await expect(page.locator(".prompt-plan__lane-k")).toHaveText(["you", "refined"]); +}); + +test("agent reply never shows raw tool markup", async ({ page }) => { + const leaked = `Working on it. +<|DSML|function_calls> +<|DSML|invoke name="analyze_video"> +<|DSML|parameter name="project_id" string="true">project-123`; + const agentSse = [ + "event: token", + `data: ${JSON.stringify({ text: leaked })}`, + "", + "event: done", + "data: {}", + "", + "", + ].join("\n"); + await installApi(page, [project], undefined, { + agentSse, + current: () => ({}), + }); + await page.goto(`/editor?projectId=${project.project_id}`); + await page.getByPlaceholder("describe an edit...").fill("inspect this video"); + await page.getByRole("button", { name: "send message" }).click(); + + await expect(page.getByText("Working on it.")).toBeVisible(); + await expect(page.getByText(/DSML|function_calls|functioncall/i)).toHaveCount(0); +});