@neuralis/brain-core 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -3
- package/app/filesystem/api/README.md +1 -1
- package/app/filesystem/api/index.ts +24 -24
- package/app/filesystem/store/README.md +1 -1
- package/app/filesystem/store/filesystemStore.ts +11 -3
- package/app/filesystem/store/listSlice.ts +1 -0
- package/app/filesystem/ui/FileEditor.tsx +42 -4
- package/app/filesystem/ui/GitControlPanel.tsx +0 -1
- package/app/filesystem/ui/GitDiffView.tsx +12 -5
- package/app/filesystem/ui/GitFileDiff.tsx +15 -9
- package/app/filesystem/ui/PendingChangesPanel.tsx +95 -34
- package/app/filesystem/ui/README.md +9 -9
- package/app/filesystem/ui/SourcesPanel.tsx +1 -3
- package/app/filesystem/ui/Toolbar.tsx +5 -4
- package/app/filesystem/ui/VectorHealthPanel.tsx +43 -14
- package/dist/app/host.js +40 -40
- package/dist/src/config/env.d.ts +4 -0
- package/dist/src/config/env.d.ts.map +1 -1
- package/dist/src/config/env.js +13 -0
- package/dist/src/config/env.js.map +1 -1
- package/dist/src/connectors/LocalDiskConnector.d.ts +9 -1
- package/dist/src/connectors/LocalDiskConnector.d.ts.map +1 -1
- package/dist/src/connectors/LocalDiskConnector.js +51 -2
- package/dist/src/connectors/LocalDiskConnector.js.map +1 -1
- package/dist/src/domain/healthTypes.d.ts +1 -7
- package/dist/src/domain/healthTypes.d.ts.map +1 -1
- package/dist/src/domain/pendingChange.d.ts +31 -0
- package/dist/src/domain/pendingChange.d.ts.map +1 -1
- package/dist/src/domain/pendingChange.js.map +1 -1
- package/dist/src/routes/health.js +2 -3
- package/dist/src/routes/health.js.map +1 -1
- package/dist/src/routes/pending.d.ts +7 -7
- package/dist/src/routes/pending.d.ts.map +1 -1
- package/dist/src/routes/pending.js +21 -21
- package/dist/src/routes/pending.js.map +1 -1
- package/dist/src/services/FilesystemService.d.ts +13 -61
- package/dist/src/services/FilesystemService.d.ts.map +1 -1
- package/dist/src/services/FilesystemService.js +47 -189
- package/dist/src/services/FilesystemService.js.map +1 -1
- package/dist/src/services/FilesystemUiService.d.ts +33 -1
- package/dist/src/services/FilesystemUiService.d.ts.map +1 -1
- package/dist/src/services/FilesystemUiService.js +50 -1
- package/dist/src/services/FilesystemUiService.js.map +1 -1
- package/dist/src/store/PendingChangeStore.d.ts +3 -0
- package/dist/src/store/PendingChangeStore.d.ts.map +1 -1
- package/dist/src/store/PendingChangeStore.js +84 -25
- package/dist/src/store/PendingChangeStore.js.map +1 -1
- package/dist/src/usecases/orphanedPending.d.ts +2 -2
- package/dist/src/usecases/orphanedPending.d.ts.map +1 -1
- package/dist/src/usecases/orphanedPending.js +5 -3
- package/dist/src/usecases/orphanedPending.js.map +1 -1
- package/dist/src/usecases/pendingListing.d.ts +34 -0
- package/dist/src/usecases/pendingListing.d.ts.map +1 -0
- package/dist/src/usecases/pendingListing.js +244 -0
- package/dist/src/usecases/pendingListing.js.map +1 -0
- package/dist/src/usecases/vectorMaintenance.d.ts +6 -9
- package/dist/src/usecases/vectorMaintenance.d.ts.map +1 -1
- package/dist/src/usecases/vectorMaintenance.js +4 -13
- package/dist/src/usecases/vectorMaintenance.js.map +1 -1
- package/dist/src/vector/InMemoryVectorIndex.d.ts.map +1 -1
- package/dist/src/vector/InMemoryVectorIndex.js +30 -9
- package/dist/src/vector/InMemoryVectorIndex.js.map +1 -1
- package/package.json +26 -4
- package/rules/brain-rules.md +1 -1
- package/skills/create-package/SKILL.md +3 -3
- package/skills/create-package/references/config-and-credentials.md +1 -1
- package/skills/create-package/references/runtime-and-build.md +1 -1
- package/skills/create-package/references/skills-and-context.md +25 -12
- package/skills/create-package/references/test-and-ship.md +9 -16
- package/skills/create-package/scripts/verify-loaded.sh +18 -27
- package/skills/git/SKILL.md +27 -29
- package/skills/manage-files/SKILL.md +37 -32
- package/skills/manage-files/scripts/upload.sh +7 -9
- package/skills/manage-files/scripts/write.sh +2 -6
- package/skills/manage-sources/SKILL.md +93 -95
- package/skills/manage-sources/scripts/reembed.sh +2 -6
- package/skills/manage-uri-policy/SKILL.md +30 -58
- package/skills/git/scripts/git.sh +0 -48
- package/skills/manage-files/scripts/approve.sh +0 -9
- package/skills/manage-files/scripts/list.sh +0 -9
- package/skills/manage-files/scripts/pending.sh +0 -23
- package/skills/manage-files/scripts/raw.sh +0 -11
- package/skills/manage-files/scripts/read.sh +0 -9
- package/skills/manage-files/scripts/search.sh +0 -15
- package/skills/manage-sources/scripts/browse.sh +0 -12
- package/skills/manage-sources/scripts/cancel.sh +0 -14
- package/skills/manage-sources/scripts/create.sh +0 -13
- package/skills/manage-sources/scripts/delete.sh +0 -17
- package/skills/manage-sources/scripts/discoverable.sh +0 -6
- package/skills/manage-sources/scripts/get.sh +0 -7
- package/skills/manage-sources/scripts/kind-schema.sh +0 -7
- package/skills/manage-sources/scripts/kinds.sh +0 -6
- package/skills/manage-sources/scripts/list.sh +0 -6
- package/skills/manage-sources/scripts/purge-index.sh +0 -18
- package/skills/manage-sources/scripts/reindex.sh +0 -13
- package/skills/manage-sources/scripts/reset.sh +0 -11
- package/skills/manage-sources/scripts/set-enabled.sh +0 -17
- package/skills/manage-sources/scripts/sync-status.sh +0 -14
- package/skills/manage-sources/scripts/sync.sh +0 -13
- package/skills/manage-sources/scripts/update.sh +0 -18
- package/skills/manage-uri-policy/scripts/manifests.sh +0 -20
- package/skills/manage-uri-policy/scripts/set-uri.sh +0 -66
- package/skills/manage-uri-policy/scripts/show-uri.sh +0 -23
|
@@ -153,7 +153,7 @@ export const PendingDiffCard = memo(function PendingDiffCard({
|
|
|
153
153
|
setLoadError(error instanceof Error ? error.message : 'Failed to load review'); setLoading(false);
|
|
154
154
|
});
|
|
155
155
|
return () => { seq.current++; };
|
|
156
|
-
}, [agentId, projectId, rec.id, cacheKey, expanded, shown, queue.run]);
|
|
156
|
+
}, [client, agentId, projectId, rec.id, cacheKey, expanded, shown, queue.run]);
|
|
157
157
|
|
|
158
158
|
const hunks = useMemo(() => preview ? hunksOf(preview) : null, [preview]);
|
|
159
159
|
const computed = useMemo(() => hunks ? countHunks(hunks) : null, [hunks]);
|
|
@@ -184,6 +184,7 @@ export const PendingDiffCard = memo(function PendingDiffCard({
|
|
|
184
184
|
{indexState && indexState !== 'stored' ? <span className="text-[10px] text-amber-200/80 shrink-0">
|
|
185
185
|
{indexState === 'missing' ? 'Index missing' : indexState === 'stale' ? 'Index stale' : 'Recovery only'}
|
|
186
186
|
</span> : null}
|
|
187
|
+
{rec.conflict === 'PENDING_GENERATION_CHANGED' ? <span className="text-[10px] text-amber-200/80 shrink-0" title="Pending generation changed; this record cannot be repaired automatically.">Pending generation changed</span> : null}
|
|
187
188
|
{orphaned ? ORPHAN_BADGE : rec.status === 'approved' || rec.status === 'reverted' ? (
|
|
188
189
|
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${statusTone(rec.status)}`} title={rec.status} />
|
|
189
190
|
) : null}
|
|
@@ -261,17 +262,20 @@ export function PendingChangesPanel({ agentId, projectId }: Props) {
|
|
|
261
262
|
const [nonces, setNonces] = useState<Readonly<Record<string, number>>>({});
|
|
262
263
|
const latest = useRef({ nonces, revisions }); latest.current = { nonces, revisions };
|
|
263
264
|
const refetch = useCallback(async (rec: PendingChangeRecord): Promise<PendingPreview> => {
|
|
265
|
+
const epoch = contextEpoch.current;
|
|
264
266
|
refetches.current.set(rec.id, null);
|
|
265
267
|
try {
|
|
266
268
|
const listed = (await relisted.current.promise)?.find(r => r.id === rec.id) ?? rec;
|
|
269
|
+
if (epoch !== contextEpoch.current) throw new Error('Review context changed');
|
|
267
270
|
const fresh = await previewPendingChange({ agentId, ...(projectId ? { projectId } : {}), id: rec.id }, client);
|
|
271
|
+
if (epoch !== contextEpoch.current) throw new Error('Review context changed');
|
|
268
272
|
const revision = (latest.current.nonces[rec.id] ?? 0) + 1 + (latest.current.revisions[listed.sourceSlug] ?? 0);
|
|
269
273
|
refetches.current.set(rec.id, { key: previewKey(listed, revision), preview: fresh });
|
|
270
274
|
previews.current.set(rec.id, fresh);
|
|
271
275
|
return fresh;
|
|
272
|
-
} catch (failure) { refetches.current.delete(rec.id); throw failure; }
|
|
273
|
-
finally { setNonces(prev => ({ ...prev, [rec.id]: (prev[rec.id] ?? 0) + 1 })); }
|
|
274
|
-
}, [agentId, projectId]);
|
|
276
|
+
} catch (failure) { if (epoch === contextEpoch.current) refetches.current.delete(rec.id); throw failure; }
|
|
277
|
+
finally { if (epoch === contextEpoch.current) setNonces(prev => ({ ...prev, [rec.id]: (prev[rec.id] ?? 0) + 1 })); }
|
|
278
|
+
}, [client, agentId, projectId]);
|
|
275
279
|
const [focusIndex, setFocusIndex] = useState(0);
|
|
276
280
|
const [review, setReview] = useState<ReviewRequest | null>(null);
|
|
277
281
|
const [results, setResults] = useState<ReviewResult[]>([]);
|
|
@@ -282,27 +286,50 @@ export function PendingChangesPanel({ agentId, projectId }: Props) {
|
|
|
282
286
|
const [collapsedGit, setCollapsedGit] = useState<ReadonlySet<string>>(new Set());
|
|
283
287
|
const repairTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
284
288
|
const listSeq = useRef(0);
|
|
289
|
+
const orphanSeq = useRef(0);
|
|
290
|
+
const contextEpoch = useRef(0);
|
|
291
|
+
const changesRef = useRef(changes); changesRef.current = changes;
|
|
292
|
+
const classified = useRef(new Map<string, string>());
|
|
293
|
+
const [nextCursor, setNextCursor] = useState<string | undefined>();
|
|
294
|
+
const [partial, setPartial] = useState(false);
|
|
295
|
+
const [repairProgress, setRepairProgress] = useState('');
|
|
296
|
+
const repairStop = useRef(false);
|
|
297
|
+
const repairCursor = useRef<string | undefined>(undefined);
|
|
298
|
+
const [repairPaused, setRepairPaused] = useState(false);
|
|
299
|
+
const generation = (rec: PendingChangeRecord): string => JSON.stringify([rec.id, rec.updatedAt, rec.snapshotHash, rec.status]);
|
|
285
300
|
|
|
286
301
|
const inventory = useGitChangesInventory({ agentId, projectId, sources, revisions, resetKey: `${agentId}\u0000${projectId}\u0000${convFilter}` });
|
|
287
302
|
|
|
288
303
|
/** The pending list. The orphan probe (one connector stat per record) runs only on mount, Refresh and Repair. */
|
|
289
|
-
const fetchPending = useCallback(async (withOrphans: boolean): Promise<PendingChangeRecord[] | undefined> => {
|
|
304
|
+
const fetchPending = useCallback(async (withOrphans: boolean, append = false): Promise<PendingChangeRecord[] | undefined> => {
|
|
290
305
|
if (!agentId) return undefined;
|
|
291
306
|
const mine = ++listSeq.current;
|
|
307
|
+
const scan = withOrphans ? ++orphanSeq.current : orphanSeq.current;
|
|
308
|
+
const epoch = contextEpoch.current;
|
|
292
309
|
setLoading(true); setError(null);
|
|
293
310
|
try {
|
|
294
311
|
const list = await listPendingChanges({ agentId, ...(projectId ? { projectId } : {}),
|
|
295
|
-
...(convFilter ? { conversationId: convFilter } : {}), ...(withOrphans ? { include_orphans: true } : {})
|
|
312
|
+
...(convFilter ? { conversationId: convFilter } : {}), ...(withOrphans ? { include_orphans: true } : {}),
|
|
313
|
+
...(append && nextCursor ? { cursor: nextCursor } : {}) }, client);
|
|
314
|
+
if (epoch !== contextEpoch.current) return undefined;
|
|
315
|
+
if (withOrphans && scan === orphanSeq.current) {
|
|
316
|
+
for (const rec of list.changes) classified.current.set(rec.id, `${generation(rec)}:${list.orphanIds?.includes(rec.id) === true}`);
|
|
317
|
+
setOrphanIds(new Set(changesRef.current.filter(rec => classified.current.get(rec.id) === `${generation(rec)}:true`).map(rec => rec.id)));
|
|
318
|
+
}
|
|
296
319
|
if (mine !== listSeq.current) return undefined;
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
320
|
+
if (list.restartRequired) { setNextCursor(undefined); setError('Changes moved. Refresh to restart the list.'); return undefined; }
|
|
321
|
+
const prior = new Map(changesRef.current.map(rec => [rec.id, rec]));
|
|
322
|
+
const fresh = new Map(list.changes.map(rec => [rec.id, rec]));
|
|
323
|
+
const merged = append ? [...changesRef.current.map(rec => fresh.get(rec.id) ?? rec), ...list.changes.filter(rec => !prior.has(rec.id))] : list.changes;
|
|
324
|
+
changesRef.current = merged; setChanges(merged);
|
|
325
|
+
setOrphanIds(new Set(merged.filter(rec => classified.current.get(rec.id) === `${generation(rec)}:true`).map(rec => rec.id)));
|
|
326
|
+
setNextCursor(list.nextCursor); setPartial(list.partial);
|
|
327
|
+
return merged;
|
|
301
328
|
} catch (failure) {
|
|
302
|
-
if (mine === listSeq.current) setError(failure instanceof Error ? failure.message : 'Failed to load changes');
|
|
329
|
+
if (epoch === contextEpoch.current && mine === listSeq.current) setError(failure instanceof Error ? failure.message : 'Failed to load changes');
|
|
303
330
|
return undefined;
|
|
304
|
-
} finally { if (mine === listSeq.current) setLoading(false); }
|
|
305
|
-
}, [agentId, projectId, convFilter]);
|
|
331
|
+
} finally { if (epoch === contextEpoch.current && mine === listSeq.current) setLoading(false); }
|
|
332
|
+
}, [client, agentId, projectId, convFilter, nextCursor]);
|
|
306
333
|
|
|
307
334
|
const refreshAll = useCallback(async () => {
|
|
308
335
|
await Promise.all([fetchPending(true), inventory.refresh(true)]);
|
|
@@ -310,10 +337,14 @@ export function PendingChangesPanel({ agentId, projectId }: Props) {
|
|
|
310
337
|
}, [fetchPending, inventory.refresh, refreshPendingCount, agentId, projectId]);
|
|
311
338
|
|
|
312
339
|
useEffect(() => {
|
|
313
|
-
|
|
340
|
+
contextEpoch.current++; repairStop.current = true; repairCursor.current = undefined; setRepairPaused(false);
|
|
341
|
+
relisted.current.resolve(undefined); relisted.current = Promise.withResolvers(); refetches.current.clear();
|
|
342
|
+
setRepairing(false); setBusyId(null); setApprovingAll(false); setConfirmAll(false); setConfirmRepair(false);
|
|
343
|
+
classified.current.clear(); previews.current.clear(); setReview(null); changesRef.current = [];
|
|
344
|
+
setChanges([]); setOrphanIds(new Set()); setResults([]); setNextCursor(undefined); setPartial(false); setHeld(null);
|
|
314
345
|
void fetchPending(true);
|
|
315
|
-
return () => { listSeq.current++; previews.current.clear(); };
|
|
316
|
-
}, [
|
|
346
|
+
return () => { contextEpoch.current++; listSeq.current++; orphanSeq.current++; repairStop.current = true; previews.current.clear(); };
|
|
347
|
+
}, [client, agentId, projectId, convFilter]);
|
|
317
348
|
// A source revision re-reads the plain list, never the orphan probe; a context change is the effect above's.
|
|
318
349
|
const fetchPendingRef = useRef(fetchPending); fetchPendingRef.current = fetchPending;
|
|
319
350
|
const firstRevision = useRef(true);
|
|
@@ -380,6 +411,7 @@ export function PendingChangesPanel({ agentId, projectId }: Props) {
|
|
|
380
411
|
}, [removeFilePath, storeApi]);
|
|
381
412
|
|
|
382
413
|
const afterMutation = useCallback(async (done: ReviewResult[]) => {
|
|
414
|
+
const epoch = contextEpoch.current;
|
|
383
415
|
for (const result of done) {
|
|
384
416
|
const rec = changes.find(c => c.id === result.id);
|
|
385
417
|
if (rec && result.outcome === 'ok' && result.action !== 'revert') dropResolvedDeleteRow(rec);
|
|
@@ -390,6 +422,7 @@ export function PendingChangesPanel({ agentId, projectId }: Props) {
|
|
|
390
422
|
if (refused.length) setNonces(prev => { const next = { ...prev }; for (const r of refused) next[r.id] = (next[r.id] ?? 0) + 1; return next; });
|
|
391
423
|
setResults(done);
|
|
392
424
|
const after = await fetchPending(false);
|
|
425
|
+
if (epoch !== contextEpoch.current) return;
|
|
393
426
|
// The STORE's value, not the last render's: the batch's own bumps may not have rendered yet.
|
|
394
427
|
listedRevisions.current = storeApi.getState().sourceChangeRevision; setHeld(null);
|
|
395
428
|
relisted.current.resolve(after); relisted.current = Promise.withResolvers();
|
|
@@ -400,38 +433,55 @@ export function PendingChangesPanel({ agentId, projectId }: Props) {
|
|
|
400
433
|
if (!canWrite) return;
|
|
401
434
|
const preview = previews.current.get(rec.id);
|
|
402
435
|
if (!preview || preview.requiresAcknowledgement.length) { setReview({ kind: 'single', rec, action, ...(preview ? { preview } : {}) }); return; }
|
|
436
|
+
const epoch = contextEpoch.current;
|
|
403
437
|
setBusyId(rec.id);
|
|
404
|
-
try {
|
|
405
|
-
finally { setBusyId(null); }
|
|
406
|
-
}, [canWrite, agentId, projectId, afterMutation]);
|
|
438
|
+
try { const result = await applyReview({ agentId, projectId, client }, rec, preview, action, false); if (epoch === contextEpoch.current) await afterMutation([result]); }
|
|
439
|
+
finally { if (epoch === contextEpoch.current) setBusyId(null); }
|
|
440
|
+
}, [client, canWrite, agentId, projectId, afterMutation]);
|
|
407
441
|
|
|
408
442
|
// Discard a single ORPHANED row — removes the review record + baseline blob + vector point.
|
|
409
443
|
const discard = useCallback(async (rec: PendingChangeRecord) => {
|
|
444
|
+
const epoch = contextEpoch.current;
|
|
410
445
|
setBusyId(rec.id);
|
|
411
446
|
try {
|
|
412
447
|
const res = await discardPendingChange({ agentId, ...(projectId ? { projectId } : {}), id: rec.id }, client);
|
|
448
|
+
if (epoch !== contextEpoch.current) return;
|
|
413
449
|
if (!res.ok) setError(res.error || 'Discard failed'); else dropResolvedDeleteRow(rec);
|
|
414
450
|
await fetchPending(false);
|
|
415
451
|
void refreshPendingCount({ agentId, projectId });
|
|
416
|
-
} catch (e) { setError(e instanceof Error ? e.message : 'Discard failed'); } finally { setBusyId(null); }
|
|
417
|
-
}, [agentId, projectId, fetchPending, dropResolvedDeleteRow, refreshPendingCount]);
|
|
452
|
+
} catch (e) { if (epoch === contextEpoch.current) setError(e instanceof Error ? e.message : 'Discard failed'); } finally { if (epoch === contextEpoch.current) setBusyId(null); }
|
|
453
|
+
}, [client, agentId, projectId, fetchPending, dropResolvedDeleteRow, refreshPendingCount]);
|
|
418
454
|
|
|
419
455
|
// Repair: bulk-discard every orphaned change the caller can write. Which records
|
|
420
456
|
// went is read from the refetch — the route re-authorizes each one.
|
|
421
457
|
const repair = useCallback(async () => {
|
|
422
458
|
if (repairTimer.current) { clearTimeout(repairTimer.current); repairTimer.current = null; }
|
|
423
|
-
|
|
459
|
+
const epoch = contextEpoch.current;
|
|
460
|
+
repairStop.current = false; holdRevisions(); setRepairing(true); setConfirmRepair(false); setError(null);
|
|
461
|
+
let cursor = repairCursor.current; setRepairPaused(false);
|
|
462
|
+
let scanned = 0; let discarded = 0; let refused = 0; let failed = 0;
|
|
424
463
|
try {
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
464
|
+
do {
|
|
465
|
+
const page = await discardOrphanedPending({ agentId, ...(projectId ? { projectId } : {}),
|
|
466
|
+
...(convFilter ? { conversationId: convFilter } : {}), ...(cursor ? { cursor } : {}) }, client);
|
|
467
|
+
if (epoch !== contextEpoch.current) return;
|
|
468
|
+
scanned += page.scannedVisible; discarded += page.discarded; refused += page.refused; failed += page.failed;
|
|
469
|
+
setRepairProgress(`${scanned} scanned · ${discarded} discarded · ${refused} refused · ${failed} failed`);
|
|
470
|
+
cursor = page.nextCursor; repairCursor.current = cursor;
|
|
471
|
+
if (page.restartRequired) { repairCursor.current = undefined; setRepairPaused(true); setError('Changes moved. Refresh before continuing repair.'); break; }
|
|
472
|
+
if (page.failed > 0) { setRepairPaused(true); setError('Repair paused after a failed change. Refresh before continuing.'); break; }
|
|
473
|
+
if (page.partial) { setRepairPaused(true); setError('Repair paused; part of the inventory or orphan checks are unavailable.'); break; }
|
|
474
|
+
} while (cursor && !repairStop.current);
|
|
475
|
+
if (cursor) setRepairPaused(true);
|
|
476
|
+
if (epoch !== contextEpoch.current) return;
|
|
477
|
+
await fetchPending(true);
|
|
478
|
+
if (epoch !== contextEpoch.current) return;
|
|
479
|
+
listedRevisions.current = storeApi.getState().sourceChangeRevision; setHeld(null);
|
|
432
480
|
void refreshPendingCount({ agentId, projectId });
|
|
433
|
-
} catch (e) { setError(e instanceof Error ? e.message : 'Repair failed'); }
|
|
434
|
-
|
|
481
|
+
} catch (e) { if (epoch === contextEpoch.current) { setRepairPaused(true); setError(e instanceof Error ? e.message : 'Repair failed'); } }
|
|
482
|
+
finally { if (epoch === contextEpoch.current) { setRepairing(false); setHeld(null); } }
|
|
483
|
+
}, [client, agentId, projectId, convFilter, fetchPending, refreshPendingCount, holdRevisions, storeApi]);
|
|
484
|
+
|
|
435
485
|
const onRepairClick = useCallback(() => {
|
|
436
486
|
if (confirmRepair) { void repair(); return; }
|
|
437
487
|
setConfirmRepair(true);
|
|
@@ -443,27 +493,32 @@ export function PendingChangesPanel({ agentId, projectId }: Props) {
|
|
|
443
493
|
// confirm). A record whose fresh preview turns out to need one is reported AND handed
|
|
444
494
|
// to the grouped review dialog, where it is approved or dismissed with its acknowledgement.
|
|
445
495
|
const approveAll = useCallback(async () => {
|
|
496
|
+
const epoch = contextEpoch.current;
|
|
446
497
|
setApprovingAll(true); setConfirmAll(false); setError(null); holdRevisions();
|
|
447
498
|
const done: ReviewResult[] = [];
|
|
448
499
|
try {
|
|
449
500
|
for (const rec of approvable) {
|
|
501
|
+
if (epoch !== contextEpoch.current) break;
|
|
450
502
|
let preview = previews.current.get(rec.id);
|
|
451
503
|
if (!preview) {
|
|
452
504
|
try { preview = await previewPendingChange({ agentId, ...(projectId ? { projectId } : {}), id: rec.id }, client); }
|
|
453
505
|
catch (e) { done.push({ id: rec.id, uri: rec.uri, action: 'approve', outcome: 'error', detail: e instanceof Error ? e.message : 'Preview failed' }); continue; }
|
|
506
|
+
if (epoch !== contextEpoch.current) break;
|
|
454
507
|
previews.current.set(rec.id, preview);
|
|
455
508
|
}
|
|
456
509
|
if (preview.requiresAcknowledgement.length) { done.push({ id: rec.id, uri: rec.uri, action: 'approve', outcome: 'needs-ack' }); continue; }
|
|
457
510
|
done.push(await applyReview({ agentId, projectId, client }, rec, preview, 'approve', false));
|
|
458
511
|
}
|
|
459
512
|
} finally {
|
|
513
|
+
if (epoch !== contextEpoch.current) return;
|
|
460
514
|
setApprovingAll(false);
|
|
461
515
|
const handed = approvable.filter(rec => done.some(r => r.id === rec.id && r.outcome === 'needs-ack'));
|
|
462
516
|
if (handed.length) setAckPreviews(n => n + 1);
|
|
463
517
|
await afterMutation(done);
|
|
518
|
+
if (epoch !== contextEpoch.current) return;
|
|
464
519
|
if (handed.length && canWrite) { reviewed.current.clear(); setReview({ kind: 'group', records: handed.slice(0, REVIEW_BATCH), total: handed.length }); }
|
|
465
520
|
}
|
|
466
|
-
}, [approvable, agentId, projectId, afterMutation, canWrite]);
|
|
521
|
+
}, [approvable, agentId, projectId, client, afterMutation, canWrite]);
|
|
467
522
|
|
|
468
523
|
const move = useCallback((delta: number) => setFocusIndex((i) => (visible.length === 0 ? 0
|
|
469
524
|
: Math.max(0, Math.min(visible.length - 1, i + delta)))), [visible.length]);
|
|
@@ -514,6 +569,7 @@ export function PendingChangesPanel({ agentId, projectId }: Props) {
|
|
|
514
569
|
</div>;
|
|
515
570
|
};
|
|
516
571
|
|
|
572
|
+
const renderedEpoch = contextEpoch.current;
|
|
517
573
|
const failed = results.filter(r => r.outcome !== 'ok');
|
|
518
574
|
const busy = loading || inventory.loading;
|
|
519
575
|
return (
|
|
@@ -522,7 +578,7 @@ export function PendingChangesPanel({ agentId, projectId }: Props) {
|
|
|
522
578
|
key={review.kind === 'group' ? `group\u0000${review.records[0]?.id ?? ''}` : `single\u0000${review.rec.id}`}
|
|
523
579
|
onClose={() => setReview(null)} next={review.kind === 'group' ? unreviewed.filter(rec => !review.records.some(r => r.id === rec.id)).length : 0}
|
|
524
580
|
onNext={openReviewBatch} refetch={refetch} onBatchStart={holdRevisions}
|
|
525
|
-
onDone={done => { for (const result of done) reviewed.current.add(result.id); void afterMutation(done); }} /> : null}
|
|
581
|
+
onDone={done => { if (renderedEpoch !== contextEpoch.current) return; for (const result of done) reviewed.current.add(result.id); void afterMutation(done); }} /> : null}
|
|
526
582
|
{/* Header: title + nav cursor + approve-all + view toggle + refresh */}
|
|
527
583
|
<div className="flex flex-wrap items-center gap-1.5 px-3 py-2 border-b border-white/5 shrink-0">
|
|
528
584
|
<span className="text-xs font-semibold text-white/80">Changes</span>
|
|
@@ -573,10 +629,15 @@ export function PendingChangesPanel({ agentId, projectId }: Props) {
|
|
|
573
629
|
</button>
|
|
574
630
|
{/* Additive — the per-row buttons stay the primary path. */}
|
|
575
631
|
<SkillLauncher variant="icon" size="sm" skill="manage-files" packageId="@neuralis/brain-core"
|
|
576
|
-
|
|
632
|
+
label="Review or approve pending changes with the agent" icon="FileText" />
|
|
577
633
|
</div>
|
|
578
634
|
</div>
|
|
579
635
|
|
|
636
|
+
{repairing ? <button type="button" onClick={() => { repairStop.current = true; }} className="px-3 py-1 text-xs text-amber-200">Stop after this batch</button> : null}
|
|
637
|
+
{repairProgress ? <div role="status" className="px-3 py-1 text-xs text-white/60">{repairProgress}</div> : null}
|
|
638
|
+
{canWrite && repairPaused && !repairing ? <button type="button" onClick={() => void repair()} className="px-3 py-1 text-xs text-amber-200">{repairCursor.current ? 'Continue available repair' : 'Retry repair'}</button> : null}
|
|
639
|
+
{partial ? <div role="status" className="px-3 py-1 text-xs text-amber-200">Showing available changes; some inventory or orphan checks are unavailable.</div> : null}
|
|
640
|
+
{nextCursor ? <button type="button" disabled={loading || repairing} onClick={() => void fetchPending(true, true)} className="px-3 py-1 text-xs text-violet-200 disabled:opacity-40">Load more</button> : null}
|
|
580
641
|
{/* Filter bar */}
|
|
581
642
|
<div className="flex flex-wrap items-center gap-2 px-3 py-1.5 border-b border-white/5 shrink-0 text-[11px]">
|
|
582
643
|
{convFilter ? <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-violet-500/15 text-violet-200/90" title={convFilter}>
|
|
@@ -25,9 +25,9 @@ Re-exported from `app/filesystem/ui/index.ts`:
|
|
|
25
25
|
- **`DiffModeToggle`** is the ONE Auto/Unified/Split control bound to the store `diffViewMode`, shared by BOTH the review diff AND the git diff (Auto picks unified/split by container width via `useContainerWidth`).
|
|
26
26
|
- The editor also carries the VS Code-style **git left-gutter** (`codeEditor/editorGitGutter.ts` + the pure `codeEditor/gitLineStatus.ts` parser) for git-tracked files — a thin emerald(added)/blue(modified) bar + red delete caret, derived **fail-soft** from `getGitDiff({staged:false})` (Working↔Index).
|
|
27
27
|
- `DiffView` (the old hand-rolled Myers review diff) was **DELETED** — superseded by `ReviewDiffView`. NB `app/cards/DiffView.tsx` is a DIFFERENT file (the chat-card single-column diff) and stays.
|
|
28
|
-
- `Toolbar` — top toolbar with the active agent’s standalone configured icon (no visible name or identity badge), sidebar toggle, refresh, upload, and panel switches. The Changes switch carries the caller's visible pending COUNT as a static badge (`pendingCount`, the server `count_only` answer — `1000+`
|
|
28
|
+
- `Toolbar` — top toolbar with the active agent’s standalone configured icon (no visible name or identity badge), sidebar toggle, refresh, upload, and panel switches. The Changes switch carries the caller's visible pending COUNT as a static badge (`pendingCount`, the server `count_only` answer — `1000+` display cap; partial totals carry `+`, stale/unavailable totals `?`); the client `pendingReviewPaths` set only tones tree rows.
|
|
29
29
|
- `SourcesPanel`, `PackagesPanel`, `VectorHealthPanel`, `PanelScaffold` — right-hand panels. Sources offers advisory metrics exclusions only when enabling an attested own data source with no sync block; Save persists the editable draft. Media summaries include local structural PNG failures, and chunk-only cleanup refreshes Files separately from removed-file counts.
|
|
30
|
-
- `PendingChangesPanel` — Changes groups current Git status and pending review by source/repository, including non-Git sources. Normalized URI joining renders a file once while retaining separately labeled staged/unstaged comparisons for each overlapping repository and one pending review. All cards start expanded in ONE `useVirtualizer` list (`pendingChangesModel.ts` owns the filters, the flat row list, `rowEstimate`/`isMeasuredRow` and the Approve split; the compact row pins `h-8`, every card is measured; `overflow-anchor:none` plus an above-the-fold size adjustment keep the view still); the content lanes cache every loaded preview/diff, and source revisions plus context sequences fence obsolete replies. A **Type** filter (all · pending · Git) narrows the layers; the repository picker uses the Git panel's `resolveRepoLabel`. **Approve N** counts records with no acknowledgement on their own fields (`ackHintFor`, the server preview's own predicate) or in an already-loaded preview (`splitApprovable`'s `knownPreview`, no fetch of its own); the rest open `PendingReviewDialog` grouped by reason, each record sent with its own fingerprint, and every sweep reports a per-record result row (a refused revert included) — a record the sweep found needing an acknowledgement is reported AND handed to that grouped dialog (approve or dismiss with the acknowledgement). A batch (the sweep, a dialog group) HOLDS the source revisions its own writes bump (`holdRevisions`, released in `afterMutation`): N writes cost N verb requests, ONE re-list and at most one re-preview per card still listed — never a re-list plus every visible card's re-preview per write. A `changed` answer (`PENDING_STATE_CHANGED` / `PENDING_GENERATION_CHANGED`) is not final: the dialog fetches ONE fresh preview for that record (per-record sequence, liveness cleared on unmount only) and waits for a new click — never a replay. In the panel that ONE fetch is the panel's `refetch`: it waits for the re-list, the record's card holds meanwhile and adopts the answer under its final key (`takeRefetch`), so the card sends nothing of its own; a direct (dialog-less) refusal bumps only that card's preview nonce. A confirmed absence with a saved baseline is named in the preview body (`acceptsAbsence`). The orphan probe runs on mount, Refresh and Repair only — a source revision re-reads the plain list. Source inventory is separate from content, completed siblings are cached, truncation is shown, and Git errors do not hide other sources. Review reads a bounded pending preview/fingerprint with typed current/baseline/index states; ack-required state opens an explicit confirmation. Actual WRITE access gates card, compact, bulk and repair controls; historical cards remain read-only. Orphan ids arrive with the same pending listing, so Repair remains orphan-only and re-authorized server-side.
|
|
30
|
+
- `PendingChangesPanel` — Changes groups current Git status and pending review by source/repository, including non-Git sources. Normalized URI joining renders a file once while retaining separately labeled staged/unstaged comparisons for each overlapping repository and one pending review. All cards start expanded in ONE `useVirtualizer` list (`pendingChangesModel.ts` owns the filters, the flat row list, `rowEstimate`/`isMeasuredRow` and the Approve split; the compact row pins `h-8`, every card is measured; `overflow-anchor:none` plus an above-the-fold size adjustment keep the view still); the content lanes cache every loaded preview/diff, and source revisions plus context sequences fence obsolete replies. A **Type** filter (all · pending · Git) narrows the layers; the repository picker uses the Git panel's `resolveRepoLabel`. **Approve N** counts records with no acknowledgement on their own fields (`ackHintFor`, the server preview's own predicate) or in an already-loaded preview (`splitApprovable`'s `knownPreview`, no fetch of its own); the rest open `PendingReviewDialog` grouped by reason, each record sent with its own fingerprint, and every sweep reports a per-record result row (a refused revert included) — a record the sweep found needing an acknowledgement is reported AND handed to that grouped dialog (approve or dismiss with the acknowledgement). A batch (the sweep, a dialog group) HOLDS the source revisions its own writes bump (`holdRevisions`, released in `afterMutation`): N writes cost N verb requests, ONE re-list and at most one re-preview per card still listed — never a re-list plus every visible card's re-preview per write. A `changed` answer (`PENDING_STATE_CHANGED` / `PENDING_GENERATION_CHANGED`) is not final: the dialog fetches ONE fresh preview for that record (per-record sequence, liveness cleared on unmount only) and waits for a new click — never a replay. In the panel that ONE fetch is the panel's `refetch`: it waits for the re-list, the record's card holds meanwhile and adopts the answer under its final key (`takeRefetch`), so the card sends nothing of its own; a direct (dialog-less) refusal bumps only that card's preview nonce. A confirmed absence with a saved baseline is named in the preview body (`acceptsAbsence`). The orphan probe runs on mount, Refresh and Repair only — a source revision re-reads the plain list. Source inventory is separate from content, completed siblings are cached, truncation is shown, and Git errors do not hide other sources. Review reads a bounded pending preview/fingerprint with typed current/baseline/index states; ack-required state opens an explicit confirmation. Actual WRITE access gates card, compact, bulk and repair controls; historical cards remain read-only. Orphan ids arrive with the same pending listing, so Repair remains orphan-only and re-authorized server-side. Changes exposes Load more and incomplete inventory state. Repair processes batches sequentially with progress and Stop after the current batch; partial pages pause at their cursor for explicit Continue available/Retry. List and orphan epochs are independent; classification survives a plain relist only for matching record generations. FileEditor opens the shared review dialog for the exact visible URI. Returned generation conflicts appear in the existing Changes row status before preview succeeds. Git hunk rows retain geometry during same-context permission refresh while actions remain disabled until permission and diff settle.
|
|
31
31
|
|
|
32
32
|
- `InlineUploader` — the Upload tab (folder pick, new folder, drop zone). The two modals it once sat beside (`EditFileModal`, `UploadFileModal`) were deleted 2026-09-04: nothing had mounted either since the repository's baseline, so the settling-sandpiper M9 reconcile tails had been fixed on dead code while the live in-place Edit still called the upsert-only `refresh`. Those tails are store actions now (`saveFileInPlace` / `markFileDeleted`, `../store/README.md`) that `FileEditor` calls.
|
|
33
33
|
- `GitControlPanel`, `StackedSections` (+ pure `stackedSectionsLayout.ts`), `GitCommitGraph` (+ pure `gitGraphLayout.ts`/`gitGraphPaging.ts`/`gitGraphItems.ts` — the item model: expandable rows, the outgoing divider, ref-chip/dot classing, the branch-bar ahead/behind chips), `GitDiffView` + `GitFileDiff` + `GitCommitChangesView` (re-exported; + pure `gitDiffExpand.ts`/`gitDiffPatch.ts`/`gitFileDiffMode.ts`), `GitStatusBadge`, `DiffCountBadge` — the Git Control surface: the sidebar pane-stack panel, the in-panel virtualized commit graph with commit drill-in + outgoing markers, the side-by-side diff view (single + multi-file stacked tab), the per-file tree badge. The owning narrative (sections, paging/live-refresh, drill-in, outgoing, caps, write gating) is the package-root README "Git Control panel" section — this bullet only anchors the files.
|
|
@@ -39,7 +39,7 @@ Re-exported from `app/filesystem/ui/index.ts`:
|
|
|
39
39
|
- `PolicyEditor` — compact admin-style permissions editor for the source-config panel. Renders the persisted JSON tree (`default`, `byRole`, `byUser`, `byAgent`, `paths[]`) with tri-state R/W/X pill badges (allow / deny / inherit cycle), per-role/user/agent override rows (with `+ Add` input + trash), and path rules with clickable role chips + per-rule agent chips (`$self/agentId`, inline-add). `OVR` toggles the rule's `override` flag. Adding a rule **prepends** it (visible at the top of long policy sets; first-match-wins means the fresh rule also takes precedence). Two modes: `edit` (Save button visible when dirty) and `view` (read-only chat-config render). It is wired into `SourcesPanel`, laid out admin-style, and carries the By Role / By User / By Agent sections. Save threads through `handlePolicySave` → `updateSourceConfig` without disturbing the user's pending sync/credential edits.
|
|
40
40
|
- `SourcesPanel` "Discoverable" section — lives INSIDE the Vector Health expandable, alongside a `mount.mts` explainer card. Suggestions arrive from `GET /sources/discoverable` and use the shared `dimmedSourceTone('unattached')` util. The icon name (`AppWindow`, `GitBranch`, `FolderTree`, …) resolves through the shared `resolveLucideIcon` helper (same `ICON_MAP` as the workspace dock-left). Click pre-fills the add-source form with `{ key, path, kind, hostRoot? }` from the suggestion, plus the suggestion's optional `suggestedAccess` (read/write/exec) which overrides the connector-kind default, and — for `seed:'discoverable'` package declarations (e.g. the admin app zone, suggestion id `discover:decl:app`) — the **declared `description`** prefills the add form's description field. The **Full Host** suggestion (native/non-Docker only — `/` is the readable host root; in Docker the host must first be bound via `pnpm neuralis:mount add /`) ships `suggestedAccess: { read:true, write:false, exec:false }` so the whole-host root attaches **read-only by default** — the owner widens it (or narrows the uri-policy) in the form before saving. A host source is readable from the container shell, but host-native binaries still won't execute under the container runtime (a host PTY broker is a separate, future capability).
|
|
41
41
|
- `SourcesPanel` row actions say what they do: **Rescan changes** (the `reindex` route without `force` — changed files are embedded again, unchanged ones keep their vectors), **Re-embed…** (`ReembedDialog` — every file of the source with the active model, estimate first), **Embed pending…** (`ReembedDialog kind="pending"`; it and **Re-embed…** are disabled while the row's live `embedding_enabled` says off), **Forget sync state** (the `reset` route — the next sync compares every file again; deletes no file and no vector). A started re-embed is followed by the same job poll + count refresh as a rescan (`followReindex`); an embedding pass that ends `paused` (terminal, not failed) shows "Embedding paused: <reason>" with **Resume** (the same owed-only apply). The Modify-source body states `binarySkipLine` ("N binary files not indexed" — the last count a pass measured) and `embeddingLine` beside `mediaSkipLine`: off — the live `embedding_enabled`, never the last pass — reads neutral ("Semantic embedding off — text and field search only"); the last pass's other reasons read amber ("Semantic embedding pending: <reasons>").
|
|
42
|
-
- `SourcesPanel` row is **name-first** (the redesign for the always-narrow widget). The source name is the dominant element (`flex-1`, larger/bold); the kind icon uses the SAME resolved icon set as the file tree (`resolveIconWithFallback` over the connector-manifest `kind_icon`/`meta.icon`) and is **coloured by `getSourceVisualTone`** — so the icon tone IS the sync/live signal, matching the tree (the old `Healthy/Idle/Error` status text + `statusTone` are gone). The per-row **trust badge** (`first-party`/…) and the **scope badge** (`project`/`user`/`agent` — already a tab filter above) are removed; only `observed`, `orphan`, `unsaved`, and the enabled toggle remain inline. Rows are **grouped by `origin_package`** — the **declaring package id** the server resolves per source (the CONTRACT-BACKED `PackageSourceDeclaration` owner, returned on each `SourceRuntimeSummary` / `s.origin_package`), one group **per package**: each real `@neuralis/*` (or any other) package sorts **alphabetically** and user-added mounts collapse into a single **`Mounted (user-added)`** group that sorts **LAST** (`sourceOriginGroup` / `MOUNTED_GROUP`). The small left-indented group header **replaces** the per-row trust/`first-party` badge. The old client-side heuristic that inferred the owner from the connector kind's npm scope (`sourceOriginLabel`) is **gone** — attribution is now resolved server-side from the declarations (`matchDeclaredSource`, slug + resolved-root for rooted kinds; kind-only for rootless sandbox kinds), so a user mount named `app` that is NOT rooted at the platform app zone is NOT attributed to admin — the grouping is **unspoofable**.
|
|
42
|
+
- `SourcesPanel` row is **name-first** (the redesign for the always-narrow widget). The source name stays left-aligned and is the dominant element (`flex-1`, larger/bold); the kind icon uses the SAME resolved icon set as the file tree (`resolveIconWithFallback` over the connector-manifest `kind_icon`/`meta.icon`) and is **coloured by `getSourceVisualTone`** — so the icon tone IS the sync/live signal, matching the tree (the old `Healthy/Idle/Error` status text + `statusTone` are gone). The per-row **trust badge** (`first-party`/…) and the **scope badge** (`project`/`user`/`agent` — already a tab filter above) are removed; only `observed`, `orphan`, `unsaved`, and the enabled toggle remain inline. Rows are **grouped by `origin_package`** — the **declaring package id** the server resolves per source (the CONTRACT-BACKED `PackageSourceDeclaration` owner, returned on each `SourceRuntimeSummary` / `s.origin_package`), one group **per package**: each real `@neuralis/*` (or any other) package sorts **alphabetically** and user-added mounts collapse into a single **`Mounted (user-added)`** group that sorts **LAST** (`sourceOriginGroup` / `MOUNTED_GROUP`). The small left-indented group header **replaces** the per-row trust/`first-party` badge. The old client-side heuristic that inferred the owner from the connector kind's npm scope (`sourceOriginLabel`) is **gone** — attribution is now resolved server-side from the declarations (`matchDeclaredSource`, slug + resolved-root for rooted kinds; kind-only for rootless sandbox kinds), so a user mount named `app` that is NOT rooted at the platform app zone is NOT attributed to admin — the grouping is **unspoofable**.
|
|
43
43
|
- `SourcesPanel` source body: when a row is expanded it renders at **full height** (no `max-h` clamp) so every Path Rule / uri-policy is visible and the outer panel scrolls — the closed state keeps the collapse animation. The Sync sub-section shows a per-trigger explainer (Manual = only the Sync button; Auto = file-watch + reconcile; Scheduled = interval) and clarifies the "Always active" pin. Disabling sync (unchecking "Enable sync" + Save) sends `clearSync: true` — an omitted `sync` means "no change" server-side, so without the flag the disable would silently revert.
|
|
44
44
|
- `SourcesPanel` per-source counts are **lazy** — the exact `connector` / `indexed` / `unindexed` stats row is fetched ONLY when its row is opened (`toggleSource` → `fetchSourceCounts` → a list scoped to `<source>://` with `include_counts`, so `computeSourcesRegistry` walks that ONE source), cached in `countsBySource`, and shown behind a "Loading counts…" placeholder. Panel-open itself does a **count-FREE** `refresh()` (no `withCounts`) — that still populates `sourceSummaries` (`s.sources`) for source discovery (observed-only sources, project scope) + per-row connector liveness dim, but fires **zero** project-wide count walks. There is deliberately no eager `refresh({withCounts:true})` on open — that, together with the Vector Health walk below, is what used to make opening one panel slow everything. Counted refreshes are narrowed to the operations that actually MOVE a number: sync, reindex, and a config save that changed the sync FILTER set (`syncFilterChanged` — exclude/include/`maxScanFiles`/a cleared sync block, which `connector.count()` applies, so the on-disk total shifts at once). A permissions save, a delete and an add refresh count-FREE: the indexed count is a project-scoped `countByFilter` with no per-caller policy input, the on-disk count is physical truth, a deleted row is gone and a brand-new row's numbers are one expand away. Every counted path also evicts that source's cached counts (`clearCachedCounts`) so the fresh summary is what renders — the lazy cache used to shadow it indefinitely after an exclude edit. `pollSyncJob` (1.5 s × `MAX_POLLS` 2400 = a sixty-minute ceiling) checks a `mountedRef` each turn and returns the moment the panel unmounts; the ref is cleared on UNMOUNT ONLY, never in a dependency-change cleanup, so a live poll is never dropped while the panel is still there. And EVERY hook in `SourcesPanel` sits above its `if (!open) return null` guard: two `useCallback`s used to live below it, so the component rendered a different number of hooks open than closed — React refuses that on one instance, and only the parent's mount gate carrying the same `workspacePanels.sources` flag kept it from ever firing.
|
|
45
45
|
- `useFilesystemSSE` `sync:completed`: the handler is matched **before** the `if (!uri) return` guard — the event carries `source` (the slug), NOT a file `uri`. It runs the authoritative `reconcileSourceFolders` over that source's VISIBLE folders (a sync can have removed files), skips the pass entirely when every change count on the payload is zero (a missing count is unknown and still re-lists), and falls back to `refreshTree` only when no source is present. That pass rides the ONE per-source reconcile schedule shared with external-change events (a trailing quiet window with a max-wait), so a burst of changes becomes one pass. Owner: `app/filesystem/hooks/README.md`.
|
|
@@ -53,13 +53,13 @@ Re-exported from `app/filesystem/ui/index.ts`:
|
|
|
53
53
|
|
|
54
54
|
- `SourceDeleteConfirm` — inline rose-tinted popover under the row (no modal, matches the panel's "no overlay" pattern). Headline + one-line reminder that the host folder is not touched + `[ ] Purge vector index entries (~N point(s))` checkbox (default OFF — conservative) + Docker-bind-mount warning ribbon when `connection.config.hostRoot` is set, telling the user to run `neuralis mount remove <slug>` before restarting the container. On confirm, `SourcesPanel.handleDelete` calls `deleteSource({ purgeVectors })` and surfaces a transient `successMsg` ("Deleted "<source>" (vectors purged: N)").
|
|
55
55
|
- **`SkillLauncher` coverage** (`@neuralis/package-system/client`, additive "+" launchers) — every filesystem function the LLM can drive has a visible composer-prefill trace. Clicking a launcher PREFILLS the chat composer with the skill (+ scripts) like a `/` slash pick; it never sends/streams. **No launcher states a feature list**: visibility is DERIVED from the host's per-caller skill catalog, so it cannot drift from each skill's own `SKILL.md` `requiredFeatures` and an unresolved catalog renders nothing (the gate named per row below is the SKILL's, documented here, not repeated in the JSX). `packageId` is the REGISTRY id (`@neuralis/brain-core`), matched as an exact pair with the skill id. The launchers resolve the `WorkspaceHostPort` from the `WorkspaceHostPortProvider` mounted once in `host/installFilesystemHostComponents.tsx` — without it they render `null` silently. Current placements:
|
|
56
|
-
- `Toolbar` — `sync` (`drive.sync`, icon-`brain`, script-less) next to the Sources panel toggle, and `manage-files` (`drive.write`; `scripts: mkdir, copy, modify
|
|
57
|
-
- `SourcesPanel` header — `manage-sources` (`drive.read`, attached-left to the `+ Source` button
|
|
58
|
-
- `SourcesPanel` per-source **Permissions** header — `manage-uri-policy` (`drive.policy` — no grant below the `admin` tier, grantable to any custom role
|
|
56
|
+
- `Toolbar` — `sync` (`drive.sync`, icon-`brain`, script-less) next to the Sources panel toggle, and `manage-files` (`drive.write`; `scripts: mkdir, copy, modify`) as the general file-ops handoff. The tree's CONTEXT MENU deliberately carries none: it is a keyboard-navigable click-to-act list with one uniform item model, and a composer-prefill affordance would need a bespoke item kind there.
|
|
57
|
+
- `SourcesPanel` header — `manage-sources` (`drive.read`, attached-left to the `+ Source` button).
|
|
58
|
+
- `SourcesPanel` per-source **Permissions** header — `manage-uri-policy` (`drive.policy` — no grant below the `admin` tier, grantable to any custom role) hands the URI-policy / path-permission edit for that source to the agent, alongside the `PolicyEditor`.
|
|
59
59
|
- `FileTree` empty-state — `manage-files` (`drive.write`, label; `scripts: upload, modify`) to populate a source.
|
|
60
|
-
- `GitControlPanel` header — `git` (`drive.read
|
|
61
|
-
- `PendingChangesPanel` toolbar — `manage-files` (`drive.write
|
|
62
|
-
- `VectorHealthPanel` actions row — `manage-sources` (`drive.read
|
|
60
|
+
- `GitControlPanel` header — `git` (`drive.read`) next to the refresh button. The `git` skill drives the SAME `/git/*` routes this panel does — its own SKILL.md says so — and the panel shipped with no way to reach it until 2026-07-28.
|
|
61
|
+
- `PendingChangesPanel` toolbar — `manage-files` (`drive.write`) for bulk review; the per-row approve/revert buttons stay the primary path.
|
|
62
|
+
- `VectorHealthPanel` actions row — `manage-sources` (`drive.read`). The index is per-SOURCE, so the agent-side equivalent of scan/repair is a source reindex plus a sync-status readout.
|
|
63
63
|
- `PackagesPanel` header — `create-package` (label, script-less) hands the whole authoring flow (scaffold, manifest, tools, surfaces, connectors, build — including the WASM pipeline) to the agent; additive to the per-row `Build` button. This is the ONE launcher that narrows further, with `alsoRequires={["packages.author"]}`, even though the SKILL ships ungated (so it stays importable): it scaffolds into `packages://`, which needs write + install/build capability the default `member` role does not hold, so showing it to a caller whose first action would be denied is worse UX than hiding it. `alsoRequires` can only ever hide a launcher, never reveal one, so this cannot become a disclosure. Deliberate — do not "align" it with the skill frontmatter.
|
|
64
64
|
|
|
65
65
|
## How it plugs into the package-system kernel
|
|
@@ -1261,7 +1261,6 @@ export function SourcesPanel(props: Props) {
|
|
|
1261
1261
|
attached="left"
|
|
1262
1262
|
skill="manage-sources"
|
|
1263
1263
|
packageId="@neuralis/brain-core"
|
|
1264
|
-
scripts={["scripts/sync.sh", "scripts/reindex.sh", "scripts/set-enabled.sh"]}
|
|
1265
1264
|
label="Manage sources with the agent"
|
|
1266
1265
|
/>
|
|
1267
1266
|
</div>
|
|
@@ -1677,7 +1676,7 @@ export function SourcesPanel(props: Props) {
|
|
|
1677
1676
|
<div className="w-full flex items-center gap-2 py-2 text-left group hover:bg-white/[0.02] rounded-md px-1 transition">
|
|
1678
1677
|
<button type="button" onClick={() => toggleSource(config.source)} aria-expanded={isOpen}
|
|
1679
1678
|
title={disconnected ? sourceLiveReasonLabel(config.live_reason) : undefined}
|
|
1680
|
-
className="min-w-0 flex-1 flex items-center gap-2">
|
|
1679
|
+
className="min-w-0 flex-1 flex items-center gap-2 text-left">
|
|
1681
1680
|
{/* Git indicator (G2.1) — a GitBranch overlay on a git-repo
|
|
1682
1681
|
source's icon, ORTHOGONAL to the sync tone (visualTone still
|
|
1683
1682
|
owns the icon colour). */}
|
|
@@ -1902,7 +1901,6 @@ export function SourcesPanel(props: Props) {
|
|
|
1902
1901
|
size="sm"
|
|
1903
1902
|
skill="manage-uri-policy"
|
|
1904
1903
|
packageId="@neuralis/brain-core"
|
|
1905
|
-
scripts={["scripts/show-uri.sh", "scripts/set-uri.sh", "scripts/manifests.sh"]}
|
|
1906
1904
|
label={`Edit URI policy for "${config.source}" with the agent`}
|
|
1907
1905
|
icon="shield"
|
|
1908
1906
|
/>
|
|
@@ -20,6 +20,7 @@ export function Toolbar(props: { agentId: string; onOpenProjectFiles: () => void
|
|
|
20
20
|
const activeWorkspaceTab = useFilesystemStore((s) => s.activeWorkspaceTab);
|
|
21
21
|
// The server count of the caller's visible pending reviews (`refreshPendingCount`),
|
|
22
22
|
// never the client set of loaded folders' rows.
|
|
23
|
+
const pendingCountState = useFilesystemStore((s) => s.pendingCountState);
|
|
23
24
|
const pendingCount = useFilesystemStore((s) => s.pendingCount);
|
|
24
25
|
|
|
25
26
|
return (
|
|
@@ -112,7 +113,7 @@ export function Toolbar(props: { agentId: string; onOpenProjectFiles: () => void
|
|
|
112
113
|
size="sm"
|
|
113
114
|
skill="manage-files"
|
|
114
115
|
packageId="@neuralis/brain-core"
|
|
115
|
-
scripts={["scripts/mkdir.sh", "scripts/copy.sh", "scripts/modify.sh"
|
|
116
|
+
scripts={["scripts/mkdir.sh", "scripts/copy.sh", "scripts/modify.sh"]}
|
|
116
117
|
label="Create, copy, edit or search files with the agent"
|
|
117
118
|
icon="Folder"
|
|
118
119
|
/>
|
|
@@ -133,7 +134,7 @@ export function Toolbar(props: { agentId: string; onOpenProjectFiles: () => void
|
|
|
133
134
|
|
|
134
135
|
<button
|
|
135
136
|
className={`relative p-1.5 rounded-lg transition-colors ${workspacePanels.pending && activeWorkspaceTab === 'pending' ? 'bg-amber-500/15 text-amber-300' : 'text-white/50 hover:bg-white/5 hover:text-amber-200'}`}
|
|
136
|
-
title={pendingCount > 0 ? `Pending changes — ${pendingCount} to review` : 'Pending changes — review, approve & revert'}
|
|
137
|
+
title={pendingCountState === 'stale' ? `Pending changes — count unavailable${pendingCount > 0 ? ` (last count ${pendingCount})` : ''}` : pendingCountState === 'partial' ? `Pending changes — ${pendingCount}+ (incomplete) to review` : pendingCount > 0 ? `Pending changes — ${pendingCount} to review` : 'Pending changes — review, approve & revert'}
|
|
137
138
|
onClick={() => {
|
|
138
139
|
if (workspacePanels.pending && activeWorkspaceTab === 'pending') {
|
|
139
140
|
setWorkspacePanelOpen('pending', false);
|
|
@@ -143,9 +144,9 @@ export function Toolbar(props: { agentId: string; onOpenProjectFiles: () => void
|
|
|
143
144
|
}}
|
|
144
145
|
>
|
|
145
146
|
<FileDiff className="w-4 h-4" />
|
|
146
|
-
{pendingCount > 0 ? (
|
|
147
|
+
{pendingCount > 0 || pendingCountState !== 'exact' ? (
|
|
147
148
|
<span data-testid="pending-count-badge" className="absolute -top-1 -right-1 min-w-[14px] rounded-full px-1 text-center text-[9px] leading-[14px] font-semibold bg-amber-500/15 text-amber-300">
|
|
148
|
-
{pendingCount
|
|
149
|
+
{pendingCount > 999 ? '1000+' : pendingCount}{pendingCountState === 'partial' && pendingCount <= 999 ? '+' : pendingCountState === 'stale' ? '?' : ''}
|
|
149
150
|
</span>
|
|
150
151
|
) : null}
|
|
151
152
|
</button>
|
|
@@ -99,9 +99,17 @@ export function VectorHealthPanel({ agentId, projectId }: Props) {
|
|
|
99
99
|
* Cleared automatically after 3s. Skipped when `dryRun === true` since
|
|
100
100
|
* preview-only doesn't need a guard. */
|
|
101
101
|
const [confirmingAction, setConfirmingAction] = useState<ActionKind | null>(null);
|
|
102
|
+
const epoch = useRef(0);
|
|
103
|
+
const reportSeq = useRef(0);
|
|
104
|
+
const scanSeq = useRef(0);
|
|
105
|
+
const stop = useRef(false);
|
|
106
|
+
const pendingCursor = useRef<string | undefined>(undefined);
|
|
107
|
+
const [pendingPaused, setPendingPaused] = useState(false);
|
|
108
|
+
useEffect(() => { pendingCursor.current = undefined; setPendingPaused(false); }, [dryRun, retentionHours]);
|
|
102
109
|
const confirmTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
103
110
|
|
|
104
111
|
const fetchReport = useCallback(async () => {
|
|
112
|
+
const mine = epoch.current; const request = ++reportSeq.current;
|
|
105
113
|
setLoading(true);
|
|
106
114
|
setError(null);
|
|
107
115
|
try {
|
|
@@ -128,26 +136,29 @@ export function VectorHealthPanel({ agentId, projectId }: Props) {
|
|
|
128
136
|
return meta ? { ...row, ...meta } : row;
|
|
129
137
|
});
|
|
130
138
|
}
|
|
139
|
+
if (mine !== epoch.current || request !== reportSeq.current) return;
|
|
131
140
|
setReport(enriched);
|
|
132
141
|
} catch (err) {
|
|
133
|
-
setError(err instanceof Error ? err.message : 'Failed to fetch health report');
|
|
142
|
+
if (mine === epoch.current && request === reportSeq.current) setError(err instanceof Error ? err.message : 'Failed to fetch health report');
|
|
134
143
|
} finally {
|
|
135
|
-
setLoading(false);
|
|
144
|
+
if (mine === epoch.current && request === reportSeq.current) setLoading(false);
|
|
136
145
|
}
|
|
137
|
-
}, [agentId, projectId]);
|
|
146
|
+
}, [client, agentId, projectId]);
|
|
138
147
|
|
|
139
148
|
const runScan = useCallback(async () => {
|
|
149
|
+
const mine = epoch.current; const request = ++scanSeq.current;
|
|
140
150
|
setScanLoading(true);
|
|
141
151
|
setError(null);
|
|
142
152
|
try {
|
|
143
153
|
const result = await scanVectorHealth({ agentId, ...(projectId ? { projectId } : {}) }, client);
|
|
154
|
+
if (mine !== epoch.current || request !== scanSeq.current) return;
|
|
144
155
|
setScan({ orphanChunkCount: result.orphanChunkCount ?? 0, duplicateGroups: result.duplicateGroups ?? 0 });
|
|
145
156
|
} catch (err) {
|
|
146
|
-
setError(err instanceof Error ? err.message : 'Scan failed');
|
|
157
|
+
if (mine === epoch.current && request === scanSeq.current) setError(err instanceof Error ? err.message : 'Scan failed');
|
|
147
158
|
} finally {
|
|
148
|
-
setScanLoading(false);
|
|
159
|
+
if (mine === epoch.current && request === scanSeq.current) setScanLoading(false);
|
|
149
160
|
}
|
|
150
|
-
}, [agentId, projectId]);
|
|
161
|
+
}, [client, agentId, projectId]);
|
|
151
162
|
|
|
152
163
|
const runAction = useCallback(async (action: ActionKind) => {
|
|
153
164
|
// Two-click confirm for destructive actions when not in dry-run mode.
|
|
@@ -162,6 +173,7 @@ export function VectorHealthPanel({ agentId, projectId }: Props) {
|
|
|
162
173
|
confirmTimerRef.current = null;
|
|
163
174
|
}
|
|
164
175
|
setConfirmingAction(null);
|
|
176
|
+
const mine = epoch.current; stop.current = false;
|
|
165
177
|
setActionLoading(true);
|
|
166
178
|
setActionResult(null);
|
|
167
179
|
setLastActionKind(action);
|
|
@@ -170,8 +182,22 @@ export function VectorHealthPanel({ agentId, projectId }: Props) {
|
|
|
170
182
|
// we're actually deleting something (`dryRun=false`).
|
|
171
183
|
setPrevReport(!dryRun ? report : null);
|
|
172
184
|
try {
|
|
173
|
-
|
|
174
|
-
|
|
185
|
+
let cursor = action === 'repair-pending' ? pendingCursor.current : undefined; setPendingPaused(false);
|
|
186
|
+
const totals: Record<string, number> = {};
|
|
187
|
+
do {
|
|
188
|
+
const result = await runHealthAction({ agentId, ...(projectId ? { projectId } : {}), action, dryRun, retentionHours, ...(cursor ? { cursor } : {}) }, client);
|
|
189
|
+
if (mine !== epoch.current) return;
|
|
190
|
+
for (const key of ['scannedVisible', 'orphaned', 'attempted', 'discarded', 'refused', 'failed', 'legacyUnknownDraft']) {
|
|
191
|
+
if (typeof result[key] === 'number') totals[key] = (totals[key] ?? 0) + result[key];
|
|
192
|
+
}
|
|
193
|
+
setActionResult({ ...result, ...totals });
|
|
194
|
+
cursor = action === 'repair-pending' && !result.restartRequired && typeof result.nextCursor === 'string' ? result.nextCursor : undefined;
|
|
195
|
+
if (action === 'repair-pending') pendingCursor.current = cursor;
|
|
196
|
+
if (result.partial === true) { setPendingPaused(true); setError('Repair paused; part of the inventory or orphan checks are unavailable.'); break; }
|
|
197
|
+
if (result.restartRequired) { pendingCursor.current = undefined; setPendingPaused(true); setError('Changes moved. Refresh before continuing repair.'); }
|
|
198
|
+
if (typeof result.failed === 'number' && result.failed > 0) { cursor = undefined; setPendingPaused(true); setError('Repair paused after a failed change. Refresh before continuing.'); }
|
|
199
|
+
} while (cursor && !stop.current);
|
|
200
|
+
if (cursor) setPendingPaused(true);
|
|
175
201
|
if (!dryRun) {
|
|
176
202
|
void fetchReport();
|
|
177
203
|
// A destructive action changes orphan/dup counts — refresh the scan
|
|
@@ -179,15 +205,17 @@ export function VectorHealthPanel({ agentId, projectId }: Props) {
|
|
|
179
205
|
if (scan) void runScan();
|
|
180
206
|
}
|
|
181
207
|
} catch (err) {
|
|
182
|
-
setError(err instanceof Error ? err.message : 'Action failed');
|
|
208
|
+
if (mine === epoch.current) { if (action === 'repair-pending') setPendingPaused(true); setError(err instanceof Error ? err.message : 'Action failed'); }
|
|
183
209
|
} finally {
|
|
184
|
-
setActionLoading(false);
|
|
210
|
+
if (mine === epoch.current) setActionLoading(false);
|
|
185
211
|
}
|
|
186
|
-
}, [agentId, projectId, dryRun, retentionHours, fetchReport, confirmingAction, report, scan, runScan]);
|
|
212
|
+
}, [client, agentId, projectId, dryRun, retentionHours, fetchReport, confirmingAction, report, scan, runScan]);
|
|
187
213
|
|
|
188
214
|
// Auto-load on mount
|
|
189
215
|
useEffect(() => {
|
|
216
|
+
epoch.current++; stop.current = true; pendingCursor.current = undefined; setPendingPaused(false); setActionLoading(false); setActionResult(null); setReport(null); setScan(null); setError(null);
|
|
190
217
|
void fetchReport();
|
|
218
|
+
return () => { epoch.current++; stop.current = true; };
|
|
191
219
|
}, [fetchReport]);
|
|
192
220
|
|
|
193
221
|
// Clean up confirm timer on unmount.
|
|
@@ -211,6 +239,8 @@ export function VectorHealthPanel({ agentId, projectId }: Props) {
|
|
|
211
239
|
|
|
212
240
|
return (
|
|
213
241
|
<div className="space-y-3 px-2 pb-2">
|
|
242
|
+
{actionLoading && lastActionKind === 'repair-pending' ? <button type="button" onClick={() => { stop.current = true; }} className="text-xs text-amber-200">Stop after this batch</button> : null}
|
|
243
|
+
{canWrite && pendingPaused && !actionLoading ? <button type="button" onClick={() => void runAction('repair-pending')} className="text-xs text-amber-200">{pendingCursor.current ? 'Continue available repair' : 'Retry repair'}</button> : null}
|
|
214
244
|
{/* Description */}
|
|
215
245
|
<div className="text-[10px] text-white/40 italic leading-relaxed">
|
|
216
246
|
Cleanup tool for the vector index. Removes leftover records:{' '}
|
|
@@ -370,7 +400,6 @@ export function VectorHealthPanel({ agentId, projectId }: Props) {
|
|
|
370
400
|
size="sm"
|
|
371
401
|
skill="manage-sources"
|
|
372
402
|
packageId="@neuralis/brain-core"
|
|
373
|
-
scripts={["scripts/sync-status.sh", "scripts/reindex.sh"]}
|
|
374
403
|
label="Inspect sync status or reindex a source with the agent"
|
|
375
404
|
icon="Database"
|
|
376
405
|
/>
|
|
@@ -378,13 +407,13 @@ export function VectorHealthPanel({ agentId, projectId }: Props) {
|
|
|
378
407
|
<div className="flex items-center gap-3">
|
|
379
408
|
<label className="inline-flex items-center gap-1 text-[10px] text-white/60 cursor-pointer"
|
|
380
409
|
title="Preview only — nothing will be deleted.">
|
|
381
|
-
<input type="checkbox" checked={dryRun} onChange={(e) => setDryRun(e.target.checked)} className="accent-emerald-400 h-3 w-3" />
|
|
410
|
+
<input type="checkbox" disabled={actionLoading} checked={dryRun} onChange={(e) => setDryRun(e.target.checked)} className="accent-emerald-400 h-3 w-3" />
|
|
382
411
|
Dry run
|
|
383
412
|
</label>
|
|
384
413
|
<label className="inline-flex items-center gap-1 text-[10px] text-white/60"
|
|
385
414
|
title="Records marked as deleted within this many hours are kept (GC ignores them).">
|
|
386
415
|
Retention:
|
|
387
|
-
<input type="number" min={1} value={retentionHours}
|
|
416
|
+
<input type="number" disabled={actionLoading} min={1} value={retentionHours}
|
|
388
417
|
onChange={(e) => setRetentionHours(Math.max(1, Number(e.target.value) || 24))}
|
|
389
418
|
className="w-12 h-5 rounded px-1 text-[10px] bg-white/[0.04] border border-white/[0.08] text-white/80 outline-none" />h
|
|
390
419
|
</label>
|