Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 39 additions & 7 deletions apps/web/src/components/OneLoopStudio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
pickOfficialTemplate,
stackCheckStatus,
stackCheckStatusLabel,
studioStackCheckView,
} from '@/lib/official-templates';
import { clsx } from 'clsx';
import Nav from '@/components/Nav';
Expand Down Expand Up @@ -76,6 +77,7 @@ import {
studioPlayerPhase,
studioPromotePackWorkbench,
studioRunQuality,
studioShipCta,
studioStatusLabel,
studioStatusMessage,
studioTranscriptBody,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1024,12 +1035,14 @@ export default function OneLoopStudio({
<div className="flex items-center gap-1.5" role="toolbar" aria-label="Pack actions">
<button
type="button"
onClick={exportPkg}
disabled={!hasPayload}
data-testid="studio-ide-export"
className="uvai-btn disabled:opacity-40"
onClick={() => void exportPkg()}
disabled={!shipCta.enabled}
data-testid="studio-ide-ship"
title={shipCta.hint}
Comment on lines +1038 to +1041

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Route anonymous Ship through an accessible export

For the anonymous users targeted by this change, this handler still calls downloadScaffoldPackage, which posts to /api/workspace/export; that route invokes requireProFeatureAccess, and its route test confirms non-Pro callers receive a 402 checkout response rather than a ZIP. Consequently the newly enabled primary CTA and its “Download the Video Pack ZIP” hint do not deliver the stated anonymous Ship flow. Either use an export endpoint anonymous users may access or present this as a Pro checkout action.

Useful? React with 👍 / 👎.

className="uvai-btn uvai-btn-primary inline-flex items-center gap-1.5 disabled:opacity-40"
>
Export
<Rocket className="h-4 w-4" aria-hidden />
Ship this
</button>
<button
type="button"
Expand All @@ -1043,10 +1056,20 @@ export default function OneLoopStudio({
? 'Verify pack health and open the hosted app, or get recovery steps if the pack is missing.'
: 'Paste a YouTube URL and run analysis first.'
}
className="uvai-btn uvai-btn-primary disabled:opacity-40"
className="uvai-btn disabled:opacity-40"
>
{buildBusy ? 'Building…' : 'Build live'}
</button>
{showTranscriptRetry ? (
<button
type="button"
data-testid="studio-transcript-retry"
onClick={() => void runAnalysis(url || selected?.url || '')}
Comment on lines +1063 to +1067

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retry the failed selection instead of edited input

When a failed video is selected but the user has edited the URL field, url wins over selected.url, so clicking “Retry transcript” analyzes the newly typed video rather than retrying the failed transcript shown on screen. The existing recovery paths intentionally prefer selected?.url; this handler should do the same so the retry remains scoped to the failure that caused the button to appear.

Useful? React with 👍 / 👎.

className="uvai-btn"
>
Retry transcript
</button>
) : null}
</div>
<p className="w-full font-mono text-[11px]" style={{ color: 'var(--uvai-ink-faint)' }} role="status" data-testid="studio-ide-status">
{statusText}
Expand Down Expand Up @@ -1122,6 +1145,15 @@ export default function OneLoopStudio({
outputPane={
<StudioIdeOutput
video={selected}
stackChecks={stackCheckViews}
reviewActions={(workflowActions?.actions ?? []).map((action) => {
const card = studioActionCard(action);
Comment on lines +1148 to +1150

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add a reachable path that populates review actions

These cards cannot appear in the current Studio UI: workflowActions starts as null and is only populated inside act(), but a repo-wide search of this component finds no button or other caller that invokes act. Passing that unreachable state here therefore does not restore review_action cards for users, despite the isolated child-component test; wire the existing Act workflow into the IDE or populate the cards from a reachable result source.

Useful? React with 👍 / 👎.

return {
tool: action.tool,
status: action.status,
result: action.result ?? card.detail,
};
})}
specReview={
selected?.videoPack?.pack ? (
<GroundedSpecReview
Expand Down Expand Up @@ -1149,7 +1181,7 @@ export default function OneLoopStudio({
data-testid="studio-export-toast"
role={exportToast.tone === 'error' ? 'alert' : 'status'}
className={clsx(
'fixed bottom-20 left-1/2 z-40 w-[min(36rem,calc(100%-2rem))] -translate-x-1/2 rounded-lg border px-4 py-3 text-sm shadow-lg',
'fixed bottom-4 left-1/2 z-40 w-[min(36rem,calc(100%-2rem))] -translate-x-1/2 rounded-lg border px-4 py-3 text-sm shadow-lg',
exportToast.tone === 'success'
? 'border-amber-300 bg-amber-50 text-amber-800'
: 'border-red-300 bg-red-50 text-red-800',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ describe('OneLoopStudio IDE shell', () => {
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();
});

Expand Down Expand Up @@ -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(<OneLoopStudio showAgentWorkflowUi={false} />);
expect(screen.getByTestId('studio-transcript-retry')).toBeTruthy();
});

it('shows a cancel button while a run is in flight', async () => {
Expand Down
99 changes: 82 additions & 17 deletions apps/web/src/components/studio/StudioIdeOutput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<OutputSection>('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 ?? [];
Expand Down Expand Up @@ -141,7 +159,7 @@ export default function StudioIdeOutput({

{active === 'tools' && (
<div data-testid="studio-ide-output-tools" className="flex flex-col gap-3">
{toolNames.length === 0 && !linkedSop?.steps.length ? (
{toolNames.length === 0 && !linkedSop?.steps.length && stackChecks.length === 0 ? (
<EmptyNote text="No tools named in this video yet." />
) : (
<>
Expand All @@ -160,6 +178,28 @@ export default function StudioIdeOutput({
</ul>
</div>
) : null}
{stackChecks.length > 0 ? (
<div className="uvai-card p-4" data-testid="studio-stack-checks">
<p className="uvai-section-label mb-3">Stack checks</p>
<ul className="flex flex-col gap-2">
{stackChecks.map((check) => (
<li
key={check.id}
data-testid={`studio-stack-check-${check.id}`}
className="uvai-doc-row"
>
<span className="uvai-doc-icon" aria-hidden>
{check.status === 'done' ? '✓' : check.status === 'pending' ? '•' : '?'}
</span>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-slate-800">{check.title}</p>
<p className="text-xs text-slate-400">{check.statusLabel}</p>
</div>
</li>
))}
</ul>
</div>
) : null}
{linkedSop?.steps?.length ? (
<div className="uvai-card p-4">
<p className="uvai-section-label mb-3">SOP steps</p>
Expand All @@ -183,24 +223,49 @@ export default function StudioIdeOutput({

{active === 'intent' && (
<ul className="flex flex-col gap-3" data-testid="studio-ide-output-intent">
{actionItems.length === 0 ? (
{reviewActions.length === 0 && actionItems.length === 0 ? (
<EmptyNote text="No action items extracted yet — what should the viewer do or make from this video?" />
) : (
actionItems.map((a, i) => (
<li key={a.id ?? i} className="uvai-card border-l-4 !border-l-neutral-900 p-4">
<div className="flex items-start gap-3">
<span className="uvai-metric-icon mt-0.5 shrink-0">
<ArrowIcon />
</span>
<div className="min-w-0">
<p className="text-sm font-semibold text-slate-900">{a.title}</p>
{a.description ? (
<p className="mt-1 text-xs leading-relaxed text-slate-500">{a.description}</p>
) : null}
<>
{reviewActions.map((action, i) => {
const card = studioActionCard(action);
return (
<li
key={`${action.tool}-${i}`}
data-testid="studio-review-action-card"
className="uvai-card border-l-4 !border-l-amber-500 p-4"
>
<div className="flex items-start gap-3">
<span className="uvai-metric-icon mt-0.5 shrink-0">
<ArrowIcon />
</span>
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-wide text-amber-700">
{card.statusLabel}
</p>
<p className="mt-1 text-sm font-semibold text-slate-900">{card.title}</p>
<p className="mt-1 text-xs leading-relaxed text-slate-500">{card.detail}</p>
</div>
</div>
</li>
);
})}
{actionItems.map((a, i) => (
<li key={a.id ?? i} className="uvai-card border-l-4 !border-l-neutral-900 p-4">
<div className="flex items-start gap-3">
<span className="uvai-metric-icon mt-0.5 shrink-0">
<ArrowIcon />
</span>
<div className="min-w-0">
<p className="text-sm font-semibold text-slate-900">{a.title}</p>
{a.description ? (
<p className="mt-1 text-xs leading-relaxed text-slate-500">{a.description}</p>
) : null}
</div>
</div>
</div>
</li>
))
</li>
))}
</>
)}
</ul>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<StudioIdeOutput
video={baseVideo}
stackChecks={[
{
id: 'chk_tool_seadance',
title: 'Use SeaDance 2.5 as named in the video',
status: 'unknown',
statusLabel: 'Unknown — UVAI cannot run this check',
holdsShip: false,
},
]}
/>,
);
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(
<StudioIdeOutput
video={baseVideo}
reviewActions={[
{ tool: 'review_action', status: 'proposed', result: 'Review evidence' },
]}
/>,
);
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/);
});
});
8 changes: 8 additions & 0 deletions apps/web/src/lib/__tests__/official-templates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
stackCheckItems,
stackCheckStatus,
stackCheckStatusLabel,
studioStackCheckView,
} from '@/lib/official-templates';

/** Live pBsT6v-ciO8 pack.stack.tools — none are runnable in UVAI. */
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand Down
Loading
Loading