Skip to content
Open
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
60 changes: 50 additions & 10 deletions apps/web/src/components/OneLoopStudio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,11 @@ export default function OneLoopStudio({
setMessage('Cancelling analysis…');
}, []);

const retryTranscript = () => {
const next = (selected?.url || url).trim();
if (next) void runAnalysis(next);
};

useEffect(() => {
applyStudioQueryAutoStart({
query: studioQueryFromSearchParams(searchParams),
Expand Down Expand Up @@ -570,6 +575,17 @@ export default function OneLoopStudio({
artifactCount: packFormation.artifacts.length,
toolCount: packFormation.tools.length,
});
const eventsEmptyMessage = selected
? studioEventsEmptyMessage({
busy: transcriptWorking,
hasCompletedRun: selected.status !== 'processing',
eventCount,
hasTranscript: Boolean(selected.transcript?.trim()),
hasArchitecture: Boolean(packFormation.architecture),
artifactCount: packFormation.artifacts.length,
toolCount: packFormation.tools.length,
})
: undefined;
const hasPayload = studioCanExport({
transcript: selected?.transcript,
eventCount,
Expand Down Expand Up @@ -1033,6 +1049,27 @@ export default function OneLoopStudio({
</div>
) : null}
<div className="flex items-center gap-1.5" role="toolbar" aria-label="Pack actions">
{showTranscriptRetry ? (
<button
type="button"
onClick={retryTranscript}
data-testid="studio-transcript-retry"
className="uvai-btn"
>
Retry transcript
</button>
) : null}
{showAgentWorkflowUi ? (
<button
type="button"
onClick={() => void act()}
disabled={actBusy || busy || !getYouTubeId(url || selected?.url || '')}
Comment on lines +1061 to +1065

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 Require an analyzed run before enabling Act

With agentWorkflowUi enabled, merely typing a valid URL (or choosing Sample) enables this button even when selected is undefined; act() then submits a URL-only payload directly to startVideoToActions, bypassing runAnalysis's emitVideoPack and stored-pack path and producing actions without the hashed Video Pack the Studio workflow requires. Gate the CTA on a matching analyzed selection rather than a parseable URL alone.

AGENTS.md reference: AGENTS.md:L8-L14

Useful? React with 👍 / 👎.

data-testid="studio-act-button"
className="uvai-btn disabled:opacity-40"
>
{actBusy ? 'Acting…' : 'Act on this run'}
</button>
) : null}
<button
type="button"
onClick={() => void exportPkg()}
Expand Down Expand Up @@ -1060,16 +1097,6 @@ export default function OneLoopStudio({
>
{buildBusy ? 'Building…' : 'Build live'}
</button>
{showTranscriptRetry ? (
<button
type="button"
data-testid="studio-transcript-retry"
onClick={() => void runAnalysis(url || selected?.url || '')}
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 @@ -1145,6 +1172,19 @@ export default function OneLoopStudio({
outputPane={
<StudioIdeOutput
video={selected}
eventsEmptyMessage={eventsEmptyMessage}
packWorkbench={
promotePack ? (
<PackWorkbench
architecture={packFormation.architecture}
artifacts={packFormation.artifacts}
onExport={() => void exportPkg()}
Comment on lines +1174 to +1178

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 Render tools in the promoted pack workbench

For an empty-events pack that contains stack.tools but no architecture or artifacts, studioPromotePackWorkbench still promotes this panel and the empty-state copy says the stack is shown below, but only architecture and artifacts are passed, leaving an otherwise blank “From this pack” card with an Export button. Pass the tools into the workbench (or direct the copy to the Tools tab) so tool-only packs retain their advertised pack-quality content.

AGENTS.md reference: AGENTS.md:L34-L39

Useful? React with 👍 / 👎.

canExport={hasPayload}
/>
) : undefined
}
showTranscriptRetry={showTranscriptRetry}
onRetryTranscript={retryTranscript}
stackChecks={stackCheckViews}
reviewActions={(workflowActions?.actions ?? []).map((action) => {
const card = studioActionCard(action);
Expand Down
148 changes: 148 additions & 0 deletions apps/web/src/components/__tests__/OneLoopStudio.playflow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// @vitest-environment jsdom
import React from 'react';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import OneLoopStudio from '@/components/OneLoopStudio';
import { useDashboardStore, type Video } from '@/store/dashboard-store';

vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
usePathname: () => '/studio',
}));
vi.mock('@/components/Nav', () => ({ default: () => null }));
vi.mock('@/app/studio/actions', () => ({ openGitHubPrsForApprovedSpecs: vi.fn() }));
vi.mock('@/lib/use-youtube-player', () => ({
useYouTubePlayer: () => ({
containerRef: { current: null },
ready: true,
failed: false,
seekTo: vi.fn(),
}),
}));

const dogfoodVideo: Video = {
id: 'pBsT6v-ciO8',
title: 'AI gold rushes',
url: 'https://www.youtube.com/watch?v=pBsT6v-ciO8',
status: 'complete',
progress: 100,
transcript:
'What do you think are the three mini gold rushes happening inside AI right now that no one is talking about? SeaDance 2.5 and Cluely Desktop.',
events: [],
};

beforeEach(() => {
vi.stubGlobal('React', React);
vi.spyOn(useDashboardStore.persist, 'rehydrate').mockImplementation(() => undefined);
useDashboardStore.setState({ videos: [], selectedVideoId: null });
});

afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

describe('OneLoopStudio playflow CTAs after the IDE rebuild', () => {
it('shows Retry transcript when analysis failed and does not leave a silent wait', async () => {
const { reviewPackFixture } = await import('@/test/grounded-spec-fixture');
const pack = reviewPackFixture();
useDashboardStore.setState({
videos: [
{
...dogfoodVideo,
status: 'failed',
transcript: '',
failure: {
stage: 'analysis',
message: 'Captions were not available.',
retryable: true,
failedAt: '2026-10-17T00:00:00.000Z',
},
videoPack: {
packId: pack.id,
videoId: 'pBsT6v-ciO8',
sourceUrl: dogfoodVideo.url,
sourceHash: pack.provenance.source_hash,
version: pack.version,
pack: { ...pack, video_id: 'pBsT6v-ciO8', source_url: dogfoodVideo.url },
},
},
],
selectedVideoId: dogfoodVideo.id,
});

render(<OneLoopStudio showAgentWorkflowUi />);

expect(screen.getByTestId('studio-transcript-retry').textContent).toMatch(/retry transcript/i);
expect(screen.getByTestId('studio-ide-status').textContent.toLowerCase()).not.toMatch(
/reviewed actions|durable workflows/,
);
});

it('keeps Export and Build live enabled for anonymous SeaDance/Cluely packs with empty events[]', async () => {
const { reviewPackFixture } = await import('@/test/grounded-spec-fixture');
const pack = reviewPackFixture();
pack.architecture = {
summary: 'Five-stage monetization pipeline.',
stages: [{ id: 'stage_1', name: 'Target Lead Extraction' }],
mermaid: null,
};
pack.artifacts = [
{
path_hint: 'interfaces/agency_pipeline.ts',
purpose: 'Lead and retainer contracts.',
interface: 'export interface AgencyClientPipeline {}',
},
{
path_hint: 'interfaces/cluely_architecture.ts',
purpose: 'Micro-app factory.',
interface: 'export interface ProductSuiteManager {}',
},
];
pack.stack = {
tools: [
{ name: 'SeaDance 2.5', check: 'seadance --version' },
{ name: 'Cluely Desktop', check: 'curl -I https://cluely.com' },
],
};

useDashboardStore.setState({
videos: [
{
...dogfoodVideo,
videoPack: {
packId: pack.id,
videoId: 'pBsT6v-ciO8',
sourceUrl: dogfoodVideo.url,
sourceHash: pack.provenance.source_hash,
version: pack.version,
pack: { ...pack, video_id: 'pBsT6v-ciO8', source_url: dogfoodVideo.url },
},
},
],
selectedVideoId: dogfoodVideo.id,
});

render(<OneLoopStudio showAgentWorkflowUi />);

expect(screen.getByTestId('studio-ide-ship').hasAttribute('disabled')).toBe(false);
expect(screen.getByTestId('studio-ide-ship').textContent).toMatch(/ship this/i);
expect(screen.getByTestId('studio-build-live-button').hasAttribute('disabled')).toBe(false);
expect(screen.getByTestId('studio-act-button').textContent).toMatch(/act on this run/i);

expect(screen.getByTestId('studio-ide-output-events').textContent).toMatch(/no extracted events/i);
expect(screen.getByTestId('studio-ide-output-events').textContent.toLowerCase()).not.toContain(
'insights',
);
expect(screen.getByTestId('pack-workbench')).toBeTruthy();
expect(screen.getByTestId('pack-architecture').textContent).toMatch(/five-stage/i);

fireEvent.click(screen.getByTestId('studio-ide-output-tab-tools'));
const tools = screen.getByTestId('studio-ide-output-tools');
expect(tools.textContent).toMatch(/SeaDance/i);
expect(tools.textContent).toMatch(/Cluely/i);
expect(tools.textContent).toMatch(/cannot run this check/i);
});
});
129 changes: 129 additions & 0 deletions apps/web/src/components/__tests__/StudioIdeOutput.playflow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// @vitest-environment jsdom
import React from 'react';
import { cleanup, fireEvent, render, screen } 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 { studioEventsEmptyMessage } from '@/lib/studio-pipeline-status';

const emptyEventsVideo: Video = {
id: 'pBsT6v-ciO8',
title: 'AI gold rushes',
url: 'https://www.youtube.com/watch?v=pBsT6v-ciO8',
status: 'complete',
progress: 100,
transcript: 'SeaDance 2.5 and Cluely Desktop from the video.',
events: [],
};

afterEach(() => {
cleanup();
});

describe('StudioIdeOutput playflow (B/E/F + events vs Insights)', () => {
it('labels the Events tab Events, never Insights, and tells the truth when events[] is empty', () => {
const emptyMessage = studioEventsEmptyMessage({
busy: false,
hasCompletedRun: true,
eventCount: 0,
hasArchitecture: true,
artifactCount: 2,
toolCount: 4,
});

render(
<StudioIdeOutput
video={emptyEventsVideo}
eventsEmptyMessage={emptyMessage}
packWorkbench={<div data-testid="pack-workbench">Architecture from this pack</div>}
/>,
);

expect(screen.getByTestId('studio-ide-output-tab-events').textContent).toBe('Events');
expect(screen.queryByRole('tab', { name: /insights/i })).toBeNull();
expect(screen.getByTestId('studio-ide-output-events').textContent).toMatch(/no extracted events/i);
expect(screen.getByTestId('studio-ide-output-events').textContent.toLowerCase()).not.toContain(
'insights',
);
expect(screen.getByTestId('pack-workbench').textContent).toMatch(/architecture/i);
});

it('shows skip/unknown stack-check labels so SeaDance/Cluely do not look like a production hold', () => {
render(
<StudioIdeOutput
video={emptyEventsVideo}
stackChecks={[
{
id: 'chk_seadance',
title: 'Use SeaDance 2.5 as named in the video',
status: 'unknown',
statusLabel: 'Unknown — UVAI cannot run this check',
holdsShip: false,
},
{
id: 'chk_cluely',
title: 'Use Cluely Desktop 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 tools = screen.getByTestId('studio-ide-output-tools');
expect(tools.textContent).toMatch(/SeaDance/i);
expect(tools.textContent).toMatch(/Cluely/i);
expect(tools.textContent).toMatch(/cannot run this check/i);
expect(tools.textContent.toLowerCase()).not.toMatch(/held until/);
});

it('renders review_action as a card with a clear CTA, not Run tools → review_action', () => {
render(
<StudioIdeOutput
video={emptyEventsVideo}
reviewActions={[
{
tool: 'review_action',
status: 'proposed',
result: 'Check the pack evidence before you ship.',
},
]}
/>,
);

fireEvent.click(screen.getByTestId('studio-ide-output-tab-intent'));
const intent = screen.getByTestId('studio-ide-output-intent');
expect(intent.textContent).toMatch(/review this result/i);
expect(intent.textContent).toMatch(/needs review/i);
expect(intent.textContent).toContain('Check the pack evidence before you ship.');
expect(intent.textContent.toLowerCase()).not.toContain('run tools');
expect(intent.textContent).not.toMatch(/review_action/);
});

it('offers Retry transcript in the Transcript tab when the run failed', () => {
const onRetry = vi.fn();
render(
<StudioIdeOutput
video={{
...emptyEventsVideo,
status: 'failed',
transcript: '',
failure: {
stage: 'analysis',
message: 'Captions were not available.',
retryable: true,
failedAt: '2026-10-17T00:00:00.000Z',
},
}}
showTranscriptRetry
onRetryTranscript={onRetry}
/>,
);

fireEvent.click(screen.getByTestId('studio-ide-output-tab-transcript'));
fireEvent.click(screen.getByTestId('studio-transcript-retry'));
expect(onRetry).toHaveBeenCalledTimes(1);
});
});
Loading
Loading