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
19 changes: 16 additions & 3 deletions src/studio/StudioGenerate.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors
import React, { useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { DiffEditor } from '@monaco-editor/react';
import { useGeneration } from '../funnel/hooks/useGeneration';
import type { GenerateEvent } from '../funnel/lib/generateClient';
Expand All @@ -10,7 +10,7 @@ import { ConceptResult } from './components/ConceptResult';
import { useCode } from './context/CodeContext';
import { useGeometry } from './context/GeometryContext';
import { useFeatureSelection } from './hooks/useFeatureSelection';
import { useShellStore } from './store/useShellStore';
import { useShellStore, shellStore } from './store/useShellStore';

/** Web-only gate. No hooks here, so the conditional return is safe. */
export const StudioGenerate: React.FC = () => {
Expand Down Expand Up @@ -38,7 +38,7 @@ const StudioGenerateInner: React.FC = () => {
const currentCode = code ?? '';
const { executeGeometry } = useGeometry();
const { selectedFeatureId } = useFeatureSelection();
const { agentDraftPrompt, agentDraftPromptVersion } = useShellStore();
const { agentDraftPrompt, agentDraftPromptVersion, agentRepairWorkflow } = useShellStore();
const [editedPrompt, setEditedPrompt] = useState('');
const [acknowledgedDraftVersion, setAcknowledgedDraftVersion] = useState(-1);
// The generationId we've already accepted/rejected — gates the review panel
Expand Down Expand Up @@ -73,6 +73,11 @@ const StudioGenerateInner: React.FC = () => {

const steps = useMemo(() => events.map(stepLabel).filter(Boolean) as string[], [events]);

useEffect(() => {
if (phase.state !== 'error' || agentRepairWorkflow?.state !== 'running') return;
shellStore.setAgentRepairWorkflow({ ...agentRepairWorkflow, state: 'drafted' });
}, [agentRepairWorkflow, phase.state]);

const runAgent = (text: string) => {
// Edit mode: hand the agent the current model so it iterates instead of
// replacing. Empty editor → fresh generation.
Expand All @@ -89,6 +94,14 @@ const StudioGenerateInner: React.FC = () => {
const trimmed = prompt.trim();
if (!trimmed || busy) return;
const agentPrompt = selectedFeatureId === null ? trimmed : `Edit selected target "${selectedFeatureId}": ${trimmed}`;
if (
agentRepairWorkflow != null &&
agentRepairWorkflow.state === 'drafted' &&
agentRepairWorkflow.promptText === trimmed &&
agentRepairWorkflow.targetId === selectedFeatureId
) {
shellStore.setAgentRepairWorkflow({ ...agentRepairWorkflow, state: 'running' });
}
runAgent(agentPrompt);
};

Expand Down
91 changes: 91 additions & 0 deletions src/studio/__tests__/StudioGenerate.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ const mockSelection = vi.hoisted(() => ({
const mockShell = vi.hoisted(() => ({
agentDraftPrompt: null as string | null,
agentDraftPromptVersion: 0,
agentRepairWorkflow: null as {
cardId: string;
code: string;
promptText: string;
targetId: string | null;
promptSource: 'review' | 'fallback';
validityFingerprint: string;
state: 'drafted' | 'running';
} | null,
setAgentRepairWorkflow: vi.fn(),
}));

vi.mock('../agentAvailability', () => ({
Expand Down Expand Up @@ -55,7 +65,11 @@ vi.mock('../store/useShellStore', () => ({
useShellStore: () => ({
agentDraftPrompt: mockShell.agentDraftPrompt,
agentDraftPromptVersion: mockShell.agentDraftPromptVersion,
agentRepairWorkflow: mockShell.agentRepairWorkflow,
}),
shellStore: {
setAgentRepairWorkflow: mockShell.setAgentRepairWorkflow,
},
}));

import { StudioGenerate } from '../StudioGenerate';
Expand All @@ -69,6 +83,8 @@ beforeEach(() => {
mockSelection.selectedFeatureId = null;
mockShell.agentDraftPrompt = null;
mockShell.agentDraftPromptVersion = 0;
mockShell.agentRepairWorkflow = null;
mockShell.setAgentRepairWorkflow.mockReset();
});

afterEach(() => cleanup());
Expand Down Expand Up @@ -106,6 +122,81 @@ describe('StudioGenerate', () => {
);
});

it('marks the active repair workflow running when the drafted prompt is submitted', () => {
mockSelection.selectedFeatureId = 'output-horn';
mockShell.agentDraftPrompt = 'Fix assembly.part.floating: output-horn floats Action: add a mate';
mockShell.agentDraftPromptVersion = 1;
mockShell.agentRepairWorkflow = {
cardId: 'diagnostic:assembly.part.floating:output-horn:0',
code: 'assembly.part.floating',
promptText: 'Fix assembly.part.floating: output-horn floats Action: add a mate',
targetId: 'output-horn',
promptSource: 'fallback',
validityFingerprint: 'before',
state: 'drafted',
};
render(<StudioGenerate />);

const prompt = screen.getByLabelText('Generate prompt');
fireEvent.submit(prompt.closest('form')!);

expect(mockShell.setAgentRepairWorkflow).toHaveBeenCalledWith({
...mockShell.agentRepairWorkflow,
state: 'running',
});
expect(mockGeneration.submit).toHaveBeenCalledWith(
'Edit selected target "output-horn": Fix assembly.part.floating: output-horn floats Action: add a mate',
);
});

it('does not mark a drafted repair running when the selected target changed', () => {
mockSelection.selectedFeatureId = 'hinge-pin';
mockShell.agentDraftPrompt = 'Fix assembly.part.floating: output-horn floats Action: add a mate';
mockShell.agentDraftPromptVersion = 1;
mockShell.agentRepairWorkflow = {
cardId: 'diagnostic:assembly.part.floating:output-horn:0',
code: 'assembly.part.floating',
promptText: 'Fix assembly.part.floating: output-horn floats Action: add a mate',
targetId: 'output-horn',
promptSource: 'fallback',
validityFingerprint: 'before',
state: 'drafted',
};
render(<StudioGenerate />);

const prompt = screen.getByLabelText('Generate prompt');
fireEvent.submit(prompt.closest('form')!);

expect(mockShell.setAgentRepairWorkflow).not.toHaveBeenCalled();
expect(mockGeneration.submit).toHaveBeenCalledWith(
'Edit selected target "hinge-pin": Fix assembly.part.floating: output-horn floats Action: add a mate',
);
});

it('rolls a running repair workflow back to drafted when generation errors', () => {
mockGeneration.phase = {
state: 'error',
code: 'network',
message: 'stream failed',
};
mockShell.agentRepairWorkflow = {
cardId: 'diagnostic:assembly.part.floating:output-horn:0',
code: 'assembly.part.floating',
promptText: 'Fix assembly.part.floating: output-horn floats Action: add a mate',
targetId: 'output-horn',
promptSource: 'fallback',
validityFingerprint: 'before',
state: 'running',
};

render(<StudioGenerate />);

expect(mockShell.setAgentRepairWorkflow).toHaveBeenCalledWith({
...mockShell.agentRepairWorkflow,
state: 'drafted',
});
});

it('loads the agent draft prompt into the textarea', () => {
mockShell.agentDraftPrompt = 'Fix assembly.part.floating: output-horn floats Action: add a mate';

Expand Down
28 changes: 28 additions & 0 deletions src/studio/__tests__/shellStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ describe('ShellStore', () => {
expect(typeof s.agentRailOpen).toBe('boolean');
expect(s.agentDraftPrompt).toBeNull();
expect(s.agentDraftPromptVersion).toBe(0);
expect(s.agentRepairWorkflow).toBeNull();
expect(s.previousValidity).toBeNull();
expect(s.currentValidity).toBeNull();
});
Expand Down Expand Up @@ -94,6 +95,33 @@ describe('ShellStore', () => {
expect(store.getSnapshot().agentDraftPromptVersion).toBe(2);
});

it('setAgentRepairWorkflow tracks the active validity repair workflow', () => {
store = new ShellStore();
const listener = vi.fn();
store.subscribe(listener);
const workflow = {
cardId: 'diagnostic:assembly.part.floating:output-horn:0',
code: 'assembly.part.floating',
promptText: 'Fix assembly.part.floating',
targetId: 'output-horn',
promptSource: 'fallback' as const,
validityFingerprint: 'before',
state: 'drafted' as const,
};

store.setAgentRepairWorkflow(workflow);
expect(store.getSnapshot().agentRepairWorkflow).toBe(workflow);

store.setAgentRepairWorkflow({ ...workflow, state: 'running' });
expect(store.getSnapshot().agentRepairWorkflow?.state).toBe('running');

store.setAgentRepairWorkflow(null);
store.setAgentRepairWorkflow(null);

expect(listener).toHaveBeenCalledTimes(3);
expect(store.getSnapshot().agentRepairWorkflow).toBeNull();
});

it('publishValidity rotates current → previous', () => {
store = new ShellStore();
const v1 = makeValidity('solved');
Expand Down
Loading
Loading