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
96 changes: 92 additions & 4 deletions src/studio/StagedEditSlot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useCallback, useState } from 'react';
import { Check, RotateCcw, X } from 'lucide-react';
import { useShellStore, shellStore } from './store/useShellStore';
import { useWorkbench } from './context/WorkbenchContext';
import type { StagedEdit } from './store/shellStore';
import type { AppliedEditHistoryEntry, StagedEdit } from './store/shellStore';

// Slice 1.5: real body. Reads stagedEdit from the shell store. When
// populated, renders the intent, a minimal line-by-line diff, and
Expand Down Expand Up @@ -128,8 +128,89 @@ function StagedEditContextDetails({ edit }: { edit: StagedEdit }) {
);
}

function AppliedEditHistory({ entries }: { entries: readonly AppliedEditHistoryEntry[] }) {
if (entries.length === 0) return null;

return (
<div
className="mt-1 border-t border-[#252a33] pt-2 space-y-1"
data-testid="applied-edit-history"
aria-label="Recent staged edit outcomes"
>
<div className="uppercase tracking-wide text-[10px] text-gray-500">
Recent edits
</div>
{entries.map((entry) => {
const metadata = formatHistoryMetadata(entry);
return (
<div
key={entry.id}
className="min-w-0 rounded border border-[#252a33] bg-[#111318] px-2 py-1.5 text-[10px] text-gray-400 space-y-1"
>
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<span className="font-medium text-gray-200">{formatOutcome(entry.outcome)}</span>
<span className="font-mono text-emerald-300">
+{entry.addedLines} / -{entry.removedLines}
</span>
{entry.recheckStatus !== 'not-applicable' && (
<span className="text-gray-500">{formatRecheckStatus(entry.recheckStatus)}</span>
)}
</div>
<div className="line-clamp-1 text-gray-300" title={entry.intent}>
{entry.intent}
</div>
{entry.promptText && (
<div className="line-clamp-2 break-words text-gray-500">{entry.promptText}</div>
)}
{metadata.length > 0 && (
<div className="line-clamp-1 break-words text-gray-500">
{metadata.join(' · ')}
</div>
)}
</div>
);
})}
</div>
);
}

function formatHistoryMetadata(entry: AppliedEditHistoryEntry): string[] {
const metadata: string[] = [];
if (entry.sourceLabel) metadata.push(entry.sourceLabel);
if (entry.selectedFeatureId) metadata.push(entry.selectedFeatureId);
if (entry.repairPromptSource) metadata.push(`${entry.repairPromptSource} repair`);
if (entry.generationId) metadata.push(entry.generationId);
return metadata;
}

function formatOutcome(outcome: AppliedEditHistoryEntry['outcome']): string {
switch (outcome) {
case 'approved':
return 'Approved';
case 'rejected':
return 'Rejected';
case 'rerun':
return 'Rerun';
}
}

function formatRecheckStatus(status: AppliedEditHistoryEntry['recheckStatus']): string {
switch (status) {
case 'pending-recheck':
return 'pending recheck';
case 'rechecked-solved':
return 'rechecked solved';
case 'rechecked-issues':
return 'rechecked issues';
case 'recheck-error':
return 'recheck error';
case 'not-applicable':
return '';
}
}

export function StagedEditSlot() {
const { stagedEdit } = useShellStore();
const { stagedEdit, appliedEditHistory } = useShellStore();
const { code, setCode } = useWorkbench();
const [staleWarning, setStaleWarning] = useState<{ editId: string; message: string } | null>(null);
const visibleStaleWarning =
Expand All @@ -147,16 +228,21 @@ export function StagedEditSlot() {
return;
}
setCode(stagedEdit.toCode);
shellStore.recordStagedEditOutcome(stagedEdit, 'approved');
shellStore.clearStagedEdit();
}, [code, stagedEdit, setCode]);

const handleReject = useCallback(() => {
if (stagedEdit != null) {
shellStore.recordStagedEditOutcome(stagedEdit, 'rejected');
}
setStaleWarning(null);
shellStore.clearStagedEdit();
}, []);
}, [stagedEdit]);

const handleRerunPrompt = useCallback(() => {
const context = stagedEdit?.context;
if (stagedEdit == null) return;
const context = stagedEdit.context;
if (context == null) return;

const prompt = context.promptText.trim();
Expand All @@ -168,6 +254,7 @@ export function StagedEditSlot() {
context.repairWorkflow == null ? null : { ...context.repairWorkflow, state: 'drafted' }
);
shellStore.setAgentRailOpen(true);
shellStore.recordStagedEditOutcome(stagedEdit, 'rerun');
shellStore.clearStagedEdit();
setStaleWarning(null);
}, [stagedEdit]);
Expand Down Expand Up @@ -232,6 +319,7 @@ export function StagedEditSlot() {
</div>
</>
)}
<AppliedEditHistory entries={appliedEditHistory} />
</div>
);
}
Expand Down
13 changes: 12 additions & 1 deletion src/studio/__tests__/StagedEditSlot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,17 @@ describe('StagedEditSlot', () => {
intent: 'demo',
fromCode,
toCode,
source: { kind: 'agent', label: 'Studio Generate' },
});
const { getByTestId } = render(<StagedEditSlot />);
fireEvent.click(getByTestId('staged-edit-approve'));
expect(setCodeMock).toHaveBeenCalledTimes(1);
expect(setCodeMock).toHaveBeenCalledWith(toCode);
expect(shellStore.getSnapshot().stagedEdit).toBeNull();
expect(getByTestId('applied-edit-history').textContent).toContain('Approved');
expect(getByTestId('applied-edit-history').textContent).toContain('demo');
expect(getByTestId('applied-edit-history').textContent).toContain('Studio Generate');
expect(getByTestId('applied-edit-history').textContent).toContain('+2 / -1');
});

