diff --git a/apps/web/src/components/OneLoopStudio.tsx b/apps/web/src/components/OneLoopStudio.tsx index c418a40d1..eebb07129 100644 --- a/apps/web/src/components/OneLoopStudio.tsx +++ b/apps/web/src/components/OneLoopStudio.tsx @@ -11,6 +11,7 @@ import { pickOfficialTemplate, stackCheckStatus, stackCheckStatusLabel, + studioStackCheckView, } from '@/lib/official-templates'; import { clsx } from 'clsx'; import Nav from '@/components/Nav'; @@ -76,6 +77,7 @@ import { studioPlayerPhase, studioPromotePackWorkbench, studioRunQuality, + studioShipCta, studioStatusLabel, studioStatusMessage, studioTranscriptBody, @@ -406,6 +408,14 @@ export default function OneLoopStudio({ const holdReason = deployHoldReason(linkedSop, completedChecks, 'anonymous'); const officialTemplate = pickOfficialTemplate(linkedSop); + const stackCheckViews = stackChecks.map((item) => { + const status = stackCheckStatus(item, completedChecks, 'anonymous'); + return { + ...studioStackCheckView(item, completedChecks, 'anonymous'), + status, + statusLabel: stackCheckStatusLabel(status), + }; + }); useEffect(() => { useDashboardStore.persist.rehydrate(); @@ -569,6 +579,7 @@ export default function OneLoopStudio({ hasLinkedSopSteps: Boolean(linkedSop?.steps.length), hasProjectScaffold: Boolean(selected?.insights?.project_scaffold), }); + const shipCta = studioShipCta({ canExport: hasPayload, holdReason }); const runState = busy ? 'working' : selected ? 'ready' : 'idle'; const quality = studioRunQuality( selected?.jobId ? { ok: true, status: 200, jobId: selected.jobId } : null, @@ -1024,12 +1035,14 @@ export default function OneLoopStudio({
+ {showTranscriptRetry ? ( + + ) : null}

{statusText} @@ -1122,6 +1145,15 @@ export default function OneLoopStudio({ outputPane={ { + const card = studioActionCard(action); + return { + tool: action.tool, + status: action.status, + result: action.result ?? card.detail, + }; + })} specReview={ selected?.videoPack?.pack ? ( { expect(screen.getByTestId('studio-ide-splitter-right')).toBeTruthy(); // Toolbar carries the run + pack actions. expect(screen.getByTestId('studio-ide-run')).toBeTruthy(); - expect(screen.getByTestId('studio-ide-export')).toBeTruthy(); + expect(screen.getByTestId('studio-ide-ship')).toBeTruthy(); + expect(screen.getByTestId('studio-ide-ship').textContent).toMatch(/Ship this/); expect(screen.getByTestId('studio-build-live-button')).toBeTruthy(); }); @@ -92,6 +93,28 @@ describe('OneLoopStudio IDE shell', () => { expect(screen.getByTestId('studio-ide-output-tab-tools')).toBeTruthy(); expect(screen.getByTestId('studio-ide-output-tab-intent')).toBeTruthy(); expect(screen.getByTestId('studio-ide-output-tab-signals')).toBeTruthy(); + expect(screen.getByTestId('studio-ide-ship')).toBeTruthy(); + expect((screen.getByTestId('studio-ide-ship') as HTMLButtonElement).disabled).toBe(false); + }); + + it('shows transcript retry when analysis failed and is retryable', () => { + useDashboardStore.setState({ + videos: [ + { + ...baseVideo, + status: 'failed', + failure: { + stage: 'analysis', + message: 'Gemini 3.8 Flash returned no extracted spec content.', + retryable: true, + failedAt: '2026-10-17T00:00:00.000Z', + }, + }, + ], + selectedVideoId: baseVideo.id, + }); + render(); + expect(screen.getByTestId('studio-transcript-retry')).toBeTruthy(); }); it('shows a cancel button while a run is in flight', async () => { diff --git a/apps/web/src/components/studio/StudioIdeOutput.tsx b/apps/web/src/components/studio/StudioIdeOutput.tsx index 6c5ad6ddd..ae4cc5b42 100644 --- a/apps/web/src/components/studio/StudioIdeOutput.tsx +++ b/apps/web/src/components/studio/StudioIdeOutput.tsx @@ -3,6 +3,14 @@ import { useState } from 'react'; import type { Video } from '@/store/dashboard-types'; import type { ExtractedEvent } from '@/lib/types'; +import { studioActionCard } from '@/lib/studio-pipeline-status'; +import type { StudioStackCheckView } from '@/lib/official-templates'; + +export type StudioReviewActionInput = { + tool: string; + status: string; + result?: string; +}; type OutputSection = 'events' | 'lingo' | 'tools' | 'intent' | 'signals' | 'spec' | 'transcript'; @@ -34,13 +42,23 @@ const SECTIONS: Array<{ id: OutputSection; label: string }> = [ export default function StudioIdeOutput({ video, specReview, + stackChecks = [], + reviewActions = [], }: { video: Video | undefined; specReview?: React.ReactNode; + stackChecks?: StudioStackCheckView[]; + reviewActions?: StudioReviewActionInput[]; }) { const [active, setActive] = useState('events'); - const hasContent = Boolean(video?.videoPack || video?.events?.length || video?.insights); + const hasContent = Boolean( + video?.videoPack || + video?.events?.length || + video?.insights || + stackChecks.length > 0 || + reviewActions.length > 0, + ); const pack = video?.videoPack?.pack; const events: ExtractedEvent[] = video?.events ?? []; @@ -141,7 +159,7 @@ export default function StudioIdeOutput({ {active === 'tools' && (

- {toolNames.length === 0 && !linkedSop?.steps.length ? ( + {toolNames.length === 0 && !linkedSop?.steps.length && stackChecks.length === 0 ? ( ) : ( <> @@ -160,6 +178,28 @@ export default function StudioIdeOutput({
) : null} + {stackChecks.length > 0 ? ( +
+

Stack checks

+ +
+ ) : null} {linkedSop?.steps?.length ? (

SOP steps

@@ -183,24 +223,49 @@ export default function StudioIdeOutput({ {active === 'intent' && (
- - )) + + ))} + )} )} diff --git a/apps/web/src/components/studio/__tests__/StudioIdeOutput.playflow.test.tsx b/apps/web/src/components/studio/__tests__/StudioIdeOutput.playflow.test.tsx new file mode 100644 index 000000000..fa67b7c34 --- /dev/null +++ b/apps/web/src/components/studio/__tests__/StudioIdeOutput.playflow.test.tsx @@ -0,0 +1,59 @@ +// @vitest-environment jsdom +import React from 'react'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import StudioIdeOutput from '@/components/studio/StudioIdeOutput'; +import type { Video } from '@/store/dashboard-types'; + +const baseVideo: Video = { + id: 'row-output', + title: 'Playflow output', + url: 'https://www.youtube.com/watch?v=auJzb1D-fag', + status: 'complete', + progress: 100, + transcript: 'Enough transcript text for analysis ready state in Studio tests.', +}; + +afterEach(() => { + cleanup(); +}); + +describe('StudioIdeOutput playflow', () => { + it('renders pack-named stack checks as skip/unknown rows that do not look like a hold', () => { + render( + , + ); + fireEvent.click(screen.getByTestId('studio-ide-output-tab-tools')); + const row = screen.getByTestId('studio-stack-check-chk_tool_seadance'); + expect(row.textContent).toMatch(/SeaDance 2\.5/); + expect(row.textContent).toMatch(/cannot run this check/i); + expect(row.textContent).not.toMatch(/production is held/i); + }); + + it('renders review_action results as cards with status, title, and detail', () => { + render( + , + ); + fireEvent.click(screen.getByTestId('studio-ide-output-tab-intent')); + const card = screen.getByTestId('studio-review-action-card'); + expect(card.textContent).toMatch(/review this result/i); + expect(card.textContent).toMatch(/needs review/i); + expect(card.textContent).toMatch(/Review evidence/); + }); +}); diff --git a/apps/web/src/lib/__tests__/official-templates.test.ts b/apps/web/src/lib/__tests__/official-templates.test.ts index b58600bf1..8df57bcc2 100644 --- a/apps/web/src/lib/__tests__/official-templates.test.ts +++ b/apps/web/src/lib/__tests__/official-templates.test.ts @@ -7,6 +7,7 @@ import { stackCheckItems, stackCheckStatus, stackCheckStatusLabel, + studioStackCheckView, } from '@/lib/official-templates'; /** Live pBsT6v-ciO8 pack.stack.tools — none are runnable in UVAI. */ @@ -83,6 +84,9 @@ describe('deployHoldReason', () => { expect(first).toBeDefined(); expect(stackCheckStatus(first, [], 'anonymous')).toBe('skip'); expect(stackCheckStatusLabel('skip')).toMatch(/not runnable while signed out/i); + expect(studioStackCheckView(first, [], 'anonymous').holdsShip).toBe(false); + expect(studioStackCheckView(first, [], 'signed-in').holdsShip).toBe(true); + expect(studioStackCheckView(first, [first.id], 'signed-in').holdsShip).toBe(false); }); it('does not hold anonymous Deploy on pBsT6v-ciO8 seadance/cluely checks', () => { @@ -107,6 +111,10 @@ describe('deployHoldReason', () => { expect(stackCheckStatus(item, [], 'signed-in')).toBe('unknown'); } expect(stackCheckStatusLabel('unknown')).toMatch(/cannot run this check/i); + const rows = stack.map((item) => studioStackCheckView(item, [], 'anonymous')); + expect(rows.every((row) => row.status === 'unknown')).toBe(true); + expect(rows.every((row) => row.holdsShip === false)).toBe(true); + expect(rows[0]?.statusLabel).toMatch(/cannot run this check/i); }); it('does not let unknown tool checks hold a signed-in Vercel pack', () => { diff --git a/apps/web/src/lib/__tests__/studio-pipeline-status.test.ts b/apps/web/src/lib/__tests__/studio-pipeline-status.test.ts index 119c49e9a..05167ecba 100644 --- a/apps/web/src/lib/__tests__/studio-pipeline-status.test.ts +++ b/apps/web/src/lib/__tests__/studio-pipeline-status.test.ts @@ -6,6 +6,8 @@ import { studioActionCard, studioCanExport, studioCanRetryTranscript, + studioShipCta, + studioShipEnabled, studioDeployButtonLabel, studioDeployEnabledHint, studioDeployOutcomeMessage, @@ -367,10 +369,34 @@ describe('studio-pipeline-status', () => { expect(studio).toContain('studioTranscriptEtaLabel'); // IDE: transcript stage + ETA surface in the toolbar status line. expect(studio).toContain('data-testid="studio-ide-status"'); + expect(studio).toContain('data-testid="studio-transcript-retry"'); expect(output).toContain("data-testid={`studio-ide-output-tab-${s.id}`}"); expect(output).toContain("{ id: 'transcript', label: 'Transcript' }"); }); + it('enables Ship this from pack exportability and ignores stack-check hold copy', () => { + expect(studioShipEnabled({ canExport: true, holdReason: null })).toBe(true); + expect( + studioShipEnabled({ + canExport: true, + holdReason: 'Production is held until 4 stack check(s) pass. Next: Use SeaDance 2.5 as named in the video', + }), + ).toBe(true); + expect(studioShipEnabled({ canExport: false, holdReason: null })).toBe(false); + const cta = studioShipCta({ canExport: true, holdReason: 'Production is held until 4 stack check(s) pass.' }); + expect(cta.label).toBe('Ship this'); + expect(cta.enabled).toBe(true); + expect(cta.hint.toLowerCase()).toMatch(/skip|unknown/); + expect(studioShipCta({ canExport: false }).enabled).toBe(false); + + const studio = readFileSync(join(process.cwd(), 'src/components/OneLoopStudio.tsx'), 'utf8'); + expect(studio).toContain('studioShipCta'); + expect(studio).toContain('data-testid="studio-ide-ship"'); + expect(studio).toContain('Ship this'); + expect(studio).not.toContain('studio-deploy-button'); + expect(studio).not.toMatch(/disabled=\{Boolean\(holdReason\)\}/); + }); + it('does not claim Deploy completed without a verified live receipt', () => { expect( studioDeployOutcomeMessage({ @@ -543,6 +569,8 @@ describe('studio-pipeline-status', () => { const studio = readFileSync(join(process.cwd(), 'src/components/OneLoopStudio.tsx'), 'utf8'); const output = readFileSync(join(process.cwd(), 'src/components/studio/StudioIdeOutput.tsx'), 'utf8'); expect(studio).toContain('studioActionCard'); + expect(output).toContain('studioActionCard'); + expect(output).toContain('data-testid="studio-review-action-card"'); // IDE: actions surface in the output pane Intent tab. expect(output).toContain("{ id: 'intent', label: 'Intent' }"); }); @@ -571,6 +599,7 @@ describe('studio-pipeline-status', () => { expect(studio).toContain('data-testid="studio-export-toast"'); expect(studio).not.toMatch(/setExportToast\(toast\);\s*setMessage\(/); expect(studio).toMatch(/role=\{exportToast\.tone === 'error' \? 'alert' : 'status'\}/); + expect(studio).not.toContain('bottom-20'); }); it('covers the player until load and names a load error instead of silent 0:00', () => { 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 c8146e97f..29cccc1a8 100644 --- a/apps/web/src/lib/__tests__/uvai-surface-unification.test.ts +++ b/apps/web/src/lib/__tests__/uvai-surface-unification.test.ts @@ -81,6 +81,9 @@ describe('UVAI is one product surface', () => { expect(studio).toContain("deployHoldReason(linkedSop, completedChecks, 'anonymous')"); expect(studio).toContain('stackCheckStatus'); expect(studio).toContain('stackCheckStatusLabel'); + expect(studio).toContain('studioShipCta'); + expect(studio).toContain('data-testid="studio-ide-ship"'); expect(studio).not.toContain('href="/ship"'); + expect(studio).not.toContain('studio-deploy-button'); }); }); diff --git a/apps/web/src/lib/official-templates.ts b/apps/web/src/lib/official-templates.ts index d3be9b765..63c79df27 100644 --- a/apps/web/src/lib/official-templates.ts +++ b/apps/web/src/lib/official-templates.ts @@ -88,6 +88,29 @@ export function stackCheckStatusLabel(status: StackCheckStatus): string { } } +export interface StudioStackCheckView { + id: string; + title: string; + status: StackCheckStatus; + statusLabel: string; + holdsShip: boolean; +} + +export function studioStackCheckView( + item: ChecklistItem, + completedIds: Iterable, + viewer: StudioViewer = 'anonymous', +): StudioStackCheckView { + const status = stackCheckStatus(item, completedIds, viewer); + return { + id: item.id, + title: item.title, + status, + statusLabel: stackCheckStatusLabel(status), + holdsShip: status === 'pending', + }; +} + export function pickOfficialTemplate(sop: LinkedSop | null | undefined): OfficialTemplate | null { const stacks = detectedStacks(sop); if (stacks.has('vercel')) return NEXT; diff --git a/apps/web/src/lib/studio-pipeline-status.ts b/apps/web/src/lib/studio-pipeline-status.ts index c43fd886c..2bd869c7e 100644 --- a/apps/web/src/lib/studio-pipeline-status.ts +++ b/apps/web/src/lib/studio-pipeline-status.ts @@ -156,7 +156,7 @@ export function studioWorkbenchEmptyView(input: { return { title: 'Analysis in progress', description: - 'Waiting on transcript and pack storage. Export and Build live unlock when the run finishes.', + 'Waiting on transcript and pack storage. Ship this and Build live unlock when the run finishes.', primaryAction: null, }; } @@ -240,6 +240,32 @@ export function studioCanExport(input: { ); } +/** + * Anonymous Ship this is pack-exportability only. Stack-check hold copy + * (SeaDance/Cluely-class unknown, signed-out vendor skip) never disables it. + */ +export function studioShipEnabled(input: { + canExport: boolean; + holdReason?: string | null; +}): boolean { + void input.holdReason; + return input.canExport; +} + +export function studioShipCta(input: { + canExport: boolean; + holdReason?: string | null; +}): { label: 'Ship this'; enabled: boolean; hint: string } { + const enabled = studioShipEnabled(input); + return { + label: 'Ship this', + enabled, + hint: enabled + ? 'Download the Video Pack ZIP. Named tools that UVAI cannot run are skip/unknown — they do not block ship.' + : 'Run analysis first so there is a pack to ship.', + }; +} + export function studioInvalidHandoffMessage(raw: string): string { const preview = raw.trim() || 'that input'; return `Need a valid YouTube URL. "${preview}" is not a watchable video.`; diff --git a/apps/web/src/lib/studio-playflow-PLAN.md b/apps/web/src/lib/studio-playflow-PLAN.md index 8e94b8fc6..ab68c22ec 100644 --- a/apps/web/src/lib/studio-playflow-PLAN.md +++ b/apps/web/src/lib/studio-playflow-PLAN.md @@ -1,28 +1,29 @@ -# TASK: Studio playflow for empty events[] packs +# TASK: Restore Studio Ship this playflow after IDE shell ## 1. Goal & Scope -* **Objective:** Remove Studio dead ends when a live pack has transcript + architecture + artifacts + stack.tools but `events[]` is empty (`pBsT6v-ciO8` and similar). -* **Context:** Hayden flagged `https://uvai.io/studio?video=https://www.youtube.com/watch?v=pBsT6v-ciO8`. Pack 200: transcript ~1767 chars, architecture + artifacts[2] + stack.tools present, events count = 0. Do not invent fake events from keyframes/concepts. +* **Objective:** Get UVAI Studio playflow back toward ≥9/10 by restoring a discoverable **Ship this** path and rewiring leftover B/E/G helpers the IDE shell left unused. +* **Context:** Kill A (#1692) merged. B–G helpers exist (`studioTranscriptStage`, `studioActionCard`, `deployHoldReason` skip/unknown, export toast, player overlay). The 2026-10-07 IDE refactor deleted `studio-deploy-button` and stopped rendering Ship this, stack-check honesty, review_action cards, and transcript retry. Dogfood kill F still stands: no Ship this; seadance/cluely-class checks must not hold anonymous ship. * **Scope:** - * `apps/web/src/lib/studio-handoff.ts` — reconstruct unencoded `?video=https://www.youtube.com/watch?v=ID` - * `apps/web/src/lib/studio-pipeline-status.ts` — honest empty-events copy; promote pack workbench - * `apps/web/src/lib/action-surface.ts` — export architecture/artifacts when events are empty - * `apps/web/src/components/OneLoopStudio.tsx` — consume helpers; no fake events - * *Initial check:* Extend existing helpers. No Origin G.A.T.E., ClipToAction, forge, or Ship checkout. + * `apps/web/src/lib/studio-pipeline-status.ts` — `studioShipEnabled` / `studioShipCta` (exportability gates ship; holdReason never does) + * `apps/web/src/lib/official-templates.ts` — stack-check row view (skip/unknown) + * `apps/web/src/components/OneLoopStudio.tsx` — Ship this CTA, transcript retry, toast position, pass checks/actions + * `apps/web/src/components/studio/StudioIdeOutput.tsx` — stack checks + `studioActionCard` + * *Initial check:* Extend existing helpers. Do not restore `studio-deploy-button`, origin deploy kickoff, fake events, or G.A.T.E. contract changes. ## 2. Execution Plan -- [x] Failing tests: unencoded query, empty-events copy, export without events, no keyframe→event mapping -- [x] Reconstruct `video` + sibling `v` for Home/share handoff -- [x] Honest Events empty state + promote architecture/artifacts/stack -- [x] Export pack files from formation when events[] is empty -- [x] Verify focused Vitest +- [x] Failing tests for Ship this enablement, skip/unknown checks, review cards, retry, toast position +- [x] Ship this CTA enabled by `studioCanExport`, never by non-runnable stack checks +- [x] Tools tab shows honest skip/unknown; Intent tab renders `review_action` cards +- [x] Transcript retry control when `studioCanRetryTranscript` +- [x] Export toast no longer uses sticky-footer `bottom-20` +- [x] Focused Vitest ## 3. Definition of Done (Success Verification) -* **Expected Outcome:** Studio for `pBsT6v-ciO8` loads the player from `?video=`, shows transcript, honest empty Events, prominent architecture/artifacts/stack, and Export is usable. No fake events. -* **Verification Method:** `cd apps/web && npx vitest run src/lib/__tests__/studio-handoff.test.ts src/lib/__tests__/studio-pipeline-status.test.ts src/lib/__tests__/action-surface.test.ts` -* **Proof Artifact:** `npx vitest run src/lib/__tests__/studio-handoff.test.ts src/lib/__tests__/studio-pipeline-status.test.ts src/lib/__tests__/action-surface.test.ts src/lib/__tests__/uvai-surface-unification.test.ts src/lib/__tests__/home-sell-surface.test.ts` → 5 files, 43 passed. Live GET `https://uvai.io/api/video/pack?url=https://www.youtube.com/watch?v=pBsT6v-ciO8` → 200; transcript 1767 chars; architecture present; artifacts 2; stack.tools 4; no `events` field; keyframes/concepts not mapped into Events. +* **Expected Outcome:** Anonymous Studio on a seadance/cluely pack can click **Ship this** (ZIP export). Stack checks read skip/unknown and do not disable the button. No live hostname claim. Transcript retry and review cards are 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 && npm test -- 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, 49 passed**. Related: OneLoopStudio.build-live + job-strip + linked-sop → **3 files, 16 passed**. `tsc --noEmit` and eslint on touched files passed. ## 4. Post-Task Reflection -* **What was done:** Honest empty Events copy after a completed run; promoted architecture/artifacts workbench when `events[]` is empty; Export writes ARCHITECTURE.md + artifacts.json; `?video=` handoff reads unencoded watch URLs and reconstructs split `video`+`v` params. -* **Why it was needed:** Packs like `pBsT6v-ciO8` have useful formation but zero events. Studio previously left a dead “Events show up after Run.” rail and could refuse export. -* **How it was tested:** RED then GREEN Vitest for handoff, empty-state copy, export without events, and OneLoopStudio source guards (no keyframe→event mapping). Live pack GET confirmed the empty-events shape. +* **What was done:** Restored a discoverable **Ship this** toolbar CTA that downloads the pack ZIP. Anonymous/unknown SeaDance/Cluely stack checks render as skip/unknown and never disable the button. Intent tab renders `review_action` cards. Transcript retry is visible on retryable failure. Export toast sits at `bottom-4` (no sticky footer). Origin deploy button was not restored. +* **Why it was needed:** IDE shell left playflow helpers unwired; dogfood kill F was a disabled Deploy with no Ship this. +* **How it was tested:** Focused Vitest (49 + 16), type-check, eslint. No production live claim. GitHub issue create was 403 so this cut has no new issue number.