@neuralis/brain-core 0.3.0 → 0.3.2

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 +41 -40
  2. package/app/README.md +3 -3
  3. package/app/filesystem/FilesystemHost.tsx +7 -0
  4. package/app/filesystem/README.md +1 -1
  5. package/app/filesystem/api/index.ts +4 -4
  6. package/app/filesystem/hooks/README.md +1 -1
  7. package/app/filesystem/hooks/useFilesystemSSE.ts +41 -1
  8. package/app/filesystem/host/README.md +2 -2
  9. package/app/filesystem/host/buildFilesystemHostValue.ts +9 -2
  10. package/app/filesystem/lib/sourceKindForm.ts +109 -5
  11. package/app/filesystem/store/filesystemStore.ts +13 -3
  12. package/app/filesystem/ui/DiscoverDock.tsx +5 -1
  13. package/app/filesystem/ui/FileTree.tsx +34 -8
  14. package/app/filesystem/ui/FilesystemWidget.tsx +10 -2
  15. package/app/filesystem/ui/GitControlPanel.tsx +2 -2
  16. package/app/filesystem/ui/PendingChangesPanel.tsx +1 -1
  17. package/app/filesystem/ui/README.md +14 -13
  18. package/app/filesystem/ui/SourcesPanel.tsx +124 -24
  19. package/app/filesystem/ui/VectorHealthPanel.tsx +2 -2
  20. package/dist/app/host.js +41 -37
  21. package/dist/src/channels/filesystem.d.ts +1 -1
  22. package/dist/src/channels/filesystem.js +1 -1
  23. package/dist/src/domain/mime.d.ts +3 -3
  24. package/dist/src/domain/mime.js +3 -3
  25. package/dist/src/domain/scopeAccessCtx.d.ts +2 -2
  26. package/dist/src/domain/scopeAccessCtx.js +2 -2
  27. package/dist/src/domain/types.d.ts +8 -1
  28. package/dist/src/domain/types.d.ts.map +1 -1
  29. package/dist/src/events/pending.d.ts +1 -1
  30. package/dist/src/events/pending.js +1 -1
  31. package/dist/src/git/sourceGitControl.d.ts +2 -2
  32. package/dist/src/git/sourceGitControl.js +3 -3
  33. package/dist/src/lifecycle.d.ts +1 -1
  34. package/dist/src/routes/approve.d.ts +1 -1
  35. package/dist/src/routes/approve.js +1 -1
  36. package/dist/src/routes/events.d.ts +1 -1
  37. package/dist/src/routes/events.js +1 -1
  38. package/dist/src/routes/git.d.ts +7 -7
  39. package/dist/src/routes/git.js +18 -18
  40. package/dist/src/routes/health.d.ts +6 -6
  41. package/dist/src/routes/health.js +12 -12
  42. package/dist/src/routes/helpers/sourceNotConfigured.d.ts +1 -1
  43. package/dist/src/routes/helpers/sourceNotConfigured.js +1 -1
  44. package/dist/src/routes/helpers.d.ts +1 -1
  45. package/dist/src/routes/helpers.js +1 -1
  46. package/dist/src/routes/list.d.ts +1 -1
  47. package/dist/src/routes/list.js +1 -1
  48. package/dist/src/routes/pending.d.ts +4 -4
  49. package/dist/src/routes/pending.js +9 -9
  50. package/dist/src/routes/policies.d.ts +4 -4
  51. package/dist/src/routes/policies.d.ts.map +1 -1
  52. package/dist/src/routes/policies.js +11 -11
  53. package/dist/src/routes/policies.js.map +1 -1
  54. package/dist/src/routes/raw.d.ts +1 -1
  55. package/dist/src/routes/raw.js +1 -1
  56. package/dist/src/routes/read.d.ts +1 -1
  57. package/dist/src/routes/read.js +1 -1
  58. package/dist/src/routes/search.d.ts +1 -1
  59. package/dist/src/routes/search.js +1 -1
  60. package/dist/src/routes/sources.d.ts +20 -5
  61. package/dist/src/routes/sources.d.ts.map +1 -1
  62. package/dist/src/routes/sources.js +118 -118
  63. package/dist/src/routes/sources.js.map +1 -1
  64. package/dist/src/routes/upload.d.ts +1 -1
  65. package/dist/src/routes/upload.js +1 -1
  66. package/dist/src/routes/write.d.ts +2 -2
  67. package/dist/src/routes/write.js +2 -2
  68. package/dist/src/services/BridgeService.d.ts +7 -1
  69. package/dist/src/services/BridgeService.d.ts.map +1 -1
  70. package/dist/src/services/BridgeService.js +15 -3
  71. package/dist/src/services/BridgeService.js.map +1 -1
  72. package/dist/src/services/FilesystemService.d.ts +1 -1
  73. package/dist/src/services/FilesystemService.js +2 -2
  74. package/dist/src/services/declaredSources.d.ts +9 -3
  75. package/dist/src/services/declaredSources.d.ts.map +1 -1
  76. package/dist/src/services/declaredSources.js +2 -1
  77. package/dist/src/services/declaredSources.js.map +1 -1
  78. package/dist/src/tools/execCtx.d.ts +2 -2
  79. package/dist/src/tools/execCtx.js +2 -2
  80. package/dist/src/tools/fs_read.d.ts.map +1 -1
  81. package/dist/src/tools/fs_read.js +5 -0
  82. package/dist/src/tools/fs_read.js.map +1 -1
  83. package/dist/src/usecases/embeddingBacklog.js +1 -1
  84. package/dist/src/usecases/embeddingBacklog.js.map +1 -1
  85. package/dist/src/usecases/readFiles.d.ts.map +1 -1
  86. package/dist/src/usecases/readFiles.js +84 -1
  87. package/dist/src/usecases/readFiles.js.map +1 -1
  88. package/dist/src/usecases/syncPointWrites.d.ts +2 -0
  89. package/dist/src/usecases/syncPointWrites.d.ts.map +1 -1
  90. package/dist/src/usecases/syncPointWrites.js +5 -1
  91. package/dist/src/usecases/syncPointWrites.js.map +1 -1
  92. package/dist/src/usecases/vectorMaintenance.js +1 -1
  93. package/dist/src/usecases/vectorMaintenance.js.map +1 -1
  94. package/dist/src/usecases/write/content.js +1 -1
  95. package/dist/tools/fs_list.json +1 -1
  96. package/dist/tools/fs_read.json +1 -1
  97. package/dist/tools/fs_search.json +1 -1
  98. package/dist/tools/fs_write.json +1 -1
  99. package/instructions/brain-model.md +1 -1
  100. package/package.json +70 -63
  101. package/rules/brain-rules.md +2 -2
  102. package/skills/brain-start/SKILL.md +1 -1
  103. package/skills/connect-business-data/SKILL.md +1 -1
  104. package/skills/create-package/SKILL.md +8 -8
  105. package/skills/create-package/references/connectors-and-uri-policies.md +7 -2
  106. package/skills/create-package/references/runtime-and-build.md +1 -1
  107. package/skills/create-package/references/skills-and-context.md +1 -1
  108. package/skills/create-package/references/test-and-ship.md +4 -4
  109. package/skills/create-package/scripts/check-package.sh +1 -1
  110. package/skills/git/SKILL.md +23 -23
  111. package/skills/manage-files/SKILL.md +13 -13
  112. package/skills/manage-files/scripts/upload.sh +1 -1
  113. package/skills/manage-sources/SKILL.md +55 -54
  114. package/skills/manage-sources/scripts/reembed.sh +1 -1
  115. package/skills/manage-uri-policy/SKILL.md +9 -9
  116. package/skills/organize/SKILL.md +1 -1
  117. package/skills/sync/SKILL.md +2 -2
  118. package/tools/fs_list.json +1 -1
  119. package/tools/fs_read.json +1 -1
  120. package/tools/fs_search.json +1 -1
  121. package/tools/fs_write.json +1 -1
  122. package/workflows/sources-digest.json +1 -1
  123. package/docs/drive-overview.md +0 -59
