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
9 changes: 9 additions & 0 deletions src/studio/StagedEditSlot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,15 @@ export function StagedEditSlot() {
const handleReject = useCallback(() => {
if (stagedEdit != null) {
shellStore.recordStagedEditOutcome(stagedEdit, 'rejected');
const workflow = stagedEdit.context?.repairWorkflow;
const currentWorkflow = shellStore.getSnapshot().agentRepairWorkflow;
if (
workflow?.state === 'running' &&
currentWorkflow?.state === 'running' &&
currentWorkflow.cardId === workflow.cardId
) {
shellStore.setAgentRepairWorkflow({ ...currentWorkflow, state: 'drafted' });
}
}
setStaleWarning(null);
shellStore.clearStagedEdit();
Expand Down
31 changes: 23 additions & 8 deletions src/studio/StudioGenerate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const StudioGenerateInner: React.FC = () => {
const { code } = useCode();
const currentCode = code ?? '';
const { selectedFeatureId } = useFeatureSelection();
const { agentDraftPrompt, agentDraftPromptVersion, agentRepairWorkflow } = useShellStore();
const { agentDraftPrompt, agentDraftPromptVersion, agentRepairWorkflow, stagedEdit } = useShellStore();
const [editedPrompt, setEditedPrompt] = useState('');
const [acknowledgedDraftVersion, setAcknowledgedDraftVersion] = useState(-1);
// The generationId we've already staged/rejected — gates the review panel
Expand Down Expand Up @@ -102,21 +102,27 @@ const StudioGenerateInner: React.FC = () => {
e.preventDefault();
const trimmed = prompt.trim();
if (!trimmed || busy) return;
const agentPrompt = selectedFeatureId === null ? trimmed : `Edit selected target "${selectedFeatureId}": ${trimmed}`;
let repairWorkflowForRun = agentRepairWorkflow;
if (
const matchesDraftedRepair =
agentRepairWorkflow != null &&
agentRepairWorkflow.state === 'drafted' &&
agentRepairWorkflow.promptText === trimmed &&
agentRepairWorkflow.targetId === selectedFeatureId
agentRepairWorkflow.promptText === trimmed;
const runTargetId =
matchesDraftedRepair && agentRepairWorkflow.targetId === null
? null
: selectedFeatureId;
const agentPrompt = runTargetId === null ? trimmed : `Edit selected target "${runTargetId}": ${trimmed}`;
let repairWorkflowForRun = agentRepairWorkflow;
if (
matchesDraftedRepair &&
agentRepairWorkflow.targetId === runTargetId
) {
repairWorkflowForRun = { ...agentRepairWorkflow, state: 'running' };
shellStore.setAgentRepairWorkflow(repairWorkflowForRun);
}
runAgent(agentPrompt, {
fromCode: currentCode,
promptText: trimmed,
selectedFeatureId,
selectedFeatureId: runTargetId,
repairWorkflow: repairWorkflowForRun,
});
};
Expand Down Expand Up @@ -154,6 +160,7 @@ const StudioGenerateInner: React.FC = () => {

const stageGeneratedEdit = () => {
if (phase.state !== 'done') return;
if (stagedEdit != null) return;
const snapshot = reviewSnapshot ?? {
fromCode: currentCode,
promptText: prompt.trim(),
Expand All @@ -177,6 +184,9 @@ const StudioGenerateInner: React.FC = () => {
};
const reject = () => {
if (phase.state !== 'done') return;
if (agentRepairWorkflow?.state === 'running') {
shellStore.setAgentRepairWorkflow({ ...agentRepairWorkflow, state: 'drafted' });
}
setResolution({ generationId: phase.generationId, action: 'discarded' });
};

Expand Down Expand Up @@ -229,7 +239,7 @@ const StudioGenerateInner: React.FC = () => {
)}

{/* Review gate: diff + verified badge + accept/reject. Never auto-applies. */}
{reviewing && phase.state === 'done' && (
{reviewing && phase.state === 'done' && stagedEdit == null && (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<div className="text-[10px] text-gray-300 truncate" title={phase.artifact.title}>
Expand Down Expand Up @@ -274,6 +284,11 @@ const StudioGenerateInner: React.FC = () => {
</div>
</div>
)}
{reviewing && phase.state === 'done' && stagedEdit != null && (
<div className="rounded border border-amber-900/60 bg-amber-950/30 px-2 py-1 text-[10px] text-amber-200">
Review the current staged edit before staging another proposal.
</div>
)}

{phase.state === 'done' && !reviewing && resolution?.action === 'staged' && (
<div className="text-[10px] text-green-500 truncate" aria-live="polite">
Expand Down
34 changes: 34 additions & 0 deletions src/studio/__tests__/StagedEditSlot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,40 @@ describe('StagedEditSlot', () => {
expect(getByTestId('applied-edit-history').textContent).toContain('demo');
});

it('Reject rolls a running staged repair workflow back to drafted', () => {
const repairWorkflow = {
cardId: 'diagnostic:assembly.part.floating:output-horn:0',
code: 'assembly.part.floating',
promptText: 'Fix output horn',
targetId: 'output-horn',
promptSource: 'fallback' as const,
validityFingerprint: 'before',
state: 'running' as const,
};
shellStore.setAgentRepairWorkflow(repairWorkflow);
shellStore.proposeStagedEdit({
id: 'e-reject-workflow',
intent: 'Reject repair',
fromCode: 'return box(10);',
toCode: 'return box(20);',
context: {
promptText: 'Fix output horn',
selectedFeatureId: 'output-horn',
repairWorkflow,
generationId: 'gen-reject',
},
});

const { getByTestId } = render(<StagedEditSlot />);
fireEvent.click(getByTestId('staged-edit-reject'));

expect(shellStore.getSnapshot().agentRepairWorkflow).toEqual({
...repairWorkflow,
state: 'drafted',
});
expect(getByTestId('applied-edit-history').textContent).toContain('Rejected');
});

it('source label renders when provided', () => {
shellStore.proposeStagedEdit({
id: 'e4',
Expand Down
65 changes: 65 additions & 0 deletions src/studio/__tests__/StudioGenerate.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const mockShell = vi.hoisted(() => ({
validityFingerprint: string;
state: 'drafted' | 'running';
} | null,
stagedEdit: null as { id: string } | null,
setAgentRepairWorkflow: vi.fn(),
proposeStagedEdit: vi.fn(),
}));
Expand Down Expand Up @@ -72,6 +73,7 @@ vi.mock('../store/useShellStore', () => ({
agentDraftPrompt: mockShell.agentDraftPrompt,
agentDraftPromptVersion: mockShell.agentDraftPromptVersion,
agentRepairWorkflow: mockShell.agentRepairWorkflow,
stagedEdit: mockShell.stagedEdit,
}),
shellStore: {
setAgentRepairWorkflow: mockShell.setAgentRepairWorkflow,
Expand All @@ -92,6 +94,7 @@ beforeEach(() => {
mockShell.agentDraftPrompt = null;
mockShell.agentDraftPromptVersion = 0;
mockShell.agentRepairWorkflow = null;
mockShell.stagedEdit = null;
mockShell.setAgentRepairWorkflow.mockReset();
mockShell.proposeStagedEdit.mockReset();
});
Expand Down Expand Up @@ -182,6 +185,33 @@ describe('StudioGenerate', () => {
);
});

it('submits a drafted whole-model repair without stale selected-feature prefix', () => {
mockSelection.selectedFeatureId = 'hinge-pin';
mockShell.agentDraftPrompt = 'Repair deterministic mechanism failures.';
mockShell.agentDraftPromptVersion = 1;
mockShell.agentRepairWorkflow = {
cardId: 'mechanism:mechanism.disconnect:0',
code: 'mechanism.disconnect',
promptText: 'Repair deterministic mechanism failures.',
targetId: null,
promptSource: 'review',
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(
'Repair deterministic mechanism failures.',
);
});

it('rolls a running repair workflow back to drafted when generation errors', () => {
mockGeneration.phase = {
state: 'error',
Expand Down Expand Up @@ -312,6 +342,15 @@ describe('StudioGenerate', () => {
});

it('does not show staged status after discarding a generated artifact', () => {
mockShell.agentRepairWorkflow = {
cardId: 'diagnostic:assembly.part.floating:output-horn:0',
code: 'assembly.part.floating',
promptText: 'Fix output horn',
targetId: 'output-horn',
promptSource: 'fallback',
validityFingerprint: 'before',
state: 'running',
};
mockGeneration.phase = {
state: 'done',
generationId: 'gen-discard',
Expand All @@ -329,10 +368,36 @@ describe('StudioGenerate', () => {
fireEvent.click(screen.getByRole('button', { name: /discard/i }));

expect(mockShell.proposeStagedEdit).not.toHaveBeenCalled();
expect(mockShell.setAgentRepairWorkflow).toHaveBeenCalledWith({
...mockShell.agentRepairWorkflow,
state: 'drafted',
});
expect(screen.queryByText(/staged for review/i)).toBeNull();
expect(screen.getByText(/discarded — Throwaway proposal/i)).toBeTruthy();
});

it('does not overwrite an existing staged edit with a new generated proposal', () => {
mockShell.stagedEdit = { id: 'existing-edit' };
mockCode.code = 'return box(10);';
mockGeneration.phase = {
state: 'done',
generationId: 'gen-overwrite',
anonId: 'anon-overwrite',
artifact: {
title: 'New proposal',
code: 'return box(20);',
parameters: [],
suggestions: [],
},
};

render(<StudioGenerate />);

expect(screen.queryByRole('button', { name: /stage edit/i })).toBeNull();
expect(screen.getByText(/review the current staged edit before staging another/i)).toBeTruthy();
expect(mockShell.proposeStagedEdit).not.toHaveBeenCalled();
});

it('preserves prompt, target, and repair workflow context on staged generated edits', () => {
mockCode.code = 'return box(10);';
mockSelection.selectedFeatureId = 'output-horn';
Expand Down
34 changes: 34 additions & 0 deletions src/studio/__tests__/tabs/ValidityTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,40 @@ describe('ValidityTab', () => {
expect(card.textContent).toContain('Still failing');
});

it('marks a pair repair still failing when the validator reverses part order', () => {
const first: ValidatorDiagnostic = {
code: 'assembly.interference.overlap',
severity: 'error',
message: 'bracket collides with cover',
hint: 'move the cover clear of the bracket',
partA: 'bracket',
partB: 'cover',
};
const reversed: ValidatorDiagnostic = {
...first,
message: 'cover still collides with bracket',
partA: 'cover',
partB: 'bracket',
};
mockUseRecomputeResult.mockReturnValue(
withValidity(makeValidity('error', [first], 2, 0)),
);
const { rerender } = render(<ValidityTab />);

fireEvent.click(screen.getByRole('button', { name: 'Use prompt' }));
const workflow = shellStore.getSnapshot().agentRepairWorkflow;
shellStore.setAgentRepairWorkflow(workflow == null ? null : { ...workflow, state: 'running' });
mockUseRecomputeResult.mockReturnValue(
withValidity(makeValidity('error', [reversed], 2, 0)),
);
rerender(<ValidityTab />);

const card = screen.getByTestId('validity-suggestion-card');
expect(card.getAttribute('data-workflow-state')).toBe('still-failing');
expect(card.textContent).toContain('Still failing');
expect(screen.queryByTestId('validity-suggestion-workflow-summary')).toBeNull();
});

it('does not mark drafted cards rechecked before the repair has been submitted', () => {
const diag: ValidatorDiagnostic = {
code: 'assembly.part.floating',
Expand Down
38 changes: 38 additions & 0 deletions src/studio/__tests__/useRecomputeResult.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -304,4 +304,42 @@ describe('useRecomputeResult — Slice 1.2 data wiring', () => {
recheckedScriptFingerprint: fingerprintStudioScript(approvedCode),
});
});

it('does not recheck an approved edit with stale review output when only code changed', async () => {
const oldCode = 'const part = box(10);\nreturn part;';
const approvedCode = 'const part = box(20);\nreturn part;';
shellStore.recordStagedEditOutcome(
{
id: 'approved-stale-review',
intent: 'approved stale review',
fromCode: oldCode,
toCode: approvedCode,
},
'approved',
);
const oldReview: ScriptReviewSummary = { ok: true, diagnostics: [] };
workbenchValue.code = oldCode;
workbenchValue.scriptReview = oldReview;

const { useRecomputeResult } = await import('../hooks/useRecomputeResult');
const { rerender } = renderHook(() => useRecomputeResult());

workbenchValue.code = approvedCode;
workbenchValue.scriptReview = oldReview;
rerender();

expect(shellStore.getSnapshot().appliedEditHistory[0]).toMatchObject({
editId: 'approved-stale-review',
recheckStatus: 'pending-recheck',
});

workbenchValue.scriptReview = { ok: true, diagnostics: [] };
rerender();

expect(shellStore.getSnapshot().appliedEditHistory[0]).toMatchObject({
editId: 'approved-stale-review',
recheckStatus: 'rechecked-solved',
recheckedScriptFingerprint: fingerprintStudioScript(approvedCode),
});
});
});
13 changes: 10 additions & 3 deletions src/studio/hooks/useRecomputeResult.ts
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 { useEffect, useMemo } from 'react';
import { useEffect, useMemo, useRef } from 'react';
import { useWorkbench } from '../context/WorkbenchContext';
import { reviewToValidity, reviewToMechanismBanner } from '../adapters/reviewToValidity';
import { serializedParamsToTable } from '../adapters/serializedParamsToTable';
Expand Down Expand Up @@ -31,6 +31,7 @@ import type { StudioRecomputeResult, StudioRepairEvidence } from '../types';
*/
export function useRecomputeResult(): StudioRecomputeResult {
const workbench = useWorkbench();
const lastPublishedReviewRef = useRef<ScriptReviewSummary | null | typeof UNPUBLISHED_REVIEW>(UNPUBLISHED_REVIEW);

const validity = useMemo(
() => reviewToValidity(workbench.scriptReview ?? null),
Expand Down Expand Up @@ -88,8 +89,12 @@ export function useRecomputeResult(): StudioRecomputeResult {
// Publish validity into the shell store so BottomDrawer +
// ValidityDeltaHeader see the delta (current ↔ previous).
useEffect(() => {
shellStore.publishValidity(validity, { scriptFingerprint });
}, [scriptFingerprint, validity]);
const reviewChanged = lastPublishedReviewRef.current !== workbench.scriptReview;
lastPublishedReviewRef.current = workbench.scriptReview ?? null;
shellStore.publishValidity(validity, {
scriptFingerprint: reviewChanged ? scriptFingerprint : undefined,
});
}, [scriptFingerprint, validity, workbench.scriptReview]);

const updateParam = (workbench as { updateParam?: StudioRecomputeResult['updateParam'] }).updateParam;
const setGeometryTransformOverride =
Expand Down Expand Up @@ -140,6 +145,8 @@ export function useRecomputeResult(): StudioRecomputeResult {
);
}

const UNPUBLISHED_REVIEW = Symbol('kernelcad.unpublishedReview');

function normalizePrompt(value: string | undefined): string | null {
if (value == null) return null;
const trimmed = value.trim();
Expand Down
1 change: 1 addition & 0 deletions src/studio/store/shellStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export interface AgentRepairWorkflow {
readonly code: string;
readonly promptText: string;
readonly targetId: SelectedFeatureId;
readonly targetIds?: readonly string[];
readonly promptSource: 'review' | 'fallback';
readonly validityFingerprint: string;
readonly state: AgentRepairWorkflowState;
Expand Down
Loading
Loading