desktop: Fix with AI on scan findings that need a code change (aiFix: true, AI_FIX_RULES). The host (src-tauri/src/ai_fix.rs) runs the owner's local Claude Code headless in the app folder with Read / Edit / Write / Grep / Glob only (Bash, web tools, sub-agents disallowed) and a prompt it builds from the finding's fields; it refuses while the folder has uncommitted files, streams progress, times out after 10 min with a heartbeat, kills the process group on Cancel or exit, and records the run's changed files so Undo restores exactly those; the window shows the diff with Keep / Undo and rescans. Without claude the button copies the prompt (Copy for AI). Only the claude binary, git and kill / taskkill are started there (tests/security.rs); tests/ai_fix.rs covers the prompt, dirty-tree refusal, undo scope, timeout and cancel
This commit is contained in:
parent
992f5bcc0e
commit
c95f2af8ff
24 changed files with 1023 additions and 10 deletions
115
apps/desktop/src/ui/AiFix.tsx
Normal file
115
apps/desktop/src/ui/AiFix.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
// "Fix with AI" for one scan finding: the owner's local Claude Code CLI, run by the host (src-tauri/src/ai_fix.rs)
|
||||
// in the app folder with the file tools only. Without claude on PATH the button copies the same prompt ("Copy for
|
||||
// AI"). A dirty git tree is refused with the list of files; a run streams its lines, can be cancelled, and ends with
|
||||
// the diff of what it changed, Keep (nothing more) and Undo (the host restores exactly those files), and a fresh scan
|
||||
// that says whether the finding is gone.
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { type AiChange, type AiFinding, type AiFixRun, api, errorText, newJob, onAiFix } from '../api';
|
||||
import { t } from '../i18n';
|
||||
import type { ScanFinding } from '../lib/model';
|
||||
import { useData } from '../store';
|
||||
import { Icon } from './icons';
|
||||
|
||||
export const aiFinding = (f: ScanFinding): AiFinding => ({
|
||||
rule: f.rule, guideline: f.guideline, store: f.store, title: f.title, fix: f.fix, skill: f.skill,
|
||||
evidence: (Array.isArray(f.evidence) ? f.evidence : []).slice(0, 5).map(e => ({ file: e.file, line: e.line, ...(e.text ? { text: e.text } : {}) })),
|
||||
});
|
||||
|
||||
type Phase =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'dirty'; files: AiChange[] }
|
||||
| { kind: 'running'; job: string; lines: string[] }
|
||||
| { kind: 'done'; run: Extract<AiFixRun, { runId: string }>; lines: string[]; undone?: string[] }
|
||||
| { kind: 'error'; msg: string };
|
||||
|
||||
/** One finding's AI fix, under the finding's text: the button (Fix with AI / Copy for AI), then its panel. */
|
||||
export function AiFix({ projectId, f, claude, busy, setBusy, rescan, stillThere, onActive }: {
|
||||
projectId: string; f: ScanFinding; claude: boolean | null; busy: boolean; setBusy: (b: boolean) => void;
|
||||
rescan: () => void; stillThere: boolean; onActive: (on: boolean) => void;
|
||||
}) {
|
||||
const d = useData();
|
||||
const [p, setP] = useState<Phase>({ kind: 'idle' });
|
||||
const logRef = useRef<HTMLPreElement>(null);
|
||||
useEffect(() => { if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; });
|
||||
const active = p.kind !== 'idle';
|
||||
useEffect(() => { onActive(active); }, [active]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
const info = await api.aiFixInfo(projectId, aiFinding(f));
|
||||
await navigator.clipboard.writeText(info.prompt);
|
||||
d.toast({ kind: 'ok', title: t('health.copied') });
|
||||
} catch (e) { d.toast({ kind: 'bad', title: t('health.copyFailed'), detail: errorText(e) }); }
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
const job = newJob('ai-fix');
|
||||
setBusy(true);
|
||||
const lines: string[] = [];
|
||||
const un = await onAiFix(e => { if (e.job === job) { lines.push(e.line); setP({ kind: 'running', job, lines: lines.slice(-200) }); } });
|
||||
try {
|
||||
const info = await api.aiFixInfo(projectId, aiFinding(f));
|
||||
if (!info.git) { setP({ kind: 'error', msg: info.gitError ?? t('ai.noGit') }); return; }
|
||||
if (info.dirty.length) { setP({ kind: 'dirty', files: info.dirty }); return; }
|
||||
setP({ kind: 'running', job, lines: [] });
|
||||
const r = await api.aiFixRun(projectId, aiFinding(f), job);
|
||||
if (r.refused) { setP({ kind: 'dirty', files: r.dirty }); return; }
|
||||
setP({ kind: 'done', run: r, lines: lines.slice(-200) });
|
||||
if (r.changes.length) rescan();
|
||||
} catch (e) { setP({ kind: 'error', msg: errorText(e) }); }
|
||||
finally { un(); setBusy(false); }
|
||||
};
|
||||
|
||||
const undo = async (run: Extract<AiFixRun, { runId: string }>) => {
|
||||
try {
|
||||
const files = await api.aiFixUndo(run.runId);
|
||||
setP(x => (x.kind === 'done' ? { ...x, undone: files } : x));
|
||||
rescan();
|
||||
} catch (e) { setP({ kind: 'error', msg: errorText(e) }); }
|
||||
};
|
||||
|
||||
const button = claude === false
|
||||
? <button className="btn btn-sm" onClick={copy} title={t('ai.copyHint')}><Icon name="copy" />{t('ai.copy')}</button>
|
||||
: <button className="btn btn-sm" onClick={start} disabled={busy || claude === null} title={t('ai.runHint')} aria-busy={p.kind === 'running' || undefined}>
|
||||
<Icon name={p.kind === 'running' ? 'refresh' : 'sparkle'} className={p.kind === 'running' ? 'spin' : undefined} />{t('ai.run')}</button>;
|
||||
|
||||
let panel: ReactNode = null;
|
||||
if (p.kind === 'dirty') panel = (
|
||||
<div className="ai-panel" role="status">
|
||||
<span className="li-sub">{t('ai.dirty', { count: p.files.length })}</span>
|
||||
<pre className="log selectable">{p.files.slice(0, 30).map(c => `${c.status.padEnd(2)} ${c.path}`).join('\n')}</pre>
|
||||
<div className="row"><button className="btn btn-sm" onClick={() => setP({ kind: 'idle' })}>{t('common.close')}</button></div>
|
||||
</div>
|
||||
);
|
||||
else if (p.kind === 'running') panel = (
|
||||
<div className="ai-panel" role="status">
|
||||
<span className="li-sub">{t('ai.running')}</span>
|
||||
<pre className="log" ref={logRef} aria-live="polite">{p.lines.join('\n') || t('ai.starting')}</pre>
|
||||
<div className="row"><button className="btn btn-sm" onClick={() => { api.cancel(p.job).catch(() => {}); }}><Icon name="x" />{t('common.cancel')}</button></div>
|
||||
</div>
|
||||
);
|
||||
else if (p.kind === 'done') {
|
||||
const o = p.run.outcome;
|
||||
const head = o.cancelled ? t('ai.cancelled') : o.timed_out ? t('ai.timedOut') : !p.run.changes.length ? t('ai.noChanges') : t('ai.changed', { count: p.run.changes.length });
|
||||
panel = (
|
||||
<div className="ai-panel" role="status">
|
||||
<span className="li-sub">{head}{p.run.changes.length > 0 && !p.undone ? ` · ${stillThere ? t('ai.stillThere') : t('ai.gone')}` : ''}</span>
|
||||
{o.summary && <span className="li-sub selectable">{o.summary}</span>}
|
||||
{p.run.diff && !p.undone && <pre className="log selectable ai-diff" aria-label={t('ai.diff')}>{p.run.diff}</pre>}
|
||||
{p.undone ? <><span className="li-sub">{t('ai.undone', { count: p.undone.length })}</span><div className="row"><button className="btn btn-sm" onClick={() => setP({ kind: 'idle' })}>{t('common.close')}</button></div></> : (
|
||||
<div className="row">
|
||||
{p.run.changes.length > 0 && <button className="btn btn-sm btn-primary" onClick={() => setP({ kind: 'idle' })}><Icon name="check" />{t('ai.keep')}</button>}
|
||||
{p.run.changes.length > 0 && <button className="btn btn-sm" onClick={() => undo(p.run)}><Icon name="rotate" />{t('ai.undo')}</button>}
|
||||
{!p.run.changes.length && <button className="btn btn-sm" onClick={() => setP({ kind: 'idle' })}>{t('common.close')}</button>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else if (p.kind === 'error') panel = (
|
||||
<div className="ai-panel" role="alert">
|
||||
<span className="li-sub">{t('ai.failed', { msg: p.msg })}</span>
|
||||
<div className="row"><button className="btn btn-sm" onClick={() => setP({ kind: 'idle' })}>{t('common.close')}</button></div>
|
||||
</div>
|
||||
);
|
||||
return <>{p.kind === 'idle' || p.kind === 'running' ? <div className="ai-actions">{button}</div> : null}{panel}</>;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue