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
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
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