@neuralis/brain-core 0.2.0 → 0.3.0
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/README.md +1 -1
- package/app/filesystem/README.md +3 -3
- package/app/filesystem/api/README.md +4 -4
- package/app/filesystem/api/index.ts +144 -139
- package/app/filesystem/domain/types.ts +12 -0
- package/app/filesystem/hooks/README.md +5 -5
- package/app/filesystem/hooks/useDiscoverableSources.ts +4 -2
- package/app/filesystem/hooks/useFilesystemSSE.ts +31 -23
- package/app/filesystem/hooks/useGitChangesInventory.ts +4 -2
- package/app/filesystem/host/README.md +3 -2
- package/app/filesystem/host/installFilesystemHostComponents.tsx +22 -9
- package/app/filesystem/store/README.md +12 -8
- package/app/filesystem/store/filesystemStore.ts +444 -163
- package/app/filesystem/store/gitSlice.ts +6 -6
- package/app/filesystem/store/listSlice.ts +9 -6
- package/app/filesystem/store/searchSlice.ts +3 -3
- package/app/filesystem/ui/FileEditor.tsx +90 -39
- package/app/filesystem/ui/FileTree.tsx +10 -9
- package/app/filesystem/ui/GitCommitChangesView.tsx +5 -3
- package/app/filesystem/ui/GitCommitGraph.tsx +6 -5
- package/app/filesystem/ui/GitControlPanel.tsx +14 -14
- package/app/filesystem/ui/GitDiffView.tsx +14 -6
- package/app/filesystem/ui/GitFileDiff.tsx +22 -15
- package/app/filesystem/ui/PendingChangesPanel.tsx +108 -44
- package/app/filesystem/ui/PendingReviewDialog.tsx +8 -5
- package/app/filesystem/ui/README.md +9 -9
- package/app/filesystem/ui/ReembedDialog.tsx +4 -2
- package/app/filesystem/ui/SourcesPanel.tsx +26 -28
- package/app/filesystem/ui/Toolbar.tsx +5 -4
- package/app/filesystem/ui/VectorHealthPanel.tsx +48 -17
- package/dist/app/host.js +46 -46
- 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 +27 -5
- 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/manifest-and-discovery.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/surfaces-and-design.md +7 -5
- package/skills/create-package/references/test-and-ship.md +9 -16
- package/skills/create-package/scripts/check-package.sh +47 -1
- 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/app/filesystem/hooks/useContainerWidth.ts +0 -39
- 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
|
@@ -453,6 +453,7 @@ type ConfirmState = { title: string; message: string; confirmLabel: string; onCo
|
|
|
453
453
|
type RescueState = { message: string; rescue: GitRescue };
|
|
454
454
|
|
|
455
455
|
export function GitControlPanel({ agentId }: { agentId: string }) {
|
|
456
|
+
const client = useFilesystemStore((s) => s.client);
|
|
456
457
|
const sources = useFilesystemStore((s) => s.sources);
|
|
457
458
|
const projectId = useFilesystemStore((s) => s.activeProjectId);
|
|
458
459
|
const activeSource = useFilesystemStore((s) => s.activeSource);
|
|
@@ -554,7 +555,7 @@ export function GitControlPanel({ agentId }: { agentId: string }) {
|
|
|
554
555
|
if (cached && (cached instanceof Promise || !opts?.force)) return cached;
|
|
555
556
|
const task = (async () => {
|
|
556
557
|
try {
|
|
557
|
-
const res = await getGitRepos({ agentId, ...(projectId ? { projectId } : {}), source: slug });
|
|
558
|
+
const res = await getGitRepos({ agentId, ...(projectId ? { projectId } : {}), source: slug }, client);
|
|
558
559
|
const repos = Array.isArray(res?.repos) ? res.repos : [];
|
|
559
560
|
repoListsRef.current.set(slug, repos);
|
|
560
561
|
setRepoLists((prev) => new Map(prev).set(slug, repos));
|
|
@@ -799,7 +800,7 @@ export function GitControlPanel({ agentId }: { agentId: string }) {
|
|
|
799
800
|
const handleStage = useCallback(
|
|
800
801
|
(paths: string[]) => {
|
|
801
802
|
if (paths.length === 0) return;
|
|
802
|
-
void doWrite(() => stagePaths({ agentId, ...p, source: selectedSlug, paths }));
|
|
803
|
+
void doWrite(() => stagePaths({ agentId, ...p, source: selectedSlug, paths }, client));
|
|
803
804
|
},
|
|
804
805
|
[doWrite, agentId, p, selectedSlug],
|
|
805
806
|
);
|
|
@@ -807,7 +808,7 @@ export function GitControlPanel({ agentId }: { agentId: string }) {
|
|
|
807
808
|
const handleUnstage = useCallback(
|
|
808
809
|
(paths: string[]) => {
|
|
809
810
|
if (paths.length === 0) return;
|
|
810
|
-
void doWrite(() => unstagePaths({ agentId, ...p, source: selectedSlug, paths }));
|
|
811
|
+
void doWrite(() => unstagePaths({ agentId, ...p, source: selectedSlug, paths }, client));
|
|
811
812
|
},
|
|
812
813
|
[doWrite, agentId, p, selectedSlug],
|
|
813
814
|
);
|
|
@@ -815,7 +816,7 @@ export function GitControlPanel({ agentId }: { agentId: string }) {
|
|
|
815
816
|
const runDiscard = useCallback(
|
|
816
817
|
(paths: string[]) => {
|
|
817
818
|
void (async () => {
|
|
818
|
-
const res = await doWrite(() => discardPaths({ agentId, ...p, source: selectedSlug, paths }));
|
|
819
|
+
const res = await doWrite(() => discardPaths({ agentId, ...p, source: selectedSlug, paths }, client));
|
|
819
820
|
if (res && res.rescue) {
|
|
820
821
|
setRescue({
|
|
821
822
|
message: `Discarded ${paths.length} file${paths.length === 1 ? '' : 's'} — recoverable`,
|
|
@@ -875,7 +876,7 @@ export function GitControlPanel({ agentId }: { agentId: string }) {
|
|
|
875
876
|
const message = commitMessage.trim();
|
|
876
877
|
if (!message) return;
|
|
877
878
|
void (async () => {
|
|
878
|
-
const res = await doWrite(() => commitGit({ agentId, ...p, source: selectedSlug, message, amend }));
|
|
879
|
+
const res = await doWrite(() => commitGit({ agentId, ...p, source: selectedSlug, message, amend }, client));
|
|
879
880
|
if (res && res.ok) {
|
|
880
881
|
setCommitMessage('');
|
|
881
882
|
setAmend(false);
|
|
@@ -901,7 +902,7 @@ export function GitControlPanel({ agentId }: { agentId: string }) {
|
|
|
901
902
|
(ref: string) => {
|
|
902
903
|
setBranchPickerOpen(false);
|
|
903
904
|
void (async () => {
|
|
904
|
-
const res = await doWrite(() => checkoutGitBranch({ agentId, ...p, source: selectedSlug, ref }));
|
|
905
|
+
const res = await doWrite(() => checkoutGitBranch({ agentId, ...p, source: selectedSlug, ref }, client));
|
|
905
906
|
if (res)
|
|
906
907
|
void loadGitBranches({ agentId, projectId: projectId ?? null, source: selectedSlug, repo: activeRepoRel });
|
|
907
908
|
})();
|
|
@@ -920,10 +921,10 @@ export function GitControlPanel({ agentId }: { agentId: string }) {
|
|
|
920
921
|
void (async () => {
|
|
921
922
|
// Create then switch to it (VS-Code "Create new branch" also checks out).
|
|
922
923
|
const created = await doWrite(() =>
|
|
923
|
-
createGitBranch({ agentId, ...p, source: selectedSlug, name, ...(from ? { startPoint: from } : {}) }),
|
|
924
|
+
createGitBranch({ agentId, ...p, source: selectedSlug, name, ...(from ? { startPoint: from } : {}) }, client),
|
|
924
925
|
);
|
|
925
926
|
if (created) {
|
|
926
|
-
await doWrite(() => checkoutGitBranch({ agentId, ...p, source: selectedSlug, ref: name }));
|
|
927
|
+
await doWrite(() => checkoutGitBranch({ agentId, ...p, source: selectedSlug, ref: name }, client));
|
|
927
928
|
void loadGitBranches({ agentId, projectId: projectId ?? null, source: selectedSlug, repo: activeRepoRel });
|
|
928
929
|
}
|
|
929
930
|
})();
|
|
@@ -932,7 +933,7 @@ export function GitControlPanel({ agentId }: { agentId: string }) {
|
|
|
932
933
|
const runDeleteBranch = useCallback(
|
|
933
934
|
(name: string) => {
|
|
934
935
|
void (async () => {
|
|
935
|
-
const res = await doWrite(() => deleteGitBranch({ agentId, ...p, source: selectedSlug, name }));
|
|
936
|
+
const res = await doWrite(() => deleteGitBranch({ agentId, ...p, source: selectedSlug, name }, client));
|
|
936
937
|
if (res && res.rescue) {
|
|
937
938
|
setRescue({ message: `Deleted branch "${name}" — recoverable`, rescue: res.rescue });
|
|
938
939
|
}
|
|
@@ -966,7 +967,7 @@ export function GitControlPanel({ agentId }: { agentId: string }) {
|
|
|
966
967
|
if (!rescue) return;
|
|
967
968
|
const carrier = rescue.rescue;
|
|
968
969
|
setRescue(null);
|
|
969
|
-
void doWrite(() => restoreGit({ agentId, ...p, source: selectedSlug, rescue: carrier }));
|
|
970
|
+
void doWrite(() => restoreGit({ agentId, ...p, source: selectedSlug, rescue: carrier }, client));
|
|
970
971
|
}, [rescue, doWrite, agentId, p, selectedSlug]);
|
|
971
972
|
|
|
972
973
|
// --- Remote operations (G4 keyring-kestrel Slice 2) ------------------------
|
|
@@ -976,17 +977,17 @@ export function GitControlPanel({ agentId }: { agentId: string }) {
|
|
|
976
977
|
|
|
977
978
|
const handleFetch = useCallback(() => {
|
|
978
979
|
setRemotePickerOpen(false);
|
|
979
|
-
void doWrite(() => fetchGit({ agentId, ...p, source: selectedSlug }));
|
|
980
|
+
void doWrite(() => fetchGit({ agentId, ...p, source: selectedSlug }, client));
|
|
980
981
|
}, [doWrite, agentId, p, selectedSlug]);
|
|
981
982
|
|
|
982
983
|
const handlePull = useCallback(() => {
|
|
983
984
|
setRemotePickerOpen(false);
|
|
984
|
-
void doWrite(() => pullGit({ agentId, ...p, source: selectedSlug }));
|
|
985
|
+
void doWrite(() => pullGit({ agentId, ...p, source: selectedSlug }, client));
|
|
985
986
|
}, [doWrite, agentId, p, selectedSlug]);
|
|
986
987
|
|
|
987
988
|
const handlePush = useCallback(() => {
|
|
988
989
|
setRemotePickerOpen(false);
|
|
989
|
-
void doWrite(() => pushGit({ agentId, ...p, source: selectedSlug }));
|
|
990
|
+
void doWrite(() => pushGit({ agentId, ...p, source: selectedSlug }, client));
|
|
990
991
|
}, [doWrite, agentId, p, selectedSlug]);
|
|
991
992
|
|
|
992
993
|
const unstagedChanges = gitStatus?.unstaged ?? [];
|
|
@@ -1201,7 +1202,6 @@ export function GitControlPanel({ agentId }: { agentId: string }) {
|
|
|
1201
1202
|
size="sm"
|
|
1202
1203
|
skill="git"
|
|
1203
1204
|
packageId="@neuralis/brain-core"
|
|
1204
|
-
scripts={["scripts/git.sh"]}
|
|
1205
1205
|
label="Status, diff, commit, branch, fetch or push with the agent"
|
|
1206
1206
|
icon="GitBranch"
|
|
1207
1207
|
/>
|
|
@@ -26,15 +26,21 @@ export function GitDiffView({
|
|
|
26
26
|
agentId: string | null;
|
|
27
27
|
projectId: string | null;
|
|
28
28
|
}): React.ReactElement {
|
|
29
|
-
const
|
|
29
|
+
const client = useFilesystemStore((s) => s.client);
|
|
30
30
|
const revision = useFilesystemStore(state => state.sourceChangeRevision[target.source] ?? 0);
|
|
31
|
+
const context = `${agentId ?? ''}\u0000${projectId ?? ''}\u0000${target.source}\u0000${target.repo ?? ''}`;
|
|
32
|
+
const [permission, setPermission] = useState<{ client: typeof client; context: string; revision: number; allowed: boolean; pending: boolean } | null>(null);
|
|
33
|
+
const current = permission?.client === client && permission.context === context;
|
|
34
|
+
const canWrite = current && !permission.pending && permission.revision === revision && permission.allowed;
|
|
35
|
+
const stageRows = current && permission.allowed;
|
|
31
36
|
useEffect(() => {
|
|
32
|
-
let alive = true;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
37
|
+
let alive = true;
|
|
38
|
+
setPermission(previous => ({ client, context, revision, pending: true, allowed: previous?.client === client && previous.context === context && previous.allowed }));
|
|
39
|
+
if (agentId) void getGitRepos({ agentId, ...(projectId ? { projectId } : {}), source: target.source }, client).then(result => {
|
|
40
|
+
if (alive) setPermission({ client, context, revision, pending: false, allowed: result.repos.find(repo => repo.rel === (target.repo ?? ''))?.canWrite === true });
|
|
41
|
+
}).catch(() => { if (alive) setPermission({ client, context, revision, pending: false, allowed: false }); });
|
|
36
42
|
return () => { alive = false; };
|
|
37
|
-
}, [agentId, projectId, target.source, target.repo, revision]);
|
|
43
|
+
}, [client, context, agentId, projectId, target.source, target.repo, revision]);
|
|
38
44
|
const mode = useMemo(() => gitDiffModeFromTarget(target), [target]);
|
|
39
45
|
return (
|
|
40
46
|
<GitFileDiff
|
|
@@ -48,6 +54,8 @@ export function GitDiffView({
|
|
|
48
54
|
projectId={projectId}
|
|
49
55
|
variant="tab"
|
|
50
56
|
canWrite={canWrite}
|
|
57
|
+
stageRows={stageRows}
|
|
58
|
+
permissionPending={!current || permission.pending || permission.revision !== revision}
|
|
51
59
|
revision={revision}
|
|
52
60
|
/>
|
|
53
61
|
);
|
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
import { computeGitDiffTokens, renderShikiToken } from './gitDiffShikiTokenizer';
|
|
37
37
|
import { resolveShikiLang, isShikiReady, ensureLanguage, getHighlighter } from './codeEditor/shikiHighlighter';
|
|
38
38
|
import { detectCodeLanguage } from '../lib/detectLanguage';
|
|
39
|
-
import { useContainerWidth } from '
|
|
39
|
+
import { useContainerWidth } from '@neuralis/package-system/client';
|
|
40
40
|
import type { VisibleDiffQueue } from '../hooks/useVisibleDiffCards';
|
|
41
41
|
import { DiffModeToggle } from './DiffModeToggle';
|
|
42
42
|
|
|
@@ -130,6 +130,8 @@ export function GitFileDiff({
|
|
|
130
130
|
variant = 'tab',
|
|
131
131
|
revision = 0,
|
|
132
132
|
canWrite = false,
|
|
133
|
+
stageRows = canWrite,
|
|
134
|
+
permissionPending = false,
|
|
133
135
|
runContent,
|
|
134
136
|
peekContent,
|
|
135
137
|
}: {
|
|
@@ -151,11 +153,14 @@ export function GitFileDiff({
|
|
|
151
153
|
variant?: 'tab' | 'card';
|
|
152
154
|
revision?: number;
|
|
153
155
|
canWrite?: boolean;
|
|
156
|
+
stageRows?: boolean;
|
|
157
|
+
permissionPending?: boolean;
|
|
154
158
|
/** A virtualized list's content lane; `cache` lets a re-mounted card reuse its answer. */
|
|
155
159
|
runContent?: VisibleDiffQueue['run'];
|
|
156
160
|
/** The lane's synchronous cache read: a re-mounted card paints its last answer on its first frame. */
|
|
157
161
|
peekContent?: VisibleDiffQueue['peek'];
|
|
158
162
|
}): React.ReactElement {
|
|
163
|
+
const client = useFilesystemStore((s) => s.client);
|
|
159
164
|
const [initial] = useState(() => peekContent?.<DiffAnswer>(`${diffFamily(source, repo, path, mode)}\u0000${revision}`, diffFamily(source, repo, path, mode)));
|
|
160
165
|
const [raw, setRaw] = useState<string | null>(() => initial === undefined ? null : initial && initial.ok !== false ? initial.diff : '');
|
|
161
166
|
const [loading, setLoading] = useState(false);
|
|
@@ -229,7 +234,7 @@ export function GitFileDiff({
|
|
|
229
234
|
}, [language]);
|
|
230
235
|
|
|
231
236
|
useEffect(() => {
|
|
232
|
-
sourceCacheRef.current.clear();
|
|
237
|
+
sourceCacheRef.current.clear(); setHunkBusy(null); setHunkError(null);
|
|
233
238
|
// New target/revision (or post-stage refetch): drop cached source and expansion state —
|
|
234
239
|
// the ranges are relative to the OLD raw's original hunks — AND the
|
|
235
240
|
// server-flag states: `serverTruncated`/`tooLarge` belong to the PREVIOUS
|
|
@@ -262,7 +267,7 @@ export function GitFileDiff({
|
|
|
262
267
|
path,
|
|
263
268
|
// ONE mode per request — the server 400s any combination.
|
|
264
269
|
...diffRequestParams(normalizedMode),
|
|
265
|
-
});
|
|
270
|
+
}, client);
|
|
266
271
|
// Keyed on everything the answer depends on; after a hunk apply the refetch
|
|
267
272
|
// replaces the cached answer instead of reading it.
|
|
268
273
|
const family = diffFamily(source, repo, path, normalizedMode);
|
|
@@ -292,7 +297,7 @@ export function GitFileDiff({
|
|
|
292
297
|
}
|
|
293
298
|
})();
|
|
294
299
|
return () => { seqRef.current++; };
|
|
295
|
-
}, [agentId, projectId, source, repo, path, normalizedMode, reloadNonce, revision, runContent]);
|
|
300
|
+
}, [client, agentId, projectId, source, repo, path, normalizedMode, reloadNonce, revision, runContent]);
|
|
296
301
|
|
|
297
302
|
// The ONE parse of the raw diff — the ORIGINAL (staging) model.
|
|
298
303
|
const { files, truncated, hunkPatches } = useMemo(
|
|
@@ -316,7 +321,7 @@ export function GitFileDiff({
|
|
|
316
321
|
// parsed count, or the index→patch mapping is unsafe.
|
|
317
322
|
const singleFile: FileData | null = files.length === 1 ? files[0] : null;
|
|
318
323
|
const canStageHunks =
|
|
319
|
-
|
|
324
|
+
stageRows && kind === 'working' &&
|
|
320
325
|
!!agentId &&
|
|
321
326
|
!!singleFile &&
|
|
322
327
|
singleFile.type === 'modify' &&
|
|
@@ -364,7 +369,7 @@ export function GitFileDiff({
|
|
|
364
369
|
...(repo ? { repo } : {}),
|
|
365
370
|
path,
|
|
366
371
|
rev: expanderSrc.rev,
|
|
367
|
-
});
|
|
372
|
+
}, client);
|
|
368
373
|
const res = runContent ? await runContent(fetchSource, () => mySeq === seqRef.current) : await fetchSource();
|
|
369
374
|
if (!res || mySeq !== seqRef.current) return null;
|
|
370
375
|
return settle(res.ok ? { ok: true, content: res.content } : { ok: false });
|
|
@@ -376,7 +381,7 @@ export function GitFileDiff({
|
|
|
376
381
|
agentId,
|
|
377
382
|
...(projectId ? { projectId } : {}),
|
|
378
383
|
files: [`${source}://${repo ? `${repo}/` : ''}${path}`],
|
|
379
|
-
});
|
|
384
|
+
}, client);
|
|
380
385
|
const res = runContent ? await runContent(fetchSource, () => mySeq === seqRef.current) : await fetchSource();
|
|
381
386
|
if (!res || mySeq !== seqRef.current) return null;
|
|
382
387
|
const file = extractReadFilesPayload(res)[0];
|
|
@@ -385,7 +390,7 @@ export function GitFileDiff({
|
|
|
385
390
|
} catch {
|
|
386
391
|
return settle({ ok: false });
|
|
387
392
|
}
|
|
388
|
-
}, [agentId, projectId, source, repo, path, expanderSrc, runContent]);
|
|
393
|
+
}, [client, agentId, projectId, source, repo, path, expanderSrc, runContent]);
|
|
389
394
|
|
|
390
395
|
// The synthetic OLD-side lines (`expandFromRawCode` slices by old-line
|
|
391
396
|
// numbers) + the RENDER-ONLY expanded hunk list. `oldTotal` also sizes the
|
|
@@ -457,20 +462,22 @@ export function GitFileDiff({
|
|
|
457
462
|
const applyHunk = useCallback(
|
|
458
463
|
async (hunkIndex: number) => {
|
|
459
464
|
// A raw shown while its fresh answer loads may be an older revision's — never stage from it.
|
|
460
|
-
if (loading || !canStageHunks || !hunkPatches || !agentId) return;
|
|
465
|
+
if (loading || permissionPending || !canWrite || !canStageHunks || !hunkPatches || !agentId) return;
|
|
461
466
|
const patch = assembleHunkPatch(hunkPatches.header, hunkPatches.hunks[hunkIndex]);
|
|
462
467
|
if (!patch) {
|
|
463
468
|
setHunkError('Could not build a hunk patch for this change.');
|
|
464
469
|
return;
|
|
465
470
|
}
|
|
471
|
+
const mySeq = seqRef.current;
|
|
466
472
|
setHunkBusy(hunkIndex);
|
|
467
473
|
setHunkError(null);
|
|
468
474
|
try {
|
|
469
475
|
if (staged) {
|
|
470
|
-
await unstageHunk({ agentId, ...(projectId ? { projectId } : {}), source, ...(repo ? { repo } : {}), hunk: patch });
|
|
476
|
+
await unstageHunk({ agentId, ...(projectId ? { projectId } : {}), source, ...(repo ? { repo } : {}), hunk: patch }, client);
|
|
471
477
|
} else {
|
|
472
|
-
await stageHunk({ agentId, ...(projectId ? { projectId } : {}), source, ...(repo ? { repo } : {}), hunk: patch });
|
|
478
|
+
await stageHunk({ agentId, ...(projectId ? { projectId } : {}), source, ...(repo ? { repo } : {}), hunk: patch }, client);
|
|
473
479
|
}
|
|
480
|
+
if (mySeq !== seqRef.current) return;
|
|
474
481
|
// The index changed → the cached `rev=index` source is stale (the WT
|
|
475
482
|
// entry would survive, but a whole-map clear is cheap and race-free).
|
|
476
483
|
sourceCacheRef.current.clear();
|
|
@@ -486,12 +493,12 @@ export function GitFileDiff({
|
|
|
486
493
|
void loadGitStatus({ agentId, projectId });
|
|
487
494
|
setReloadNonce((n) => n + 1);
|
|
488
495
|
} catch (e) {
|
|
489
|
-
setHunkError(e instanceof Error ? e.message : 'Hunk apply failed');
|
|
496
|
+
if (mySeq === seqRef.current) setHunkError(e instanceof Error ? e.message : 'Hunk apply failed');
|
|
490
497
|
} finally {
|
|
491
|
-
setHunkBusy(null);
|
|
498
|
+
if (mySeq === seqRef.current) setHunkBusy(null);
|
|
492
499
|
}
|
|
493
500
|
},
|
|
494
|
-
[loading, canStageHunks, hunkPatches, agentId, projectId, source, repo, staged, loadGitStatus],
|
|
501
|
+
[client, loading, permissionPending, canWrite, canStageHunks, hunkPatches, agentId, projectId, source, repo, staged, loadGitStatus],
|
|
495
502
|
);
|
|
496
503
|
|
|
497
504
|
/** One collapsed-gap row: clickable expander, or static text once refused. */
|
|
@@ -541,7 +548,7 @@ export function GitFileDiff({
|
|
|
541
548
|
key={idx}
|
|
542
549
|
type="button"
|
|
543
550
|
onClick={() => void applyHunk(idx)}
|
|
544
|
-
disabled={hunkBusy !== null || loading}
|
|
551
|
+
disabled={hunkBusy !== null || loading || permissionPending || !canWrite}
|
|
545
552
|
className={cn(
|
|
546
553
|
'inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[10px] transition-colors disabled:opacity-40',
|
|
547
554
|
staged
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
ChevronLeft, CheckCheck, X, XCircle, Wrench, Unlink, ShieldAlert,
|
|
24
24
|
} from 'lucide-react';
|
|
25
25
|
import { SkillLauncher } from '@neuralis/package-system/client';
|
|
26
|
-
import { useFilesystemStore } from '../store/filesystemStore';
|
|
26
|
+
import { useFilesystemStore, useFilesystemStoreApi } from '../store/filesystemStore';
|
|
27
27
|
import {
|
|
28
28
|
listPendingChanges, previewPendingChange, discardPendingChange, discardOrphanedPending,
|
|
29
29
|
type PendingChangeRecord, type PendingPreview,
|
|
@@ -115,6 +115,7 @@ export const PendingDiffCard = memo(function PendingDiffCard({
|
|
|
115
115
|
/** The dialog's post-409 refetch: `'wait'` while in flight, then its answer ONCE for the key it was taken for. */
|
|
116
116
|
takeRefetch: (id: string, key: string) => PendingPreview | 'wait' | undefined; extra?: React.ReactNode;
|
|
117
117
|
}) {
|
|
118
|
+
const client = useFilesystemStore((s) => s.client);
|
|
118
119
|
// Cards open inline by default — the diff is the review unit.
|
|
119
120
|
const [expanded, setExpanded] = useState(true);
|
|
120
121
|
// The last answer this record had (a remount or a new source revision) stays on screen
|
|
@@ -140,7 +141,7 @@ export const PendingDiffCard = memo(function PendingDiffCard({
|
|
|
140
141
|
if (adopted === 'wait') { setLoading(true); return; }
|
|
141
142
|
if (adopted) { setPreview(adopted); setLoadError(null); setAnswerKey(cacheKey); loadedKey.current = cacheKey; setLoading(false); return; }
|
|
142
143
|
setLoading(true);
|
|
143
|
-
void queue.run(() => previewPendingChange({ agentId, ...(projectId ? { projectId } : {}), id: rec.id }),
|
|
144
|
+
void queue.run(() => previewPendingChange({ agentId, ...(projectId ? { projectId } : {}), id: rec.id }, client),
|
|
144
145
|
() => generation === seq.current && active.current, { key: cacheKey, bytes: previewBytes }).then(next => {
|
|
145
146
|
if (!next || generation !== seq.current || !active.current) return;
|
|
146
147
|
onPreview(next);
|
|
@@ -152,7 +153,7 @@ export const PendingDiffCard = memo(function PendingDiffCard({
|
|
|
152
153
|
setLoadError(error instanceof Error ? error.message : 'Failed to load review'); setLoading(false);
|
|
153
154
|
});
|
|
154
155
|
return () => { seq.current++; };
|
|
155
|
-
}, [agentId, projectId, rec.id, cacheKey, expanded, shown, queue.run]);
|
|
156
|
+
}, [client, agentId, projectId, rec.id, cacheKey, expanded, shown, queue.run]);
|
|
156
157
|
|
|
157
158
|
const hunks = useMemo(() => preview ? hunksOf(preview) : null, [preview]);
|
|
158
159
|
const computed = useMemo(() => hunks ? countHunks(hunks) : null, [hunks]);
|
|
@@ -183,6 +184,7 @@ export const PendingDiffCard = memo(function PendingDiffCard({
|
|
|
183
184
|
{indexState && indexState !== 'stored' ? <span className="text-[10px] text-amber-200/80 shrink-0">
|
|
184
185
|
{indexState === 'missing' ? 'Index missing' : indexState === 'stale' ? 'Index stale' : 'Recovery only'}
|
|
185
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}
|
|
186
188
|
{orphaned ? ORPHAN_BADGE : rec.status === 'approved' || rec.status === 'reverted' ? (
|
|
187
189
|
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${statusTone(rec.status)}`} title={rec.status} />
|
|
188
190
|
) : null}
|
|
@@ -212,8 +214,10 @@ const NO_FILTERS: ChangeFilters = { kind: 'all', source: '', repo: '', branch: '
|
|
|
212
214
|
/** One bulk pass (the sweep and the grouped dialog) covers at most this many records. */ const REVIEW_BATCH = 100;
|
|
213
215
|
|
|
214
216
|
export function PendingChangesPanel({ agentId, projectId }: Props) {
|
|
217
|
+
const client = useFilesystemStore((s) => s.client);
|
|
215
218
|
const host = useFilesystemHost();
|
|
216
219
|
const canWrite = hasFeature({ grantedFeatures: [...(host.grantedFeatures ?? [])] }, 'drive.write');
|
|
220
|
+
const storeApi = useFilesystemStoreApi();
|
|
217
221
|
const sources = useFilesystemStore(s => s.sources);
|
|
218
222
|
const liveRevisions = useFilesystemStore(s => s.sourceChangeRevision);
|
|
219
223
|
// A review batch (the sweep, a dialog group) HOLDS the revisions its own writes bump: one re-list
|
|
@@ -258,17 +262,20 @@ export function PendingChangesPanel({ agentId, projectId }: Props) {
|
|
|
258
262
|
const [nonces, setNonces] = useState<Readonly<Record<string, number>>>({});
|
|
259
263
|
const latest = useRef({ nonces, revisions }); latest.current = { nonces, revisions };
|
|
260
264
|
const refetch = useCallback(async (rec: PendingChangeRecord): Promise<PendingPreview> => {
|
|
265
|
+
const epoch = contextEpoch.current;
|
|
261
266
|
refetches.current.set(rec.id, null);
|
|
262
267
|
try {
|
|
263
268
|
const listed = (await relisted.current.promise)?.find(r => r.id === rec.id) ?? rec;
|
|
264
|
-
|
|
269
|
+
if (epoch !== contextEpoch.current) throw new Error('Review context changed');
|
|
270
|
+
const fresh = await previewPendingChange({ agentId, ...(projectId ? { projectId } : {}), id: rec.id }, client);
|
|
271
|
+
if (epoch !== contextEpoch.current) throw new Error('Review context changed');
|
|
265
272
|
const revision = (latest.current.nonces[rec.id] ?? 0) + 1 + (latest.current.revisions[listed.sourceSlug] ?? 0);
|
|
266
273
|
refetches.current.set(rec.id, { key: previewKey(listed, revision), preview: fresh });
|
|
267
274
|
previews.current.set(rec.id, fresh);
|
|
268
275
|
return fresh;
|
|
269
|
-
} catch (failure) { refetches.current.delete(rec.id); throw failure; }
|
|
270
|
-
finally { setNonces(prev => ({ ...prev, [rec.id]: (prev[rec.id] ?? 0) + 1 })); }
|
|
271
|
-
}, [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]);
|
|
272
279
|
const [focusIndex, setFocusIndex] = useState(0);
|
|
273
280
|
const [review, setReview] = useState<ReviewRequest | null>(null);
|
|
274
281
|
const [results, setResults] = useState<ReviewResult[]>([]);
|
|
@@ -279,27 +286,50 @@ export function PendingChangesPanel({ agentId, projectId }: Props) {
|
|
|
279
286
|
const [collapsedGit, setCollapsedGit] = useState<ReadonlySet<string>>(new Set());
|
|
280
287
|
const repairTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
281
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]);
|
|
282
300
|
|
|
283
301
|
const inventory = useGitChangesInventory({ agentId, projectId, sources, revisions, resetKey: `${agentId}\u0000${projectId}\u0000${convFilter}` });
|
|
284
302
|
|
|
285
303
|
/** The pending list. The orphan probe (one connector stat per record) runs only on mount, Refresh and Repair. */
|
|
286
|
-
const fetchPending = useCallback(async (withOrphans: boolean): Promise<PendingChangeRecord[] | undefined> => {
|
|
304
|
+
const fetchPending = useCallback(async (withOrphans: boolean, append = false): Promise<PendingChangeRecord[] | undefined> => {
|
|
287
305
|
if (!agentId) return undefined;
|
|
288
306
|
const mine = ++listSeq.current;
|
|
307
|
+
const scan = withOrphans ? ++orphanSeq.current : orphanSeq.current;
|
|
308
|
+
const epoch = contextEpoch.current;
|
|
289
309
|
setLoading(true); setError(null);
|
|
290
310
|
try {
|
|
291
311
|
const list = await listPendingChanges({ agentId, ...(projectId ? { projectId } : {}),
|
|
292
|
-
...(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
|
+
}
|
|
293
319
|
if (mine !== listSeq.current) return undefined;
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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;
|
|
298
328
|
} catch (failure) {
|
|
299
|
-
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');
|
|
300
330
|
return undefined;
|
|
301
|
-
} finally { if (mine === listSeq.current) setLoading(false); }
|
|
302
|
-
}, [agentId, projectId, convFilter]);
|
|
331
|
+
} finally { if (epoch === contextEpoch.current && mine === listSeq.current) setLoading(false); }
|
|
332
|
+
}, [client, agentId, projectId, convFilter, nextCursor]);
|
|
303
333
|
|
|
304
334
|
const refreshAll = useCallback(async () => {
|
|
305
335
|
await Promise.all([fetchPending(true), inventory.refresh(true)]);
|
|
@@ -307,10 +337,14 @@ export function PendingChangesPanel({ agentId, projectId }: Props) {
|
|
|
307
337
|
}, [fetchPending, inventory.refresh, refreshPendingCount, agentId, projectId]);
|
|
308
338
|
|
|
309
339
|
useEffect(() => {
|
|
310
|
-
|
|
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);
|
|
311
345
|
void fetchPending(true);
|
|
312
|
-
return () => { listSeq.current++; previews.current.clear(); };
|
|
313
|
-
}, [
|
|
346
|
+
return () => { contextEpoch.current++; listSeq.current++; orphanSeq.current++; repairStop.current = true; previews.current.clear(); };
|
|
347
|
+
}, [client, agentId, projectId, convFilter]);
|
|
314
348
|
// A source revision re-reads the plain list, never the orphan probe; a context change is the effect above's.
|
|
315
349
|
const fetchPendingRef = useRef(fetchPending); fetchPendingRef.current = fetchPending;
|
|
316
350
|
const firstRevision = useRef(true);
|
|
@@ -372,11 +406,12 @@ export function PendingChangesPanel({ agentId, projectId }: Props) {
|
|
|
372
406
|
*/
|
|
373
407
|
const dropResolvedDeleteRow = useCallback((rec: PendingChangeRecord) => {
|
|
374
408
|
if (rec.op !== 'delete' || !rec.uri) return;
|
|
375
|
-
const node =
|
|
409
|
+
const node = storeApi.getState().files[rec.uri];
|
|
376
410
|
if (!node || node.status === 'deleted') removeFilePath(rec.uri);
|
|
377
|
-
}, [removeFilePath]);
|
|
411
|
+
}, [removeFilePath, storeApi]);
|
|
378
412
|
|
|
379
413
|
const afterMutation = useCallback(async (done: ReviewResult[]) => {
|
|
414
|
+
const epoch = contextEpoch.current;
|
|
380
415
|
for (const result of done) {
|
|
381
416
|
const rec = changes.find(c => c.id === result.id);
|
|
382
417
|
if (rec && result.outcome === 'ok' && result.action !== 'revert') dropResolvedDeleteRow(rec);
|
|
@@ -387,48 +422,66 @@ export function PendingChangesPanel({ agentId, projectId }: Props) {
|
|
|
387
422
|
if (refused.length) setNonces(prev => { const next = { ...prev }; for (const r of refused) next[r.id] = (next[r.id] ?? 0) + 1; return next; });
|
|
388
423
|
setResults(done);
|
|
389
424
|
const after = await fetchPending(false);
|
|
425
|
+
if (epoch !== contextEpoch.current) return;
|
|
390
426
|
// The STORE's value, not the last render's: the batch's own bumps may not have rendered yet.
|
|
391
|
-
listedRevisions.current =
|
|
427
|
+
listedRevisions.current = storeApi.getState().sourceChangeRevision; setHeld(null);
|
|
392
428
|
relisted.current.resolve(after); relisted.current = Promise.withResolvers();
|
|
393
429
|
void refreshPendingCount({ agentId, projectId });
|
|
394
|
-
}, [changes, dropResolvedDeleteRow, fetchPending, refreshPendingCount, agentId, projectId]);
|
|
430
|
+
}, [changes, dropResolvedDeleteRow, fetchPending, refreshPendingCount, agentId, projectId, storeApi]);
|
|
395
431
|
|
|
396
432
|
const resolveReview = useCallback(async (rec: PendingChangeRecord, action: ReviewAction) => {
|
|
397
433
|
if (!canWrite) return;
|
|
398
434
|
const preview = previews.current.get(rec.id);
|
|
399
435
|
if (!preview || preview.requiresAcknowledgement.length) { setReview({ kind: 'single', rec, action, ...(preview ? { preview } : {}) }); return; }
|
|
436
|
+
const epoch = contextEpoch.current;
|
|
400
437
|
setBusyId(rec.id);
|
|
401
|
-
try {
|
|
402
|
-
finally { setBusyId(null); }
|
|
403
|
-
}, [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]);
|
|
404
441
|
|
|
405
442
|
// Discard a single ORPHANED row — removes the review record + baseline blob + vector point.
|
|
406
443
|
const discard = useCallback(async (rec: PendingChangeRecord) => {
|
|
444
|
+
const epoch = contextEpoch.current;
|
|
407
445
|
setBusyId(rec.id);
|
|
408
446
|
try {
|
|
409
|
-
const res = await discardPendingChange({ agentId, ...(projectId ? { projectId } : {}), id: rec.id });
|
|
447
|
+
const res = await discardPendingChange({ agentId, ...(projectId ? { projectId } : {}), id: rec.id }, client);
|
|
448
|
+
if (epoch !== contextEpoch.current) return;
|
|
410
449
|
if (!res.ok) setError(res.error || 'Discard failed'); else dropResolvedDeleteRow(rec);
|
|
411
450
|
await fetchPending(false);
|
|
412
451
|
void refreshPendingCount({ agentId, projectId });
|
|
413
|
-
} catch (e) { setError(e instanceof Error ? e.message : 'Discard failed'); } finally { setBusyId(null); }
|
|
414
|
-
}, [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]);
|
|
415
454
|
|
|
416
455
|
// Repair: bulk-discard every orphaned change the caller can write. Which records
|
|
417
456
|
// went is read from the refetch — the route re-authorizes each one.
|
|
418
457
|
const repair = useCallback(async () => {
|
|
419
458
|
if (repairTimer.current) { clearTimeout(repairTimer.current); repairTimer.current = null; }
|
|
420
|
-
|
|
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;
|
|
421
463
|
try {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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);
|
|
429
480
|
void refreshPendingCount({ agentId, projectId });
|
|
430
|
-
} catch (e) { setError(e instanceof Error ? e.message : 'Repair failed'); }
|
|
431
|
-
|
|
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
|
+
|
|
432
485
|
const onRepairClick = useCallback(() => {
|
|
433
486
|
if (confirmRepair) { void repair(); return; }
|
|
434
487
|
setConfirmRepair(true);
|
|
@@ -440,27 +493,32 @@ export function PendingChangesPanel({ agentId, projectId }: Props) {
|
|
|
440
493
|
// confirm). A record whose fresh preview turns out to need one is reported AND handed
|
|
441
494
|
// to the grouped review dialog, where it is approved or dismissed with its acknowledgement.
|
|
442
495
|
const approveAll = useCallback(async () => {
|
|
496
|
+
const epoch = contextEpoch.current;
|
|
443
497
|
setApprovingAll(true); setConfirmAll(false); setError(null); holdRevisions();
|
|
444
498
|
const done: ReviewResult[] = [];
|
|
445
499
|
try {
|
|
446
500
|
for (const rec of approvable) {
|
|
501
|
+
if (epoch !== contextEpoch.current) break;
|
|
447
502
|
let preview = previews.current.get(rec.id);
|
|
448
503
|
if (!preview) {
|
|
449
|
-
try { preview = await previewPendingChange({ agentId, ...(projectId ? { projectId } : {}), id: rec.id }); }
|
|
504
|
+
try { preview = await previewPendingChange({ agentId, ...(projectId ? { projectId } : {}), id: rec.id }, client); }
|
|
450
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;
|
|
451
507
|
previews.current.set(rec.id, preview);
|
|
452
508
|
}
|
|
453
509
|
if (preview.requiresAcknowledgement.length) { done.push({ id: rec.id, uri: rec.uri, action: 'approve', outcome: 'needs-ack' }); continue; }
|
|
454
|
-
done.push(await applyReview({ agentId, projectId }, rec, preview, 'approve', false));
|
|
510
|
+
done.push(await applyReview({ agentId, projectId, client }, rec, preview, 'approve', false));
|
|
455
511
|
}
|
|
456
512
|
} finally {
|
|
513
|
+
if (epoch !== contextEpoch.current) return;
|
|
457
514
|
setApprovingAll(false);
|
|
458
515
|
const handed = approvable.filter(rec => done.some(r => r.id === rec.id && r.outcome === 'needs-ack'));
|
|
459
516
|
if (handed.length) setAckPreviews(n => n + 1);
|
|
460
517
|
await afterMutation(done);
|
|
518
|
+
if (epoch !== contextEpoch.current) return;
|
|
461
519
|
if (handed.length && canWrite) { reviewed.current.clear(); setReview({ kind: 'group', records: handed.slice(0, REVIEW_BATCH), total: handed.length }); }
|
|
462
520
|
}
|
|
463
|
-
}, [approvable, agentId, projectId, afterMutation, canWrite]);
|
|
521
|
+
}, [approvable, agentId, projectId, client, afterMutation, canWrite]);
|
|
464
522
|
|
|
465
523
|
const move = useCallback((delta: number) => setFocusIndex((i) => (visible.length === 0 ? 0
|
|
466
524
|
: Math.max(0, Math.min(visible.length - 1, i + delta)))), [visible.length]);
|
|
@@ -511,6 +569,7 @@ export function PendingChangesPanel({ agentId, projectId }: Props) {
|
|
|
511
569
|
</div>;
|
|
512
570
|
};
|
|
513
571
|
|
|
572
|
+
const renderedEpoch = contextEpoch.current;
|
|
514
573
|
const failed = results.filter(r => r.outcome !== 'ok');
|
|
515
574
|
const busy = loading || inventory.loading;
|
|
516
575
|
return (
|
|
@@ -519,7 +578,7 @@ export function PendingChangesPanel({ agentId, projectId }: Props) {
|
|
|
519
578
|
key={review.kind === 'group' ? `group\u0000${review.records[0]?.id ?? ''}` : `single\u0000${review.rec.id}`}
|
|
520
579
|
onClose={() => setReview(null)} next={review.kind === 'group' ? unreviewed.filter(rec => !review.records.some(r => r.id === rec.id)).length : 0}
|
|
521
580
|
onNext={openReviewBatch} refetch={refetch} onBatchStart={holdRevisions}
|
|
522
|
-
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}
|
|
523
582
|
{/* Header: title + nav cursor + approve-all + view toggle + refresh */}
|
|
524
583
|
<div className="flex flex-wrap items-center gap-1.5 px-3 py-2 border-b border-white/5 shrink-0">
|
|
525
584
|
<span className="text-xs font-semibold text-white/80">Changes</span>
|
|
@@ -570,10 +629,15 @@ export function PendingChangesPanel({ agentId, projectId }: Props) {
|
|
|
570
629
|
</button>
|
|
571
630
|
{/* Additive — the per-row buttons stay the primary path. */}
|
|
572
631
|
<SkillLauncher variant="icon" size="sm" skill="manage-files" packageId="@neuralis/brain-core"
|
|
573
|
-
|
|
632
|
+
label="Review or approve pending changes with the agent" icon="FileText" />
|
|
574
633
|
</div>
|
|
575
634
|
</div>
|
|
576
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}
|
|
577
641
|
{/* Filter bar */}
|
|
578
642
|
<div className="flex flex-wrap items-center gap-2 px-3 py-1.5 border-b border-white/5 shrink-0 text-[11px]">
|
|
579
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}>
|