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:
CRTkafa 2026-10-15 06:26:29 +08:00
parent 992f5bcc0e
commit c95f2af8ff
24 changed files with 1023 additions and 10 deletions

View file

@ -69,6 +69,13 @@ async function listen<T>(event: string, cb: (payload: T) => void): Promise<Unlis
return tauriListen<T>(event, e => cb(e.payload));
}
/** The finding fields the host builds the Fix with AI prompt from. */
export interface AiFinding { rule: string; guideline: string; store: 'apple' | 'play'; title: string; evidence: { file: string; line: number; text?: string }[]; fix: string; skill: string }
export interface AiChange { path: string; status: string }
export interface AiFixInfo { claude: boolean; prompt: string; git: boolean; gitError: string | null; dirty: AiChange[] }
export type AiFixRun = { refused: 'dirty'; dirty: AiChange[] }
| { refused?: undefined; runId: string; outcome: { code: number | null; timed_out: boolean; cancelled: boolean; summary: string }; changes: AiChange[]; diff: string };
let jobSeq = 0;
/** A fresh job id for one background run (progress events and cancel refer to it). */
export const newJob = (kind: string): string => `${kind.replace(/[^A-Za-z0-9_.-]/g, '-').slice(0, 40)}-${Date.now().toString(36)}-${++jobSeq}`;
@ -84,6 +91,10 @@ export const api = {
invoke<CacheEntry<T> | null>('cache_get', { method, projectId, args }),
clearCache: () => invoke<void>('cache_clear'),
cancel: (job: string) => invoke<boolean>('job_cancel', { job }),
/** Fix with AI (src-tauri/src/ai_fix.rs): claude installed?, the prompt (also for Copy for AI), the dirty files. */
aiFixInfo: (projectId: string, finding: AiFinding) => invoke<AiFixInfo>('ai_fix_info', { projectId, finding }),
aiFixRun: (projectId: string, finding: AiFinding, job: string) => invoke<AiFixRun>('ai_fix_run', { projectId, finding, job }),
aiFixUndo: (runId: string) => invoke<string[]>('ai_fix_undo', { runId }),
/** Dry run, native confirm dialog with that dry run, then the real run with a host-minted token. */
writeWithConfirm: (method: string, projectId: string, args: Record<string, unknown> = {}, job?: string) =>
invoke<WriteOutcome>('write_with_confirm', { method, projectId, args, job: job ?? null }),
@ -155,6 +166,8 @@ export const reads = {
export interface ProgressEvent extends ProgressParams { job?: string }
export const onProgress = (cb: (p: ProgressEvent) => void): Promise<UnlistenFn> =>
listen<{ params: ProgressParams; job?: string }>('sidecar-progress', e => cb({ ...e.params, job: e.job }));
/** One progress line of a Fix with AI run. */
export const onAiFix = (cb: (e: { job: string; line: string }) => void): Promise<UnlistenFn> => listen<{ job: string; line: string }>('ai-fix', cb);
export const onDownload = (cb: (e: DownloadEvent) => void): Promise<UnlistenFn> => listen<DownloadEvent>('tool-download', cb);
export const onDeepLink = (cb: (screen: string) => void): Promise<UnlistenFn> => listen<string>('deep-link', cb);
/** storebound://open?path=... selected or added an app folder (its project id). */

View file

@ -359,15 +359,19 @@ const SCAN_PRISM: ScanResult['findings'] = [
{ rule: 'att', guideline: '5.1.2(i)', store: 'apple', severity: 'high', title: 'Tracking-capable code without App Tracking Transparency (AppLovin MAX, Unity Ads)',
evidence: [{ file: 'Packages/manifest.json', line: 4, text: 'com.applovin.mediation.ads 8.1.0' }], fix: 'Add NSUserTrackingUsageDescription and request ATT before the SDKs start, or configure non-personalized ads and answer "no tracking".', skill: 'ads-mediation-checks', ref: 'https://developer.apple.com/app-store/review/guidelines/' },
{ rule: 'play-target-sdk', guideline: 'Play: Target API level', store: 'play', severity: 'blocker', title: 'targetSdk 35 is below 36, which new apps and updates need since 2026-08-31',
evidence: [{ file: 'ProjectSettings/ProjectSettings.asset', line: 212, text: 'targetSdk 35' }], fix: 'Raise targetSdk to 36, test, upload a new AAB.', skill: 'play-first-release', ref: 'https://developer.android.com/google/play/requirements/target-sdk' },
evidence: [{ file: 'ProjectSettings/ProjectSettings.asset', line: 212, text: 'targetSdk 35' }], fix: 'Raise targetSdk to 36, test, upload a new AAB.', skill: 'play-first-release', ref: 'https://developer.android.com/google/play/requirements/target-sdk', aiFix: true },
{ rule: 'restore-purchases', guideline: '3.1.1', store: 'apple', severity: 'high', title: 'Restorable purchases (non-consumables or subscriptions) but no Restore Purchases path was found',
evidence: [{ file: 'storebound.config.ts', line: 41, text: 'no_ads (non_consumable)' }], fix: 'Add a visible "Restore Purchases" button on the store screen or in Settings.', skill: 'store-iap-setup', ref: 'https://developer.apple.com/app-store/review/guidelines/' },
{ rule: 'demo-account', guideline: '2.1(a)', store: 'apple', severity: 'blocker', title: 'Sign-in is required but there is no demo account for App Review',
evidence: [{ file: 'storebound.config.ts', line: 17, text: 'reviewAccess: { signInRequired: true }' }], fix: 'Add the App Review demo account in .storebound/ (review-access skill).', skill: 'review-access', ref: 'https://developer.apple.com/app-store/review/guidelines/', accepted: 'leaderboards only; the game plays without signing in' },
// code changes: Fix with AI (the owner's Claude Code) or Copy for AI
{ rule: 'ats', guideline: '2.5.1 / ATS', store: 'apple', severity: 'medium', title: 'NSAllowsArbitraryLoads is true: App Review asks for a reason',
evidence: [{ file: 'ios/Unity-iPhone/Info.plist', line: 48, text: '<key>NSAllowsArbitraryLoads</key><true/>' }], fix: 'Remove NSAllowsArbitraryLoads (or limit it with NSExceptionDomains) unless the app must load arbitrary http content.', skill: 'review-rejections', ref: 'https://developer.apple.com/documentation/bundleresources/information-property-list/nsapptransportsecurity', aiFix: true },
// a deterministic fix (scan.write: the detected answers into the config)
{ rule: 'privacy-labels', guideline: 'Play: User Data (Data safety)', store: 'play', severity: 'medium', title: 'Data safety likely misses PSL_DEVICE_ID: an SDK collects it, dataSafety does not declare it',
evidence: [{ file: 'Packages/manifest.json', line: 4, text: 'com.applovin.mediation.ads 8.1.0' }], fix: 'Confirm with the SDK\'s Data safety guidance, add it with `storebound scan --write` (or an edit of the config), then `storebound play data-safety`.', skill: 'store-declarations', ref: 'https://support.google.com/googleplay/android-developer/answer/10787469', action: { method: 'scan.write', args: {} } },
];
const aiRuns = new Map<string, ScanResult['findings'][number]>();
const SCAN: Record<string, ScanResult> = {
'p-prism': { stacks: ['unity'], sdks: [{ id: 'applovin', name: 'AppLovin MAX', category: 'ads', tracking: true }, { id: 'unity-ads', name: 'Unity Ads', category: 'ads', tracking: true }, { id: 'gameanalytics', name: 'GameAnalytics', category: 'analytics', tracking: false }],
findings: SCAN_PRISM, counts: scanCounts(SCAN_PRISM),
@ -578,6 +582,29 @@ async function answer(cmd: string, a: Record<string, unknown>): Promise<unknown>
return fixtureRead(method, id, ((a.params as { args?: Record<string, unknown> } | null)?.args) ?? {});
}
case 'job_cancel': cancelled.add(a.job as string); return true;
case 'ai_fix_info': {
const f = a.finding as { rule: string; evidence: { file: string; line: number }[] };
return { claude: !new URLSearchParams(location.search).has('noclaude'), git: true, gitError: null, dirty: new URLSearchParams(location.search).has('dirty') ? [{ path: 'ios/Unity-iPhone/Info.plist', status: 'M' }] : [],
prompt: `Fix one App Store rejection risk that \`storebound scan\` found in this app folder.\n\nFinding (${f.rule}): ...\nEvidence:\n${f.evidence.map(e => `- ${e.file}:${e.line}`).join('\n')}\n` };
}
case 'ai_fix_run': {
const job = a.job as string, f = a.finding as { rule: string; title: string; evidence: { file: string }[] };
for (const line of ['Read ios/Unity-iPhone/Info.plist', 'Grep NSAllowsArbitraryLoads', 'Edit ios/Unity-iPhone/Info.plist', 'Removed NSAllowsArbitraryLoads; no http URLs are loaded.']) {
await sleep(400); if (cancelled.has(job)) break; emit('ai-fix', { job, line });
}
if (cancelled.has(job)) return { runId: job, outcome: { code: null, timed_out: false, cancelled: true, summary: '' }, changes: [], diff: '' };
const file = f.evidence[0]?.file ?? 'ios/Unity-iPhone/Info.plist';
const i = SCAN_PRISM.findIndex(x => x.rule === f.rule && x.title === f.title);
const removed = i >= 0 ? SCAN_PRISM.splice(i, 1)[0] : undefined;
if (removed) { aiRuns.set(job, removed); SCAN['p-prism']!.counts = scanCounts(SCAN_PRISM); }
return { runId: job, outcome: { code: 0, timed_out: false, cancelled: false, summary: 'Removed NSAllowsArbitraryLoads; no http URLs are loaded.' }, changes: [{ path: file, status: 'M' }],
diff: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -45,8 +45,6 @@\n <key>NSAppTransportSecurity</key>\n <dict>\n- <key>NSAllowsArbitraryLoads</key>\n- <true/>\n </dict>\n` };
}
case 'ai_fix_undo': {
const r = aiRuns.get(a.runId as string);
if (r) { SCAN_PRISM.push(r); SCAN['p-prism']!.counts = scanCounts(SCAN_PRISM); aiRuns.delete(a.runId as string); }
return ['ios/Unity-iPhone/Info.plist'];
}
case 'write_with_confirm': {
const method = a.method as string, args = (a.args ?? {}) as Record<string, unknown>;
if (method === 'console.fill') {

View file

@ -444,6 +444,25 @@ export const en = {
'checks.fixHint.play.listing': 'Pushes the listing file to Google Play (text only, nothing is published). You see the dry run first.',
'checks.fixHint.apple.listing': 'Pushes the listing file and copyright to the App Store version (text only, nothing is submitted). You see the dry run first.',
'checks.fixHint.apple.review_details': 'Sends the App Review contact and notes from the config (nothing is submitted). You see the dry run first.',
'ai.run': 'Fix with AI',
'ai.runHint': 'Runs Claude Code on this machine in the app folder with file tools only (read and edit; no commands, no network, no git push). You see the diff and can undo it.',
'ai.copy': 'Copy for AI',
'ai.copyHint': 'Claude Code (claude) is not installed: copies a prompt for this finding to paste into your AI agent.',
'ai.starting': 'Starting Claude Code...',
'ai.running': 'Claude Code is fixing this finding (at most 10 minutes).',
'ai.dirty': 'Uncommitted files in the app folder ({count}): commit or stash them first, so Undo can restore exactly what the AI changes.',
'ai.noGit': 'The app folder is not in a git repository: Fix with AI needs git for the diff and Undo.',
'ai.cancelled': 'Cancelled',
'ai.timedOut': 'Stopped after 10 minutes',
'ai.noChanges': 'No files changed',
'ai.changed': 'Changed files: {count}',
'ai.stillThere': 'the scan still reports it',
'ai.gone': 'the scan no longer reports it',
'ai.diff': 'Changes',
'ai.keep': 'Keep',
'ai.undo': 'Undo',
'ai.undone': 'Undone. Restored files: {count}',
'ai.failed': 'Fix with AI failed: {msg}',
'checks.accepted': 'Accepted: {reason}',
'checks.sev.blocker': 'Blocker',
'checks.sev.high': 'High',

View file

@ -446,6 +446,25 @@ export const tr: Record<keyof typeof en, string> = {
'checks.fixHint.play.listing': "Mağaza metni dosyasını Google Play'e gönderir (yalnız metin, yayınlanmaz). Önce deneme çalıştırmasını görürsün.",
'checks.fixHint.apple.listing': 'Mağaza metnini ve telif hakkını App Store sürümüne gönderir (yalnız metin, incelemeye gönderilmez). Önce deneme çalıştırmasını görürsün.',
'checks.fixHint.apple.review_details': 'App Review iletişim bilgisini ve notları ayardan gönderir (incelemeye gönderilmez). Önce deneme çalıştırmasını görürsün.',
'ai.run': 'Yapay zekâyla düzelt',
'ai.runHint': "Claude Code'u bu bilgisayarda, uygulama klasöründe yalnız dosya araçlarıyla çalıştırır (okuma ve düzenleme; komut, ağ, git push yok). Farkı görür, geri alabilirsin.",
'ai.copy': 'Yapay zekâ için kopyala',
'ai.copyHint': 'Claude Code (claude) kurulu değil: bu bulgu için bir istemi yapay zekâ ajanına yapıştırmak üzere kopyalar.',
'ai.starting': 'Claude Code başlıyor...',
'ai.running': 'Claude Code bu bulguyu düzeltiyor (en çok 10 dakika).',
'ai.dirty': 'Uygulama klasöründe commit edilmemiş {count} dosya var: önce commit et ya da stash yap ki Geri al tam olarak yapay zekânın değiştirdiğini geri yüklesin.',
'ai.noGit': 'Uygulama klasörü bir git deposunda değil: Yapay zekâyla düzelt fark ve Geri al için git ister.',
'ai.cancelled': 'İptal edildi',
'ai.timedOut': '10 dakika sonra durduruldu',
'ai.noChanges': 'Hiçbir dosya değişmedi',
'ai.changed': 'Değişen dosya: {count}',
'ai.stillThere': 'tarama hâlâ bildiriyor',
'ai.gone': 'tarama artık bildirmiyor',
'ai.diff': 'Değişiklikler',
'ai.keep': 'Tut',
'ai.undo': 'Geri al',
'ai.undone': 'Geri alındı. Geri yüklenen dosya: {count}',
'ai.failed': 'Yapay zekâyla düzeltme başarısız: {msg}',
'checks.accepted': 'Kabul edildi: {reason}',
'checks.sev.blocker': 'Engel',
'checks.sev.high': 'Yüksek',

View file

@ -47,7 +47,7 @@ export interface ValidateResult { blockers: number; findings: Finding[] }
export type ScanSeverity = 'blocker' | 'high' | 'medium' | 'low';
export interface ScanFinding {
rule: string; guideline: string; store: 'apple' | 'play'; severity: ScanSeverity; title: string;
evidence: { file: string; line: number; text?: string }[]; fix: string; skill: string; ref: string; accepted?: string; action?: FixAction;
evidence: { file: string; line: number; text?: string }[]; fix: string; skill: string; ref: string; accepted?: string; action?: FixAction; aiFix?: boolean;
}
export interface ScanResult {
stacks: string[];

View file

@ -3,9 +3,12 @@
import type { Project } from '../../api';
import { t } from '../../i18n';
import { ago } from '../../lib/format';
import type { Finding, FixAction, ScanResult, ScanSeverity, ValidateResult } from '../../lib/model';
import type { Finding, FixAction, ScanFinding, ScanResult, ScanSeverity, ValidateResult } from '../../lib/model';
import { useData, useRead, useWrite } from '../../store';
import { WriteButton } from '../../ui/WriteButton';
import { AiFix, aiFinding } from '../../ui/AiFix';
import { api } from '../../api';
import { useEffect, useState } from 'react';
import { Icon } from '../../ui/icons';
import { Card, Chip, Empty, ErrorNotice, ReadControl, Skeleton } from '../../ui/kit';
import { exportName, validateMd } from '../../lib/exports';
@ -33,6 +36,20 @@ function ScanCard({ project }: { project: Project }) {
const s = useRead<ScanResult>(project.id, 'scan', { network: false, title: t('checks.scan') });
const w = useWrite(project.id);
const findings = Array.isArray(s.data?.findings) ? s.data!.findings : [];
// Fix with AI: is claude installed (else Copy for AI); one run at a time
const [claude, setClaude] = useState<boolean | null>(null);
const [aiBusy, setAiBusy] = useState(false);
// the finding an AI fix works on stays in the list (its diff, Keep / Undo) even after the scan stops reporting it
const [aiActive, setAiActive] = useState<ScanFinding | null>(null);
const same = (a: ScanFinding, b: ScanFinding) => a.store === b.store && a.rule === b.rule && a.title === b.title;
const rows = aiActive && !findings.some(x => same(x, aiActive)) ? [...findings, aiActive] : findings;
const seen = new Map<string, number>();
const keyOf = (f: ScanFinding) => { const k = `${f.store}|${f.rule}|${f.title}`; const n = (seen.get(k) ?? 0) + 1; seen.set(k, n); return `${k}|${n}`; };
const firstAi = findings.find(f => f.aiFix && !f.accepted && !fixable(f.action));
useEffect(() => {
if (!firstAi || claude !== null) return;
api.aiFixInfo(project.id, aiFinding(firstAi)).then(i => setClaude(i.claude), () => setClaude(false));
}, [project.id, firstAi, claude]);
const sdks = Array.isArray(s.data?.sdks) ? s.data!.sdks : [];
const asks = (Array.isArray(s.data?.declarations?.items) ? s.data!.declarations.items : []).filter(x => x.status === 'ask').length;
return (
@ -40,19 +57,21 @@ function ScanCard({ project }: { project: Project }) {
actions={<ReadControl r={s} label={t('checks.scanAgain')} />}>
{s.error && <div className="card-body"><ErrorNotice error={s.error} keep={!!s.data} /></div>}
{!s.data ? (s.loading && !s.error ? <div className="card-body"><Skeleton lines={4} /></div> : null)
: !findings.length ? <Empty compact illo="shield" title={t('checks.scanClean')}>{t('checks.scanCleanText')}</Empty> : (
: !rows.length ? <Empty compact illo="shield" title={t('checks.scanClean')}>{t('checks.scanCleanText')}</Empty> : (
<div className="list">
{findings.map((f, i) => {
{rows.map(f => {
const [tone, icon] = f.accepted ? ['ok', 'check'] as const : SEV[f.severity] ?? SEV.low;
const ev = Array.isArray(f.evidence) ? f.evidence : [];
return (
<div className="li" key={`${f.rule}-${i}`}>
<div className="li" key={keyOf(f)}>
<span className={`li-icon tone-bg-${tone}`}><Icon name={icon} /></span>
<div className="li-main">
<span className="li-title">{f.guideline} · {f.title}</span>
{ev.slice(0, 2).map((e, j) => <span className="li-sub selectable" key={j}><code>{e.file}:{e.line}</code>{e.text ? ` ${e.text}` : ''}</span>)}
{f.accepted ? <span className="li-sub">{t('checks.accepted', { reason: f.accepted })}</span>
: <span className="fix"><Icon name="wand" /><span className="selectable">{f.fix} <code>{f.skill}</code></span></span>}
{!f.accepted && f.aiFix && !fixable(f.action) && <AiFix projectId={project.id} f={f} claude={claude} busy={aiBusy} setBusy={setAiBusy} rescan={() => { s.refresh(); }}
stillThere={findings.some(x => same(x, f))} onActive={on => setAiActive(on ? f : null)} />}
</div>
{!f.accepted && fixable(f.action) && <FixButton action={f.action} what={f.title} w={w} />}
<Chip tone={f.accepted ? 'ok' : tone}>{t(f.accepted ? 'checks.sev.accepted' : `checks.sev.${f.severity}` as Parameters<typeof t>[0])}</Chip>

View file

@ -1008,3 +1008,9 @@ button.ck.ck-pick[aria-checked="true"] { border-color: var(--accent-solid); box-
.ed-scenes li { display: grid; grid-template-columns: 28px minmax(80px, 140px) minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 10px 14px; border-top: 1px solid var(--line); }
.ed-scene-frame { min-width: 0; }
.rot90 { transform: rotate(90deg); }
/* Fix with AI (ui/AiFix.tsx): the button under a finding, its run log, the diff with Keep / Undo */
.ai-actions { display: flex; gap: 8px; margin-top: 6px; }
.ai-panel { display: grid; grid-template-columns: minmax(0, 1fr); gap: 6px; margin-top: 8px; }
.ai-panel .row { display: flex; gap: 8px; flex-wrap: wrap; }
.ai-panel .ai-diff { max-height: 320px; }

View 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}</>;
}