@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.
Files changed (123) hide show
  1. package/README.md +8 -3
  2. package/app/README.md +1 -1
  3. package/app/filesystem/README.md +3 -3
  4. package/app/filesystem/api/README.md +4 -4
  5. package/app/filesystem/api/index.ts +144 -139
  6. package/app/filesystem/domain/types.ts +12 -0
  7. package/app/filesystem/hooks/README.md +5 -5
  8. package/app/filesystem/hooks/useDiscoverableSources.ts +4 -2
  9. package/app/filesystem/hooks/useFilesystemSSE.ts +31 -23
  10. package/app/filesystem/hooks/useGitChangesInventory.ts +4 -2
  11. package/app/filesystem/host/README.md +3 -2
  12. package/app/filesystem/host/installFilesystemHostComponents.tsx +22 -9
  13. package/app/filesystem/store/README.md +12 -8
  14. package/app/filesystem/store/filesystemStore.ts +444 -163
  15. package/app/filesystem/store/gitSlice.ts +6 -6
  16. package/app/filesystem/store/listSlice.ts +9 -6
  17. package/app/filesystem/store/searchSlice.ts +3 -3
  18. package/app/filesystem/ui/FileEditor.tsx +90 -39
  19. package/app/filesystem/ui/FileTree.tsx +10 -9
  20. package/app/filesystem/ui/GitCommitChangesView.tsx +5 -3
  21. package/app/filesystem/ui/GitCommitGraph.tsx +6 -5
  22. package/app/filesystem/ui/GitControlPanel.tsx +14 -14
  23. package/app/filesystem/ui/GitDiffView.tsx +14 -6
  24. package/app/filesystem/ui/GitFileDiff.tsx +22 -15
  25. package/app/filesystem/ui/PendingChangesPanel.tsx +108 -44
  26. package/app/filesystem/ui/PendingReviewDialog.tsx +8 -5
  27. package/app/filesystem/ui/README.md +9 -9
  28. package/app/filesystem/ui/ReembedDialog.tsx +4 -2
  29. package/app/filesystem/ui/SourcesPanel.tsx +26 -28
  30. package/app/filesystem/ui/Toolbar.tsx +5 -4
  31. package/app/filesystem/ui/VectorHealthPanel.tsx +48 -17
  32. package/dist/app/host.js +46 -46
  33. package/dist/src/config/env.d.ts +4 -0
  34. package/dist/src/config/env.d.ts.map +1 -1
  35. package/dist/src/config/env.js +13 -0
  36. package/dist/src/config/env.js.map +1 -1
  37. package/dist/src/connectors/LocalDiskConnector.d.ts +9 -1
  38. package/dist/src/connectors/LocalDiskConnector.d.ts.map +1 -1
  39. package/dist/src/connectors/LocalDiskConnector.js +51 -2
  40. package/dist/src/connectors/LocalDiskConnector.js.map +1 -1
  41. package/dist/src/domain/healthTypes.d.ts +1 -7
  42. package/dist/src/domain/healthTypes.d.ts.map +1 -1
  43. package/dist/src/domain/pendingChange.d.ts +31 -0
  44. package/dist/src/domain/pendingChange.d.ts.map +1 -1
  45. package/dist/src/domain/pendingChange.js.map +1 -1
  46. package/dist/src/routes/health.js +2 -3
  47. package/dist/src/routes/health.js.map +1 -1
  48. package/dist/src/routes/pending.d.ts +7 -7
  49. package/dist/src/routes/pending.d.ts.map +1 -1
  50. package/dist/src/routes/pending.js +21 -21
  51. package/dist/src/routes/pending.js.map +1 -1
  52. package/dist/src/services/FilesystemService.d.ts +13 -61
  53. package/dist/src/services/FilesystemService.d.ts.map +1 -1
  54. package/dist/src/services/FilesystemService.js +47 -189
  55. package/dist/src/services/FilesystemService.js.map +1 -1
  56. package/dist/src/services/FilesystemUiService.d.ts +33 -1
  57. package/dist/src/services/FilesystemUiService.d.ts.map +1 -1
  58. package/dist/src/services/FilesystemUiService.js +50 -1
  59. package/dist/src/services/FilesystemUiService.js.map +1 -1
  60. package/dist/src/store/PendingChangeStore.d.ts +3 -0
  61. package/dist/src/store/PendingChangeStore.d.ts.map +1 -1
  62. package/dist/src/store/PendingChangeStore.js +84 -25
  63. package/dist/src/store/PendingChangeStore.js.map +1 -1
  64. package/dist/src/usecases/orphanedPending.d.ts +2 -2
  65. package/dist/src/usecases/orphanedPending.d.ts.map +1 -1
  66. package/dist/src/usecases/orphanedPending.js +5 -3
  67. package/dist/src/usecases/orphanedPending.js.map +1 -1
  68. package/dist/src/usecases/pendingListing.d.ts +34 -0
  69. package/dist/src/usecases/pendingListing.d.ts.map +1 -0
  70. package/dist/src/usecases/pendingListing.js +244 -0
  71. package/dist/src/usecases/pendingListing.js.map +1 -0
  72. package/dist/src/usecases/vectorMaintenance.d.ts +6 -9
  73. package/dist/src/usecases/vectorMaintenance.d.ts.map +1 -1
  74. package/dist/src/usecases/vectorMaintenance.js +4 -13
  75. package/dist/src/usecases/vectorMaintenance.js.map +1 -1
  76. package/dist/src/vector/InMemoryVectorIndex.d.ts.map +1 -1
  77. package/dist/src/vector/InMemoryVectorIndex.js +30 -9
  78. package/dist/src/vector/InMemoryVectorIndex.js.map +1 -1
  79. package/package.json +27 -5
  80. package/rules/brain-rules.md +1 -1
  81. package/skills/create-package/SKILL.md +3 -3
  82. package/skills/create-package/references/config-and-credentials.md +1 -1
  83. package/skills/create-package/references/manifest-and-discovery.md +1 -1
  84. package/skills/create-package/references/runtime-and-build.md +1 -1
  85. package/skills/create-package/references/skills-and-context.md +25 -12
  86. package/skills/create-package/references/surfaces-and-design.md +7 -5
  87. package/skills/create-package/references/test-and-ship.md +9 -16
  88. package/skills/create-package/scripts/check-package.sh +47 -1
  89. package/skills/create-package/scripts/verify-loaded.sh +18 -27
  90. package/skills/git/SKILL.md +27 -29
  91. package/skills/manage-files/SKILL.md +37 -32
  92. package/skills/manage-files/scripts/upload.sh +7 -9
  93. package/skills/manage-files/scripts/write.sh +2 -6
  94. package/skills/manage-sources/SKILL.md +93 -95
  95. package/skills/manage-sources/scripts/reembed.sh +2 -6
  96. package/skills/manage-uri-policy/SKILL.md +30 -58
  97. package/app/filesystem/hooks/useContainerWidth.ts +0 -39
  98. package/skills/git/scripts/git.sh +0 -48
  99. package/skills/manage-files/scripts/approve.sh +0 -9
  100. package/skills/manage-files/scripts/list.sh +0 -9
  101. package/skills/manage-files/scripts/pending.sh +0 -23
  102. package/skills/manage-files/scripts/raw.sh +0 -11
  103. package/skills/manage-files/scripts/read.sh +0 -9
  104. package/skills/manage-files/scripts/search.sh +0 -15
  105. package/skills/manage-sources/scripts/browse.sh +0 -12
  106. package/skills/manage-sources/scripts/cancel.sh +0 -14
  107. package/skills/manage-sources/scripts/create.sh +0 -13
  108. package/skills/manage-sources/scripts/delete.sh +0 -17
  109. package/skills/manage-sources/scripts/discoverable.sh +0 -6
  110. package/skills/manage-sources/scripts/get.sh +0 -7
  111. package/skills/manage-sources/scripts/kind-schema.sh +0 -7
  112. package/skills/manage-sources/scripts/kinds.sh +0 -6
  113. package/skills/manage-sources/scripts/list.sh +0 -6
  114. package/skills/manage-sources/scripts/purge-index.sh +0 -18
  115. package/skills/manage-sources/scripts/reindex.sh +0 -13
  116. package/skills/manage-sources/scripts/reset.sh +0 -11
  117. package/skills/manage-sources/scripts/set-enabled.sh +0 -17
  118. package/skills/manage-sources/scripts/sync-status.sh +0 -14
  119. package/skills/manage-sources/scripts/sync.sh +0 -13
  120. package/skills/manage-sources/scripts/update.sh +0 -18
  121. package/skills/manage-uri-policy/scripts/manifests.sh +0 -20
  122. package/skills/manage-uri-policy/scripts/set-uri.sh +0 -66
  123. package/skills/manage-uri-policy/scripts/show-uri.sh +0 -23
