(null);
const [usedSameRun, setUsedSameRun] = useState(false);
const [buildBusy, setBuildBusy] = useState(false);
@@ -338,6 +364,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 selectedActPackKey = actPackKey(selected);
+ const scopedWorkflowActions = workflowPackKey === selectedActPackKey ? workflowActions : null;
+ const scopedActRunId = workflowPackKey === selectedActPackKey ? actRunId : null;
const scopedPackLiveUrl = studioPackLiveReceiptForSelection({
selectedVideoId,
receiptVideoId: packLiveReceiptVideoId,
@@ -387,6 +416,15 @@ export default function OneLoopStudio({
linkedSop?.entities,
);
+ useEffect(() => {
+ actAttemptRef.current += 1;
+ setWorkflowActions(null);
+ setWorkflowPackKey(null);
+ setActRunId(null);
+ setActBusy(false);
+ setUsedSameRun(false);
+ }, [selectedActPackKey]);
+
useEffect(() => {
setCompletedChecks([]);
setApprovedSpecIds([]);
@@ -406,12 +444,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),
};
@@ -503,6 +542,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 +614,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,
@@ -619,15 +674,29 @@ export default function OneLoopStudio({
const canAttemptBuildLive = Boolean(
getYouTubeId(url || selected?.url || '') && (selected || url.trim()),
);
+ const canActOnSelectedPack = Boolean(
+ selected?.status === 'complete' &&
+ selected?.videoPack &&
+ selectedActPackKey &&
+ getYouTubeId(selected.url) === selected.videoPack.videoId &&
+ (!url.trim() || getYouTubeId(url) === selected.videoPack.videoId),
+ );
const act = async () => {
- const next = (selected?.url || url).trim();
- if (!getYouTubeId(next)) {
+ if (!canActOnSelectedPack || !selected || !selectedActPackKey) {
setMessage('Analyze a video before acting.');
return;
}
+ const next = selected.url.trim();
+ const attempt = ++actAttemptRef.current;
+ const isCurrentAct = () => {
+ const state = useDashboardStore.getState();
+ const current = state.videos.find((video) => video.id === state.selectedVideoId);
+ return attempt === actAttemptRef.current && actPackKey(current) === selectedActPackKey;
+ };
setActBusy(true);
setWorkflowActions(null);
+ setWorkflowPackKey(selectedActPackKey);
setActRunId(null);
setMessage('Acting on this run\'s transcript…');
try {
@@ -641,6 +710,7 @@ export default function OneLoopStudio({
videoUrl: next,
videoId: selected.id,
});
+ if (!isCurrentAct()) return;
if (enriched) {
updateVideo(selected.id, { events: enriched });
events = enriched;
@@ -664,6 +734,7 @@ export default function OneLoopStudio({
setUsedSameRun(Boolean(payload.transcript || payload.events?.length));
const started = await startVideoToActions(payload);
+ if (!isCurrentAct()) return;
if (!started.ok || !started.runId) {
if (started.status === 401 || started.status === 403) {
router.push(`/login?callbackUrl=${encodeURIComponent(CANONICAL_STUDIO_PATH)}`);
@@ -683,6 +754,7 @@ export default function OneLoopStudio({
attempts: 24,
delayMs: 2000,
});
+ if (!isCurrentAct()) return;
if (polled.runStatus === 'completed' && polled.result) {
setWorkflowActions(polled.result);
const sameRun =
@@ -696,9 +768,9 @@ export default function OneLoopStudio({
setMessage(polled.error || `Act status: ${polled.runStatus || 'unknown'}.`);
}
} catch (err) {
- setMessage(err instanceof Error ? err.message : 'Act failed.');
+ if (isCurrentAct()) setMessage(err instanceof Error ? err.message : 'Act failed.');
} finally {
- setActBusy(false);
+ if (isCurrentAct()) setActBusy(false);
}
};
@@ -715,7 +787,7 @@ export default function OneLoopStudio({
const actions = actionsFromStudioRun({
insightActions,
events: selected?.events,
- workflowActions: workflowActions?.actions,
+ workflowActions: scopedWorkflowActions?.actions,
});
if (
actions.length === 0 &&
@@ -940,7 +1012,7 @@ export default function OneLoopStudio({
const hasSummaryPane = Boolean(summaryText.trim()) && !summaryRedundant;
const hasTranscriptPane =
Boolean(selected?.transcript?.trim()) || transcriptWorking || showTranscriptRetry;
- const hasActionsPane = Boolean(showAgentWorkflowUi && (actRunId || workflowActions));
+ const hasActionsPane = Boolean(showAgentWorkflowUi && (scopedActRunId || scopedWorkflowActions));
const workbenchTabs = studioWorkbenchTabs({
hasTranscript: hasTranscriptPane,
hasActions: hasActionsPane,
@@ -1033,6 +1105,27 @@ export default function OneLoopStudio({
) : null}
+ {showTranscriptRetry ? (
+
+ ) : null}
+ {showAgentWorkflowUi ? (
+
+ ) : null}
- {showTranscriptRetry ? (
-
- ) : null}
{statusText}
@@ -1145,8 +1228,22 @@ export default function OneLoopStudio({
outputPane={
void exportPkg()}
+ canExport={hasPayload}
+ />
+ ) : undefined
+ }
+ showTranscriptRetry={showTranscriptRetry}
+ onRetryTranscript={retryTranscript}
stackChecks={stackCheckViews}
- reviewActions={(workflowActions?.actions ?? []).map((action) => {
+ reviewActions={(scopedWorkflowActions?.actions ?? []).map((action) => {
const card = studioActionCard(action);
return {
tool: action.tool,
diff --git a/apps/web/src/components/__tests__/OneLoopStudio.build-live.test.tsx b/apps/web/src/components/__tests__/OneLoopStudio.build-live.test.tsx
index 3983306d1..ef83fa4bf 100644
--- a/apps/web/src/components/__tests__/OneLoopStudio.build-live.test.tsx
+++ b/apps/web/src/components/__tests__/OneLoopStudio.build-live.test.tsx
@@ -12,6 +12,7 @@ vi.mock('next/navigation', () => ({
useRouter: () => ({ push: navigation.push }),
useSearchParams: () => new URLSearchParams(),
}));
+vi.mock('next-auth/react', () => ({ useSession: () => ({ data: null, status: 'unauthenticated' }) }));
vi.mock('@/components/Nav', () => ({ default: () => null }));
vi.mock('@/app/studio/actions', () => ({ openGitHubPrsForApprovedSpecs: vi.fn() }));
vi.mock('@/lib/use-youtube-player', () => ({
diff --git a/apps/web/src/components/__tests__/OneLoopStudio.job-strip.test.tsx b/apps/web/src/components/__tests__/OneLoopStudio.job-strip.test.tsx
index 5ffe8f1b2..4cebc5e6c 100644
--- a/apps/web/src/components/__tests__/OneLoopStudio.job-strip.test.tsx
+++ b/apps/web/src/components/__tests__/OneLoopStudio.job-strip.test.tsx
@@ -10,6 +10,7 @@ vi.mock('next/navigation', () => ({
useSearchParams: () => new URLSearchParams(),
usePathname: () => '/studio',
}));
+vi.mock('next-auth/react', () => ({ useSession: () => ({ data: null, status: 'unauthenticated' }) }));
vi.mock('@/components/Nav', () => ({ default: () => null }));
vi.mock('@/app/studio/actions', () => ({ openGitHubPrsForApprovedSpecs: vi.fn() }));
vi.mock('@/lib/use-youtube-player', () => ({
diff --git a/apps/web/src/components/__tests__/OneLoopStudio.playflow.test.tsx b/apps/web/src/components/__tests__/OneLoopStudio.playflow.test.tsx
new file mode 100644
index 000000000..c51bf15ed
--- /dev/null
+++ b/apps/web/src/components/__tests__/OneLoopStudio.playflow.test.tsx
@@ -0,0 +1,231 @@
+// @vitest-environment jsdom
+import React from 'react';
+import { cleanup, fireEvent, render, screen, waitFor } 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';
+import { pollVideoToActions, startVideoToActions } from '@/lib/studio-workflow';
+
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
+ useSearchParams: () => new URLSearchParams(),
+ usePathname: () => '/studio',
+}));
+const mockSession = vi.hoisted(() => ({ current: null as null | { user: { email: string } } }));
+vi.mock('next-auth/react', () => ({ useSession: () => ({ data: mockSession.current, status: mockSession.current ? 'authenticated' : 'unauthenticated' }) }));
+vi.mock('@/lib/studio-workflow', () => ({ startVideoToActions: vi.fn(), pollVideoToActions: vi.fn() }));
+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(() => {
+ mockSession.current = null;
+ vi.mocked(startVideoToActions).mockReset();
+ vi.mocked(pollVideoToActions).mockReset();
+ 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();
+
+ 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();
+
+ 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);
+ });
+
+ it('requires a stored analyzed pack before Act is enabled', () => {
+ useDashboardStore.setState({ videos: [dogfoodVideo], selectedVideoId: dogfoodVideo.id });
+ render();
+ expect(screen.getByTestId('studio-act-button').hasAttribute('disabled')).toBe(true);
+ fireEvent.click(screen.getByTestId('studio-act-button'));
+ expect(startVideoToActions).not.toHaveBeenCalled();
+ });
+
+ it('shows runnable stack checks as pending for a signed-in viewer', async () => {
+ const { reviewPackFixture } = await import('@/test/grounded-spec-fixture');
+ const pack = reviewPackFixture();
+ pack.stack = { tools: [{ name: 'Vercel' }] };
+ mockSession.current = { user: { email: 'viewer@example.com' } };
+ useDashboardStore.setState({
+ videos: [{ ...dogfoodVideo, videoPack: {
+ packId: pack.id, videoId: dogfoodVideo.id, sourceUrl: dogfoodVideo.url,
+ sourceHash: pack.provenance.source_hash, version: pack.version,
+ pack: { ...pack, video_id: dogfoodVideo.id, source_url: dogfoodVideo.url },
+ } }],
+ selectedVideoId: dogfoodVideo.id,
+ });
+ render();
+ fireEvent.click(screen.getByTestId('studio-ide-output-tab-tools'));
+ expect(screen.getByTestId('studio-stack-checks').textContent).toMatch(/pending/i);
+ expect(screen.getByTestId('studio-stack-checks').textContent).not.toMatch(/signed out/i);
+ });
+
+ it('shows named tools in the promoted workbench for a tool-only pack', async () => {
+ const { reviewPackFixture } = await import('@/test/grounded-spec-fixture');
+ const pack = reviewPackFixture();
+ pack.architecture = null;
+ pack.artifacts = [];
+ pack.stack = { tools: [{ name: 'Vercel', evidence: 'Mentioned in the video' }] };
+ useDashboardStore.setState({
+ videos: [{ ...dogfoodVideo, videoPack: {
+ packId: pack.id, videoId: dogfoodVideo.id, sourceUrl: dogfoodVideo.url,
+ sourceHash: pack.provenance.source_hash, version: pack.version,
+ pack: { ...pack, video_id: dogfoodVideo.id, source_url: dogfoodVideo.url },
+ } }],
+ selectedVideoId: dogfoodVideo.id,
+ });
+ render();
+ expect(screen.getByTestId('pack-tools').textContent).toMatch(/Vercel/);
+ expect(screen.getByTestId('pack-tools').textContent).toMatch(/Mentioned in the video/);
+ });
+
+ it('discards late Act results after switching to another stored pack', async () => {
+ const { reviewPackFixture } = await import('@/test/grounded-spec-fixture');
+ const pack = reviewPackFixture();
+ const rowA: Video = { ...dogfoodVideo, videoPack: {
+ packId: pack.id, videoId: dogfoodVideo.id, sourceUrl: dogfoodVideo.url,
+ sourceHash: pack.provenance.source_hash, version: pack.version,
+ pack: { ...pack, video_id: dogfoodVideo.id, source_url: dogfoodVideo.url },
+ } };
+ const otherUrl = 'https://www.youtube.com/watch?v=auJzb1D-fag';
+ const rowB: Video = { ...rowA, id: 'auJzb1D-fag', url: otherUrl,
+ videoPack: { ...rowA.videoPack!, packId: 'other-pack', videoId: 'auJzb1D-fag', sourceUrl: otherUrl,
+ pack: { ...pack, id: 'other-pack', video_id: 'auJzb1D-fag', source_url: otherUrl } },
+ };
+ useDashboardStore.setState({ videos: [rowA, rowB], selectedVideoId: rowA.id });
+ vi.mocked(startVideoToActions).mockResolvedValue({ ok: true, status: 200, runId: 'run-a' });
+ let finishPoll!: (value: Awaited>) => void;
+ vi.mocked(pollVideoToActions).mockImplementation(() => new Promise((resolve) => { finishPoll = resolve; }));
+ render();
+ fireEvent.click(screen.getByTestId('studio-act-button'));
+ await waitFor(() => expect(pollVideoToActions).toHaveBeenCalled());
+ useDashboardStore.setState({ selectedVideoId: rowB.id });
+ finishPoll({ ok: true, status: 200, runId: 'run-a', runStatus: 'completed',
+ result: { url: rowA.url, transcriptChars: 0, actionCount: 1,
+ actions: [{ tool: 'Old pack action', status: 'completed', result: 'from A' }] } });
+ await waitFor(() => expect(screen.getByTestId('studio-act-button').textContent).toMatch(/Act on this run/));
+ expect(screen.queryByText('Old pack action')).toBeNull();
+ expect(screen.queryByText('from A')).toBeNull();
+ });
+
+});
diff --git a/apps/web/src/components/__tests__/OneLoopStudio.three-panel.test.tsx b/apps/web/src/components/__tests__/OneLoopStudio.three-panel.test.tsx
index fb1369b51..456e9b43c 100644
--- a/apps/web/src/components/__tests__/OneLoopStudio.three-panel.test.tsx
+++ b/apps/web/src/components/__tests__/OneLoopStudio.three-panel.test.tsx
@@ -9,6 +9,7 @@ vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
}));
+vi.mock('next-auth/react', () => ({ useSession: () => ({ data: null, status: 'unauthenticated' }) }));
vi.mock('@/components/Nav', () => ({ default: () => null }));
vi.mock('@/app/studio/actions', () => ({ openGitHubPrsForApprovedSpecs: vi.fn() }));
vi.mock('@/lib/use-youtube-player', () => ({
diff --git a/apps/web/src/components/__tests__/StudioIdeOutput.playflow.test.tsx b/apps/web/src/components/__tests__/StudioIdeOutput.playflow.test.tsx
new file mode 100644
index 000000000..62fe7c77d
--- /dev/null
+++ b/apps/web/src/components/__tests__/StudioIdeOutput.playflow.test.tsx
@@ -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(
+ Architecture from this pack}
+ />,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ fireEvent.click(screen.getByTestId('studio-ide-output-tab-transcript'));
+ fireEvent.click(screen.getByTestId('studio-transcript-retry'));
+ expect(onRetry).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/apps/web/src/components/studio/StudioIdeOutput.tsx b/apps/web/src/components/studio/StudioIdeOutput.tsx
index ae4cc5b42..16c40bf22 100644
--- a/apps/web/src/components/studio/StudioIdeOutput.tsx
+++ b/apps/web/src/components/studio/StudioIdeOutput.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useState } from 'react';
+import { useState, type ReactNode } from 'react';
import type { Video } from '@/store/dashboard-types';
import type { ExtractedEvent } from '@/lib/types';
import { studioActionCard } from '@/lib/studio-pipeline-status';
@@ -42,11 +42,19 @@ const SECTIONS: Array<{ id: OutputSection; label: string }> = [
export default function StudioIdeOutput({
video,
specReview,
+ eventsEmptyMessage,
+ packWorkbench,
+ showTranscriptRetry,
+ onRetryTranscript,
stackChecks = [],
reviewActions = [],
}: {
video: Video | undefined;
- specReview?: React.ReactNode;
+ specReview?: ReactNode;
+ eventsEmptyMessage?: string;
+ packWorkbench?: ReactNode;
+ showTranscriptRetry?: boolean;
+ onRetryTranscript?: () => void;
stackChecks?: StudioStackCheckView[];
reviewActions?: StudioReviewActionInput[];
}) {
@@ -56,6 +64,10 @@ export default function StudioIdeOutput({
video?.videoPack ||
video?.events?.length ||
video?.insights ||
+ video?.transcript?.trim() ||
+ video?.failure ||
+ eventsEmptyMessage ||
+ packWorkbench ||
stackChecks.length > 0 ||
reviewActions.length > 0,
);
@@ -110,29 +122,39 @@ export default function StudioIdeOutput({
) : (
<>
{active === 'events' && (
-
+
{events.length === 0 ? (
-
+ <>
+
+ {packWorkbench}
+ >
) : (
- events.map((e) => (
-
-
-
-
-
- {e.type}
-
- {e.timestamp ? (
-
{e.timestamp}
+
- {e.title}
- {e.description ? (
- {e.description}
- ) : null}
-
- ))
+
+ ))}
+
)}
-
+
)}
{active === 'lingo' && (
@@ -319,16 +341,28 @@ export default function StudioIdeOutput({
)}
{active === 'transcript' && (
-
- {video?.transcript?.trim() ? (
-
- {video.transcript}
-
- ) : video?.failure?.message ? (
-
{video.failure.message}
- ) : (
-
Nothing yet.
- )}
+
+
+ {video?.transcript?.trim() ? (
+
+ {video.transcript}
+
+ ) : video?.failure?.message ? (
+
{video.failure.message}
+ ) : (
+
Nothing yet.
+ )}
+
+ {showTranscriptRetry && onRetryTranscript ? (
+
+ ) : null}
)}
>
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 05167ecba..4927f6d63 100644
--- a/apps/web/src/lib/__tests__/studio-pipeline-status.test.ts
+++ b/apps/web/src/lib/__tests__/studio-pipeline-status.test.ts
@@ -532,6 +532,9 @@ describe('studio-pipeline-status', () => {
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');
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 29cccc1a8..6a4a5dfe2 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 2bd869c7e..9dc5acfef 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 ab68c22ec..feeb4c26c 100644
--- a/apps/web/src/lib/studio-playflow-PLAN.md
+++ b/apps/web/src/lib/studio-playflow-PLAN.md
@@ -1,29 +1,26 @@
-# TASK: Restore Studio Ship this playflow after IDE shell
+# TASK: Restore remaining Studio IDE playflow after Ship this
## 1. Goal & Scope
-* **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.
+* **Objective:** Keep #2437’s **Ship this** ZIP CTA, and restore the leftover dogfood rails this branch still owns: honest empty Events (never Insights), pack workbench, Act on this run, transcript-tab retry, and softened “run tools” copy.
+* **Context:** #2437 (`560f07120`) already closed #2435 with Ship this + skip/unknown stack rows + review cards + toolbar retry. This branch layers Events honesty and the flag-gated Act CTA on that landing. C (player) and D (export toast at `bottom-4`) stay. No Origin G.A.T.E., ClipToAction, fake events, or Ship checkout.
* **Scope:**
- * `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.
+ * `apps/web/src/components/OneLoopStudio.tsx` — Act on this run, honest empty-events + pack workbench into output; keep Ship this
+ * `apps/web/src/components/studio/StudioIdeOutput.tsx` — Events empty copy, pack workbench, transcript-tab retry on main’s stackChecks/reviewActions API
+ * Focused Vitest for the dogfood path
+ * *Initial check:* Do not restore `studio-deploy-button`. Do not replace Ship this with a second Export button.
## 2. Execution Plan
-- [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
+- [x] Merge origin/main (#2437 Ship this) and keep complementary Events/Act wiring
+- [x] Honest Events empty + pack workbench; Tools skip/unknown; Intent review cards
+- [x] Soften remaining “run tools” paste-ready copy
+- [x] Verify focused Vitest after the merge
## 3. Definition of Done (Success Verification)
-* **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.
+* **Expected Outcome:** On `/studio?video=…pBsT6v-ciO8` Events says no extracted events (not Insights), architecture/artifacts visible, SeaDance/Cluely Unknown, **Ship this** enabled, failed transcript has Retry, Act cards say Review this result.
+* **Verification Method:** `npm --workspace=apps/web exec vitest run src/components/__tests__/OneLoopStudio.playflow.test.tsx src/components/__tests__/StudioIdeOutput.playflow.test.tsx src/components/studio/__tests__/StudioIdeOutput.playflow.test.tsx src/components/__tests__/OneLoopStudio.three-panel.test.tsx src/components/__tests__/OneLoopStudio.build-live.test.tsx src/components/__tests__/OneLoopStudio.job-strip.test.tsx src/lib/__tests__/studio-pipeline-status.test.ts src/lib/__tests__/official-templates.test.ts src/lib/__tests__/uvai-surface-unification.test.ts`
+* **Proof Artifact:** 9 files, 62 passed after merging `origin/main` (#2437).
## 4. Post-Task Reflection
-* **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.
+* **What was done:** Merged #2437’s Ship this landing and kept the remaining playflow: Events honesty, pack workbench, Act on this run (`agentWorkflowUi`), transcript-tab retry, and “act, export, or save” paste copy.
+* **Why it was needed:** #2437 restored Ship this / stack rows / review cards; empty `events[]` packs still needed an honest Events pane and the flag-gated Act CTA.
+* **How it was tested:** Focused Vitest after merge: 9 files, 62 passed.
diff --git a/package-lock.json b/package-lock.json
index 2784aabd0..a65df8ddd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7065,16 +7065,16 @@
}
},
"node_modules/@vitest/expect": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
- "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz",
+ "integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
- "@vitest/spy": "4.1.11",
- "@vitest/utils": "4.1.11",
+ "@vitest/spy": "4.1.12",
+ "@vitest/utils": "4.1.12",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -7083,13 +7083,13 @@
}
},
"node_modules/@vitest/mocker": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
- "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz",
+ "integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/spy": "4.1.11",
+ "@vitest/spy": "4.1.12",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -7120,9 +7120,9 @@
}
},
"node_modules/@vitest/pretty-format": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
- "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz",
+ "integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7133,13 +7133,13 @@
}
},
"node_modules/@vitest/runner": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
- "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz",
+ "integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/utils": "4.1.11",
+ "@vitest/utils": "4.1.12",
"pathe": "^2.0.3"
},
"funding": {
@@ -7147,14 +7147,14 @@
}
},
"node_modules/@vitest/snapshot": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
- "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz",
+ "integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.1.11",
- "@vitest/utils": "4.1.11",
+ "@vitest/pretty-format": "4.1.12",
+ "@vitest/utils": "4.1.12",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -7163,9 +7163,9 @@
}
},
"node_modules/@vitest/spy": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
- "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz",
+ "integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==",
"dev": true,
"license": "MIT",
"funding": {
@@ -7173,13 +7173,13 @@
}
},
"node_modules/@vitest/utils": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
- "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz",
+ "integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.1.11",
+ "@vitest/pretty-format": "4.1.12",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -15757,9 +15757,9 @@
}
},
"node_modules/tinyrainbow": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
- "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz",
+ "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -16676,19 +16676,19 @@
}
},
"node_modules/vitest": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
- "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz",
+ "integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/expect": "4.1.11",
- "@vitest/mocker": "4.1.11",
- "@vitest/pretty-format": "4.1.11",
- "@vitest/runner": "4.1.11",
- "@vitest/snapshot": "4.1.11",
- "@vitest/spy": "4.1.11",
- "@vitest/utils": "4.1.11",
+ "@vitest/expect": "4.1.12",
+ "@vitest/mocker": "4.1.12",
+ "@vitest/pretty-format": "4.1.12",
+ "@vitest/runner": "4.1.12",
+ "@vitest/snapshot": "4.1.12",
+ "@vitest/spy": "4.1.12",
+ "@vitest/utils": "4.1.12",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -16716,12 +16716,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
- "@vitest/browser-playwright": "4.1.11",
- "@vitest/browser-preview": "4.1.11",
- "@vitest/browser-webdriverio": "4.1.11",
- "@vitest/coverage-istanbul": "4.1.11",
- "@vitest/coverage-v8": "4.1.11",
- "@vitest/ui": "4.1.11",
+ "@vitest/browser-playwright": "4.1.12",
+ "@vitest/browser-preview": "4.1.12",
+ "@vitest/browser-webdriverio": "^4.1.11",
+ "@vitest/coverage-istanbul": "4.1.12",
+ "@vitest/coverage-v8": "4.1.12",
+ "@vitest/ui": "4.1.12",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"