Repository navigation
fix(web): restore Studio IDE playflow CTAs after rebuild #2439
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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), | ||
|
|
@@ -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, | ||
|
|
@@ -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 || '')} | ||
| 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()} | ||
|
|
@@ -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} | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For an empty-events pack that contains 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); | ||
|
|
||
| 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); | ||
| }); | ||
| }); |
| 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); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With
agentWorkflowUienabled, merely typing a valid URL (or choosing Sample) enables this button even whenselectedis undefined;act()then submits a URL-only payload directly tostartVideoToActions, bypassingrunAnalysis'semitVideoPackand 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 👍 / 👎.