@@ -5,7 +5,7 @@ import { sourceFromUri } from './helpers';
5
5
  /**
6
6
  * gitSlice (G2.1 / spellbound-heron) — client state for the read-only "Git
7
7
  * Control" sidebar panel. Mirrors `searchSlice`'s shape: a plain slice combined
8
- * into the single `useFilesystemStore`, with async actions calling the `../api`
8
+ * into each window's Files store (`createFilesystemStore`), with async actions calling the `../api`
9
9
  * git fetchers.
10
10
  *
11
11
  * The FOCUSED repo is NOT a git-slice field — it is the PAIR
@@ -18,9 +18,6 @@ import { sourceFromUri } from './helpers';
18
18
  * branch repo-correct for free. Write actions pass both explicitly.
19
19
  */
20
20
 
21
- let gitStatusSeq = 0;
22
- let gitBranchSeq = 0;
23
-
24
21
  export interface GitSliceState {
25
22
  gitStatus: GitControlStatus | null;
26
23
  /**
@@ -144,6 +141,9 @@ export function createGitSlice(
144
141
  set: (fn: (s: any) => any) => void,
145
142
  get: () => any,
146
143
  ): GitSliceActions {
144
+ // The newest status / branch request of THIS store — an older answer is dropped.
145
+ let gitStatusSeq = 0;
146
+ let gitBranchSeq = 0;
147
147
  return {
148
148
  setGitViewMode: (mode) => set(() => ({ gitViewMode: mode })),
149
149
 
@@ -196,7 +196,7 @@ export function createGitSlice(
196
196
  ...(projectId ? { projectId } : {}),
197
197
  source: slug,
198
198
  ...(repoRel ? { repo: repoRel } : {}),
199
- });
199
+ }, get().client);
200
200
  if (mySeq !== gitStatusSeq) return;
201
201
  // `totals` rides the SAME response as the rows it sums (the server
202
202
  // computes it after its read filter and cap), so the header can never
@@ -232,7 +232,7 @@ export function createGitSlice(
232
232
  ...(projectId ? { projectId } : {}),
233
233
  source: slug,
234
234
  ...(repoRel ? { repo: repoRel } : {}),
235
- });
235
+ }, get().client);
236
236
  if (mySeq !== gitBranchSeq) return;
237
237
  set(() => ({ gitBranches: Array.isArray(res?.branches) ? res.branches : [] }));
238
238
  } catch (error) {
@@ -8,8 +8,6 @@ import {
8
8
  sourceFromUri,
9
9
  } from './helpers';
10
10
 
11
- const listInFlight = new Map<string, Promise<void>>();
12
-
13
11
  export interface ListSliceActions {
14
12
  /**
15
13
  * Load the SOURCE SUMMARIES — the registry rows the tree headers and the Sources
@@ -50,9 +48,10 @@ export interface ListSliceActions {
50
48
  * context's slice — THESE keys — there and restores it from there; a context
51
49
  * reset must never wipe it, that is the whole point of it),
52
50
  * or the named-exception list — so a new key has to be filed somewhere:
53
- * - preferences, deliberately kept: `activeAgentId`/`activeProjectId` (the callers
54
- * own them), `sidebarOpen`, `sidebarWidth`, `gitViewMode`, `searchMode`,
55
- * `previewEditorsEnabled`, `diffViewMode`;
51
+ * - preferences and window identity, deliberately kept: `activeAgentId`/
52
+ * `activeProjectId` (the callers own them), `windowId` + `client` (the window
53
+ * the store was created for), `sidebarOpen`, `sidebarWidth`, `gitViewMode`,
54
+ * `searchMode`, `previewEditorsEnabled`, `diffViewMode`;
56
55
  * - NOT reset, deliberately: `uploading` alone — it self-heals through the
57
56
  * upload's `finally`, and an upload in flight across a switch must still
58
57
  * release it.
@@ -77,6 +76,7 @@ export function contextResetPatch(): Record<string, unknown> {
77
76
  pendingReviewPaths: new Set<string>(),
78
77
  // The Changes badge count is the context's own server answer.
79
78
  pendingCount: 0,
79
+ pendingCountState: 'stale',
80
80
  // Nothing is pre-expanded — under lazy hydration an expanded entry is a
81
81
  // FETCH, and every source branch (including `brain://`) starts collapsed.
82
82
  expandedFolders: new Set<string>(),
@@ -230,6 +230,9 @@ export function createListSlice(
230
230
  set: (fn: (s: any) => any) => void,
231
231
  get: () => any,
232
232
  ): ListSliceActions {
233
+ // The root-listing dedup belongs to ONE store (one Files window): two windows
234
+ // listing the same agent must each get their own answer into their own store.
235
+ const listInFlight = new Map<string, Promise<void>>();
233
236
  return {
234
237
  async loadList({ agentId, projectId, userId, withCounts }) {
235
238
  if (!agentId) return;
@@ -312,7 +315,7 @@ export function createListSlice(
312
315
  sources_only: true,
313
316
  recursive: false,
314
317
  ...(withCounts ? { include_counts: true } : {}),
315
- });
318
+ }, get().client);
316
319
  } catch {
317
320
  set(() => ({ snapshotError: 'Filesystem service unavailable', sourcesLoading: false }));
318
321
  return;
@@ -1,8 +1,6 @@
1
1
  import { searchFiles } from '../api';
2
2
  import { sourceRootUri } from './helpers';
3
3
 
4
- let searchSeq = 0;
5
-
6
4
  export type SearchSliceHit = {
7
5
  id: string;
8
6
  uri: string;
@@ -95,6 +93,8 @@ export function createSearchSlice(
95
93
  set: (fn: (s: any) => any) => void,
96
94
  get: () => any,
97
95
  ): SearchSliceActions {
96
+ // The newest search of THIS store — an older answer never overwrites a newer one.
97
+ let searchSeq = 0;
98
98
  return {
99
99
  setSearchQuery: (query) => set(() => ({ searchQuery: query })),
100
100
  setSearchMode: (mode) => set(() => ({ searchMode: mode })),
@@ -167,7 +167,7 @@ export function createSearchSlice(
167
167
  ...(searchMode !== 'grep' && filters.createdAfter.trim() ? { created_after: filters.createdAfter.trim() } : {}),
168
168
  ...(searchMode !== 'grep' && filters.createdBefore.trim() ? { created_before: filters.createdBefore.trim() } : {}),
169
169
  limit: searchMode === 'semantic' ? 30 : 50,
170
- });
170
+ }, get().client);
171
171
  if (mySeq !== searchSeq) return;
172
172
  const rows = res.results || [];
173
173
  // Backend error rows (grep guidance, index-unavailable) carry `error`
@@ -6,7 +6,7 @@ import { RefreshCw, FileCode, Pencil, Trash2, Loader2, Upload, Check, X, GitComp
6
6
  import { cn } from '../lib/cn';
7
7
  import { useFilesystemHost } from '../FilesystemHost';
8
8
  import { buildAgentIndex } from '../lib/agentUtils';
9
- import { useContainerWidth } from '../hooks/useContainerWidth';
9
+ import { useContainerWidth } from '@neuralis/package-system/client';
10
10
  import { detectCodeLanguage } from '../lib/detectLanguage';
11
11
  import { parseGitLineStatus, isEmptyGitLineStatus, type GitLineStatusMap } from './codeEditor/gitLineStatus';
12
12
  import { EditorTabBar, EditorTabsMenu, TAB_SCROLLER_ATTR } from './EditorTabBar';
@@ -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';
@@ -45,6 +49,7 @@ function normalizeUserUriInput(raw: string, fallbackUri: string): string {
45
49
  }
46
50
 
47
51
  export function FileEditor() {
52
+ const client = useFilesystemStore((s) => s.client);
48
53
  const openFiles = useFilesystemStore((s) => s.openFiles);
49
54
  const activeFile = useFilesystemStore((s) => s.activeFile);
50
55
  const files = useFilesystemStore((s) => s.files);
@@ -98,6 +103,7 @@ export function FileEditor() {
98
103
  const [reverting, setReverting] = useState(false);
99
104
  const [showDiff, setShowDiff] = useState(true);
100
105
  const [metaExpanded, setMetaExpanded] = useState(false);
106
+ const refreshPendingCount = useFilesystemStore((s) => s.refreshPendingCount);
101
107
  const filePrevByPath = useFilesystemStore((s) => s.filePrevByPath);
102
108
  const activePrev = activeFile ? filePrevByPath[activeFile] : undefined;
103
109
  const hasPendingChanges = !!activePrev && activeNode?.status === 'pending_review';
@@ -137,6 +143,31 @@ export function FileEditor() {
137
143
  !showUploadTab && !showSourcesTab && !showPackagesTab && !showPendingTab && !showGitDiff && !showGitCommit;
138
144
  const rawAgentId = activeNode?.sourceAgentId ?? agentId ?? undefined;
139
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
+
140
171
  const rawUrl = useMemo(() => {
141
172
  if (!activeNode?.uri || !rawAgentId) return null;
142
173
  return getRawFileUrl(rawAgentId, activeNode.uri, projectId ?? undefined);
@@ -169,7 +200,7 @@ export function FileEditor() {
169
200
  }
170
201
  void (async () => {
171
202
  try {
172
- const res = await getGitDiff({ agentId: targetAgentId, ...(projectId ? { projectId } : {}), source, path, staged: false });
203
+ const res = await getGitDiff({ agentId: targetAgentId, ...(projectId ? { projectId } : {}), source, path, staged: false }, client);
173
204
  if (cancelled) return;
174
205
  if (res && (res as { ok?: boolean }).ok === false) {
175
206
  setGitLineStatus(undefined);
@@ -243,7 +274,7 @@ export function FileEditor() {
243
274
  id: activeNode.id,
244
275
  uri: activeNode.uri,
245
276
  fields: { status: 'active' },
246
- });
277
+ }, client);
247
278
  if (res && res.ok === false) {
248
279
  setEditError(writeRefusalMessage(res, 'Restore failed'));
249
280
  return;
@@ -266,7 +297,7 @@ export function FileEditor() {
266
297
  const handleApprove = useCallback(async () => {
267
298
  if (!targetAgentId || !activeNode || approving) return;
268
299
  if (activeNode.status === 'pending_review') {
269
- setWorkspacePanelOpen('pending', true); setActiveWorkspaceTab('pending'); return;
300
+ await openPendingReview('approve'); return;
270
301
  }
271
302
  setApproving(true);
272
303
  try {
@@ -275,7 +306,7 @@ export function FileEditor() {
275
306
  ...(projectId ? { projectId } : {}),
276
307
  id: activeNode.id,
277
308
  uri: activeNode.uri,
278
- });
309
+ }, client);
279
310
  if (res && res.ok) {
280
311
  const nodeKey = activeNode.storeKey || activeFile || fileKeyFromNode(activeNode);
281
312
  if (nodeKey) {
@@ -295,12 +326,12 @@ export function FileEditor() {
295
326
  } finally {
296
327
  setApproving(false);
297
328
  }
298
- }, [targetAgentId, activeNode, approving, projectId, openFile, resolvedAgent?.id, activeFile, patchFileNode, clearFilePrev]);
329
+ }, [openPendingReview, client, targetAgentId, activeNode, approving, projectId, openFile, resolvedAgent?.id, activeFile, patchFileNode, clearFilePrev]);
299
330
 
300
331
  const handleRevert = useCallback(async () => {
301
332
  if (!targetAgentId || !activeNode || reverting) return;
302
333
  if (activeNode.status === 'pending_review') {
303
- setWorkspacePanelOpen('pending', true); setActiveWorkspaceTab('pending'); return;
334
+ await openPendingReview('revert'); return;
304
335
  }
305
336
  setReverting(true);
306
337
  try {
@@ -310,7 +341,7 @@ export function FileEditor() {
310
341
  id: activeNode.id,
311
342
  uri: activeNode.uri,
312
343
  revert: true,
313
- });
344
+ }, client);
314
345
  if (res && res.ok === false) {
315
346
  setEditError(writeRefusalMessage(res, 'Revert failed'));
316
347
  return;
@@ -334,7 +365,7 @@ export function FileEditor() {
334
365
  } finally {
335
366
  setReverting(false);
336
367
  }
337
- }, [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]);
338
369
 
339
370
  useEffect(() => {
340
371
  if (!activeNode) {
@@ -419,6 +450,14 @@ export function FileEditor() {
419
450
 
420
451
  return (
421
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}
422
461
  {openFiles.length > 0 || workspacePanels.upload || (supportsSources && workspacePanels.sources) || workspacePanels.packages || workspacePanels.pending || gitDiffTarget || gitCommitTarget ? (
423
462
  <div
424
463
  className="flex shrink-0 items-stretch"
@@ -434,98 +473,107 @@ export function FileEditor() {
434
473
  <div {...{ [TAB_SCROLLER_ATTR]: '' }} className="flex min-w-0 flex-1 overflow-x-auto overflow-y-hidden">
435
474
  <EditorTabBar />
436
475
  {workspacePanels.upload ? (
476
+ <div className="group flex shrink-0 items-center">
437
477
  <button
438
478
  type="button"
439
479
  className={cn(
440
- 'group flex items-center gap-1.5 px-3 py-2 text-xs min-w-[120px] max-w-[240px] rounded-lg',
480
+ 'flex items-center gap-1.5 px-3 py-2 text-xs min-w-[120px] max-w-[240px] rounded-lg',
441
481
  showUploadTab ? 'bg-white/10 text-white' : 'text-white/50 hover:bg-white/5 hover:text-white/80',
442
482
  )}
443
483
  onClick={() => setActiveWorkspaceTab('upload')}
444
484
  >
445
485
  <Upload className="w-3.5 h-3.5 shrink-0" />
446
486
  <span className="truncate flex-1">Upload</span>
447
- <span
448
- className="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-white/10"
487
+ </button>
488
+ <button type="button" aria-label="Close Upload"
489
+ className="opacity-0 group-hover:opacity-100 focus-visible:opacity-100 p-0.5 rounded hover:bg-white/10"
449
490
  onClick={(e) => {
450
491
  e.stopPropagation();
451
492
  setWorkspacePanelOpen('upload', false);
452
493
  }}
453
494
  >
454
495
  <X className="w-3 h-3" />
455
- </span>
456
- </button>
496
+ </button>
497
+ </div>
457
498
  ) : null}
458
499
  {supportsSources && workspacePanels.sources ? (
500
+ <div className="group flex shrink-0 items-center">
459
501
  <button
460
502
  type="button"
461
503
  className={cn(
462
- 'group flex items-center gap-1.5 px-3 py-2 text-xs min-w-[120px] max-w-[240px] rounded-lg',
504
+ 'flex items-center gap-1.5 px-3 py-2 text-xs min-w-[120px] max-w-[240px] rounded-lg',
463
505
  showSourcesTab ? 'bg-white/10 text-white' : 'text-white/50 hover:bg-white/5 hover:text-white/80',
464
506
  )}
465
507
  onClick={() => setActiveWorkspaceTab('sources')}
466
508
  >
467
509
  <SlidersHorizontal className="w-3.5 h-3.5 shrink-0" />
468
510
  <span className="truncate flex-1">Sources</span>
469
- <span
470
- className="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-white/10"
511
+ </button>
512
+ <button type="button" aria-label="Close Sources"
513
+ className="opacity-0 group-hover:opacity-100 focus-visible:opacity-100 p-0.5 rounded hover:bg-white/10"
471
514
  onClick={(e) => {
472
515
  e.stopPropagation();
473
516
  setWorkspacePanelOpen('sources', false);
474
517
  }}
475
518
  >
476
519
  <X className="w-3 h-3" />
477
- </span>
478
- </button>
520
+ </button>
521
+ </div>
479
522
  ) : null}
480
523
  {workspacePanels.packages ? (
524
+ <div className="group flex shrink-0 items-center">
481
525
  <button
482
526
  type="button"
483
527
  className={cn(
484
- 'group flex items-center gap-1.5 px-3 py-2 text-xs min-w-[120px] max-w-[240px] rounded-lg',
528
+ 'flex items-center gap-1.5 px-3 py-2 text-xs min-w-[120px] max-w-[240px] rounded-lg',
485
529
  showPackagesTab ? 'bg-white/10 text-white' : 'text-white/50 hover:bg-white/5 hover:text-white/80',
486
530
  )}
487
531
  onClick={() => setActiveWorkspaceTab('packages')}
488
532
  >
489
533
  <Package className="w-3.5 h-3.5 shrink-0" />
490
534
  <span className="truncate flex-1">Packages</span>
491
- <span
492
- className="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-white/10"
535
+ </button>
536
+ <button type="button" aria-label="Close Packages"
537
+ className="opacity-0 group-hover:opacity-100 focus-visible:opacity-100 p-0.5 rounded hover:bg-white/10"
493
538
  onClick={(e) => {
494
539
  e.stopPropagation();
495
540
  setWorkspacePanelOpen('packages', false);
496
541
  }}
497
542
  >
498
543
  <X className="w-3 h-3" />
499
- </span>
500
- </button>
544
+ </button>
545
+ </div>
501
546
  ) : null}
502
547
  {workspacePanels.pending ? (
548
+ <div className="group flex shrink-0 items-center">
503
549
  <button
504
550
  type="button"
505
551
  className={cn(
506
- 'group flex items-center gap-1.5 px-3 py-2 text-xs min-w-[120px] max-w-[240px] rounded-lg',
552
+ 'flex items-center gap-1.5 px-3 py-2 text-xs min-w-[120px] max-w-[240px] rounded-lg',
507
553
  showPendingTab ? 'bg-white/10 text-white' : 'text-white/50 hover:bg-white/5 hover:text-white/80',
508
554
  )}
509
555
  onClick={() => setActiveWorkspaceTab('pending')}
510
556
  >
511
557
  <FileDiff className="w-3.5 h-3.5 shrink-0" />
512
558
  <span className="truncate flex-1">Changes</span>
513
- <span
514
- className="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-white/10"
559
+ </button>
560
+ <button type="button" aria-label="Close Changes"
561
+ className="opacity-0 group-hover:opacity-100 focus-visible:opacity-100 p-0.5 rounded hover:bg-white/10"
515
562
  onClick={(e) => {
516
563
  e.stopPropagation();
517
564
  setWorkspacePanelOpen('pending', false);
518
565
  }}
519
566
  >
520
567
  <X className="w-3 h-3" />
521
- </span>
522
- </button>
568
+ </button>
569
+ </div>
523
570
  ) : null}
524
571
  {gitDiffTarget ? (
572
+ <div className="group flex shrink-0 items-center">
525
573
  <button
526
574
  type="button"
527
575
  className={cn(
528
- 'group flex items-center gap-1.5 px-3 py-2 text-xs min-w-[120px] max-w-[240px] rounded-lg',
576
+ 'flex items-center gap-1.5 px-3 py-2 text-xs min-w-[120px] max-w-[240px] rounded-lg',
529
577
  showGitDiff ? 'bg-white/10 text-white' : 'text-white/50 hover:bg-white/5 hover:text-white/80',
530
578
  )}
531
579
  onClick={() => setActiveWorkspaceTab('git-diff')}
@@ -537,8 +585,9 @@ export function FileEditor() {
537
585
  <span className="truncate flex-1">
538
586
  {repoQualifiedLabel(gitDiffTarget.repo, extractFilename(gitDiffTarget.path) || gitDiffTarget.path)}
539
587
  </span>
540
- <span
541
- className="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-white/10"
588
+ </button>
589
+ <button type="button" aria-label="Close Git diff"
590
+ className="opacity-0 group-hover:opacity-100 focus-visible:opacity-100 p-0.5 rounded hover:bg-white/10"
542
591
  onClick={(e) => {
543
592
  e.stopPropagation();
544
593
  setGitDiffTarget(null);
@@ -546,14 +595,15 @@ export function FileEditor() {
546
595
  }}
547
596
  >
548
597
  <X className="w-3 h-3" />
549
- </span>
550
- </button>
598
+ </button>
599
+ </div>
551
600
  ) : null}
552
601
  {gitCommitTarget ? (
602
+ <div className="group flex shrink-0 items-center">
553
603
  <button
554
604
  type="button"
555
605
  className={cn(
556
- 'group flex items-center gap-1.5 px-3 py-2 text-xs min-w-[120px] max-w-[240px] rounded-lg',
606
+ 'flex items-center gap-1.5 px-3 py-2 text-xs min-w-[120px] max-w-[240px] rounded-lg',
557
607
  showGitCommit ? 'bg-white/10 text-white' : 'text-white/50 hover:bg-white/5 hover:text-white/80',
558
608
  )}
559
609
  onClick={() => setActiveWorkspaceTab('git-commit')}
@@ -575,8 +625,9 @@ export function FileEditor() {
575
625
  ? repoQualifiedLabel(gitCommitTarget.repo, `${shortSha(gitCommitTarget.sha)} ${gitCommitTarget.subject}`)
576
626
  : repoQualifiedLabel(gitCommitTarget.repo, 'Outgoing changes')}
577
627
  </span>
578
- <span
579
- className="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-white/10"
628
+ </button>
629
+ <button type="button" aria-label="Close Git commit"
630
+ className="opacity-0 group-hover:opacity-100 focus-visible:opacity-100 p-0.5 rounded hover:bg-white/10"
580
631
  onClick={(e) => {
581
632
  e.stopPropagation();
582
633
  // The 'file' fallback for an ACTIVE git-commit tab lives in
@@ -585,8 +636,8 @@ export function FileEditor() {
585
636
  }}
586
637
  >
587
638
  <X className="w-3 h-3" />
588
- </span>
589
- </button>
639
+ </button>
640
+ </div>
590
641
  ) : null}
591
642
  </div>
592
643
  <EditorTabsMenu />
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useFilesystemStore, type SidebarView } from '../store/filesystemStore';
3
+ import { useFilesystemStore, useFilesystemStoreApi, type SidebarView } from '../store/filesystemStore';
4
4
  import React, { useMemo, useState, useCallback, useRef, useEffect } from 'react';
5
5
  import { SkillLauncher } from '@neuralis/package-system/client';
6
6
  import { meetsRequires } from '@neuralis/package-system/access';
@@ -197,6 +197,7 @@ export function flattenSource(
197
197
  export function FileTree(props: { agentId: string; sidebarView?: SidebarView }) {
198
198
  const { agentId, sidebarView = 'tree' } = props;
199
199
  const host = useFilesystemHost();
200
+ const storeApi = useFilesystemStoreApi();
200
201
 
201
202
  const files = useFilesystemStore((s) => s.files);
202
203
  const folderBadges = useFilesystemStore((s) => s.folderBadges);
@@ -527,12 +528,12 @@ export function FileTree(props: { agentId: string; sidebarView?: SidebarView })
527
528
  // Expand + load the parent so the inline input row is visible in place.
528
529
  if (!parentUri.endsWith('://') && !expanded.has(parentUri)) {
529
530
  toggleFolder(parentUri);
530
- void useFilesystemStore.getState().ensureFolderLoaded({ agentId, projectId, uri: parentUri });
531
+ void storeApi.getState().ensureFolderLoaded({ agentId, projectId, uri: parentUri });
531
532
  }
532
533
  setCreating({ parentUri, kind });
533
534
  }
534
535
  },
535
- [contextMenu, menuTargets, expanded, toggleFolder, agentId, projectId, clipboard, pasteNodes],
536
+ [contextMenu, menuTargets, expanded, toggleFolder, agentId, projectId, clipboard, pasteNodes, storeApi],
536
537
  );
537
538
 
538
539
  const handleCreateCommit = useCallback(
@@ -607,12 +608,12 @@ export function FileTree(props: { agentId: string; sidebarView?: SidebarView })
607
608
  setActiveFolderPath(params.key);
608
609
  toggleFolder(params.key);
609
610
  if (wasExpanded) return;
610
- await useFilesystemStore.getState().ensureFolderLoaded({
611
+ await storeApi.getState().ensureFolderLoaded({
611
612
  agentId,
612
613
  projectId,
613
614
  uri: params.key,
614
615
  });
615
- }, [agentId, expanded, projectId, setActiveFolderPath, toggleFolder]);
616
+ }, [agentId, expanded, projectId, setActiveFolderPath, toggleFolder, storeApi]);
616
617
 
617
618
  const handleOpenFile = useCallback((params: { agentId: string; projectId: string | null; uri: string; readAgentId?: string }) => {
618
619
  if (params.uri) {
@@ -644,7 +645,7 @@ export function FileTree(props: { agentId: string; sidebarView?: SidebarView })
644
645
  */
645
646
  const handleRefreshSource = useCallback(async (uriRoot: string) => {
646
647
  if (!agentId) return;
647
- const store = useFilesystemStore.getState();
648
+ const store = storeApi.getState();
648
649
  setSourceBranchExpanded((prev) => (prev[uriRoot] ? prev : { ...prev, [uriRoot]: true }));
649
650
  if (store.loadedFolders?.[uriRoot]) {
650
651
  const source = sourceFromUri(uriRoot);
@@ -652,7 +653,7 @@ export function FileTree(props: { agentId: string; sidebarView?: SidebarView })
652
653
  return;
653
654
  }
654
655
  await store.ensureFolderLoaded({ agentId, projectId, uri: uriRoot, force: true });
655
- }, [agentId, projectId]);
656
+ }, [agentId, projectId, storeApi]);
656
657
 