it('Approve blocks stale staged edits when the editor changed since proposal', () => {
Expand All @@ -71,11 +76,12 @@ describe('StagedEditSlot', () => {
});
workbenchCode = 'return box(15);';

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

expect(setCodeMock).not.toHaveBeenCalled();
expect(shellStore.getSnapshot().stagedEdit?.id).toBe('e-stale');
expect(queryByTestId('applied-edit-history')).toBeNull();
expect(getByTestId('staged-edit-stale-warning').textContent).toContain('changed since this edit was staged');
});

Expand All @@ -90,6 +96,8 @@ describe('StagedEditSlot', () => {
fireEvent.click(getByTestId('staged-edit-reject'));
expect(setCodeMock).not.toHaveBeenCalled();
expect(shellStore.getSnapshot().stagedEdit).toBeNull();
expect(getByTestId('applied-edit-history').textContent).toContain('Rejected');
expect(getByTestId('applied-edit-history').textContent).toContain('demo');
});

it('source label renders when provided', () => {
Expand Down Expand Up @@ -174,6 +182,9 @@ describe('StagedEditSlot', () => {
expect(snapshot.agentDraftPrompt).toBe('Fix output-horn floating mate');
expect(snapshot.selectedFeatureId).toBe('output-horn');
expect(snapshot.agentRepairWorkflow).toBeNull();
expect(getByTestId('applied-edit-history').textContent).toContain('Rerun');
expect(getByTestId('applied-edit-history').textContent).toContain('Fix output-horn floating mate');
expect(getByTestId('applied-edit-history').textContent).toContain('gen-rerun');
});

it('stale conflicts omit rerun prompt when no prompt context exists', () => {
Expand Down
83 changes: 83 additions & 0 deletions src/studio/__tests__/shellStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,4 +229,87 @@ describe('ShellStore', () => {
expect(store.getSnapshot().stagedEdit).toBeNull();
expect(listener).toHaveBeenCalledTimes(1);
});

it('records staged edit outcomes with bounded deterministic diff evidence', () => {
store = new ShellStore();
const edit = {
id: 'e-ledger',
intent: 'Repair output horn',
fromCode: 'return box(10);\n',
toCode: 'const part = box(20);\nreturn part;\n',
source: { kind: 'agent' as const, label: 'Studio Generate' },
context: {
promptText: 'Fix output-horn mate',
selectedFeatureId: 'output-horn',
repairWorkflow: null,
generationId: 'gen-ledger',
},
};

store.recordStagedEditOutcome(edit, 'approved');

const entry = store.getSnapshot().appliedEditHistory[0];
expect(entry).toMatchObject({
id: 'e-ledger:approved:1',
editId: 'e-ledger',
outcome: 'approved',
intent: 'Repair output horn',
sourceLabel: 'Studio Generate',
promptText: 'Fix output-horn mate',
selectedFeatureId: 'output-horn',
generationId: 'gen-ledger',
addedLines: 2,
removedLines: 1,
recheckStatus: 'pending-recheck',
});

for (let i = 0; i < 8; i++) {
store.recordStagedEditOutcome({ ...edit, id: `e-${i}`, intent: `edit ${i}` }, 'rejected');
}

const history = store.getSnapshot().appliedEditHistory;
expect(history).toHaveLength(6);
expect(history[0].editId).toBe('e-7');
expect(history.at(-1)?.editId).toBe('e-2');
});

it('publishValidity updates only pending approved ledger entries', () => {
store = new ShellStore();
const approved = { id: 'approved', intent: 'approved', fromCode: 'a', toCode: 'b' };
const rejected = { id: 'rejected', intent: 'rejected', fromCode: 'a', toCode: 'b' };
store.recordStagedEditOutcome(approved, 'approved');
store.recordStagedEditOutcome(rejected, 'rejected');

store.publishValidity(makeValidity('warning'));
expect(store.getSnapshot().appliedEditHistory[0]).toMatchObject({
editId: 'rejected',
recheckStatus: 'not-applicable',
});
expect(store.getSnapshot().appliedEditHistory[1]).toMatchObject({
editId: 'approved',
recheckStatus: 'rechecked-issues',
});

store.publishValidity(makeValidity('solved'));
expect(store.getSnapshot().appliedEditHistory[1]).toMatchObject({
editId: 'approved',
recheckStatus: 'rechecked-issues',
});

store = new ShellStore();
store.recordStagedEditOutcome(approved, 'approved');
store.publishValidity(makeValidity('solved'));
expect(store.getSnapshot().appliedEditHistory[0]).toMatchObject({
editId: 'approved',
recheckStatus: 'rechecked-solved',
});

store = new ShellStore();
store.recordStagedEditOutcome(approved, 'approved');
store.publishValidity(makeValidity('error'));
expect(store.getSnapshot().appliedEditHistory[0]).toMatchObject({
editId: 'approved',
recheckStatus: 'recheck-error',
});
});
});
Loading
Loading