diff --git a/apps/web/src/components/OneLoopStudio.tsx b/apps/web/src/components/OneLoopStudio.tsx index f04ba54f8..e22ca44b2 100644 --- a/apps/web/src/components/OneLoopStudio.tsx +++ b/apps/web/src/components/OneLoopStudio.tsx @@ -3,6 +3,7 @@ import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; +import { useSession } from 'next-auth/react'; import { Download, GitPullRequest, Hammer, Play, Rocket } from 'lucide-react'; import { formatSeconds, parseTimestampToSeconds, extractYouTubeId } from '@/lib/timestamp'; import { applyPackStackChecks, compileLinkedSop, type LinkedSop } from '@/lib/linked-sop'; @@ -16,7 +17,7 @@ import { import { clsx } from 'clsx'; import Nav from '@/components/Nav'; import { StudioAuthNavLink } from '@/components/StudioAuthNavLink'; -import { dashboardPersistenceSucceeded, useDashboardStore } from '@/store/dashboard-store'; +import { dashboardPersistenceSucceeded, useDashboardStore, type Video } from '@/store/dashboard-store'; import GroundedSpecReview from '@/components/GroundedSpecReview'; import { Field, FieldDescription, FieldGroup, FieldLabel } from '@/components/ui/field'; import { @@ -259,6 +260,14 @@ function StudioJobStripDestination({ } } +function reviewSelectionKey(video: Video | undefined): string | null { + if (!video?.videoPack || !video.runId || video.status !== 'complete') return null; + const videoId = extractYouTubeId(video.url); + if (!videoId || video.videoPack.videoId !== videoId || + extractYouTubeId(video.videoPack.sourceUrl) !== videoId) return null; + return JSON.stringify([video.id, video.url, video.runId, video.videoPack.packId, video.videoPack.sourceHash]); +} + export default function OneLoopStudio({ showAgentWorkflowUi, }: { @@ -266,6 +275,7 @@ export default function OneLoopStudio({ }) { const router = useRouter(); const searchParams = useSearchParams(); + const { data: session } = useSession(); const [url, setUrl] = useState(''); const [busy, setBusy] = useState(false); const [elapsed, setElapsed] = useState(0); @@ -273,7 +283,8 @@ export default function OneLoopStudio({ const [actBusy, setActBusy] = useState(false); const runAbortRef = useRef(null); const [workflowActions, setWorkflowActions] = useState(null); - const [workflowActionSelectionId, setWorkflowActionSelectionId] = useState(null); + const [workflowActionSelectionKey, setWorkflowActionSelectionKey] = useState(null); + const reviewEpochRef = useRef(0); const [buildBusy, setBuildBusy] = useState(false); const [packLiveReceiptUrl, setPackLiveReceiptUrl] = useState(null); const [packLiveReceiptVideoId, setPackLiveReceiptVideoId] = useState(null); @@ -301,8 +312,9 @@ export default function OneLoopStudio({ const selectedVideoId = useDashboardStore((s) => s.selectedVideoId); const videos = useDashboardStore((s) => s.videos); const selected = videos.find((v) => v.id === selectedVideoId); + const selectedReviewKey = reviewSelectionKey(selected); const scopedWorkflowActions = showAgentWorkflowUi && - workflowActionSelectionId === selectedVideoId && + selectedReviewKey !== null && workflowActionSelectionKey === selectedReviewKey && getYouTubeId(selected?.url || '') === getYouTubeId(workflowActions?.url || '') && (!url.trim() || getYouTubeId(url) === getYouTubeId(selected?.url || '')) ? workflowActions @@ -358,7 +370,13 @@ export default function OneLoopStudio({ useEffect(() => { setWorkflowActions(null); - setWorkflowActionSelectionId(null); + setWorkflowActionSelectionKey(null); + setActBusy(false); + reviewEpochRef.current += 1; + return () => { reviewEpochRef.current += 1; }; + }, [selectedReviewKey, showAgentWorkflowUi]); + + useEffect(() => { setCompletedChecks([]); setApprovedSpecIds([]); setBuildLiveFailure(null); @@ -377,12 +395,13 @@ export default function OneLoopStudio({ return () => window.clearTimeout(timer); }, [exportToast]); - const holdReason = deployHoldReason(linkedSop, completedChecks, 'anonymous'); + const viewer = session?.user ? 'signed-in' : 'anonymous'; + const holdReason = deployHoldReason(linkedSop, completedChecks, viewer); const officialTemplate = pickOfficialTemplate(linkedSop); const stackCheckViews = stackChecks.map((item) => { - const status = stackCheckStatus(item, completedChecks, 'anonymous'); + const status = stackCheckStatus(item, completedChecks, viewer); return { - ...studioStackCheckView(item, completedChecks, 'anonymous'), + ...studioStackCheckView(item, completedChecks, viewer), status, statusLabel: stackCheckStatusLabel(status), }; @@ -424,7 +443,7 @@ export default function OneLoopStudio({ runAbortRef.current = controller; setBusy(true); setWorkflowActions(null); - setWorkflowActionSelectionId(null); + setWorkflowActionSelectionKey(null); setMessage('Fetching transcript…'); const tick = window.setInterval(() => { const matches = useDashboardStore @@ -603,6 +622,7 @@ export default function OneLoopStudio({ const canAct = Boolean( showAgentWorkflowUi && selected?.status === 'complete' && + selectedReviewKey && selected?.runId && selectedVideoUrlId && (!url.trim() || typedVideoId === selectedVideoUrlId), @@ -613,11 +633,17 @@ export default function OneLoopStudio({ setMessage('Select the analyzed video before reviewing its actions.'); return; } - const selectionId = selected.id; const runId = selected.runId; + const selectionKey = selectedReviewKey; + const epoch = ++reviewEpochRef.current; + const isCurrentReview = () => { + const state = useDashboardStore.getState(); + const currentVideo = state.videos.find((video) => video.id === state.selectedVideoId); + return reviewEpochRef.current === epoch && reviewSelectionKey(currentVideo) === selectionKey; + }; setActBusy(true); setWorkflowActions(null); - setWorkflowActionSelectionId(null); + setWorkflowActionSelectionKey(null); setMessage('Loading proposed actions from this run…'); try { const polled = await pollVideoToActions(runId, { @@ -625,14 +651,14 @@ export default function OneLoopStudio({ attempts: 24, delayMs: 2000, }); - if (useDashboardStore.getState().selectedVideoId !== selectionId) return; + if (!isCurrentReview()) return; if (polled.status === 401 || polled.status === 403) { router.push(`/login?callbackUrl=${encodeURIComponent(CANONICAL_STUDIO_PATH)}`); return; } if (polled.runStatus === 'completed' && polled.result) { setWorkflowActions(polled.result); - setWorkflowActionSelectionId(selectionId); + setWorkflowActionSelectionKey(selectionKey); setMessage( `${polled.result.actionCount} proposed action(s) from this run are ready to review.`, ); @@ -640,11 +666,11 @@ export default function OneLoopStudio({ setMessage(polled.error || `Act status: ${polled.runStatus || 'unknown'}.`); } } catch (err) { - if (useDashboardStore.getState().selectedVideoId === selectionId) { + if (isCurrentReview()) { setMessage(err instanceof Error ? err.message : 'Could not load proposed actions.'); } } finally { - setActBusy(false); + if (isCurrentReview()) setActBusy(false); } }; @@ -862,6 +888,7 @@ export default function OneLoopStudio({ retryable: selected?.failure?.retryable !== false, elapsedSeconds: elapsed, }); + const retryTranscript = () => void runAnalysis(url || selected?.url || ''); const statusText = transcriptWorking ? `Working · ${elapsed}s — ${transcriptStage.label}. ${studioTranscriptEtaLabel(elapsed)}` : `${studioStatusLabel(quality, runState)} — ${message || studioStatusMessage(quality, runState, 'Analysis', false)}`; @@ -1024,7 +1051,7 @@ export default function OneLoopStudio({ + ) : null} )} diff --git a/apps/web/src/components/studio/__tests__/StudioIdeOutput.playflow.test.tsx b/apps/web/src/components/studio/__tests__/StudioIdeOutput.playflow.test.tsx index 19f248f59..3afb96b02 100644 --- a/apps/web/src/components/studio/__tests__/StudioIdeOutput.playflow.test.tsx +++ b/apps/web/src/components/studio/__tests__/StudioIdeOutput.playflow.test.tsx @@ -1,9 +1,11 @@ // @vitest-environment jsdom import React from 'react'; -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; -import { afterEach, describe, expect, it } from 'vitest'; +import { webcrypto } from 'node:crypto'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import StudioIdeOutput from '@/components/studio/StudioIdeOutput'; import type { Video } from '@/store/dashboard-types'; +import { reviewPackFixture } from '@/test/grounded-spec-fixture'; const baseVideo: Video = { id: 'row-output', @@ -16,9 +18,36 @@ const baseVideo: Video = { afterEach(() => { cleanup(); + vi.unstubAllGlobals(); }); describe('StudioIdeOutput playflow', () => { + it('shows source-bound tasks and their export on the default tab (synthetic fixture)', async () => { + vi.stubGlobal('crypto', webcrypto); + const pack = reviewPackFixture(); + render( + , + ); + + expect(screen.getByRole('tab', { name: 'Tasks' }).getAttribute('aria-selected')).toBe('true'); + expect(await screen.findByText('Mark a task complete')).toBeTruthy(); + expect(screen.getAllByRole('link').some((link) => + link.getAttribute('href') === 'https://www.youtube.com/watch?v=auJzb1D-fag&t=4s')).toBe(true); + const exportTasks = await screen.findByRole('button', { name: 'Export tasks' }); + await waitFor(() => expect(exportTasks.hasAttribute('disabled')).toBe(false)); + expect(screen.getAllByText(/not run/i).length).toBeGreaterThan(0); + }); + it('renders pack-named stack checks as skip/unknown rows that do not look like a hold', () => { render( { it('softens reviewed/durable overclaim copy and pads the sticky footer', () => { expect(STUDIO_PRODUCT_TAGLINE.toLowerCase()).not.toMatch(/reviewed actions|durable workflows/); expect(STUDIO_PRODUCT_TAGLINE).toMatch(/video pack/i); + expect( + studioPasteOutcomeMessage({ hasUsableTranscript: true }).toLowerCase(), + ).not.toContain('run tools'); const footer = readFileSync(join(process.cwd(), 'src/components/Footer.tsx'), 'utf8'); const studio = readFileSync(join(process.cwd(), 'src/components/OneLoopStudio.tsx'), 'utf8'); @@ -701,7 +704,7 @@ describe('studio-pipeline-status', () => { expect(output).toContain('stackChecks'); expect(studio).toMatch(/stackCheckStatus\(/); expect(studio).toMatch(/stackCheckStatusLabel\(/); - expect(studio).toMatch(/stackCheckStatus\([^)]*'anonymous'/); + expect(studio).toMatch(/stackCheckStatus\([^)]*viewer/); expect(studio).not.toMatch(/!canAttemptBuildLive\s*\|\|\s*.*holdReason/); expect(studio).not.toContain('studio-deploy-button'); diff --git a/apps/web/src/lib/__tests__/uvai-surface-unification.test.ts b/apps/web/src/lib/__tests__/uvai-surface-unification.test.ts index f9c38574b..244227de0 100644 --- a/apps/web/src/lib/__tests__/uvai-surface-unification.test.ts +++ b/apps/web/src/lib/__tests__/uvai-surface-unification.test.ts @@ -76,9 +76,10 @@ describe('UVAI is one product surface', () => { expect(studio).not.toMatch(/router\.push\(['"`]\/dashboard/); }); - it('does not hold anonymous Deploy behind non-runnable stack checks', () => { + it('uses the actual viewer for stack-check holds on the Studio page', () => { const studio = readSource('components/OneLoopStudio.tsx'); - expect(studio).toContain("deployHoldReason(linkedSop, completedChecks, 'anonymous')"); + expect(studio).toContain("const viewer = session?.user ? 'signed-in' : 'anonymous'"); + expect(studio).toContain('deployHoldReason(linkedSop, completedChecks, viewer)'); expect(studio).toContain('stackCheckStatus'); expect(studio).toContain('stackCheckStatusLabel'); expect(studio).toContain('studioShipCta'); diff --git a/apps/web/src/lib/studio-pipeline-status.ts b/apps/web/src/lib/studio-pipeline-status.ts index c65dd41df..20d4fdc32 100644 --- a/apps/web/src/lib/studio-pipeline-status.ts +++ b/apps/web/src/lib/studio-pipeline-status.ts @@ -276,7 +276,7 @@ export function studioPasteOutcomeMessage(input: { packCitation?: string | null; }): string { if (input.hasUsableTranscript) { - return 'Ready — run tools, export, or save from this page.'; + return 'Ready — act, export, or save from this page.'; } if (input.packCitation) { return `Identity pack ${input.packCitation}. No usable transcript.`; diff --git a/apps/web/src/lib/studio-playflow-PLAN.md b/apps/web/src/lib/studio-playflow-PLAN.md index 992df26f9..ad7ba20fd 100644 --- a/apps/web/src/lib/studio-playflow-PLAN.md +++ b/apps/web/src/lib/studio-playflow-PLAN.md @@ -1,26 +1,12 @@ -# TASK: Rewire leftover Studio playflow onto Ship this +# Studio playflow integration -## 1. Goal & Scope -* **Objective:** Keep #2437 **Ship this** and re-render the leftover B/E/G helpers the IDE still left unused: honest Events empty copy + pack workbench when `events[]` is empty. -* **Context:** A/#1692 and B–G helpers landed in September. #2437 restored Ship this, transcript retry, stack-check skip/unknown, and review_action cards. Live pack GET for pBsT6v-ciO8 has transcript + architecture + artifacts + stack.tools and **no `events` field**. UI must label that as **Events**, never INSIGHTs. No G.A.T.E. No fake events. No ClipToAction. -* **Scope:** - * `apps/web/src/components/OneLoopStudio.tsx` — pass `studioEventsEmptyMessage` + `PackWorkbench` when `studioPromotePackWorkbench` - * `apps/web/src/components/studio/StudioIdeOutput.tsx` — Events empty honesty + pack workbench; keep Ship this / reviewActions / stackCheckViews from #2437 - * `apps/web/src/lib/__tests__/studio-pipeline-status.test.ts` — lock Events≠INSIGHTs + workbench wiring - * *Initial check:* Do not restore `studio-deploy-button`, origin deploy kickoff, fake events, or G.A.T.E. contract changes. +Integrate PR #2439 with main `512a2558f73d6281b6a880548bedf140b7e30292`. -## 2. Execution Plan -- [x] Rebase onto origin/main after #2437 (clears DIRTY + stale required closer) -- [x] Events tab uses `studioEventsEmptyMessage` + pack workbench -- [x] Keep Ship this, transcript retry, skip/unknown stack checks, review_action cards -- [x] Focused Vitest green +- Preserve Ship this ZIP export, honest Events, named tools and a single pack workbench. +- Keep Tasks as the default output, source-bound task export and inspectable proposal inputs. +- Restore transcript-tab retry without removing the toolbar retry. +- Keep `agentWorkflowUi` off by default. Review this run only reads proposals from the selected completed, stored-pack run. It does not start a new workflow or execute a proposed tool. +- Scope pending and completed review results to the exact selected row, run and pack identity; discard obsolete async results. +- Show stack-check status for the authenticated viewer. -## 3. Definition of Done (Success Verification) -* **Expected Outcome:** Anonymous Studio can click **Ship this**. Events tab says this pack has no extracted events (not INSIGHTs) and can show the pack workbench. Stack checks stay skip/unknown and do not hold ship. Transcript retry and review cards remain visible. -* **Verification Method:** `cd apps/web && npx vitest run src/lib/__tests__/studio-pipeline-status.test.ts src/lib/__tests__/official-templates.test.ts src/lib/__tests__/uvai-surface-unification.test.ts src/components/__tests__/OneLoopStudio.three-panel.test.tsx src/components/studio/__tests__/StudioIdeOutput.playflow.test.tsx` -* **Proof Artifact:** `cd apps/web && npx vitest run src/lib/__tests__/studio-pipeline-status.test.ts src/lib/__tests__/official-templates.test.ts src/lib/__tests__/uvai-surface-unification.test.ts src/components/__tests__/OneLoopStudio.three-panel.test.tsx src/components/studio/__tests__/StudioIdeOutput.playflow.test.tsx` → **5 files, 51 passed**. - -## 4. Post-Task Reflection -* **What was done:** Rebased #2436 onto #2437. Wired honest Events empty copy and PackWorkbench into the IDE without dropping Ship this. -* **Why it was needed:** The required closer check was a stale FAILURE on the old SHA, and main had moved (CONFLICTING/DIRTY). -* **How it was tested:** Focused Vitest after rebase. Required closer then failed because #2435 closed when #2437 merged; remaining E/G lives on open #2440 (Act CTA + pricing copy). +Application tests and hosted checks must cover the resulting integration commit. Synthetic fixtures do not establish real-video acceptance. This review branch has automatic Vercel deployments disabled. Merge, preview deployment and production promotion remain separate gates. diff --git a/apps/web/vercel.json b/apps/web/vercel.json new file mode 100644 index 000000000..08e3d6b0f --- /dev/null +++ b/apps/web/vercel.json @@ -0,0 +1,7 @@ +{ + "git": { + "deploymentEnabled": { + "cursor/studio-playflow-ide-ctas-1805": false + } + } +} diff --git a/vercel.json b/vercel.json index d77369556..b342f29fa 100644 --- a/vercel.json +++ b/vercel.json @@ -7,7 +7,10 @@ { "source": "/(.*)", "has": [ - { "type": "host", "value": "event-relay-web.vercel.app" } + { + "type": "host", + "value": "event-relay-web.vercel.app" + } ], "destination": "https://uvai.io/$1", "permanent": true @@ -15,10 +18,18 @@ { "source": "/(.*)", "has": [ - { "type": "host", "value": "v0-uvai.vercel.app" } + { + "type": "host", + "value": "v0-uvai.vercel.app" + } ], "destination": "https://uvai.io/$1", "permanent": true } - ] + ], + "git": { + "deploymentEnabled": { + "cursor/studio-playflow-ide-ctas-1805": false + } + } }