Skip to content

Commit f377773

Browse files
authored
Merge pull request #600 from w1ne/agent/kyrall-staged-edit-safety
Stage generated Studio edits safely
2 parents a47f8d6 + 2daf78a commit f377773

5 files changed

Lines changed: 309 additions & 33 deletions

File tree

src/studio/StagedEditSlot.tsx

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// SPDX-License-Identifier: MIT
22
// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors
3-
import { useCallback } from 'react';
3+
import { useCallback, useState } from 'react';
44
import { Check, X } from 'lucide-react';
55
import { useShellStore, shellStore } from './store/useShellStore';
66
import { useWorkbench } from './context/WorkbenchContext';
@@ -10,10 +10,9 @@ import type { StagedEdit } from './store/shellStore';
1010
// populated, renders the intent, a minimal line-by-line diff, and
1111
// approve/reject buttons. When empty, renders the auto-apply placeholder.
1212
//
13-
// Approve writes stagedEdit.toCode through workbench.setCode, which
14-
// flows through the normal recompute pipeline (AST-edit primacy
15-
// preserved — the proposed text IS the AST-edited result; we just
16-
// hand the canonical script to the existing setter).
13+
// Approve writes stagedEdit.toCode through workbench.setCode only if the
14+
// editor still matches the staged baseline. That keeps generated edits from
15+
// overwriting intervening human changes.
1716

1817
function computeLineDiff(from: string, to: string): Array<{ kind: 'context' | 'add' | 'del'; text: string }> {
1918
// Trivial line diff: walk both, mark non-matching lines as add/del.
@@ -87,15 +86,28 @@ function PlaceholderBody() {
8786

8887
export function StagedEditSlot() {
8988
const { stagedEdit } = useShellStore();
90-
const { setCode } = useWorkbench();
89+
const { code, setCode } = useWorkbench();
90+
const [staleWarning, setStaleWarning] = useState<{ editId: string; message: string } | null>(null);
91+
const visibleStaleWarning =
92+
stagedEdit != null && staleWarning?.editId === stagedEdit.id
93+
? staleWarning.message
94+
: null;
9195

9296
const handleApprove = useCallback(() => {
9397
if (stagedEdit == null) return;
98+
if (code !== stagedEdit.fromCode) {
99+
setStaleWarning({
100+
editId: stagedEdit.id,
101+
message: 'The editor changed since this edit was staged. Review the current code before applying this proposal.',
102+
});
103+
return;
104+
}
94105
setCode(stagedEdit.toCode);
95106
shellStore.clearStagedEdit();
96-
}, [stagedEdit, setCode]);
107+
}, [code, stagedEdit, setCode]);
97108

98109
const handleReject = useCallback(() => {
110+
setStaleWarning(null);
99111
shellStore.clearStagedEdit();
100112
}, []);
101113

@@ -121,6 +133,14 @@ export function StagedEditSlot() {
121133
{stagedEdit.source.kind} · {stagedEdit.source.label}
122134
</div>
123135
)}
136+
{visibleStaleWarning != null && (
137+
<div
138+
className="rounded border border-amber-800/70 bg-amber-950/40 px-2 py-1 text-[10px] text-amber-200"
139+
data-testid="staged-edit-stale-warning"
140+
>
141+
{visibleStaleWarning}
142+
</div>
143+
)}
124144
<div className="flex gap-2">
125145
<button
126146
type="button"

src/studio/StudioGenerate.tsx

Lines changed: 66 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ import { useTextTo3dPreview } from '../funnel/hooks/useTextTo3dPreview';
88
import { inAppAgentEnabled } from './agentAvailability';
99
import { ConceptResult } from './components/ConceptResult';
1010
import { useCode } from './context/CodeContext';
11-
import { useGeometry } from './context/GeometryContext';
1211
import { useFeatureSelection } from './hooks/useFeatureSelection';
1312
import { useShellStore, shellStore } from './store/useShellStore';
13+
import type { AgentRepairWorkflow } from './store/shellStore';
14+
import type { SelectedFeatureId } from './types';
1415

1516
/** Web-only gate. No hooks here, so the conditional return is safe. */
1617
export const StudioGenerate: React.FC = () => {
@@ -32,21 +33,28 @@ function stepLabel(e: GenerateEvent): string | null {
3233
}
3334
}
3435