@@ -1,8 +1,10 @@
1
1
  /**
2
- * How a connector kind's ROOT field renders in the Add-Source form — derived
2
+ * How a connector kind's config fields render in the Add-Source form — derived
3
3
  * from the DECLARATION, never from the kind name and never from `isDocker`.
4
+ * The ROOT field first; the other fields' closed control set is `fieldControl`
5
+ * below.
4
6
  *
5
- * Every other field in that form already renders from its declared
7
+ * Every other field in that form renders from its declared
6
8
  * `x-neuralis.ui`; `root` was the one hand-written, kind-BLIND exception, which
7
9
  * is why the Host Machine kind used to show the `local` connector's Docker
8
10
  * volume picker, its `pnpm neuralis:mount add` callout and a `/mounts/...`
@@ -12,10 +14,10 @@
12
14
  * `control` carries the distinction that matters:
13
15
  * - `container-path` — the root lives in the CONTAINER filesystem, so the
14
16
  * `/mounts` volume picker, the mount-CLI callout and Browse all apply.
15
- * - `host-path` — the root lives on the operator's machine, reached through
16
- * the host broker. None of that chrome applies, and Browse least of all:
17
+ * - `host-path` — the root lives on a paired device, reached through that
18
+ * device's broker. None of that chrome applies, and Browse least of all:
17
19
  * `GET /sources/browse` readdirs in the SERVER process, i.e. the wrong
18
- * machine. A host root's authority is the operator-owned broker ceiling.
20
+ * machine. A host root is bounded by that device's own allowlist.
19
21
  * - `path` (or absent) — the pre-existing form. Treated as container-plane,
20
22
  * so an externally authored connector keeps working unchanged.
21
23
  *
@@ -64,3 +66,105 @@ export function rootFieldMeta(meta: SourceKindFormMeta | null | undefined): Root
64
66
  placeholder: typeof ui?.placeholder === 'string' ? ui.placeholder : '',
65
67
  };
66
68
  }
69
+
70
+ /**
71
+ * How a NON-root config field renders. A CLOSED set: `text` (the default — a
72
+ * plain input, `number` for a numeric type) and `select`, whose options a
73
+ * route in the DECLARING package answers (`x-neuralis.ui.optionsRoute`).
74
+ *
75
+ * The list a `select` shows is UX only: the declaring package re-checks the
76
+ * picked value when the source is created (a host source's `device` is
77
+ * re-checked by the device's owner rule), so the form never decides who may
78
+ * attach what. An `optionsRoute` the form refuses (absolute, a scheme, a `..`
79
+ * segment, an escape) renders the field as `text` and names the refusal —
80
+ * never a request to somewhere the declaration did not mean.
81
+ */
82
+ export type FieldControl =
83
+ | { control: 'text'; numeric: boolean; refused?: string }
84
+ | { control: 'select'; numeric: boolean; options: OptionsRoute };
85
+
86
+ /** A validated options route: one package-relative path + its query. */
87
+ export type OptionsRoute = { slug: string; path: string; params: Record<string, string> };
88
+
89
+ const ROUTE_PATH = /^[A-Za-z0-9][A-Za-z0-9._~-]*(?:\/[A-Za-z0-9._~-]+)*$/;
90
+ const ROUTE_QUERY_PART = /^[A-Za-z0-9._~-]+$/;
91
+
92
+ /**
93
+ * The `/api/packages/<slug>` segment of a registry id: the first-party scope is
94
+ * dropped (`@neuralis/agent-core` → `agent-core`, the same rule the route
95
+ * gateway resolves), any other id travels as ONE encoded segment.
96
+ */
97
+ export function packageRouteSlug(packageId: string): string {
98
+ return encodeURIComponent(packageId.replace(/^@neuralis\//, ''));
99
+ }
100
+
101
+ /**
102
+ * Parse a declared `optionsRoute` against the package that declares the field.
103
+ * Relative only, plain path segments (no `.`/`..`, no `%`, no `\`), an optional
104
+ * `key=value&…` query of the same characters, ≤ 200 characters.
105
+ */
106
+ export function parseOptionsRoute(raw: unknown, packageId: string): OptionsRoute | { refused: string } {
107
+ if (typeof raw !== 'string' || raw.length === 0 || raw.length > 200) {
108
+ return { refused: 'optionsRoute must be a non-empty string of at most 200 characters' };
109
+ }
110
+ if (!packageId) return { refused: 'optionsRoute has no declaring package' };
111
+ const [path, query, extra] = raw.split('?');
112
+ if (extra !== undefined || !ROUTE_PATH.test(path ?? '')) {
113
+ return { refused: 'optionsRoute must be a relative route inside the declaring package' };
114
+ }
115
+ if ((path ?? '').split('/').some((segment) => segment === '.' || segment === '..')) {
116
+ return { refused: 'optionsRoute must be a relative route inside the declaring package' };
117
+ }
118
+ const params: Record<string, string> = {};
119
+ if (query !== undefined) {
120
+ for (const pair of query.split('&')) {
121
+ const [key, value, more] = pair.split('=');
122
+ if (more !== undefined || !key || !ROUTE_QUERY_PART.test(key) || (value !== undefined && value !== '' && !ROUTE_QUERY_PART.test(value))) {
123
+ return { refused: 'optionsRoute query must be plain key=value pairs' };
124
+ }
125
+ params[key] = value ?? '';
126
+ }
127
+ }
128
+ return { slug: packageRouteSlug(packageId), path: path as string, params };
129
+ }
130
+
131
+ export function fieldControl(prop: unknown, packageId: string): FieldControl {
132
+ const p = (prop ?? {}) as { type?: unknown; 'x-neuralis'?: { ui?: { control?: unknown; optionsRoute?: unknown } } };
133
+ const numeric = p.type === 'number' || p.type === 'integer';
134
+ const ui = p['x-neuralis']?.ui;
135
+ if (ui?.control !== 'select') return { control: 'text', numeric };
136
+ const route = parseOptionsRoute(ui.optionsRoute, packageId);
137
+ if ('refused' in route) return { control: 'text', numeric, refused: route.refused };
138
+ return { control: 'select', numeric, options: route };
139
+ }
140
+
141
+ /** One row of a `select`: the stored value, its label, and whether it reads dimmed. */
142
+ export type SelectOption = { value: string; label: string; dimmed: boolean };
143
+
144
+ /**
145
+ * The options an `optionsRoute` answered. The answer is a JSON object whose
146
+ * FIRST array-valued property lists the rows; a row is `{id, name?, status?,
147
+ * confinement?}` (the shape of a device list). The label is
148
+ * `name · status · confinement`; a `revoked` row is not offered at all; any
149
+ * status other than `online` reads dimmed but stays selectable — attaching a
150
+ * source to an offline device is allowed, it simply answers once the device is
151
+ * back. Rows without a string `id` are dropped.
152
+ */
153
+ export function selectOptionsFrom(answer: unknown): SelectOption[] {
154
+ if (!answer || typeof answer !== 'object') return [];
155
+ const rows = Object.values(answer as Record<string, unknown>).find(Array.isArray) as unknown[] | undefined;
156
+ if (!rows) return [];
157
+ const out: SelectOption[] = [];
158
+ for (const row of rows) {
159
+ if (!row || typeof row !== 'object') continue;
160
+ const r = row as { id?: unknown; name?: unknown; status?: unknown; confinement?: unknown };
161
+ if (typeof r.id !== 'string' || r.id.length === 0) continue;
162
+ const status = typeof r.status === 'string' ? r.status : undefined;
163
+ if (status === 'revoked') continue;
164
+ const parts = [typeof r.name === 'string' && r.name ? r.name : r.id];
165
+ if (status) parts.push(status.replace(/_/g, ' '));
166
+ if (typeof r.confinement === 'string') parts.push(r.confinement);
167
+ out.push({ value: r.id, label: parts.join(' · '), dimmed: status !== undefined && status !== 'online' });
168
+ }
169
+ return out;
170
+ }
@@ -900,6 +900,13 @@ interface FilesystemStore extends FileSystemState, SearchSliceActions, ListSlice
900
900
  activeFolderPath: string | null;
901
901
 
902
902
  uploading: boolean;
903
+ /**
904
+ * Bumped once per burst of `devices` frames (and on a hub reconnect): a
905
+ * paired device changed state. Consumers holding their own device fetch —
906
+ * the source form's device `select` — re-read on it. Not per context and
907
+ * never persisted.
908
+ */
909
+ deviceRevision: number;
903
910
  workspacePanels: Record<WorkspacePanelId, boolean>;
904
911
  // `'git-diff'` (G2.2) and `'git-commit'` (G2.4/C2 — the multi-file stacked
905
912
  // commit/outgoing view) are VIEWS, not toggleable panels — they live outside
@@ -1088,9 +1095,11 @@ const FILESYSTEM_WIDGET_TYPE = 'filesystem';
1088
1095
  /**
1089
1096
  * File-tree sidebar is a **fixed pixel** column (mirrors the admin widget):
1090
1097
  * its width is independent of the outer widget size, so resizing the widget in
1091
- * the workspace never reflows the tree. The width is user-draggable and survives
1092
- * reloads via localStorage. The drag handle clamps the upper bound to the live
1093
- * container width so the tree can fully cover the file editor.
1098
+ * the workspace never reflows the tree until the widget edge reaches it — then
1099
+ * the rendered width is capped to the container and grows back to this stored
1100
+ * width on widening. The width is user-draggable and survives reloads via
1101
+ * localStorage. The drag handle clamps the upper bound to the live container
1102
+ * width so the tree can fully cover the file editor.
1094
1103
  */
1095
1104
  export const FILESYSTEM_SIDEBAR_MIN_WIDTH = 120;
1096
1105
  const SIDEBAR_WIDTH_DEFAULT = 240;
@@ -1178,6 +1187,7 @@ export function createFilesystemStore(init: FilesystemStoreInit): FilesystemStor
1178
1187
  activeRepoRel: '',
1179
1188
  activeFolderPath: 'brain://docs',
1180
1189
  uploading: false,
1190
+ deviceRevision: 0,
1181
1191
  workspacePanels: { upload: false, sources: false, packages: false, pending: false },
1182
1192
  activeWorkspaceTab: 'file',
1183
1193
  pendingSourceSuggestion: null,
@@ -56,9 +56,13 @@ export function DiscoverDock({ suggestions, expanded, onToggleExpanded, onPick,
56
56
  return (
57
57
  <div
58
58
  className={cn(
59
- 'group/discover shrink-0 border-t border-white/[0.06] bg-[#0b0b0f]/95',
59
+ 'group/discover shrink-0 border-t border-white/[0.06]',
60
60
  overlay ? 'absolute inset-x-0 bottom-0 z-20 shadow-[0_-8px_24px_rgba(0,0,0,0.45)]' : '',
61
61
  )}
62
+ // Collapsed it carries no surface of its own (the panel chrome owns it);
63
+ // only the overlay form needs a scrim, derived from the theme so it
64
+ // matches every workspace theme.
65
+ style={overlay ? { backgroundColor: 'rgba(var(--w-bg-rgb), 0.96)' } : undefined}
62
66
  data-testid="discover-dock"
63
67
  >
64
68
  <div className="flex items-center justify-between px-2 pt-1.5 pb-1">
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useFilesystemStore, useFilesystemStoreApi, type SidebarView } from '../store/filesystemStore';
4
- import React, { useMemo, useState, useCallback, useRef, useEffect } from 'react';
4
+ import React, { useMemo, useState, useCallback, useRef, useEffect, useLayoutEffect } from 'react';
5
5
  import { SkillLauncher } from '@neuralis/package-system/client';
6
6
  import { meetsRequires } from '@neuralis/package-system/access';
7
7
  import { useVirtualizer } from '@tanstack/react-virtual';
@@ -439,9 +439,9 @@ export function FileTree(props: { agentId: string; sidebarView?: SidebarView })
439
439
  return uris.map((u) => files[u]).filter(Boolean) as FileNode[];
440
440
  }, [contextMenu, selection, files]);
441
441
 
442
- // HIDE-ONLY mirror of the reindex route's `drive.sync` gate — the route decides.
442
+ // HIDE-ONLY mirror of the reindex route's `sources.sync` gate — the route decides.
443
443
  const canSync = useMemo(
444
- () => meetsRequires({ grantedFeatures: [...(host.grantedFeatures ?? [])] }, { features: ['drive.sync'] }),
444
+ () => meetsRequires({ grantedFeatures: [...(host.grantedFeatures ?? [])] }, { features: ['sources.sync'] }),
445
445
  [host.grantedFeatures],
446
446
  );
447
447
  const [reembedTarget, setReembedTarget] = useState<ReembedTarget | null>(null);
@@ -700,7 +700,7 @@ export function FileTree(props: { agentId: string; sidebarView?: SidebarView })
700
700
  () => sourceInfos.every((info) => !sourceBranchExpanded[info.uri_root]),
701
701
  [sourceInfos, sourceBranchExpanded],
702
702
  );
703
- // HIDE-ONLY feature gate. The server list is already `drive.read`-gated and
703
+ // HIDE-ONLY feature gate. The server list is already `files.read`-gated and
704
704
  // `seeRequires`-filtered, so this hides an affordance the caller could not use
705
705
  // and authorizes NOTHING — the attach route stays the boundary. Hidden
706
706
  // entirely, never a locked row (the S2 discipline). Evaluated with the ONE
@@ -709,7 +709,7 @@ export function FileTree(props: { agentId: string; sidebarView?: SidebarView })
709
709
  // The host value keeps the list `readonly` so no UI file can mutate the
710
710
  // caller's grants; the kernel predicate takes a mutable array, so copy at
711
711
  // the boundary rather than widening the contract.
712
- () => meetsRequires({ grantedFeatures: [...(host.grantedFeatures ?? [])] }, { features: ['drive.mount'] }),
712
+ () => meetsRequires({ grantedFeatures: [...(host.grantedFeatures ?? [])] }, { features: ['sources.mount'] }),
713
713
  [host.grantedFeatures],
714
714
  );
