@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
package/README.md
CHANGED
|
@@ -68,6 +68,11 @@ occupancy answer is ONE primitive (`uriOccupied` in `usecases/write/occupancy.ts
|
|
|
68
68
|
at any status ∪ the connector — the disk decides, never the user-scoped index alone). A HUMAN's
|
|
69
69
|
structural op commits immediately; an AGENT's stays `pending_review` (the
|
|
70
70
|
`interactive` marker — see `src/routes/README.md` + `app/filesystem/ui/README.md`).
|
|
71
|
+
Pending pages expose visible-id continuation and complete/partial/restart state. Full
|
|
72
|
+
projected vector reconstruction plus canonical URI twins occurs before slicing, so page
|
|
73
|
+
size bounds output rather than scan cost. Journal retention defaults to 32 MiB; oversize
|
|
74
|
+
journals rebuild per operation. Repair defaults to 50 visible records per batch with fresh
|
|
75
|
+
per-record authorization.
|
|
71
76
|
|
|
72
77
|
URI schemes you will see in production:
|
|
73
78
|
|
|
@@ -975,7 +980,7 @@ There is intentionally no README inside the contribution folders; this section i
|
|
|
975
980
|
- `init` delegates to `wiring.buildBrainState`, wiring the declared `ctx.config` reader and GLOBAL-only `ctx.credentials.resolveCredential` into the embedding provider before shared infrastructure is built. Platform facts are read lazily through `ctx.platform`; no config setter or source-running bootstrap push is exposed. Credential changes reach the bounded lifecycle hook, and runtime health reads structured vector checks.
|
|
976
981
|
- **Data formats.** brain-core's three durable record kinds — `brain-core/embedding-lock` (v3), `brain-core/source-config` (v2) and `brain-core/pending-changes` (v1, the `_pending/changes.jsonl` journal) — are declared on the kernel's data-format ledger at module load (`src/store/dataFormats.ts`) and claimed at the top of `init`, before the first read: a kind a newer binary raised stops the init by name. Per record, the lock's `version` and a source config's or pending change's `formatVersion` (absent = 1) are checked before every rewrite — the check reads the STORED record, never the incoming one, so a config built fresh over a newer file is refused too — and a newer record is never rewritten (the journal's appends stay open; its compaction refuses). Every source-config write stamps the declared version (`SOURCE_CONFIG_FORMAT.version`, 2); an identical re-upsert still writes nothing. `embedding-lock` v3 and `source-config` v2 are `upgrade: offline` kinds: the app refuses to start over older data naming `neuralis upgrade`, which raises them (`scripts/embeddingLockV3.ts`, exported as `@neuralis/brain-core/upgrade`). Source configs are written durably, and one that fails validation is skipped AND logged (`source-config.invalid-skipped`), never silently gone.
|
|
977
982
|
- **Declared credentials + dead-sweep.** brain-core's manifest declares the four embedding credentials — `embedding.openai`, `embedding.gemini`, `embedding.qwen`, `embedding.voyage` — in `neuralis.credentials[]` (`category: 'vector'`), so they appear in the admin catalog for an owner to set; an operator endpoint's `embedding.endpoint.<id>` is a dynamic family, never declared. `qdrant.apiKey` is deliberately NOT declared: `getQdrantClient()` is synchronous and boot-critical, so it structurally cannot read the credential store; the value comes from `QDRANT_API_KEY` in the boot env (Qdrant is infrastructure topology, not a per-scope secret — see `src/config/README.md`; setup writes the line itself and the server enforces the key on every request, with a mismatch surfacing as the structured `vector.qdrant.unauthorized` log line + a degraded Vector tab, not a boot death). `src/config/env.ts` was swept of dead credential plumbing: the old `resolveCredential()` helper, `BRAIN_CREDENTIAL_MAP`, and the `s3.*` / `media.apiKey` env reads (declared nowhere, consumed nowhere) are all removed. Only `getNeuralisEnv().qdrant.*` (URL/apiKey/collection — boot-critical infra topology from `process.env`) and the registry-driven embedding resolvers remain.
|
|
978
|
-
- **Declared config settings.** brain-core's manifest declares its **
|
|
983
|
+
- **Declared config settings.** brain-core's manifest declares its **86** platform-config keys in `neuralis.configSettings[]` (`PackageConfigSetting[]`) — sync/scan/chunking/media/binary-sniff/git-panel/watch-recovery/vector-maintenance/vector-write-retry/pending-orphan-probe/search caps and timeouts across three categories: `brain` (80 keys), `vector` (5) and `debug` (1). (Re-count the manifest array before quoting this; it has been stale before.) The originals: the `brain` category (`brainAutoSyncMode`, `brainMaxScanFiles`, `inlineVideoMaxMb`, `brainSyncConcurrency`, `brainOllamaEmbedConcurrency`, `brainDebug` — `brainEmbedConcurrency` was DELETED: every embedder implements `embedBatch`, so the only fan-out fallback was dead code), the `vector` category (`embeddingModelId`, `embeddingDimension`, `ollamaUrl`, `openaiBaseUrl`, and `embeddingEndpoints` — written only through the host's endpoint route — rendered by the admin Vector tab, not the flat list), the embedding keys in `brain` (`embeddingEnabled`, `embeddingRequestTimeoutMs`, `qwenEmbeddingBaseUrl`, `embeddingDailyUsdCap`, `vectorKeepPreviousGeneration`), the media lane's `brainSyncMaxMediaMb` and `brainSyncMaxPdfPages`, and the `debug` category (`brainVectorLogConsole` — the stdout tee described under Observability below). The host registers them into `PlatformConfigStore` at bootstrap via the agent-core collector (first-party trust-gated); values reach brain-core through the injected `BrainConfigProvider`; the shared `getBrainConfigNumber(key, env, default)` live reader (config/env.ts) backs `brainSyncConcurrency` (DeltaSyncRunner) and `brainOllamaEmbedConcurrency` (the OllamaEmbedder semaphore, embeddings/factory.ts — applies on restart/embedder rebuild). The agent-core `configDeclarationDrift` test pins brain-core's consumed ⊆ declared set bidirectionally.
|
|
979
984
|
- The `CommunityInfraFactory` uses the kernel’s bounded `ProjectServiceFactory` internally. Routes and tools resolve services via `state.infraFactory.getServices(projectId)` — no global default-project state.
|
|
980
985
|
- Source connectors are declared in package manifests via `neuralis.connectors[]`. Connector **kinds** register into the host's `SourceKindRegistry` at boot (the factory templates); Brain-core itself contributes the built-in `local` and `brain` kinds via `LocalDiskConnector` and `BrainInternalConnector`. Connector **instances** are bound PER PROJECT — `buildCommunityFilesystemInfra` constructs a fresh `ConnectorRegistry` inside each project's `FilesystemInfra` and `registerForSource`s that project's source slugs into it. Two projects with the same local slugs (`data`/`packages`/`brain`/`app`) therefore never collide; the previously-shared host registry was last-writer-wins and leaked one tenant's filesystem (and poisoned the shared vector index) into another. The host-shared registry handed in at bootstrap is retained only as the boot-readiness gate, no longer a source-binding store.
|
|
981
986
|
- Brain-core declares **no hooks**. The former `hooks.json` / `host.ts` `onFileCreated` / `onFileModified` PostToolUse callbacks were deleted: they were intentional no-ops (the create/modify use cases update the vector index inline), and their registration produced a noisy `[hooks] Callback resolution failed` warning whenever `dist/src/host.js` drifted. The `./host` package export was removed with them.
|
|
@@ -1178,7 +1183,7 @@ a deletion; it declares `Allow: POST` (the host catch-all forwards route headers
|
|
|
1178
1183
|
only on content-type-bearing responses, so it is not on the wire yet). The arm is
|
|
1179
1184
|
fenced, not removed: a `drive.write` holder (manager tier and up) runs every
|
|
1180
1185
|
action exactly as before, and the Vector Health panel already posts.
|
|
1181
|
-
`action:"repair-pending"` (dry-run
|
|
1186
|
+
`action:"repair-pending"` (dry-run supported in the panel) works on the CALLER's own
|
|
1182
1187
|
visible pending records, never the project scope: it counts orphaned and
|
|
1183
1188
|
older unknown-original records and, applied, discards the orphans through the
|
|
1184
1189
|
per-record discard (WRITE re-authorized per record); the unknown-original ones
|
|
@@ -1520,7 +1525,7 @@ the `execute action="skill"` + `execute action="shell"` pipeline:
|
|
|
1520
1525
|
| `manage-sources` | `/sources/*` CRUD + sync / sync-status / cancel / reset / reindex / enabled lifecycle, scoped index purge, kind catalog, browse + discoverable roots (both `drive.mount`, not the skill's `drive.read` floor) | 17 |
|
|
1521
1526
|
| `manage-files` | `/list`, `/search`, `/read`, `/raw`, `/create`, `/modify`, `/mkdir`, `/copy`, `/upload`, `/approve`, `/pending` (bulk; `fs_*` tools remain primary for one-offs). `/create` and `/upload` both use the write engine and `drive.write`; agent-origin uploads are journaled as pending review, while interactive human uploads land committed | 11 |
|
|
1522
1527
|
| `manage-uri-policy` | `/policies/uri/*` — read manifests baselines, read effective merged policy, patch path rules. Reads are the route's `drive.read` module feature (every built-in role holds it); the patch escalates in-handler to `drive.policy` — a feature check, not a role-name check. Immutable `floor:true` rules (trusted-manifest-only) can't be created or removed here — the route answers `409 immutable_policy_floor`. | 3 |
|
|
1523
|
-
| `git` | `/git/*` — the same routes the Git Control panel drives. Reads: status / branch / diff / log / log-stats / commit / outgoing / show / repos. Writes: stage / unstage / discard, commit, branch / checkout, `restore`, and fetch / pull / push (uri-policy `exec` + connected remote).
|
|
1528
|
+
| `git` | `/git/*` — the same routes the Git Control panel drives. Reads: status / branch / diff / log / log-stats / commit / outgoing / show / repos. Writes: stage / unstage / discard, commit, branch / checkout, `restore`, and fetch / pull / push (uri-policy `exec` + connected remote). its routes taught as `neuralis request <METHOD> @neuralis/brain-core/git/<route>` lines; also documents the raw-shell and external-GitHub-MCP git planes (a routes-based skill, NOT a `git_*` MCP bundle). **The helper takes its HTTP VERB as a positional argument, so the mutating half of the skill is invisible to anything that classifies an example by the SCRIPT FILE** — 10 of the 19 routes are POST writes, and the skill's write examples are marked `NOT DISPOSABLE` (git routes only act on an already-attached source; there is no throwaway repo) with point-of-use consequences for commit / checkout / pull / push | 1 |
|
|
1524
1529
|
|
|
1525
1530
|
`manage-uri-policy` migrated here from `@neuralis/agent-core` on
|
|
1526
1531
|
2026-05-16 because the route lives in brain-core (`src/routes/policies.ts`)
|
|
@@ -11,7 +11,7 @@ Typed client for brain-core package routes plus a small set of host-side helpers
|
|
|
11
11
|
## Public Surface
|
|
12
12
|
|
|
13
13
|
- Filesystem (`index.ts`): `resolveList`, `fetchSourceCounts`, `readFiles`, `searchFiles`, `writeFile` (every write verb on the ONE `write` route) + `writeRefusalMessage`, `approveFileReview`, `uploadFile` (the multipart byte lane), `getRawFileUrl`, `checkVectorHealth` (cheap count-only stats), `scanVectorHealth` (expensive on-demand orphan/duplicate scan → `/health/scan`), `runHealthAction`.
|
|
14
|
-
- Pending changes (`index.ts`): `listPendingChanges` (with `include_orphans` it returns `orphanIds` beside the rows, from the server's SAME listing pass — the panel needs both on every mount, and two calls ran the whole dual-source listing twice), `previewPendingChange` (authoritative current/baseline states and fingerprint), `revertPendingChange`, `scanOrphanedPending` (the
|
|
14
|
+
- Pending changes (`index.ts`): `listPendingChanges` (with `include_orphans` it returns `orphanIds` beside the rows, from the server's SAME listing pass — the panel needs both on every mount, and two calls ran the whole dual-source listing twice), `previewPendingChange` (authoritative current/baseline states and fingerprint), `revertPendingChange`, `scanOrphanedPending` (the complete scanned page in `changes`, its orphan subset in `orphans`, and the same continuation), `discardPendingChange`, `dismissPendingChange`, `discardOrphanedPending`. Pending-backed approve/dismiss/revert carry the fresh `expectedFingerprint` and any required acknowledgement; a stale intent returns409. Types: `PendingChangeRecord`, `PendingChangesQuery`, `PendingPreview`, `PendingReviewIntent`. Pending clients preserve the page envelope and URI/cursor filters; count returns `{count, exact, complete, partial}` and safe unavailable branches; the store preserves its previous count when both branches fail.
|
|
15
15
|
- Git (`index.ts`) — the `/git/*` client behind the Git Control panel. Reads: `getGitRepos`, `getGitStatus`, `getGitBranches`, `getGitDiff`, `getGitLog`, `getGitLogStats`, `getGitCommitFiles`, `getGitOutgoing`, `getGitShow`. Writes: `stagePaths`, `unstagePaths`, `stageHunk`, `unstageHunk`, `discardPaths`, `commitGit`, `createGitBranch`, `deleteGitBranch`, `restoreGit`, `checkoutGitBranch`. Remote (uri-policy `exec`): `fetchGit`, `pullGit`, `pushGit`. Type: `GitRescue` — the typed carrier a destructive op returns and `restoreGit` echoes back.
|
|
16
16
|
- Sources (`index.ts`): `listSourceConfigs`, `updateSourceConfig`, `createSource`, `deleteSource`, `triggerSourceSync`, `getSyncStatus`, `cancelSync`, `resetSync`, `reindexSource` ("Rescan changes"), `estimateReembed` / `startReembed` (the same route with a `folder` / `uri` scope and `force: true` — a re-embed with the active model; the estimate answers before any side effect, a scope with nothing readable queues nothing), `purgeSourceIndex` (source/folder/file estimate or apply; keeps source and recovery content), `getSourcesHealth`, `browseDirectories`, `listSourceKinds`, `getSourceKindSchema`, `setSourceEnabled` (W3K), `listDiscoverableSources` (W3E). Types: `ReembedScope`, `ReembedEstimate`, `SourceKindRecord`, `SourceKindSchema`, `ResolveListOpts`, `DiscoverableSourceSuggestion`.
|
|
17
17
|
- Project packages (`projectPackages.ts`): `listProjectPackages`, `buildPackage`, `setPackageTrust`, `setPackageAccessFeature`, `getGrantableFeatureIds` (the per-project base-access governance pair — see `../ui/README.md`), `getPackagesDiagnostics` (W3F — also consumed by chat-config Packages Overview through the agent-core ports). Types: `ProjectPackageRow`, `BuildResponse`, `TrustValue`, `PackageDiagnosticEntry`, `PackagesDiagnosticsResponse`.
|
|
@@ -943,7 +943,8 @@ export async function approveFileReview(params: {
|
|
|
943
943
|
// ---------------------------------------------------------------------------
|
|
944
944
|
|
|
945
945
|
export type { PendingChangeRecord, PendingPreview, PendingReviewIntent } from '@neuralis/brain-core/domain/pending-change';
|
|
946
|
-
import type { PendingChangeRecord, PendingPreview } from '@neuralis/brain-core/domain/pending-change';
|
|
946
|
+
import type { PendingChangeRecord, PendingPreview, PendingChangePage, PendingCount, PendingRepairPage } from '@neuralis/brain-core/domain/pending-change';
|
|
947
|
+
export type { PendingChangePage, PendingCount, PendingRepairPage } from '@neuralis/brain-core/domain/pending-change';
|
|
947
948
|
|
|
948
949
|
export async function previewPendingChange(params: { agentId: string; projectId?: string; id: string }, client: PackageApi): Promise<PendingPreview> {
|
|
949
950
|
const result = await client.post('pending', { ...params, action: 'preview' }) as { preview: PendingPreview };
|
|
@@ -951,6 +952,8 @@ export async function previewPendingChange(params: { agentId: string; projectId?
|
|
|
951
952
|
}
|
|
952
953
|
|
|
953
954
|
export type PendingChangesQuery = {
|
|
955
|
+
cursor?: string;
|
|
956
|
+
uri?: string;
|
|
954
957
|
agentId: string;
|
|
955
958
|
projectId?: string;
|
|
956
959
|
userId?: string;
|
|
@@ -974,26 +977,24 @@ export type PendingChangesQuery = {
|
|
|
974
977
|
export async function listPendingChanges(
|
|
975
978
|
params: PendingChangesQuery,
|
|
976
979
|
client: PackageApi,
|
|
977
|
-
): Promise<{ ok: boolean
|
|
978
|
-
const res = await client.post('pending', params) as {
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
changes: Array.isArray(res?.changes) ? res.changes : [],
|
|
984
|
-
orphanIds: Array.isArray(res?.orphanIds) ? res.orphanIds : [],
|
|
985
|
-
};
|
|
980
|
+
): Promise<PendingChangePage & { ok: boolean }> {
|
|
981
|
+
const res = await client.post('pending', params) as PendingChangePage & { ok: boolean };
|
|
982
|
+
if (!Array.isArray(res.changes) || typeof res.complete !== 'boolean' || typeof res.partial !== 'boolean') {
|
|
983
|
+
throw new Error('Pending listing unavailable; retry the request');
|
|
984
|
+
}
|
|
985
|
+
return res;
|
|
986
986
|
}
|
|
987
987
|
|
|
988
988
|
/**
|
|
989
989
|
* The caller's visible pending COUNT — the list's own per-record floor, no orphan
|
|
990
990
|
* probe and no rows on the wire. Feeds the Changes badge.
|
|
991
991
|
*/
|
|
992
|
-
export async function countPendingChanges(params: { agentId: string; projectId?: string }, client: PackageApi): Promise<
|
|
992
|
+
export async function countPendingChanges(params: { agentId: string; projectId?: string }, client: PackageApi): Promise<PendingCount> {
|
|
993
993
|
const qs: Record<string, string> = { agentId: params.agentId, count_only: '1' };
|
|
994
994
|
if (params.projectId) qs.projectId = params.projectId;
|
|
995
|
-
const res = await client.get('pending', qs) as
|
|
996
|
-
|
|
995
|
+
const res = await client.get('pending', qs) as PendingCount;
|
|
996
|
+
if (!Number.isInteger(res.count) || res.count < 0 || typeof res.exact !== 'boolean') throw new Error('Pending count unavailable');
|
|
997
|
+
return res;
|
|
997
998
|
}
|
|
998
999
|
|
|
999
1000
|
export async function revertPendingChange(params: {
|
|
@@ -1013,9 +1014,10 @@ export async function scanOrphanedPending(params: {
|
|
|
1013
1014
|
agentId: string;
|
|
1014
1015
|
projectId?: string;
|
|
1015
1016
|
conversationId?: string;
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1017
|
+
cursor?: string;
|
|
1018
|
+
uri?: string;
|
|
1019
|
+
}, client: PackageApi): Promise<PendingChangePage & { ok: boolean; orphans: PendingChangeRecord[] }> {
|
|
1020
|
+
return client.post('pending', { ...params, action: 'scan-orphans' }) as Promise<PendingChangePage & { ok: boolean; orphans: PendingChangeRecord[] }>;
|
|
1019
1021
|
}
|
|
1020
1022
|
|
|
1021
1023
|
/** Discard ONE orphaned pending record (journal mark + blob + vector point) —
|
|
@@ -1042,15 +1044,12 @@ export async function dismissPendingChange(params: {
|
|
|
1042
1044
|
return client.post('pending', { ...params, action: 'dismiss' }) as Promise<{ ok: boolean; id: string; uri?: string; error?: string; retryable?: boolean }>;
|
|
1043
1045
|
}
|
|
1044
1046
|
|
|
1045
|
-
/**
|
|
1046
|
-
*
|
|
1047
|
+
/** Repair one visible page through the shared owner; callers continue explicitly
|
|
1048
|
+
* with nextCursor and pause on partial/failure. Counters describe actual effects. */
|
|
1047
1049
|
export async function discardOrphanedPending(params: {
|
|
1048
|
-
agentId: string;
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
}, client: PackageApi): Promise<{ ok: boolean; discarded: number }> {
|
|
1052
|
-
const res = await client.post('pending', { ...params, action: 'discard-orphans' }) as { ok?: boolean; discarded?: number };
|
|
1053
|
-
return { ok: res?.ok !== false, discarded: typeof res?.discarded === 'number' ? res.discarded : 0 };
|
|
1050
|
+
agentId: string; projectId?: string; conversationId?: string; cursor?: string;
|
|
1051
|
+
}, client: PackageApi): Promise<PendingRepairPage> {
|
|
1052
|
+
return client.post('pending', { ...params, action: 'discard-orphans' }) as Promise<PendingRepairPage>;
|
|
1054
1053
|
}
|
|
1055
1054
|
|
|
1056
1055
|
export function getRawFileUrl(agentId: string, uri: string, projectId?: string): string {
|
|
@@ -1088,6 +1087,7 @@ export async function runHealthAction(params: {
|
|
|
1088
1087
|
action: 'gc' | 'repair-orphans' | 'deduplicate' | 'repair-pending';
|
|
1089
1088
|
dryRun?: boolean;
|
|
1090
1089
|
retentionHours?: number;
|
|
1090
|
+
cursor?: string;
|
|
1091
1091
|
}, client: PackageApi): Promise<Record<string, unknown>> {
|
|
1092
1092
|
return client.post('health/gc', params);
|
|
1093
1093
|
}
|
|
@@ -16,7 +16,7 @@ Zustand stores powering the Files windows — ONE live store per window (file di
|
|
|
16
16
|
|
|
17
17
|
- `reconcileSourceFolders({ agentId, projectId, source })` — re-lists ONE source's VISIBLE folders (its root + every loaded folder that is expanded AND whose every ancestor is expanded — a loaded folder under a collapsed ancestor is off screen and never a target) and applies absence semantics per **direct child** of a **non-truncated** listing: prune an `unindexed` external-only file, flip a tracked/`synced` file to `status:'deleted'` (struck-through and revertible, matching an in-platform delete), keep an existing tombstone, prune a folder when it parents nothing live — `presence` deliberately does NOT gate this: a folder has no pending record and no revert, so once it is gone from the listing there is nothing to preserve it for, and a `synced` folder left in place rendered with the ORDINARY tone, clearable only by a browser reload. A gone folder that still hosts revertible tombstones is KEPT but toned `deleted`, never left looking live. A **truncated** listing stays upsert-only (absence ≠ deletion).
|
|
18
18
|
- `reconcileFolders({ agentId, projectId, folders })` — the same pass over specific folders. Every mutation tail uses it: a move/delete/paste knows exactly which parents changed. Targets that are not in `loadedFolders` are dropped and flag the source dirty; a STALE target (`false`) is dropped silently, because opening it re-lists it authoritatively. The list is grouped per source so the coalescer applies.
|
|
19
|
-
- `refreshPendingCount({ agentId, projectId })` — re-reads `pendingCount` (the Changes badge) through `count_only`; a newer call or a context the store has left drops the answer (a same-context reset does not — the mount count must land), a failed count keeps the last number. `pendingCount` is a `contextResetPatch()` key.
|
|
19
|
+
- `refreshPendingCount({ agentId, projectId })` — re-reads `pendingCount` (the Changes badge) through `count_only`; a newer call or a context the store has left drops the answer (a same-context reset does not — the mount count must land), a failed count keeps the last number. `pendingCount` is a `contextResetPatch()` key. The badge stores exact/partial/stale state; failed requests preserve the previous number as stale, and context reset clears its authority.
|
|
20
20
|
- `refreshTree({ agentId, projectId })` — the toolbar Refresh: source summaries + an absence pass over every hydrated source. `refresh` is deliberately NOT rewritten in place — the Sources panel's `withCounts` calls depend on its loading semantics.
|
|
21
21
|
|
|
22
22
|
Two guards make this safe against the races a plain re-list would lose:
|
|
@@ -978,6 +978,7 @@ interface FilesystemStore extends FileSystemState, SearchSliceActions, ListSlice
|
|
|
978
978
|
pendingReviewPaths: Set<string>;
|
|
979
979
|
/** The caller's visible pending-review count (server `count_only`), shown on the Changes button. */
|
|
980
980
|
pendingCount: number;
|
|
981
|
+
pendingCountState: 'exact' | 'partial' | 'stale';
|
|
981
982
|
/** Re-read `pendingCount`; a newer call or another active context drops the answer. */
|
|
982
983
|
refreshPendingCount: (params: { agentId: string; projectId: string | null }) => Promise<void>;
|
|
983
984
|
|
|
@@ -1194,6 +1195,7 @@ export function createFilesystemStore(init: FilesystemStoreInit): FilesystemStor
|
|
|
1194
1195
|
snapshotError: undefined,
|
|
1195
1196
|
pendingReviewPaths: new Set<string>(),
|
|
1196
1197
|
pendingCount: 0,
|
|
1198
|
+
pendingCountState: 'stale',
|
|
1197
1199
|
|
|
1198
1200
|
...searchSliceInitial,
|
|
1199
1201
|
...createSearchSlice(set as any, get),
|
|
@@ -1791,15 +1793,21 @@ export function createFilesystemStore(init: FilesystemStoreInit): FilesystemStor
|
|
|
1791
1793
|
if (!agentId) return;
|
|
1792
1794
|
const mine = ++fences.pendingCountSeq;
|
|
1793
1795
|
// A failed count keeps the last known number — the next event, focus or reconnect re-asks.
|
|
1794
|
-
let count:
|
|
1795
|
-
try { count = await countPendingChanges({ agentId, ...(projectId ? { projectId } : {}) }, client); } catch {
|
|
1796
|
+
let count: Awaited<ReturnType<typeof countPendingChanges>>;
|
|
1797
|
+
try { count = await countPendingChanges({ agentId, ...(projectId ? { projectId } : {}) }, client); } catch {
|
|
1798
|
+
if (mine === fences.pendingCountSeq && get().activeAgentId === agentId && get().activeProjectId === projectId) set({ pendingCountState: 'stale' });
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1796
1801
|
// No reset-epoch fence: the count is the context's own server answer, so one
|
|
1797
1802
|
// that resolves after a reset of the SAME context is still right; the context
|
|
1798
1803
|
// check below is what keeps another context's number out.
|
|
1799
1804
|
if (mine !== fences.pendingCountSeq) return;
|
|
1800
1805
|
const s = get();
|
|
1801
1806
|
if (s.activeAgentId !== agentId || s.activeProjectId !== projectId) return;
|
|
1802
|
-
|
|
1807
|
+
if (count.unavailable?.includes('journal') && count.unavailable.includes('vector')) {
|
|
1808
|
+
set({ pendingCountState: 'stale' }); return;
|
|
1809
|
+
}
|
|
1810
|
+
set(() => ({ pendingCount: count.count, pendingCountState: count.exact ? 'exact' : 'partial' }));
|
|
1803
1811
|
},
|
|
1804
1812
|
|
|
1805
1813
|
async markFileDeleted({ agentId, projectId, node }) {
|
|
@@ -76,6 +76,7 @@ export function contextResetPatch(): Record<string, unknown> {
|
|
|
76
76
|
pendingReviewPaths: new Set<string>(),
|
|
77
77
|
// The Changes badge count is the context's own server answer.
|
|
78
78
|
pendingCount: 0,
|
|
79
|
+
pendingCountState: 'stale',
|
|
79
80
|
// Nothing is pre-expanded — under lazy hydration an expanded entry is a
|
|
80
81
|
// FETCH, and every source branch (including `brain://`) starts collapsed.
|
|
81
82
|
expandedFolders: new Set<string>(),
|
|
@@ -21,6 +21,10 @@ import { getRawFileUrl, approveFileReview, writeFile, writeRefusalMessage, getGi
|
|
|
21
21
|
import { InlineUploader } from './InlineUploader';
|
|
22
22
|
import { SourcesPanel } from './SourcesPanel';
|
|
23
23
|
import { PackagesPanel } from './PackagesPanel';
|
|
24
|
+
import { PendingReviewDialog, type ReviewRequest, type ReviewAction } from './PendingReviewDialog';
|
|
25
|
+
import { useVisibleDiffCards } from '../hooks/useVisibleDiffCards';
|
|
26
|
+
import { normalizePolicyUri } from '@neuralis/package-system/policies';
|
|
27
|
+
import { listPendingChanges } from '../api';
|
|
24
28
|
import { PendingChangesPanel } from './PendingChangesPanel';
|
|
25
29
|
import { getDeltaTone } from '../lib/treeVisualState';
|
|
26
30
|
import { fileKeyFromNode, folderUri, hasCompletedRead, sourceFromUri, extractFilename, sourceRootUri } from '../store/helpers';
|
|
@@ -99,6 +103,7 @@ export function FileEditor() {
|
|
|
99
103
|
const [reverting, setReverting] = useState(false);
|
|
100
104
|
const [showDiff, setShowDiff] = useState(true);
|
|
101
105
|
const [metaExpanded, setMetaExpanded] = useState(false);
|
|
106
|
+
const refreshPendingCount = useFilesystemStore((s) => s.refreshPendingCount);
|
|
102
107
|
const filePrevByPath = useFilesystemStore((s) => s.filePrevByPath);
|
|
103
108
|
const activePrev = activeFile ? filePrevByPath[activeFile] : undefined;
|
|
104
109
|
const hasPendingChanges = !!activePrev && activeNode?.status === 'pending_review';
|
|
@@ -138,6 +143,31 @@ export function FileEditor() {
|
|
|
138
143
|
!showUploadTab && !showSourcesTab && !showPackagesTab && !showPendingTab && !showGitDiff && !showGitCommit;
|
|
139
144
|
const rawAgentId = activeNode?.sourceAgentId ?? agentId ?? undefined;
|
|
140
145
|
const targetAgentId = activeNode?.sourceAgentId ?? agentId ?? null;
|
|
146
|
+
const reviewQueue = useVisibleDiffCards(null);
|
|
147
|
+
const [pendingReview, setPendingReview] = useState<ReviewRequest | null>(null);
|
|
148
|
+
const [pendingReviewError, setPendingReviewError] = useState<string | null>(null);
|
|
149
|
+
const reviewEpoch = useRef(0);
|
|
150
|
+
const pendingReviewEpoch = useRef(0);
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
reviewEpoch.current++; setPendingReview(null); setPendingReviewError(null);
|
|
153
|
+
return () => { reviewEpoch.current++; };
|
|
154
|
+
}, [client, targetAgentId, projectId, activeUri, activeFile]);
|
|
155
|
+
const openPendingReview = useCallback(async (action: ReviewAction) => {
|
|
156
|
+
if (!targetAgentId || !activeUri) return;
|
|
157
|
+
const epoch = reviewEpoch.current;
|
|
158
|
+
setPendingReviewError(null);
|
|
159
|
+
try {
|
|
160
|
+
const page = await listPendingChanges({ agentId: targetAgentId, ...(projectId ? { projectId } : {}), uri: activeUri }, client);
|
|
161
|
+
if (epoch !== reviewEpoch.current) return;
|
|
162
|
+
const records = page.changes.filter(rec => normalizePolicyUri(rec.uri) === normalizePolicyUri(activeUri));
|
|
163
|
+
if (page.partial || page.complete === false || page.restartRequired || records.length !== 1) {
|
|
164
|
+
setPendingReviewError('The pending change is unavailable or ambiguous. Refresh and try again.'); return;
|
|
165
|
+
}
|
|
166
|
+
pendingReviewEpoch.current = epoch;
|
|
167
|
+
setPendingReview({ kind: 'single', rec: records[0]!, action });
|
|
168
|
+
} catch (error) { if (epoch === reviewEpoch.current) setPendingReviewError(error instanceof Error ? error.message : 'Could not load the pending change.'); }
|
|
169
|
+
}, [client, targetAgentId, projectId, activeUri]);
|
|
170
|
+
|
|
141
171
|
const rawUrl = useMemo(() => {
|
|
142
172
|
if (!activeNode?.uri || !rawAgentId) return null;
|
|
143
173
|
return getRawFileUrl(rawAgentId, activeNode.uri, projectId ?? undefined);
|
|
@@ -267,7 +297,7 @@ export function FileEditor() {
|
|
|
267
297
|
const handleApprove = useCallback(async () => {
|
|
268
298
|
if (!targetAgentId || !activeNode || approving) return;
|
|
269
299
|
if (activeNode.status === 'pending_review') {
|
|
270
|
-
|
|
300
|
+
await openPendingReview('approve'); return;
|
|
271
301
|
}
|
|
272
302
|
setApproving(true);
|
|
273
303
|
try {
|
|
@@ -296,12 +326,12 @@ export function FileEditor() {
|
|
|
296
326
|
} finally {
|
|
297
327
|
setApproving(false);
|
|
298
328
|
}
|
|
299
|
-
}, [targetAgentId, activeNode, approving, projectId, openFile, resolvedAgent?.id, activeFile, patchFileNode, clearFilePrev]);
|
|
329
|
+
}, [openPendingReview, client, targetAgentId, activeNode, approving, projectId, openFile, resolvedAgent?.id, activeFile, patchFileNode, clearFilePrev]);
|
|
300
330
|
|
|
301
331
|
const handleRevert = useCallback(async () => {
|
|
302
332
|
if (!targetAgentId || !activeNode || reverting) return;
|
|
303
333
|
if (activeNode.status === 'pending_review') {
|
|
304
|
-
|
|
334
|
+
await openPendingReview('revert'); return;
|
|
305
335
|
}
|
|
306
336
|
setReverting(true);
|
|
307
337
|
try {
|
|
@@ -335,7 +365,7 @@ export function FileEditor() {
|
|
|
335
365
|
} finally {
|
|
336
366
|
setReverting(false);
|
|
337
367
|
}
|
|
338
|
-
}, [targetAgentId, activeNode, reverting, projectId, openFile, resolvedAgent?.id, activeFile, activePrev, patchFileNode, clearFilePrev]);
|
|
368
|
+
}, [openPendingReview, client, targetAgentId, activeNode, reverting, projectId, openFile, resolvedAgent?.id, activeFile, activePrev, patchFileNode, clearFilePrev]);
|
|
339
369
|
|
|
340
370
|
useEffect(() => {
|
|
341
371
|
if (!activeNode) {
|
|
@@ -420,6 +450,14 @@ export function FileEditor() {
|
|
|
420
450
|
|
|
421
451
|
return (
|
|
422
452
|
<div className="h-full w-full flex flex-col min-w-0 min-h-0">
|
|
453
|
+
{pendingReviewError ? <div role="alert" className="px-3 py-2 text-xs text-red-300">{pendingReviewError}</div> : null}
|
|
454
|
+
{pendingReview && targetAgentId ? <PendingReviewDialog request={pendingReview} agentId={targetAgentId} projectId={projectId}
|
|
455
|
+
run={reviewQueue.run} onClose={() => setPendingReview(null)} onDone={done => {
|
|
456
|
+
if (pendingReviewEpoch.current !== reviewEpoch.current || !done.some(result => result.outcome === 'ok')) return;
|
|
457
|
+
setPendingReview(null);
|
|
458
|
+
void refreshPendingCount({ agentId: targetAgentId, projectId });
|
|
459
|
+
void openFile({ agentId: targetAgentId, projectId: projectId ?? null, uri: activeUri, force: true });
|
|
460
|
+
}} /> : null}
|
|
423
461
|
{openFiles.length > 0 || workspacePanels.upload || (supportsSources && workspacePanels.sources) || workspacePanels.packages || workspacePanels.pending || gitDiffTarget || gitCommitTarget ? (
|
|
424
462
|
<div
|
|
425
463
|
className="flex shrink-0 items-stretch"
|
|
@@ -1202,7 +1202,6 @@ export function GitControlPanel({ agentId }: { agentId: string }) {
|
|
|
1202
1202
|
size="sm"
|
|
1203
1203
|
skill="git"
|
|
1204
1204
|
packageId="@neuralis/brain-core"
|
|
1205
|
-
scripts={["scripts/git.sh"]}
|
|
1206
1205
|
label="Status, diff, commit, branch, fetch or push with the agent"
|
|
1207
1206
|
icon="GitBranch"
|
|
1208
1207
|
/>
|
|
@@ -27,15 +27,20 @@ export function GitDiffView({
|
|
|
27
27
|
projectId: string | null;
|
|
28
28
|
}): React.ReactElement {
|
|
29
29
|
const client = useFilesystemStore((s) => s.client);
|
|
30
|
-
const [canWrite, setCanWrite] = useState(false);
|
|
31
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;
|
|
32
36
|
useEffect(() => {
|
|
33
|
-
let alive = true;
|
|
37
|
+
let alive = true;
|
|
38
|
+
setPermission(previous => ({ client, context, revision, pending: true, allowed: previous?.client === client && previous.context === context && previous.allowed }));
|
|
34
39
|
if (agentId) void getGitRepos({ agentId, ...(projectId ? { projectId } : {}), source: target.source }, client).then(result => {
|
|
35
|
-
if (alive)
|
|
36
|
-
}).catch(() => { if (alive)
|
|
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 }); });
|
|
37
42
|
return () => { alive = false; };
|
|
38
|
-
}, [agentId, projectId, target.source, target.repo, revision]);
|
|
43
|
+
}, [client, context, agentId, projectId, target.source, target.repo, revision]);
|
|
39
44
|
const mode = useMemo(() => gitDiffModeFromTarget(target), [target]);
|
|
40
45
|
return (
|
|
41
46
|
<GitFileDiff
|
|
@@ -49,6 +54,8 @@ export function GitDiffView({
|
|
|
49
54
|
projectId={projectId}
|
|
50
55
|
variant="tab"
|
|
51
56
|
canWrite={canWrite}
|
|
57
|
+
stageRows={stageRows}
|
|
58
|
+
permissionPending={!current || permission.pending || permission.revision !== revision}
|
|
52
59
|
revision={revision}
|
|
53
60
|
/>
|
|
54
61
|
);
|
|
@@ -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,6 +153,8 @@ 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. */
|
|
@@ -230,7 +234,7 @@ export function GitFileDiff({
|
|
|
230
234
|
}, [language]);
|
|
231
235
|
|
|
232
236
|
useEffect(() => {
|
|
233
|
-
sourceCacheRef.current.clear();
|
|
237
|
+
sourceCacheRef.current.clear(); setHunkBusy(null); setHunkError(null);
|
|
234
238
|
// New target/revision (or post-stage refetch): drop cached source and expansion state —
|
|
235
239
|
// the ranges are relative to the OLD raw's original hunks — AND the
|
|
236
240
|
// server-flag states: `serverTruncated`/`tooLarge` belong to the PREVIOUS
|
|
@@ -293,7 +297,7 @@ export function GitFileDiff({
|
|
|
293
297
|
}
|
|
294
298
|
})();
|
|
295
299
|
return () => { seqRef.current++; };
|
|
296
|
-
}, [agentId, projectId, source, repo, path, normalizedMode, reloadNonce, revision, runContent]);
|
|
300
|
+
}, [client, agentId, projectId, source, repo, path, normalizedMode, reloadNonce, revision, runContent]);
|
|
297
301
|
|
|
298
302
|
// The ONE parse of the raw diff — the ORIGINAL (staging) model.
|
|
299
303
|
const { files, truncated, hunkPatches } = useMemo(
|
|
@@ -317,7 +321,7 @@ export function GitFileDiff({
|
|
|
317
321
|
// parsed count, or the index→patch mapping is unsafe.
|
|
318
322
|
const singleFile: FileData | null = files.length === 1 ? files[0] : null;
|
|
319
323
|
const canStageHunks =
|
|
320
|
-
|
|
324
|
+
stageRows && kind === 'working' &&
|
|
321
325
|
!!agentId &&
|
|
322
326
|
!!singleFile &&
|
|
323
327
|
singleFile.type === 'modify' &&
|
|
@@ -386,7 +390,7 @@ export function GitFileDiff({
|
|
|
386
390
|
} catch {
|
|
387
391
|
return settle({ ok: false });
|
|
388
392
|
}
|
|
389
|
-
}, [agentId, projectId, source, repo, path, expanderSrc, runContent]);
|
|
393
|
+
}, [client, agentId, projectId, source, repo, path, expanderSrc, runContent]);
|
|
390
394
|
|
|
391
395
|
// The synthetic OLD-side lines (`expandFromRawCode` slices by old-line
|
|
392
396
|
// numbers) + the RENDER-ONLY expanded hunk list. `oldTotal` also sizes the
|
|
@@ -458,12 +462,13 @@ export function GitFileDiff({
|
|
|
458
462
|
const applyHunk = useCallback(
|
|
459
463
|
async (hunkIndex: number) => {
|
|
460
464
|
// A raw shown while its fresh answer loads may be an older revision's — never stage from it.
|
|
461
|
-
if (loading || !canStageHunks || !hunkPatches || !agentId) return;
|
|
465
|
+
if (loading || permissionPending || !canWrite || !canStageHunks || !hunkPatches || !agentId) return;
|
|
462
466
|
const patch = assembleHunkPatch(hunkPatches.header, hunkPatches.hunks[hunkIndex]);
|
|
463
467
|
if (!patch) {
|
|
464
468
|
setHunkError('Could not build a hunk patch for this change.');
|
|
465
469
|
return;
|
|
466
470
|
}
|
|
471
|
+
const mySeq = seqRef.current;
|
|
467
472
|
setHunkBusy(hunkIndex);
|
|
468
473
|
setHunkError(null);
|
|
469
474
|
try {
|
|
@@ -472,6 +477,7 @@ export function GitFileDiff({
|
|
|
472
477
|
} else {
|
|
473
478
|
await stageHunk({ agentId, ...(projectId ? { projectId } : {}), source, ...(repo ? { repo } : {}), hunk: patch }, client);
|
|
474
479
|
}
|
|
480
|
+
if (mySeq !== seqRef.current) return;
|
|
475
481
|
// The index changed → the cached `rev=index` source is stale (the WT
|
|
476
482
|
// entry would survive, but a whole-map clear is cheap and race-free).
|
|
477
483
|
sourceCacheRef.current.clear();
|
|
@@ -487,12 +493,12 @@ export function GitFileDiff({
|
|
|
487
493
|
void loadGitStatus({ agentId, projectId });
|
|
488
494
|
setReloadNonce((n) => n + 1);
|
|
489
495
|
} catch (e) {
|
|
490
|
-
setHunkError(e instanceof Error ? e.message : 'Hunk apply failed');
|
|
496
|
+
if (mySeq === seqRef.current) setHunkError(e instanceof Error ? e.message : 'Hunk apply failed');
|
|
491
497
|
} finally {
|
|
492
|
-
setHunkBusy(null);
|
|
498
|
+
if (mySeq === seqRef.current) setHunkBusy(null);
|
|
493
499
|
}
|
|
494
500
|
},
|
|
495
|
-
[loading, canStageHunks, hunkPatches, agentId, projectId, source, repo, staged, loadGitStatus],
|
|
501
|
+
[client, loading, permissionPending, canWrite, canStageHunks, hunkPatches, agentId, projectId, source, repo, staged, loadGitStatus],
|
|
496
502
|
);
|
|
497
503
|
|
|
498
504
|
/** One collapsed-gap row: clickable expander, or static text once refused. */
|
|
@@ -542,7 +548,7 @@ export function GitFileDiff({
|
|
|
542
548
|
key={idx}
|
|
543
549
|
type="button"
|
|
544
550
|
onClick={() => void applyHunk(idx)}
|
|
545
|
-
disabled={hunkBusy !== null || loading}
|
|
551
|
+
disabled={hunkBusy !== null || loading || permissionPending || !canWrite}
|
|
546
552
|
className={cn(
|
|
547
553
|
'inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[10px] transition-colors disabled:opacity-40',
|
|
548
554
|
staged
|