36+
interface GenerationReviewSnapshot {
37+
readonly fromCode: string;
38+
readonly promptText: string;
39+
readonly selectedFeatureId: SelectedFeatureId;
40+
readonly repairWorkflow: AgentRepairWorkflow | null;
41+
}
42+
3543
const StudioGenerateInner: React.FC = () => {
3644
const { phase, events, submit } = useGeneration();
37-
const { code, setCode } = useCode();
45+
const { code } = useCode();
3846
const currentCode = code ?? '';
39-
const { executeGeometry } = useGeometry();
4047
const { selectedFeatureId } = useFeatureSelection();
4148
const { agentDraftPrompt, agentDraftPromptVersion, agentRepairWorkflow } = useShellStore();
4249
const [editedPrompt, setEditedPrompt] = useState('');
4350
const [acknowledgedDraftVersion, setAcknowledgedDraftVersion] = useState(-1);
44-
// The generationId we've already accepted/rejected — gates the review panel
45-
// so an applied/dismissed proposal doesn't reappear.
46-
const [resolvedId, setResolvedId] = useState<string | null>(null);
51+
// The generationId we've already staged/rejected — gates the review panel
52+
// so a resolved proposal doesn't reappear.
53+
const [resolution, setResolution] = useState<{ generationId: string; action: 'staged' | 'discarded' } | null>(null);
4754
// The editor source captured at submit time — the "before" side of the diff
4855
// (so the diff is stable even though `code` changes once we apply).
4956
const [baseline, setBaseline] = useState('');
57+
const [reviewSnapshot, setReviewSnapshot] = useState<GenerationReviewSnapshot | null>(null);
5058

5159
const prompt =
5260
agentDraftPrompt !== null && agentDraftPromptVersion !== acknowledgedDraftVersion
@@ -69,7 +77,7 @@ const StudioGenerateInner: React.FC = () => {
6977
// One operation at a time: the rail is too narrow to narrate two runs.
7078
const busy = agentBusy || conceptBusy;
7179
// A finished, not-yet-resolved proposal → show the review (diff + accept/reject).
72-
const reviewing = phase.state === 'done' && resolvedId !== phase.generationId;
80+
const reviewing = phase.state === 'done' && resolution?.generationId !== phase.generationId;
7381

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

@@ -78,12 +86,13 @@ const StudioGenerateInner: React.FC = () => {
7886
shellStore.setAgentRepairWorkflow({ ...agentRepairWorkflow, state: 'drafted' });
7987
}, [agentRepairWorkflow, phase.state]);
8088

81-
const runAgent = (text: string) => {
89+
const runAgent = (text: string, snapshot: GenerationReviewSnapshot) => {
8290
// Edit mode: hand the agent the current model so it iterates instead of
8391
// replacing. Empty editor → fresh generation.
84-
setBaseline(currentCode);
85-
if (currentCode.trim()) {
86-
void submit(text, currentCode);
92+
setBaseline(snapshot.fromCode);
93+
setReviewSnapshot(snapshot);
94+
if (snapshot.fromCode.trim()) {
95+
void submit(text, snapshot.fromCode);
8796
return;
8897
}
8998
void submit(text);
@@ -94,15 +103,22 @@ const StudioGenerateInner: React.FC = () => {
94103
const trimmed = prompt.trim();
95104
if (!trimmed || busy) return;
96105
const agentPrompt = selectedFeatureId === null ? trimmed : `Edit selected target "${selectedFeatureId}": ${trimmed}`;
106+
let repairWorkflowForRun = agentRepairWorkflow;
97107
if (
98108
agentRepairWorkflow != null &&
99109
agentRepairWorkflow.state === 'drafted' &&
100110
agentRepairWorkflow.promptText === trimmed &&
101111
agentRepairWorkflow.targetId === selectedFeatureId
102112
) {
103-
shellStore.setAgentRepairWorkflow({ ...agentRepairWorkflow, state: 'running' });
113+
repairWorkflowForRun = { ...agentRepairWorkflow, state: 'running' };
114+
shellStore.setAgentRepairWorkflow(repairWorkflowForRun);
104115
}
105-
runAgent(agentPrompt);
116+
runAgent(agentPrompt, {
117+
fromCode: currentCode,
118+
promptText: trimmed,
119+
selectedFeatureId,
120+
repairWorkflow: repairWorkflowForRun,
121+
});
106122
};
107123

108124
const onConcept = () => {
@@ -120,6 +136,12 @@ const StudioGenerateInner: React.FC = () => {
120136
// review diff still uses the current editor code as its baseline, so
121137
// nothing is overwritten without the user accepting.
122138
setBaseline(currentCode);
139+
setReviewSnapshot({
140+
fromCode: currentCode,
141+
promptText: conceptPrompt,
142+
selectedFeatureId,
143+
repairWorkflow: agentRepairWorkflow,
144+
});
123145
// Read the concept mesh directly from the live preview phase (no mirrored
124146
// state). A done preview with no Tripo render/fingerprint yields
125147
// {renderImageUrl:null, proportions:null} — intentional and distinct from
@@ -130,19 +152,34 @@ const StudioGenerateInner: React.FC = () => {
130152
void submit(conceptPrompt, undefined, mesh);
131153
};
132154

133-
const accept = () => {
155+
const stageGeneratedEdit = () => {
134156
if (phase.state !== 'done') return;
135-
setCode(phase.artifact.code);
136-
void executeGeometry(phase.artifact.code);
137-
setResolvedId(phase.generationId);
157+
const snapshot = reviewSnapshot ?? {
158+
fromCode: currentCode,
159+
promptText: prompt.trim(),
160+
selectedFeatureId,
161+
repairWorkflow: agentRepairWorkflow,
162+
};
163+
shellStore.proposeStagedEdit({
164+
id: `agent:${phase.generationId}`,
165+
intent: phase.artifact.title,
166+
fromCode: snapshot.fromCode,
167+
toCode: phase.artifact.code,
168+
source: { kind: 'agent', label: 'Studio Generate' },
169+
context: {
170+
promptText: snapshot.promptText,
171+
selectedFeatureId: snapshot.selectedFeatureId,
172+
repairWorkflow: snapshot.repairWorkflow,
173+
generationId: phase.generationId,
174+
},
175+
});
176+
setResolution({ generationId: phase.generationId, action: 'staged' });
138177
};
139178
const reject = () => {
140179
if (phase.state !== 'done') return;
141-
setResolvedId(phase.generationId);
180+
setResolution({ generationId: phase.generationId, action: 'discarded' });
142181
};
143182

144-
const isEdit = baseline.trim().length > 0;
145-
146183
return (
147184
<div className="p-3 flex flex-col gap-2">
148185
<div className="uppercase tracking-wide text-[10px] text-gray-500">Agent</div>
@@ -222,10 +259,10 @@ const StudioGenerateInner: React.FC = () => {
222259
<div className="flex gap-2">
223260
<button
224261
type="button"
225-
onClick={accept}
262+
onClick={stageGeneratedEdit}
226263
className="flex-1 rounded bg-green-600 hover:bg-green-500 text-white px-3 py-1.5 text-[11px] font-medium transition-colors"
227264
>
228-
{isEdit ? 'Apply changes' : 'Use this'}
265+
Stage edit
229266
</button>
230267
<button
231268
type="button"
@@ -238,9 +275,14 @@ const StudioGenerateInner: React.FC = () => {
238275
</div>
239276
)}
240277

241-
{phase.state === 'done' && !reviewing && (
278+
{phase.state === 'done' && !reviewing && resolution?.action === 'staged' && (
242279
<div className="text-[10px] text-green-500 truncate" aria-live="polite">
243-
✓ applied — {phase.artifact.title}
280+
✓ staged for review — {phase.artifact.title}
281+
</div>
282+
)}
283+
{phase.state === 'done' && !reviewing && resolution?.action === 'discarded' && (
284+
<div className="text-[10px] text-gray-500 truncate" aria-live="polite">
285+
discarded — {phase.artifact.title}
244286
</div>
245287
)}
246288
{phase.state === 'error' && (

src/studio/__tests__/StagedEditSlot.test.tsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,16 @@ import { StagedEditSlot } from '../StagedEditSlot';
77
import { shellStore } from '../store/shellStore';
88

99
const setCodeMock = vi.fn();
10+
let workbenchCode = '';
1011

1112
vi.mock('../context/WorkbenchContext', () => ({
12-
useWorkbench: () => ({ setCode: setCodeMock }),
13+
useWorkbench: () => ({ code: workbenchCode, setCode: setCodeMock }),
1314
}));
1415

1516
beforeEach(() => {
1617
shellStore.reset();
1718
setCodeMock.mockReset();
19+
workbenchCode = '';
1820
});
1921

2022
afterEach(() => {
@@ -45,10 +47,12 @@ describe('StagedEditSlot', () => {
4547

4648
it('Approve calls workbench.setCode(toCode) and clears the slot', () => {
4749
const toCode = 'const a = box(10);\nreturn a;';
50+
const fromCode = 'return box(10);';
51+
workbenchCode = fromCode;
4852
shellStore.proposeStagedEdit({
4953
id: 'e2',
5054
intent: 'demo',
51-
fromCode: 'return box(10);',
55+
fromCode,
5256
toCode,
5357
});
5458
const { getByTestId } = render(<StagedEditSlot />);
@@ -58,6 +62,23 @@ describe('StagedEditSlot', () => {
5862
expect(shellStore.getSnapshot().stagedEdit).toBeNull();
5963
});
6064

65+
it('Approve blocks stale staged edits when the editor changed since proposal', () => {
66+
shellStore.proposeStagedEdit({
67+
id: 'e-stale',
68+
intent: 'demo',
69+
fromCode: 'return box(10);',
70+
toCode: 'return box(20);',
71+
});
72+
workbenchCode = 'return box(15);';
73+
74+
const { getByTestId } = render(<StagedEditSlot />);
75+
fireEvent.click(getByTestId('staged-edit-approve'));
76+
77+
expect(setCodeMock).not.toHaveBeenCalled();
78+
expect(shellStore.getSnapshot().stagedEdit?.id).toBe('e-stale');
79+
expect(getByTestId('staged-edit-stale-warning').textContent).toContain('changed since this edit was staged');
80+
});
81+
6182
it('Reject leaves the script unchanged and clears the slot', () => {
6283
shellStore.proposeStagedEdit({
6384
id: 'e3',

0 commit comments

Comments
 (0)