657
658
  // Hydrate what is VISIBLE and not yet fetched.
658
659
  //
@@ -664,7 +665,7 @@ export function FileTree(props: { agentId: string; sidebarView?: SidebarView })
664
665
  // rather than retry-looping. `ensureFolderLoaded` is itself idempotent.
665
666
  useEffect(() => {
666
667
  if (!agentId) return;
667
- const state = useFilesystemStore.getState();
668
+ const state = storeApi.getState();
668
669
  const wanted: string[] = [];
669
670
  for (const info of sourceInfos) {
670
671
  if (sourceBranchExpanded[info.uri_root]) wanted.push(info.uri_root);
@@ -676,7 +677,7 @@ export function FileTree(props: { agentId: string; sidebarView?: SidebarView })
676
677
  if (hydration && hydration !== 'idle') continue;
677
678
  void state.ensureFolderLoaded({ agentId, projectId, uri });
678
679
  }
679
- }, [agentId, projectId, sourceInfos, sourceBranchExpanded, expanded]);
680
+ }, [agentId, projectId, sourceInfos, sourceBranchExpanded, expanded, storeApi]);
680
681
 
681
682
  const handleMoveFileToFolder = useCallback(
682
683
  async ({ fromUri, id, toFolderUri }: { fromUri: string; id?: string; toFolderUri: string }) => {
@@ -16,6 +16,7 @@ import type { GitCommitFileChange, GitCommitTarget } from '../domain/types';
16
16
  import { useVisibleDiffCards, type VisibleDiffQueue } from '../hooks/useVisibleDiffCards';
17
17
  import { GitFileDiff } from './GitFileDiff';
18
18
  import { shortSha, type GitFileDiffMode } from './gitFileDiffMode';
19
+ import { useFilesystemStore } from '../store/filesystemStore';
19
20
 
20
21
  /**
21
22
  * GitCommitChangesView (G2.4/C2+C3 / cobalt-wren) — the multi-file stacked
@@ -182,6 +183,7 @@ export function GitCommitChangesView({
182
183
  agentId: string | null;
183
184
  projectId: string | null;
184
185
  }): React.ReactElement {
186
+ const client = useFilesystemStore((s) => s.client);
185
187
  const [scrollRoot, setScrollRoot] = useState<HTMLDivElement | null>(null);
186
188
  const queue = useVisibleDiffCards(scrollRoot);
187
189
  const [state, setState] = useState<ListState>({ phase: 'loading' });
@@ -211,7 +213,7 @@ export function GitCommitChangesView({
211
213
  source,
212
214
  ...(repo ? { repo } : {}),
213
215
  sha: target.sha,
214
- });
216
+ }, client);
215
217
  if (cancelled) return;
216
218
  if (res.ok === false) {
217
219
  setState({ phase: 'unavailable' });
@@ -228,7 +230,7 @@ export function GitCommitChangesView({
228
230
  ...(repo ? { repo } : {}),
229
231
  maxCount: 1,
230
232
  ref: target.sha,
231
- });
233
+ }, client);
232
234
  const head = Array.isArray(log?.commits) ? log.commits[0] : undefined;
233
235
  if (!cancelled && head) setMeta({ author: head.author, date: head.date });
234
236
  } catch {
@@ -240,7 +242,7 @@ export function GitCommitChangesView({
240
242
  ...(projectId ? { projectId } : {}),
241
243
  source,
242
244
  ...(repo ? { repo } : {}),
243
- });
245
+ }, client);
244
246
  if (cancelled) return;
245
247
  // 3b: a deep-fail is a typed `{ok:false,'unavailable'}` refusal now — treat
246
248
  // it like no-upstream (nothing to show), never crash on a missing files[].
@@ -431,6 +431,7 @@ export function GitCommitGraph({
431
431
  * E) — two-part `slug:// Name` header render; absent → slug-only fallback. */
432
432
  repoName?: string;
433
433
  }) {
434
+ const client = useFilesystemStore((s) => s.client);
434
435
  const [page, setPage] = useState<GraphPageState>(emptyGraphPageState);
435
436
  const [loading, setLoading] = useState(false);
436
437
  const [error, setError] = useState<string | null>(null);
@@ -508,7 +509,7 @@ export function GitCommitGraph({
508
509
  ...(repo ? { repo } : {}),
509
510
  maxCount,
510
511
  skip,
511
- });
512
+ }, client);
512
513
  if (mySeq !== seqRef.current) return;