715
715
  // FETCH: lazily, only once the dock could actually render. An eager fetch on
@@ -779,6 +779,32 @@ export function FileTree(props: { agentId: string; sidebarView?: SidebarView })
779
779
  getItemKey,
780
780
  });
781
781
 
782
+ // The scroller UNMOUNTS whenever the git panel or a loading/empty state replaces
783
+ // the tree, but the virtualizer instance survives with its old scroll offset. On
784
+ // re-attach virtual-core writes that offset onto the NEW element; when the new
785
+ // content is still too short the browser clamps `scrollTop` to 0 without firing
786
+ // a scroll event, and the virtualizer keeps windowing from the stale offset — the
787
+ // first rows stay unrendered above a blank gap until the user scrolls. One read
788
+ // per attach (declared after `useVirtualizer`, so it runs after its `_willUpdate`)
789
+ // re-feeds the real offset through the virtualizer's own scroll listener — in a
790
+ // microtask, because that listener re-renders with `flushSync`, which React
791
+ // refuses inside a commit; the microtask still lands before the frame paints.
792
+ const scrollerAttachedRef = useRef(false);
793
+ const attachScroller = useCallback((el: HTMLDivElement | null) => {
794
+ parentRef.current = el;
795
+ if (el) scrollerAttachedRef.current = true;
796
+ }, []);
797
+ useLayoutEffect(() => {
798
+ if (!scrollerAttachedRef.current) return;
799
+ scrollerAttachedRef.current = false;
800
+ const el = parentRef.current;
801
+ if (el && el.scrollTop !== (virtualizer.scrollOffset ?? 0)) {
802
+ queueMicrotask(() => {
803
+ if (parentRef.current === el) el.dispatchEvent(new Event('scroll'));
804
+ });
805
+ }
806
+ });
807
+
782
808
  // Git Control view (G2.1, Q1) — the panel REPLACES the tree + search IN PLACE,