513
514
  const fetched = {
514
515
  commits: Array.isArray(res?.commits) ? res.commits : [],
@@ -530,7 +531,7 @@ export function GitCommitGraph({
530
531
  ...(repo ? { repo } : {}),
531
532
  maxCount,
532
533
  skip,
533
- })
534
+ }, client)
534
535
  .then((stats) => {
535
536
  if (mySeq !== seqRef.current) return;
536
537
  setCommitStats((cur) => (mode === 'append' ? { ...cur, ...stats.stats } : { ...stats.stats }));
@@ -643,7 +644,7 @@ export function GitCommitGraph({
643
644
  source,
644
645
  ...(repo ? { repo } : {}),
645
646
  sha,
646
- });
647
+ }, client);
647
648
  if (epoch !== filesEpochRef.current) return;
648
649
  setCommitFiles((prev) =>
649
650
  new Map(prev).set(
@@ -702,7 +703,7 @@ export function GitCommitGraph({
702
703
  ...(projectId ? { projectId } : {}),
703
704
  source,
704
705
  ...(repo ? { repo } : {}),
705
- });
706
+ }, client);
706
707
  if (epoch !== outgoingEpochRef.current) return;
707
708
  // 3b: a deep-fail is a typed `{ok:false,'unavailable'}` refusal now — treat
708
709
  // it like no-upstream (nothing to show), never crash on a missing files[].
@@ -812,7 +813,7 @@ export function GitCommitGraph({
812
813
  ...(projectId ? { projectId } : {}),
813
814
  source,
814
815
  ...(repo ? { repo } : {}),
815
- });
816
+ }, client);
816
817
  if (epoch !== outgoingEpochRef.current) return;
817
818
  // 3b: a deep-fail is a typed `{ok:false,'unavailable'}` refusal now — treat
818
819
  // it like no-upstream (nothing to show), never crash on a missing files[].