783
809
  // exactly like search does. Early-return AFTER all hooks (so none are skipped)
784
810
  // and BEFORE the loading/empty tree short-circuits (the git panel owns its own
@@ -806,7 +832,7 @@ export function FileTree(props: { agentId: string; sidebarView?: SidebarView })
806
832
 
807
833
  // Root-listing failure (the SERVICE answered nothing — a single source's
808
834
  // failure is its own header's `error` state). Without this surface the tree
809
- // was silently "Empty Drive": the store wrote `snapshotError` and nothing read
835
+ // was silently "No files yet": the store wrote `snapshotError` and nothing read
810
836
  // it. Retry re-runs the same root listing; a listing that answers clears it.
811
837
  const snapshotBanner = snapshotError && !showSearch ? (
812
838
  <div
@@ -835,7 +861,7 @@ export function FileTree(props: { agentId: string; sidebarView?: SidebarView })
835
861
  ) : null;
836
862
 
837
863
  if (!allNodes.length && !showSearch && !hasSources) {
838
- return snapshotBanner ?? <div className="p-4 text-center text-xs text-white/30">Empty Drive</div>;
864
+ return snapshotBanner ?? <div className="p-4 text-center text-xs text-white/30">No files yet</div>;
839
865
  }
840
866
 
841
867
  const renderNode = (node: FileNode, depth: number) => {
@@ -881,7 +907,7 @@ export function FileTree(props: { agentId: string; sidebarView?: SidebarView })
881
907
  // `relative` anchors the dock's EXPANDED overlay form.
882
908
  <div className="relative flex min-h-0 flex-1 flex-col">
883
909
  <div
884
- ref={parentRef}
910
+ ref={attachScroller}
885
911
  tabIndex={0}
886
912
  onKeyDown={handleTreeKeyDown}
887
913
  onContextMenu={handleBackgroundContextMenu}
@@ -28,6 +28,7 @@ export function FilesystemWidget(props: { agentId: string; projectId?: string |
28
28
  const setSidebarView = useFilesystemStore((s) => s.setSidebarView);
29
29
  const lastAppliedNavRef = useRef<string | null>(null);
30
30
  const splitRef = useRef<HTMLDivElement | null>(null);
31
+ const sidebarRef = useRef<HTMLDivElement | null>(null);
31
32
  const dragRef = useRef<{ startX: number; startWidth: number } | null>(null);
32
33
  const [dragging, setDragging] = useState(false);
33
34
 
@@ -103,11 +104,16 @@ export function FilesystemWidget(props: { agentId: string; projectId?: string |
103
104
  // the move keeps tracking even when the cursor leaves the thin handle. The
104
105
  // upper bound is the live container width (minus the handle), so the tree can
105
106
  // be dragged all the way over the editor, leaving only tree + chat visible.
107
+ // The RENDERED width is capped by CSS at the same bound: a narrowing widget
108
+ // eats the editor first, then the tree, and widening grows the tree back to
109
+ // the stored width — so the drag starts from the rendered width, never the
110
+ // stored one, or a capped tree would not follow the pointer at first.
106
111
  const HANDLE_WIDTH = 8;
107
112
  const onHandlePointerDown = useCallback(
108
113
  (e: React.PointerEvent<HTMLDivElement>) => {
109
114
  e.preventDefault();
110
- dragRef.current = { startX: e.clientX, startWidth: sidebarWidth };
115
+ const rendered = sidebarRef.current?.offsetWidth;
116
+ dragRef.current = { startX: e.clientX, startWidth: rendered && rendered < sidebarWidth ? rendered : sidebarWidth };
111
117
  setDragging(true);
112
118
  e.currentTarget.setPointerCapture(e.pointerId);
113
119
  },
@@ -150,9 +156,11 @@ export function FilesystemWidget(props: { agentId: string; projectId?: string |
150
156
  className={`flex-1 min-h-0 flex flex-row ${dragging ? 'select-none cursor-col-resize' : ''}`}
151
157
  >
152
158
  <div
159
+ ref={sidebarRef}
153
160
  className="h-full flex flex-col min-h-0 shrink-0 overflow-hidden"
154
161
  style={{
155
162
  width: sidebarOpen ? sidebarWidth : 0,
163
+ maxWidth: `calc(100% - ${HANDLE_WIDTH}px)`,
156
164
  backgroundColor: 'transparent',
157
165
  borderRight: sidebarOpen ? '1px solid rgba(255,255,255,0.05)' : 'none',
158
166
  }}
@@ -238,7 +246,7 @@ export function FilesystemWidget(props: { agentId: string; projectId?: string |
238
246
  </div>
239
247
  )}
240
248
 
241
- <div className="flex-1 min-w-0 min-h-0">
249
+ <div className="flex-1 min-w-0 min-h-0 overflow-hidden">
242
250
  <FileEditor />
243
251
  </div>
244
252
  </div>
@@ -84,7 +84,7 @@ import {
84
84
  * Stage / Unstage / Discard, a commit box (message + optional amend), and a
85
85
  * branch-switcher quick-pick (checkout / create / delete). Each write is the
86
86
  * HUMAN face of the git engine — the click IS the consent (server gates
87
- * `drive.write` + uri-policy `write` + audits); a DESTRUCTIVE op (discard,
87
+ * `files.write` + uri-policy `write` + audits); a DESTRUCTIVE op (discard,
88
88
  * branch delete) is behind a CONFIRM dialog AND the server unconditionally
89
89
  * auto-snapshots (Q6) — the returned rescue ref powers a best-effort recovery
90
90
  * notice. On success the N3 badge SSE refreshes the panel; we ALSO
@@ -592,7 +592,7 @@ export function GitControlPanel({ agentId }: { agentId: string }) {
592
592
  );
593
593
  // Show the write affordances (Stage/Unstage/Discard/Commit/branch-write) IFF the
594
594
  // caller can actually write — `git.canWrite` is computed SERVER-side per-caller as
595
- // the SAME two-plane gate the write routes enforce (the `drive.write` FEATURE ∧ the
595
+ // the SAME two-plane gate the write routes enforce (the `files.write` FEATURE ∧ the
596
596
  // caller's RESOLVED uri-policy `write` on the source root). This is authoritative:
597
597
  // it must NOT read `SourceInfo.permissions.write` (the source's DEFAULT block), which
598
598
  // hid the owner's write UI on a `default write:false` source where the owner gains
@@ -216,7 +216,7 @@ const NO_FILTERS: ChangeFilters = { kind: 'all', source: '', repo: '', branch: '
216
216
  export function PendingChangesPanel({ agentId, projectId }: Props) {
217
217
  const client = useFilesystemStore((s) => s.client);
218
218
  const host = useFilesystemHost();
219
- const canWrite = hasFeature({ grantedFeatures: [...(host.grantedFeatures ?? [])] }, 'drive.write');
219
+ const canWrite = hasFeature({ grantedFeatures: [...(host.grantedFeatures ?? [])] }, 'files.write');
220
220
  const storeApi = useFilesystemStoreApi();
221
221
  const sources = useFilesystemStore(s => s.sources);
222
222
  const liveRevisions = useFilesystemStore(s => s.sourceChangeRevision);
@@ -17,7 +17,7 @@ Re-exported from `app/filesystem/ui/index.ts`:
17
17
  - `FileTree`, `FileTreeNode`, `FileTreeSearch`, `SourceBranchHeader` — virtualised tree components (uses `@tanstack/react-virtual`). `FileTreeSearch` is the search PANEL body only (mode selector `keyword|semantic|grep`, filter row, results) — the query input rides inline in the sidebar HEADER (`FilesystemWidget`), panel visibility keys on `sidebarView === 'search'` alone (the header toggle/Escape clears + closes even with text), and the ≥2-char gate inside the panel only switches results-vs-hint. The store keeps the answer's `requested_mode`/`effective_mode`/`embedding_degraded`/`warning` as `searchEffect` (a `contextResetPatch` key) and the panel prints ONE `searchEffectLine` ("Searched as keyword — semantic search unavailable (<reason>)") plus an `embedding missing|stale` badge per row.
18
18
  - `FileTreeContextMenu`, `TreeDeleteConfirm` — the VS Code-style file-management surface (see the section below).
19
19
  - `ReembedDialog` — the ONE estimate-first embed dialog, shared by the Sources panel (a whole source) and the tree's context menu (a folder or a file): opening it asks `sources/reindex` for a sample-based ESTIMATE only (no queue job, no sync-marker change); the work starts on its button and nowhere else. `kind="reembed"` (default) = the forced re-embed of every file with the active model; `kind="pending"` = **Embed pending…**, the `embeddingOnly` debt pass (only missing/stale embeddings; the estimate is the scope's upper bound). Portaled like `TreeDeleteConfirm`.
20
- - `DiscoverDock` — the **Discover** list of attachable-but-unattached sources (`GET /sources/discoverable`), pinned below the tree. **It is a SIBLING of the virtualized scroller, never a `FlatItemKind`**: a sixth item kind would have to join the `rowEstimate`/`isMeasuredRow` contract for a list that is never long enough to need virtualizing. Two placements, one component — COLLAPSED it sits in normal flow below the scroller (takes height, covers nothing, bounded to 5 rows); EXPANDED the tree renders it as an `absolute bottom-0` overlay, the ONLY state in which it may hide tree rows. Never both, so the scroller's height cannot change while the overlay is up. Visible only while EVERY source branch is collapsed (`sourceBranchExpanded`, the React-local BRANCH state — not the store's `expandedFolders`) and only for a caller holding `drive.mount`. **Consequence to know:** `activeFile` is persisted and `FileTree`'s auto-expand opens its source branch, so a returning user with an open tab lands with the dock hidden until they collapse that branch. Clicking a row hands the whole suggestion to the Sources panel through the store (`pendingSourceSuggestion`) and opens it — the prefill body lives in `SourcesPanel.applySuggestion`, ONE body shared with the panel's own Discoverable list.
20
+ - `DiscoverDock` — the **Discover** list of attachable-but-unattached sources (`GET /sources/discoverable`), pinned below the tree. **It is a SIBLING of the virtualized scroller, never a `FlatItemKind`**: a sixth item kind would have to join the `rowEstimate`/`isMeasuredRow` contract for a list that is never long enough to need virtualizing. Two placements, one component — COLLAPSED it sits in normal flow below the scroller (takes height, covers nothing, bounded to 5 rows); EXPANDED the tree renders it as an `absolute bottom-0` overlay, the ONLY state in which it may hide tree rows — and the only one with a surface: a scrim derived from `--w-bg-rgb`, so it matches every theme (collapsed it has none). Never both, so the scroller's height cannot change while the overlay is up. Visible only while EVERY source branch is collapsed (`sourceBranchExpanded`, the React-local BRANCH state — not the store's `expandedFolders`) and only for a caller holding `sources.mount`. **Consequence to know:** `activeFile` is persisted and `FileTree`'s auto-expand opens its source branch, so a returning user with an open tab lands with the dock hidden until they collapse that branch. Clicking a row hands the whole suggestion to the Sources panel through the store (`pendingSourceSuggestion`) and opens it — the prefill body lives in `SourcesPanel.applySuggestion`, ONE body shared with the panel's own Discoverable list.
21
21
  - `FileEditor`, `FileTab`, `FileMetaBar`, `CodeEditor`, `ReviewDiffView`, `EditorTabBar`, `DiffModeToggle` — the **VS Code-like editor stack**.
22
22
  - **`CodeEditor`** (CodeMirror 6 + an in-repo Shiki bridge under `codeEditor/`) is the ONE editor instance behind BOTH the read view (`readOnly`) and edit — toggling Edit only flips the `readOnly` Compartment, so the `EditorView` never remounts (no flash / scroll loss / re-tokenize). Syntax colors are the exact VS Code `dark-plus` TextMate grammars via Shiki's **JavaScript regex engine** — so **no web worker, no `.wasm`, no host CSP change** (`codeEditor/shikiHighlighter.ts` = the memoized core + `tokenizeLines` for the git-diff path — the core, the engine, both themes and all 50 `@shikijs/langs` grammars are dynamic imports, one split chunk each, so nothing of Shiki is on the module's cold path; the 15 base grammars load WITH the core so a markdown fence in one of them colours at once, the rest on first use; `codeEditor/shikiChunks.ts` = the INCREMENTAL tokenizer — position-anchored, line-aligned chunks closed at 64 lines OR 4 KB of text (whichever first) with TextMate grammar-state continuation, chunk-relative token offsets, the visible chunks tokenized synchronously under a 16 KB per-paint character budget (CodeMirror's `visibleRanges` right after a whole-document replace spans thousands of unmeasured lines — the budget, not the viewport estimate, bounds the sync path) and the prefix + a 4-chunk horizon after the viewport in idle slices (a slice is at least one chunk: measured ~90 ms worst case on dense TS/TSX and on long-line markdown, ~50 ms typical code, ~2–5 ms short-line prose — chunks past the horizon wait until scrolled to, so opening a file costs the viewport, not the document), a keystroke re-tokenizing its own chunk and walking forward only until the grammar state converges, a far scroll coloured provisionally first and corrected by the idle pass, and a whole-document replace (every file switch / reload / revert is one) split back into chunks eagerly so the first paint never tokenizes the whole file; `codeEditor/shikiDecorations.ts` = the per-view `ViewPlugin`→`DecorationSet` bridge that owns one engine per editor, because `@shikijs/codemirror` does not exist). The 120k wall is gone; `MAX_HIGHLIGHT_CHARS` (2 MB) is a memory guard on the document (above it the file renders plain), not a tokenization budget. Shared 12px/18px base theme (`codeEditor/editorTheme.ts`).
23
23
  - **`ReviewDiffView`** renders the vector-sync **pending-review** diff on `@codemirror/merge` (unified / split, read-only, accept/reject chunk controls disabled — Approve/Revert stays the banner's job) on the SAME base theme, so the editor↔diff transition never jumps.
@@ -49,23 +49,24 @@ Re-exported from `app/filesystem/ui/index.ts`:
49
49
  - `VectorHealthPanel` = **janitor** — the panel's role is "remove leftover records" (deleted past retention, orphaned chunks, duplicate report). Full per-source vector wipe lives on `SourceDeleteConfirm`, not here. `dryRun` default is `false` (clicking actually deletes); destructive buttons need a 2-click confirm; the Deleted stat card shows a `before → after (−Δ)` line after `GC`. Per-action explainer copy + tooltips spell out what each button does (GC / Repair are destructive; Deduplicate is read-only on the service side).
50
50
  - `VectorHealthPanel` is **lazy** — `SourcesPanel` renders it ONLY when the "Vector Health" section is expanded (`{healthOpen && …}`, the store's `vectorHealthOpen`, so a notification's `open-vector-health` nav can open it; a `contextResetPatch` key), never a CSS-only collapse, because the panel auto-fetches on mount; a mounted-but-hidden panel would fire the health request every time the Sources tab opens. On open it fetches the CHEAP count stats (`checkVectorHealth` → `/health`, count-only). The **Orphans** card and **Duplicate groups** count come from the EXPENSIVE on-demand scan (`scanVectorHealth` → `/health/scan`) behind an explicit **"Scan for problems"** button — they show `—` until the user runs it (and re-scan after a destructive action only if a scan was already loaded). This is the "ne lassítson 1 dolog mindent" rule: opening Sources triggers no collection walk.
51
51
  - `SourcesPanel` add form, the ROOT field — **derived from the declaration, not from `isDocker` and not from the kind name** (`lib/sourceKindForm.ts` `rootFieldMeta`, drift-tested against the real manifests in `test/ui/sourceKindForm.test.ts`). Every other field in that form already rendered from its declared `x-neuralis.ui`; `root` was the one hand-written, kind-BLIND exception, which is why the **Host Machine** kind used to show the `local` connector's `/mounts` volume picker, its `pnpm neuralis:mount add` callout and a `/mounts/...` placeholder while its own declaration said `Host root path` / `/home/you/projects` (warded-wren, closing the UI half of F-B5). The discriminator is the declared `x-neuralis.ui.control` on `root`: `container-path` gets the volume picker + callout + Browse; `host-path` gets none of them; a bare legacy `path` (or an absent control) is treated as container-plane, so an externally authored connector is unchanged. **Not `isHostPlaneKind`** — that is a NAME test whose own docstring says a future `ssh`/`smb` kind joins the plane by being added to its body, so a negative test on it would hand the container chrome to every future non-`local` kind. Browse is additionally gated on `canMount` (the shared `meetsRequires` mirror `FileTree` uses) and stays absent for host-plane roots at all times: `GET /sources/browse` readdirs in the SERVER process, the wrong machine for a broker-resolved host root — a host-plane root browser would need a broker-backed listing route, which does not exist.
52
+ - `SourcesPanel` add form, every OTHER field — renders from the closed control set in the same file (`fieldControl`): a text input (numeric for a number type), or `control: "select"`, whose options come from `x-neuralis.ui.optionsRoute` — a relative route in the DECLARING package (`kinds[].packageId`), read once per form through that package's own route client and quietly again after each burst of `devices` frames (the store's `deviceRevision`, bumped by `useFilesystemSSE`'s ONE `devices` subscription, which also re-reads the source summaries so a host source's liveness follows its device), label `name · status · confinement`, a `revoked` row never offered, any other non-`online` row dimmed but selectable (the host connector's `device` field lists `devices?scope=mine`). An `optionsRoute` that is absolute, carries a scheme, a `.`/`..` segment or a `%`/`\` is refused and the field renders as text with the reason. The list is UX only — the declaring package re-checks the value at create.
52
53
  - `SourcesPanel` description field — the VALUE is empty whenever the description is RESOLVED rather than stored (`description_effective`), and that is load-bearing: prefilling the value would freeze a copy of the package's wording into this source's config and stop it tracking the package. The resolved text therefore rides as the **placeholder** in edit state (with a one-line note saying an empty box keeps following the package) and as **labelled** text — `"… (from the package)"` — in the read state and the collapsed summary. **Both states must carry the label**: live-testing warded-wren caught it present in the read state and absent in the edit state, where the box simply looked empty; an opacity difference alone is not a label, because it does not survive the state that matters (editing).
53
54
 
54
55
  - `SourceDeleteConfirm` — inline rose-tinted popover under the row (no modal, matches the panel's "no overlay" pattern). Headline + one-line reminder that the host folder is not touched + `[ ] Purge vector index entries (~N point(s))` checkbox (default OFF — conservative) + Docker-bind-mount warning ribbon when `connection.config.hostRoot` is set, telling the user to run `neuralis mount remove <slug>` before restarting the container. On confirm, `SourcesPanel.handleDelete` calls `deleteSource({ purgeVectors })` and surfaces a transient `successMsg` ("Deleted "<source>" (vectors purged: N)").
55
56
  - **`SkillLauncher` coverage** (`@neuralis/package-system/client`, additive "+" launchers) — every filesystem function the LLM can drive has a visible composer-prefill trace. Clicking a launcher PREFILLS the chat composer with the skill (+ scripts) like a `/` slash pick; it never sends/streams. **No launcher states a feature list**: visibility is DERIVED from the host's per-caller skill catalog, so it cannot drift from each skill's own `SKILL.md` `requiredFeatures` and an unresolved catalog renders nothing (the gate named per row below is the SKILL's, documented here, not repeated in the JSX). `packageId` is the REGISTRY id (`@neuralis/brain-core`), matched as an exact pair with the skill id. The launchers resolve the `WorkspaceHostPort` from the `WorkspaceHostPortProvider` mounted once in `host/installFilesystemHostComponents.tsx` — without it they render `null` silently. Current placements:
56
- - `Toolbar` — `sync` (`drive.sync`, icon-`brain`, script-less) next to the Sources panel toggle, and `manage-files` (`drive.write`; `scripts: mkdir, copy, modify`) as the general file-ops handoff. The tree's CONTEXT MENU deliberately carries none: it is a keyboard-navigable click-to-act list with one uniform item model, and a composer-prefill affordance would need a bespoke item kind there.
57
- - `SourcesPanel` header — `manage-sources` (`drive.read`, attached-left to the `+ Source` button).
58
- - `SourcesPanel` per-source **Permissions** header — `manage-uri-policy` (`drive.policy` — no grant below the `admin` tier, grantable to any custom role) hands the URI-policy / path-permission edit for that source to the agent, alongside the `PolicyEditor`.
59
- - `FileTree` empty-state — `manage-files` (`drive.write`, label; `scripts: upload, modify`) to populate a source.
60
- - `GitControlPanel` header — `git` (`drive.read`) next to the refresh button. The `git` skill drives the SAME `/git/*` routes this panel does — its own SKILL.md says so — and the panel shipped with no way to reach it until 2026-07-28.
61
- - `PendingChangesPanel` toolbar — `manage-files` (`drive.write`) for bulk review; the per-row approve/revert buttons stay the primary path.
62
- - `VectorHealthPanel` actions row — `manage-sources` (`drive.read`). The index is per-SOURCE, so the agent-side equivalent of scan/repair is a source reindex plus a sync-status readout.
57
+ - `Toolbar` — `sync` (`sources.sync`, icon-`brain`, script-less) next to the Sources panel toggle, and `manage-files` (`files.write`; `scripts: mkdir, copy, modify`) as the general file-ops handoff. The tree's CONTEXT MENU deliberately carries none: it is a keyboard-navigable click-to-act list with one uniform item model, and a composer-prefill affordance would need a bespoke item kind there.
58
+ - `SourcesPanel` header — `manage-sources` (`files.read`, attached-left to the `+ Source` button).
59
+ - `SourcesPanel` per-source **Permissions** header — `manage-uri-policy` (`sources.policy` — no grant below the `admin` tier, grantable to any custom role) hands the URI-policy / path-permission edit for that source to the agent, alongside the `PolicyEditor`.
60
+ - `FileTree` empty-state — `manage-files` (`files.write`, label; `scripts: upload, modify`) to populate a source.
61
+ - `GitControlPanel` header — `git` (`files.read`) next to the refresh button. The `git` skill drives the SAME `/git/*` routes this panel does — its own SKILL.md says so — and the panel shipped with no way to reach it until 2026-07-28.
62
+ - `PendingChangesPanel` toolbar — `manage-files` (`files.write`) for bulk review; the per-row approve/revert buttons stay the primary path.
63
+ - `VectorHealthPanel` actions row — `manage-sources` (`files.read`). The index is per-SOURCE, so the agent-side equivalent of scan/repair is a source reindex plus a sync-status readout.
63
64
  - `PackagesPanel` header — `create-package` (label, script-less) hands the whole authoring flow (scaffold, manifest, tools, surfaces, connectors, build — including the WASM pipeline) to the agent; additive to the per-row `Build` button. This is the ONE launcher that narrows further, with `alsoRequires={["packages.author"]}`, even though the SKILL ships ungated (so it stays importable): it scaffolds into `packages://`, which needs write + install/build capability the default `member` role does not hold, so showing it to a caller whose first action would be denied is worse UX than hiding it. `alsoRequires` can only ever hide a launcher, never reveal one, so this cannot become a disclosure. Deliberate — do not "align" it with the skill frontmatter.
64
65
 
65
66
  ## How it plugs into the package-system kernel
66
67
 
67
68
  - Every component consumes the `useFilesystemHost()` context for agent state, file marks, accent colors, SSE URLs, and optional content renderers (the host can pass a markdown / code-highlight component).
68
- - API calls go through the `api/` module which wraps the kernel `PackageApi` — the brain-core route layer enforces `drive.read`, `drive.write`, `drive.search`, `drive.mount` features and source-scope checks server-side.
69
+ - API calls go through the `api/` module which wraps the kernel `PackageApi` — the brain-core route layer enforces `files.read`, `files.write`, `files.search`, `sources.mount` features and source-scope checks server-side.
69
70
  - `PackagesPanel` calls host-side routes (`/api/packages/runtime`, `/api/projects/:id/packages/:slug/{build,trust,access-feature}`) through `api/projectPackages.ts`. The widget surfaces project-package operations here because they are project-scoped — they are NOT brain-core package routes.
70
71
 
71
72
  ### `recognizesPackages` source toggle + package access-feature governance
@@ -76,7 +77,7 @@ Re-exported from `app/filesystem/ui/index.ts`:
76
77
  ## Security & scope
77
78
 
78
79
  - Components never display raw `_meta` payloads (runtime billing telemetry); only `content` and `structuredContent` are rendered.
79
- - Cross-agent file marks and source observations are gated server-side by `filesystem.observeScoped` — the UI surfaces what the route returns and never tries to bypass the gate.
80
+ - Cross-agent file marks and source observations are gated server-side by `files.readOthers` — the UI surfaces what the route returns and never tries to bypass the gate.
80
81
  - `PackagesPanel.setPackageTrust` / `setPackageAccessFeature` write to the platform zone via host routes; both require owner / admin role (`canManagePackages`). The access-feature override is restrict-only — it can attach a required feature, never grant access. Owner-only UI controls (the `recognizesPackages` toggle, the access-feature override) hide for non-owners via `host.currentRole`, but the server route is the enforcement floor.
81
82
 
82
83
  ## VS Code-style file management (context menu · drag · inline edit)
@@ -97,7 +98,7 @@ agent share the same server path, deny-by-default, server re-gated):
97
98
  target (rename = single file OR folder; New Folder disabled on `brain://`; Paste
98
99
  needs a clipboard; Re-embed… = a single file or folder, not on `brain://` — its
99
100
  content lives in the vector store, there is no file to read again — and only
100
- for a `drive.sync` holder, the reindex route's gate, mirrored hide-only).
101
+ for a `sources.sync` holder, the reindex route's gate, mirrored hide-only).
101
102
  - **Multi-select** — Ctrl/⌘-click toggles, Shift-click ranges over the flat visible
102
103
  list; bulk delete/cut/copy fan out client-side, each server-gated.
103
104
  - **Inline rename + inline New File / New Folder** — an `<input>` in the tree row
@@ -156,11 +157,11 @@ EPHEMERAL (never persisted).
156
157
  - **The virtualizer's `estimateSize` / `getItemKey` are STABLE `useCallback`s reading the row list through a ref** — `getItemKey` is the only memo dependency of virtual-core's measurement options, so an inline arrow (a new identity per render) invalidated the measurement cache and re-ran the estimator for every unmeasured row on each render. No `measureElement` override: the default measurer already reads `offsetHeight`.
157
158
  - **The tab strip keeps the ACTIVE tab in view and scrolls on a vertical wheel.** The strip lives in `FileEditor`'s `data-tab-scroller` div, whose scrollbar is an invisible overlay until hover. Measured 2026-09-04 in a 536 px widget with a 327 px sidebar: the strip is 165 px, and a third opened tab was ACTIVE yet fully outside it, with nothing on screen saying the strip scrolls. `EditorTabBar` applies two exported pure rules — `tabScrollDelta` (the active tab's overflow, applied to the scroller's `scrollLeft`; never `scrollIntoView`, which would also scroll the stage) and `wheelToHorizontal` (a native non-passive `wheel` listener — React's `onWheel` is passive and cannot `preventDefault`). The panel CLIPS the strip: a tab rect past the panel edge is geometry, not paint (`elementFromPoint` there hits the neighbour), so the defect was the invisible active tab, never an overflow.
158
159
  - **A tab's content is fetched by the STORE, never by a widget effect.** `rehydrateActiveTab` runs from `loadList` (a same-context mount — page reload) and from `setActiveFile` (a tab click), against the store's own active pair and the read STATE (`hasCompletedRead`: a string baseline or an error — a folder listing materialises content-less nodes, so FileNode presence means nothing). The effect it replaced decided from its render closure and re-opened the PREVIOUS agent's tab under the new one after a switch, and skipped the read for a listed-but-unread sibling tab ("Empty file" on click). While a read is pending the editor's placeholder says "Loading…"; "Empty file" is reserved for a completed read. The whole widget state is per context: a switch stashes the outgoing `(user, project, agent)` context's tree, tabs, caches and panels and restores the incoming one's (`contextStateByKey`, `../store/README.md`), so switching back finds the tree and the editor where they were.
159
- - **Root-listing failure is visible.** `loadList`'s catch sets `snapshotError`; the tree renders it as a rose banner with Retry (re-runs the root listing) and Dismiss, in place of "Empty Drive" when no source is known yet. A listing that answers clears it.
160
+ - **Root-listing failure is visible.** `loadList`'s catch sets `snapshotError`; the tree renders it as a rose banner with Retry (re-runs the root listing) and Dismiss, in place of "No files yet" when no source is known yet. A listing that answers clears it.
160
161
  - **Overlays.** `position: fixed` inside the widget is panel-anchored and panel-clipped at `dim` and `opaque` (the panel's `backdrop-filter`, load-bearing) and viewport-level only at `transparent` — so every toast and confirm dialog here (`PackagesPanel`'s toast + build log, `GitControlPanel`'s destructive-op confirm) is `createPortal`ed to `document.body` like `TreeDeleteConfirm`. The three header-picker click-away catchers in `GitControlPanel` are NOT scrims any more: their popovers stay inside the panel's isolated stacking context, so a body-level scrim would paint over them — they are one `useClickAway` capture-phase `mousedown` listener on the picker's zone, which also closes the picker on a click outside the widget (the clipped scrim never could).
161
162
  - The editor diff is `whitespace-pre` + horizontal scroll (long lines scroll), NOT wrapping — matching the chat card (a deliberate change from the old `<table>` `whitespace-pre-wrap break-all`).
162
163
  - **Getting the pre-edit baseline: ONE current read.** The canonical way for the file editor and chat card modify diff is a single `readFiles({ agentId, files:[uri] })` whose response carries `version.baseline.content` (the pre-edit snapshot, locked while `pending_review`) — parse it with `parseFilePrev` (`store/helpers.ts`). The pending baseline is **cumulative** (locked to pre-FIRST-edit); approval preserves the stored baseline, so ONE read gives the editor both sides of the diff. `src/routes/read.ts` used to DROP `read_version`/`line_start`/`line_end`/`include_ids` from the body, which made a `read_version:'baseline'` request over the route silently return CURRENT; it now forwards all four, so that request works — but it still answers with the baseline ALONE, and the diff needs both sides. Keep the single current read for those surfaces; Changes instead uses the bounded pending preview, whose receipt fences subsequent resolution.
163
- - The file-tree sidebar is a **fixed-pixel** column (mirrors the admin widget), not a percentage split: resizing the outer widget never reflows the tree. Its width is drag-resizable via a custom pointer-capture handle and persisted in the store / `localStorage` (`sidebarWidth`, min `FILESYSTEM_SIDEBAR_MIN_WIDTH = 120`px). The drag upper bound is the live container width, so the tree can fully cover the editor (tree + chat only). The `sidebarOpen` toggle collapses the column to width `0`.
164
+ - The file-tree sidebar is a **fixed-pixel** column (mirrors the admin widget), not a percentage split: resizing the outer widget never reflows the tree until the widget edge reaches it — then the rendered width is capped at the container (`max-width`, CSS only) and grows back to the stored width on widening, and a drag starts from the rendered width; the editor column clips (`overflow-hidden`), so at zero width its empty state and line/char metrics never paint over the tree. Its width is drag-resizable via a custom pointer-capture handle and persisted in the store / `localStorage` (`sidebarWidth`, min `FILESYSTEM_SIDEBAR_MIN_WIDTH = 120`px). The drag upper bound is the live container width, so the tree can fully cover the editor (tree + chat only). The `sidebarOpen` toggle collapses the column to width `0`.
164
165
  - `FilesystemWidget` triggers `loadList()` when the agent, project or user id changes (the user comes from `port.useUserId()` through the install entry and is part of the context key); the in-flight dedupe in the list slice prevents thrash during rapid switches.
165
166
 
166
167
  ## Tests