sh3-core 0.22.5 → 0.24.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 (89) hide show
  1. package/dist/Sh3.svelte +4 -4
  2. package/dist/actions/listActive.js +1 -0
  3. package/dist/actions/listActive.test.js +13 -0
  4. package/dist/actions/types.d.ts +12 -0
  5. package/dist/api.d.ts +3 -1
  6. package/dist/api.js +3 -1
  7. package/dist/app/admin/adminApp.js +2 -0
  8. package/dist/app/admin/adminShard.svelte.js +1 -0
  9. package/dist/app/store/StoreView.svelte +1 -1
  10. package/dist/app/store/storeApp.js +3 -1
  11. package/dist/app/store/storeShard.svelte.js +1 -0
  12. package/dist/app-appearance/appearanceShard.svelte.js +1 -0
  13. package/dist/apps/lifecycle.js +22 -10
  14. package/dist/apps/lifecycle.test.js +53 -1
  15. package/dist/apps/types.d.ts +9 -0
  16. package/dist/chrome/CompactChrome.svelte +11 -7
  17. package/dist/chrome/MenuSheet.svelte +19 -6
  18. package/dist/contributions/contextSource.d.ts +48 -0
  19. package/dist/contributions/contextSource.js +21 -0
  20. package/dist/createShell.js +40 -0
  21. package/dist/documents/picker-api.test.js +40 -0
  22. package/dist/documents/picker-primitive.d.ts +37 -8
  23. package/dist/documents/picker-primitive.js +5 -13
  24. package/dist/host.js +30 -7
  25. package/dist/layout/slotHostPool.svelte.d.ts +11 -0
  26. package/dist/layout/slotHostPool.svelte.js +41 -17
  27. package/dist/layout/slotHostPool.test.js +45 -1
  28. package/dist/layouts-shard/layoutsShard.svelte.js +1 -0
  29. package/dist/overlays/OverlayRoots.svelte +15 -4
  30. package/dist/overlays/__test__/OverlayBindHarness.svelte +20 -0
  31. package/dist/overlays/__test__/OverlayBindHarness.svelte.d.ts +3 -0
  32. package/dist/overlays/float-compact-bind.svelte.test.d.ts +1 -0
  33. package/dist/overlays/float-compact-bind.svelte.test.js +51 -0
  34. package/dist/overlays/modal.js +3 -0
  35. package/dist/overlays/modal.test.js +45 -0
  36. package/dist/overlays/types.d.ts +9 -0
  37. package/dist/primitives/widgets/DocumentFilePicker.svelte +9 -7
  38. package/dist/primitives/widgets/DocumentFilePicker.svelte.d.ts +44 -27
  39. package/dist/primitives/widgets/ShardPicker.svelte +38 -0
  40. package/dist/primitives/widgets/ShardPicker.svelte.d.ts +9 -0
  41. package/dist/primitives/widgets/_DocumentBrowser.svelte +15 -7
  42. package/dist/primitives/widgets/_DocumentBrowser.svelte.d.ts +2 -0
  43. package/dist/projects/scope-gate.d.ts +4 -0
  44. package/dist/projects/scope-gate.js +51 -0
  45. package/dist/projects/scope-gate.test.d.ts +1 -0
  46. package/dist/projects/scope-gate.test.js +92 -0
  47. package/dist/projects-shard/ProjectManage.svelte +42 -2
  48. package/dist/projects-shard/ProjectManage.svelte.test.js +10 -9
  49. package/dist/projects-shard/projectsApi.d.ts +3 -2
  50. package/dist/projects-shard/projectsApi.test.js +1 -1
  51. package/dist/projects-shard/projectsShard.svelte.js +1 -0
  52. package/dist/runtime/runVerb.d.ts +9 -0
  53. package/dist/runtime/runVerb.js +4 -4
  54. package/dist/runtime/runVerb.test.js +29 -0
  55. package/dist/sh3Api/headless.d.ts +7 -0
  56. package/dist/sh3Api/headless.js +3 -1
  57. package/dist/sh3Api/headless.svelte.test.js +42 -0
  58. package/dist/sh3core-shard/Sh3Home.svelte +3 -4
  59. package/dist/sh3core-shard/sh3coreShard.svelte.js +1 -0
  60. package/dist/shards/lifecycle.svelte.d.ts +8 -2
  61. package/dist/shards/lifecycle.svelte.js +65 -7
  62. package/dist/shards/lifecycle.test.js +110 -1
  63. package/dist/shards/types.d.ts +13 -0
  64. package/dist/shell-shard/Terminal.svelte +1 -4
  65. package/dist/shell-shard/Terminal.svelte.d.ts +0 -2
  66. package/dist/shell-shard/dispatch.d.ts +0 -2
  67. package/dist/shell-shard/dispatch.js +0 -2
  68. package/dist/shell-shard/display-cwd.test.js +4 -4
  69. package/dist/shell-shard/manifest.js +1 -0
  70. package/dist/shell-shard/shellShard.svelte.d.ts +1 -1
  71. package/dist/shell-shard/shellShard.svelte.js +9 -4
  72. package/dist/shell-shard/verbs/cat.js +3 -3
  73. package/dist/shell-shard/verbs/cat.test.js +1 -2
  74. package/dist/shell-shard/verbs/ls.js +2 -2
  75. package/dist/shell-shard/verbs/ls.test.js +1 -2
  76. package/dist/shell-shard/verbs/mkdir.js +3 -3
  77. package/dist/shell-shard/verbs/mkdir.test.js +1 -2
  78. package/dist/shell-shard/verbs/mv.js +3 -3
  79. package/dist/shell-shard/verbs/mv.test.js +1 -2
  80. package/dist/shell-shard/verbs/rm.js +3 -3
  81. package/dist/shell-shard/verbs/rm.test.js +1 -2
  82. package/dist/shell-shard/verbs/xfer.js +5 -5
  83. package/dist/shell-shard/verbs/xfer.test.js +2 -2
  84. package/dist/transport/apiFetch.js +21 -3
  85. package/dist/transport/apiFetch.test.js +63 -0
  86. package/dist/verbs/types.d.ts +10 -2
  87. package/dist/version.d.ts +1 -1
  88. package/dist/version.js +1 -1
  89. package/package.json +1 -1
package/dist/Sh3.svelte CHANGED
@@ -79,19 +79,19 @@
79
79
 
80
80
  const edgePointers = new Set<number>();
81
81
 
82
- function onPointerDown(e: PointerEvent): void {
82
+ const onPointerDown = (e: PointerEvent): void => {
83
83
  const rect = el.getBoundingClientRect();
84
84
  const local = e.clientX - rect.left;
85
85
  if (local >= EDGE_PX && local <= rect.width - EDGE_PX) return;
86
86
  const granted = claim(e.pointerId, { ownerId: 'sh3:edge', axis: 'x', priority: 'edge', depth: 0 });
87
87
  if (granted) edgePointers.add(e.pointerId);
88
- }
88
+ };
89
89
 
90
- function onPointerEnd(e: PointerEvent): void {
90
+ const onPointerEnd = (e: PointerEvent): void => {
91
91
  if (!edgePointers.has(e.pointerId)) return;
92
92
  revoke(e.pointerId, 'sh3:edge');
93
93
  edgePointers.delete(e.pointerId);
94
- }
94
+ };
95
95
 
96
96
  el.addEventListener('pointerdown', onPointerDown);
97
97
  el.addEventListener('pointerup', onPointerEnd);
@@ -50,6 +50,7 @@ export function listActionsFromEntries(entries, state) {
50
50
  ownerShardId: entry.ownerShardId,
51
51
  paletteItem: entry.action.paletteItem !== false,
52
52
  contextItem: entry.action.contextItem !== false,
53
+ aiInvocable: entry.action.aiInvocable,
53
54
  submenu: entry.action.submenu,
54
55
  submenuOf: entry.action.submenuOf,
55
56
  active,
@@ -57,6 +57,19 @@ describe('listActiveFromEntries', () => {
57
57
  expect(out[0].paletteItem).toBe(false);
58
58
  expect(out[0].contextItem).toBe(true); // defaults to true
59
59
  });
60
+ it('propagates aiInvocable from the registered action, preserving undefined', () => {
61
+ const entries = [
62
+ mkEntry({ id: 'opt-out', scope: 'home', aiInvocable: false }),
63
+ mkEntry({ id: 'opt-in', scope: 'home', aiInvocable: true }),
64
+ mkEntry({ id: 'unset', scope: 'home' }),
65
+ ];
66
+ const out = listActiveFromEntries(entries, mkState());
67
+ const byId = Object.fromEntries(out.map((d) => [d.id, d]));
68
+ expect(byId['opt-out'].aiInvocable).toBe(false);
69
+ expect(byId['opt-in'].aiInvocable).toBe(true);
70
+ // `undefined` is significant — consumers filter `=== false`, not falsy.
71
+ expect(byId['unset'].aiInvocable).toBeUndefined();
72
+ });
60
73
  it('dedupes by action id', () => {
61
74
  const entries = [
62
75
  mkEntry({ id: 'dup', scope: 'home' }, 'shard.a'),
@@ -14,6 +14,14 @@ export interface Action {
14
14
  scope: ActionScope;
15
15
  contextItem?: boolean;
16
16
  paletteItem?: boolean;
17
+ /**
18
+ * Opt-out flag for AI tool catalogs. Set `false` to hide this action
19
+ * from LLM-facing surfaces (e.g. `sh3-ai`'s action→tool adapter) — use
20
+ * for palette-only actions that need a UI picker and are meaningless
21
+ * to invoke programmatically. Defaults to `undefined` (catalog
22
+ * inclusion decided by the consumer).
23
+ */
24
+ aiInvocable?: boolean;
17
25
  /**
18
26
  * Optional menu container id. When set and the active app's declared
19
27
  * (or canonical fallback) menu list contains this id, the action
@@ -148,6 +156,8 @@ export interface ActiveActionDescriptor {
148
156
  ownerShardId: string;
149
157
  paletteItem: boolean;
150
158
  contextItem: boolean;
159
+ /** Carried through from the registered action; see `Action.aiInvocable`. */
160
+ aiInvocable?: boolean;
151
161
  /** True when this action is a submenu parent (children opened by drill). */
152
162
  submenu?: true;
153
163
  /** Parent action id when this action is a submenu child. */
@@ -187,6 +197,8 @@ export interface ActionDescriptor {
187
197
  ownerShardId: string;
188
198
  paletteItem: boolean;
189
199
  contextItem: boolean;
200
+ /** Carried through from the registered action; see `Action.aiInvocable`. */
201
+ aiInvocable?: boolean;
190
202
  /** True when this action is a submenu parent (children opened by drill). */
191
203
  submenu?: true;
192
204
  /** Parent action id when this action is a submenu child. */
package/dist/api.d.ts CHANGED
@@ -38,7 +38,7 @@ export type { ConflictItem, ConflictBranch as ConflictManagerBranch, ResolveOpti
38
38
  export { CONFLICT_RENDERER_POINT, ConflictPermissionError, ConflictSessionOrphanedError, } from './conflicts/api';
39
39
  export type { ColorPickOptions, ColorContribution, ColorApi, } from './color/api';
40
40
  export { COLOR_PICKER_POINT } from './color/api';
41
- export { registeredShards, activeShards, erroredShards } from './shards/lifecycle.svelte';
41
+ export { registeredShards, activeShards, erroredShards, listRegisteredShards } from './shards/lifecycle.svelte';
42
42
  export type { ShardErrorEntry } from './shards/lifecycle.svelte';
43
43
  export type { RegistryIndex, PackageEntry, PackageVersion, RequiredDependency, InstalledPackage, InstallResult, PackageMeta, RemoteInstallRequest, } from './registry/types';
44
44
  export type { ResolvedPackage } from './registry/client';
@@ -65,6 +65,8 @@ export type { RunVerbOpts, RunVerbResult } from './runtime';
65
65
  export { registerShellMode } from './shell-shard/registerShellMode';
66
66
  export type { ShellModeDescriptor, ShellModeOutput, ShellModeDispatchHandler, ShellModeDispatchInput, ShellModeRunsOn, RichEntryHandle, StreamHandle, } from './shell-shard/contract';
67
67
  export { SHELL_MODE_CONTRIBUTION_POINT } from './shell-shard/contract';
68
+ export { CONTEXT_SOURCE_POINT_ID } from './contributions/contextSource';
69
+ export type { ContextSource } from './contributions/contextSource';
68
70
  export type { GestureRegistry, GestureHandle } from './gestures';
69
71
  export type { GestureType, Axis, ClaimPriority, ClaimEntry, PanEvent, ScrollEvent, ButtonEvent, PanOptions, DragOptions, ButtonOptions, ScrollOptions, } from './gestures/types';
70
72
  export { VERSION } from './version';
package/dist/api.js CHANGED
@@ -42,7 +42,7 @@ export { COLOR_PICKER_POINT } from './color/api';
42
42
  // and tooling shards that need to visualize framework state. Phase 9
43
43
  // addition: diagnostic used to reach `activate.svelte` directly via $lib;
44
44
  // the package boundary requires routing through the public surface.
45
- export { registeredShards, activeShards, erroredShards } from './shards/lifecycle.svelte';
45
+ export { registeredShards, activeShards, erroredShards, listRegisteredShards } from './shards/lifecycle.svelte';
46
46
  export { fetchRegistries, fetchArchive, buildPackageMeta } from './registry/client';
47
47
  export { validateRegistryIndex } from './registry/schema';
48
48
  // Key mint/revoke types — client shards that declare `keys:mint` get ctx.keys.
@@ -65,6 +65,8 @@ export { runVerbProgrammatic } from './runtime';
65
65
  // Sh3 mode contributions (external shards extend the sh3 with new modes).
66
66
  export { registerShellMode } from './shell-shard/registerShellMode';
67
67
  export { SHELL_MODE_CONTRIBUTION_POINT } from './shell-shard/contract';
68
+ // Context-source contributions (publishers register entries; consumers like sh3-ai pick them up).
69
+ export { CONTEXT_SOURCE_POINT_ID } from './contributions/contextSource';
68
70
  // Package version.
69
71
  export { VERSION } from './version';
70
72
  // Framework shard IDs — shards that are always present (built-in to sh3-core).
@@ -8,6 +8,8 @@ export const adminApp = {
8
8
  manifest: {
9
9
  id: 'sh3-admin-app',
10
10
  label: 'Admin',
11
+ icon: 'cpu',
12
+ color: '#D16F19',
11
13
  version: VERSION,
12
14
  requiredShards: ['sh3-admin'],
13
15
  layoutVersion: 1,
@@ -24,6 +24,7 @@ export const adminShard = {
24
24
  id: 'sh3-admin',
25
25
  label: 'Admin',
26
26
  version: VERSION,
27
+ kind: 'system',
27
28
  permissions: ['documents:mount'],
28
29
  views: [
29
30
  { id: 'sh3-admin:users', label: 'Users' },
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { storeContext } from './storeShard.svelte';
10
10
  import { fetchArchive, buildPackageMeta } from '../../registry/client';
11
- import { readFileFromArchive, readManifestFromArchive } from '../../registry/archive';
11
+ import { readFileFromArchive } from '../../registry/archive';
12
12
  import { installPackage } from '../../registry/installer';
13
13
  import { loadBundleModule, type LoadedBundle } from '../../registry/loader';
14
14
  import { extractBundlePermissions } from '../../registry/permission-descriptions';
@@ -10,7 +10,9 @@ import { VERSION } from '../../version';
10
10
  export const storeApp = {
11
11
  manifest: {
12
12
  id: 'sh3-store-app',
13
- label: 'Package Store',
13
+ label: 'Package Manager',
14
+ icon: 'file-archive',
15
+ color: '#D16F19',
14
16
  version: VERSION,
15
17
  requiredShards: ['sh3-store'],
16
18
  layoutVersion: 1,
@@ -65,6 +65,7 @@ export const storeShard = {
65
65
  id: 'sh3-store',
66
66
  label: 'Package Store',
67
67
  version: VERSION,
68
+ kind: 'system',
68
69
  views: [
69
70
  { id: 'sh3-store:browse', label: 'Store' },
70
71
  ],
@@ -47,6 +47,7 @@ export const appearanceShard = {
47
47
  id: '__app-appearance__',
48
48
  label: 'App Appearance',
49
49
  version: VERSION,
50
+ kind: 'system',
50
51
  views: [],
51
52
  },
52
53
  register(ctx) {
@@ -11,11 +11,13 @@
11
11
  * `{ id: string | null }`. Writing happens on launch (id) and on
12
12
  * return-to-home (null). Boot reads it to decide whether to auto-launch.
13
13
  */
14
+ import { flushSync } from 'svelte';
14
15
  import { createStateZones } from '../state/zones.svelte';
15
16
  import { createGestureRegistry } from '../gestures';
16
17
  import { registeredShards, } from '../shards/lifecycle.svelte';
17
18
  import { shardEntries, runAppActivate, runAppDeactivate, registerAllShards, erroredShards } from '../shards/lifecycle.svelte';
18
19
  import { attachApp, acquireAppSlotHolds, detachApp, switchToApp, switchToHome, getActiveAppTree, } from '../layout/store.svelte';
20
+ import { flushPendingDestroys } from '../layout/slotHostPool.svelte';
19
21
  import { activeApp, breadcrumbApp, getRegisteredApp, registeredApps } from './registry.svelte';
20
22
  import { createZoneManager } from '../state/manage';
21
23
  import { PERMISSION_STATE_MANAGE } from '../state/types';
@@ -227,19 +229,29 @@ export function unloadApp(id, skipSwitchToHome = false) {
227
229
  if (!app)
228
230
  return;
229
231
  void ((_a = app.deactivate) === null || _a === void 0 ? void 0 : _a.call(app));
230
- // v3: call runAppDeactivate for every required shard. The shard stays
231
- // active (its register() output is intact); only its per-app bindings
232
- // and per-app contribution registrations are torn down.
233
- for (const shardId of app.manifest.requiredShards) {
234
- void runAppDeactivate(shardId, id);
235
- }
236
- // Detach layout (releases the refcount holds; pool cleanup runs on
237
- // the next microtask for any slots that no longer have a renderer).
238
- // Switch to home first so LayoutRenderer stops reading the app's
239
- // tree before detachApp drops its references.
232
+ // Teardown order is load-bearing. Views must be fully unmounted before
233
+ // any shard's onAppDeactivate runs, so shards can safely close stores /
234
+ // nullify reactive state in onAppDeactivate without tripping leaked
235
+ // $derived references in still-mounted views (the leaked-pool bug).
236
+ //
237
+ // 1) switchToHome flips the rendered root so LayoutRenderer stops
238
+ // reading the app's tree. SlotContainer cleanups are scheduled.
239
+ // 2) flushSync forces Svelte to run those cleanups now (drops the
240
+ // renderer's refcount on every app slot).
241
+ // 3) detachApp releases the per-app refcount holds. Pool entries for
242
+ // the app's slots are now at refcount 0 and queued in pendingDestroy.
243
+ // 4) flushPendingDestroys synchronously runs the destroy body for
244
+ // those entries — the Svelte components for the app's views are
245
+ // unmounted now, not on a deferred microtask.
246
+ // 5) Only now do we call runAppDeactivate on each required shard.
240
247
  if (!skipSwitchToHome)
241
248
  switchToHome();
249
+ flushSync();
242
250
  detachApp();
251
+ flushPendingDestroys();
252
+ for (const shardId of app.manifest.requiredShards) {
253
+ void runAppDeactivate(shardId, id);
254
+ }
243
255
  activeApp.id = null;
244
256
  setActiveApp(null, new Set());
245
257
  clearSelectionUnconditional();
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect, beforeEach, vi } from 'vitest';
2
2
  import { resetFramework } from '../__test__/reset';
3
3
  import { makeApp, makeShard, makeAppManifest, makeShardManifest, makeTabsNode, makeTabEntry, makeSlotNode, makeTree, } from '../__test__/fixtures';
4
- import { launchApp, returnToHome, unregisterApp } from './lifecycle';
4
+ import { launchApp, returnToHome, unloadApp, unregisterApp } from './lifecycle';
5
5
  import { registerApp } from './registry.svelte';
6
6
  import { registerShard } from '../shards/lifecycle.svelte';
7
7
  import { presetManager } from '../overlays/presets';
@@ -703,6 +703,58 @@ describe('unloadApp — onAppDeactivate hook', () => {
703
703
  expect(deactivated).toEqual(['deact-app']);
704
704
  });
705
705
  });
706
+ describe('unloadApp — view unmount happens before onAppDeactivate', () => {
707
+ beforeEach(resetFramework);
708
+ it('unmounts all of the app\'s pooled views before any shard\'s onAppDeactivate fires', async () => {
709
+ const order = [];
710
+ const shard = makeShard({
711
+ manifest: makeShardManifest({ id: 'order-shard' }),
712
+ register() { },
713
+ onAppDeactivate() { order.push('onAppDeactivate'); },
714
+ });
715
+ registerShard(shard);
716
+ // Register two views so the assertion covers an active slot AND an
717
+ // inactive-but-held slot. Both must unmount before deactivate.
718
+ registerView('order:view-a', {
719
+ mount: () => ({ unmount: () => order.push('view-a:unmount') }),
720
+ });
721
+ registerView('order:view-b', {
722
+ mount: () => ({ unmount: () => order.push('view-b:unmount') }),
723
+ });
724
+ const app = makeApp({
725
+ manifest: makeAppManifest({ id: 'order-app', requiredShards: ['order-shard'] }),
726
+ initialLayout: [
727
+ {
728
+ name: 'default',
729
+ tree: makeTree({
730
+ type: 'split',
731
+ direction: 'horizontal',
732
+ children: [
733
+ makeSlotNode('a-slot', 'order:view-a'),
734
+ makeSlotNode('b-slot', 'order:view-b'),
735
+ ],
736
+ sizes: [0.5, 0.5],
737
+ }),
738
+ },
739
+ ],
740
+ });
741
+ registerApp(app);
742
+ await launchApp('order-app');
743
+ // Let the mount microtasks settle so factories have run.
744
+ await Promise.resolve();
745
+ await Promise.resolve();
746
+ unloadApp('order-app');
747
+ // Both views must have unmounted BEFORE onAppDeactivate ran.
748
+ const deactivateIdx = order.indexOf('onAppDeactivate');
749
+ const unmountAIdx = order.indexOf('view-a:unmount');
750
+ const unmountBIdx = order.indexOf('view-b:unmount');
751
+ expect(deactivateIdx).toBeGreaterThanOrEqual(0);
752
+ expect(unmountAIdx).toBeGreaterThanOrEqual(0);
753
+ expect(unmountBIdx).toBeGreaterThanOrEqual(0);
754
+ expect(unmountAIdx).toBeLessThan(deactivateIdx);
755
+ expect(unmountBIdx).toBeLessThan(deactivateIdx);
756
+ });
757
+ });
706
758
  describe('launchApp — onLayoutWillRestore / onLayoutRestored hooks', () => {
707
759
  beforeEach(resetFramework);
708
760
  it('calls onLayoutWillRestore before slot acquisition and onLayoutRestored after switchToApp', async () => {
@@ -46,6 +46,15 @@ export interface AppManifest {
46
46
  * starting shards (diagnostic, __sh3core__) stay running.
47
47
  */
48
48
  requiredShards: string[];
49
+ /**
50
+ * Optional list of shard ids that ship in the same bundle as this app
51
+ * (combo packages). Mirrors the server-side `project-allowlist`
52
+ * middleware's resolution rule — these are part of the project-scope
53
+ * closure when the app is allowlisted, but they are NOT activated
54
+ * automatically (use `requiredShards` for that). Defaults to empty
55
+ * when omitted.
56
+ */
57
+ bundledShards?: string[];
49
58
  /**
50
59
  * Bump to invalidate persisted layouts. On launch, a persisted blob
51
60
  * whose version doesn't match this is discarded and `initialLayout`
@@ -89,16 +89,20 @@
89
89
  function toggleFloatsSheet() {
90
90
  if (floatsOpen) return;
91
91
  floatsOpen = true;
92
- const handle = sh3.modal.open(
92
+ sh3.modal.open(
93
93
  FloatsSheet,
94
94
  {},
95
- { dismissOnBackdrop: true, boxStyle: 'max-width: 320px;' },
95
+ {
96
+ dismissOnBackdrop: true,
97
+ boxStyle: 'max-width: 320px;',
98
+ // Reset on every dismissal path. Wrapping handle.close after
99
+ // open() returns doesn't work — ModalFrame captured the close
100
+ // reference at mount and bypasses any later monkey-patch.
101
+ onClose: () => {
102
+ floatsOpen = false;
103
+ },
104
+ },
96
105
  );
97
- const origClose = handle.close;
98
- handle.close = () => {
99
- origClose();
100
- floatsOpen = false;
101
- };
102
106
  }
103
107
 
104
108
  function toggleDrawer(anchor: DrawerAnchor) {
@@ -15,7 +15,6 @@
15
15
  resolveMenuContainers,
16
16
  resolveMenuItems,
17
17
  resolveSubmenuItems,
18
- type MenuBarItem,
19
18
  } from '../actions/menuBarModel';
20
19
  import { listActions } from '../actions/registry';
21
20
  import { getLiveDispatcherState } from '../actions/state.svelte';
@@ -54,7 +53,16 @@
54
53
  }
55
54
 
56
55
  // --- derived items for current nav level ---------------------------
57
- const currentItems = $derived.by(() => {
56
+ interface SheetItem {
57
+ id: string;
58
+ label: string;
59
+ isContainer: boolean;
60
+ isSubmenu: boolean;
61
+ shortcut: string | null;
62
+ disabled: boolean;
63
+ }
64
+
65
+ const currentItems = $derived.by<SheetItem[]>(() => {
58
66
  const entries = listActions();
59
67
  const nav = currentNav;
60
68
 
@@ -65,7 +73,10 @@
65
73
  .map((c) => ({
66
74
  id: c.id,
67
75
  label: c.label,
68
- isContainer: true as const,
76
+ isContainer: true,
77
+ isSubmenu: false,
78
+ shortcut: null,
79
+ disabled: false,
69
80
  }));
70
81
  }
71
82
 
@@ -74,8 +85,9 @@
74
85
  return items.map((item) => ({
75
86
  id: item.id,
76
87
  label: item.label,
77
- shortcut: item.shortcut,
88
+ isContainer: false,
78
89
  isSubmenu: item.submenu === true,
90
+ shortcut: item.shortcut,
79
91
  disabled: item.disabled,
80
92
  }));
81
93
  }
@@ -85,14 +97,15 @@
85
97
  return items.map((item) => ({
86
98
  id: item.id,
87
99
  label: item.label,
100
+ isContainer: false,
101
+ isSubmenu: item.submenu === true,
88
102
  shortcut: item.shortcut,
89
103
  disabled: item.disabled,
90
- isSubmenu: item.submenu === true,
91
104
  }));
92
105
  });
93
106
 
94
107
  // --- actions --------------------------------------------------------
95
- function handleTap(entry: { id: string; isContainer?: boolean; isSubmenu?: boolean }) {
108
+ function handleTap(entry: SheetItem) {
96
109
  if (entry.isContainer) {
97
110
  const c = containers.find((x) => x.id === entry.id);
98
111
  if (c) push({ kind: 'container', containerId: c.id, label: c.label });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Contribution point id: shards register `ContextSource` descriptors here.
3
+ * Each registration adds one pickable entry in any consuming UI (e.g. the
4
+ * "SOURCES" section of the AI Edit modal when sh3-ai is installed) and may
5
+ * be picked up by future consumers. Lifecycle is publisher-owned — register
6
+ * when content becomes relevant (app activation, project load, selection),
7
+ * dispose when it stops being relevant.
8
+ */
9
+ export declare const CONTEXT_SOURCE_POINT_ID = "sh3.contextSource";
10
+ /** A single context-source contribution. */
11
+ export interface ContextSource {
12
+ /**
13
+ * Globally unique. Convention: `<shardId>:<slug>`. Used as the picker
14
+ * selection key, so it must be stable across re-renders. Re-registering
15
+ * with an existing id silently replaces — generally dispose the prior
16
+ * registration first when swapping content.
17
+ */
18
+ id: string;
19
+ /** Short display name shown in the picker row and the chip body. */
20
+ label: string;
21
+ /**
22
+ * Tooltip in any consuming UI. Consumers may also surface this to
23
+ * downstream tools (e.g. as the description sh3-ai exposes when
24
+ * chat-side context tools land).
25
+ */
26
+ description?: string;
27
+ /**
28
+ * Drives prompt formatting (when consumed by sh3-ai) and the chip kind tag.
29
+ * - `text` (default): value coerced to string, dumped raw.
30
+ * - `markdown`: value coerced to string, wrapped in fenced ```markdown``` block.
31
+ * - `json`: value `JSON.stringify`-ed with 2-space indent, wrapped in fenced ```json``` block.
32
+ */
33
+ kind?: 'text' | 'markdown' | 'json';
34
+ /**
35
+ * Sub-header under the picker's SOURCES section (e.g. the consuming
36
+ * shard's display name). Entries without a group fall under an "Other"
37
+ * sub-header.
38
+ */
39
+ group?: string;
40
+ /**
41
+ * Lazy fetcher. Called when the user picks the chip (for the expand
42
+ * preview pane) and again at consume time. May be sync or async.
43
+ * Returning null/undefined signals "no content available right now" —
44
+ * entry is silently omitted but the chip remains. Throwing/rejecting
45
+ * surfaces a toast and skips the entry.
46
+ */
47
+ get(): unknown | Promise<unknown>;
48
+ }
@@ -0,0 +1,21 @@
1
+ /*
2
+ * Public contract for context-source contributions. Shards register
3
+ * `ContextSource` descriptors at `CONTEXT_SOURCE_POINT_ID` via the
4
+ * standard `ctx.contributions.register` API; consumers (sh3-ai today,
5
+ * potentially inspectors / hover previews / chat-side context tools
6
+ * tomorrow) enumerate them via `ctx.contributions.list`.
7
+ *
8
+ * v1 has a single consumer (sh3-ai). The descriptor shape is hosted
9
+ * here so publisher shards do not need a devDependency on sh3-ai to
10
+ * contribute. Lifecycle is consumer-owned — see the JSDoc on
11
+ * `CONTEXT_SOURCE_POINT_ID` below.
12
+ */
13
+ /**
14
+ * Contribution point id: shards register `ContextSource` descriptors here.
15
+ * Each registration adds one pickable entry in any consuming UI (e.g. the
16
+ * "SOURCES" section of the AI Edit modal when sh3-ai is installed) and may
17
+ * be picked up by future consumers. Lifecycle is publisher-owned — register
18
+ * when content becomes relevant (app activation, project load, selection),
19
+ * dispose when it stops being relevant.
20
+ */
21
+ export const CONTEXT_SOURCE_POINT_ID = 'sh3.contextSource';
@@ -23,6 +23,9 @@ import { attachGlobalListeners } from './actions/listeners';
23
23
  import { detectSatelliteMode } from './boot/satelliteMode';
24
24
  import { MemoryBackend } from './state/backends';
25
25
  import { sessionState, readPendingScope, PENDING_SCOPE_KEY } from './projects/session-state.svelte';
26
+ import { projectsApi } from './projects-shard/projectsApi';
27
+ import { projectsState } from './projects-shard/projectsShard.svelte';
28
+ import { toastManager } from './overlays/toast';
26
29
  import SatelliteShell from './satellite/SatelliteShell.svelte';
27
30
  export async function createShell(config) {
28
31
  var _a, _b;
@@ -87,6 +90,19 @@ export async function createShell(config) {
87
90
  if (satellite.payload.projectId) {
88
91
  sessionState.activeProjectId = satellite.payload.projectId;
89
92
  }
93
+ // Mirror main-mode: eager-fetch the active project so the register
94
+ // gate computed in bootstrapSatellite has the allowlist. Fail-closed
95
+ // identically — drop to personal scope on error.
96
+ if (sessionState.activeProjectId !== null) {
97
+ try {
98
+ const record = await projectsApi.get(sessionState.activeProjectId);
99
+ projectsState.projects = [record];
100
+ }
101
+ catch (err) {
102
+ console.warn(`[sh3] Satellite: failed to load project "${sessionState.activeProjectId}"; dropping to personal scope:`, err instanceof Error ? err.message : err);
103
+ sessionState.activeProjectId = null;
104
+ }
105
+ }
90
106
  __setScopeResolver(() => sessionState.activeProjectId);
91
107
  __setShardScopeResolver(() => sessionState.activeProjectId ? 'project' : 'tenant');
92
108
  if (config === null || config === void 0 ? void 0 : config.shards)
@@ -154,6 +170,30 @@ export async function createShell(config) {
154
170
  }
155
171
  }
156
172
  }
173
+ // 4b. Eager-fetch the active project record so the register gate
174
+ // (computed in bootstrap()) has the appAllowlist available.
175
+ // Fail-closed: a failed fetch drops us to personal scope rather
176
+ // than booting with no gate (which would leak shards into the
177
+ // project). The user sees a toast on next paint.
178
+ if (sessionState.activeProjectId !== null) {
179
+ try {
180
+ const record = await projectsApi.get(sessionState.activeProjectId);
181
+ projectsState.projects = [record];
182
+ }
183
+ catch (err) {
184
+ console.warn(`[sh3] Failed to load project "${sessionState.activeProjectId}"; dropping to personal scope:`, err instanceof Error ? err.message : err);
185
+ sessionState.activeProjectId = null;
186
+ if (typeof sessionStorage !== 'undefined') {
187
+ sessionStorage.removeItem(PENDING_SCOPE_KEY);
188
+ }
189
+ queueMicrotask(() => {
190
+ try {
191
+ toastManager.notify('Could not load that project; dropped to personal scope.', { level: 'error', duration: 6000 });
192
+ }
193
+ catch ( /* overlay not ready; swallow */_a) { /* overlay not ready; swallow */ }
194
+ });
195
+ }
196
+ }
157
197
  // 5. Load server-discovered packages
158
198
  await loadDiscoveredPackages(config === null || config === void 0 ? void 0 : config.discoveredPackages);
159
199
  // 6. Register consumer-provided shards and apps
@@ -44,6 +44,46 @@ beforeEach(() => {
44
44
  });
45
45
  describe('createDocumentPicker', () => {
46
46
  const sampleDoc = { shardId: 'my-shard', path: 'readme.md', kind: 'file' };
47
+ describe('options forwarding to browser modal', () => {
48
+ it('threads listFolders/handle/readOnlyShard/initialShardId into the modal props', async () => {
49
+ const listFn = async () => [];
50
+ const listFolders = vi.fn(async () => ['empty']);
51
+ const handle = {
52
+ mkdir: vi.fn(async () => { }),
53
+ rmdir: vi.fn(async () => { }),
54
+ renameFolder: vi.fn(async () => { }),
55
+ rename: vi.fn(async () => { }),
56
+ delete: vi.fn(async () => { }),
57
+ };
58
+ const readOnlyShard = vi.fn(() => false);
59
+ const picker = createDocumentPicker(listFn, {
60
+ listFolders, handle, readOnlyShard,
61
+ initialShardId: 'svg-designer', lockToShard: true,
62
+ });
63
+ mockModal();
64
+ picker.save();
65
+ await vi.waitFor(() => expect(mockModalOpen).toHaveBeenCalledOnce());
66
+ const props = mockModalOpen.mock.calls[0][1];
67
+ expect(props.listFolders).toBe(listFolders);
68
+ expect(props.handle).toBe(handle);
69
+ expect(props.readOnlyShard).toBe(readOnlyShard);
70
+ expect(props.initialShardId).toBe('svg-designer');
71
+ expect(props.lockToShard).toBe(true);
72
+ });
73
+ it('omits options when none provided (backward-compatible single-arg call)', async () => {
74
+ const listFn = async () => [];
75
+ const picker = createDocumentPicker(listFn);
76
+ mockModal();
77
+ picker.open();
78
+ await vi.waitFor(() => expect(mockModalOpen).toHaveBeenCalledOnce());
79
+ const props = mockModalOpen.mock.calls[0][1];
80
+ expect(props.listFolders).toBeUndefined();
81
+ expect(props.handle).toBeUndefined();
82
+ expect(props.readOnlyShard).toBeUndefined();
83
+ expect(props.initialShardId).toBeUndefined();
84
+ expect(props.lockToShard).toBeUndefined();
85
+ });
86
+ });
47
87
  describe('open() — modal (no anchor)', () => {
48
88
  it('resolves with OpenerValue when user commits', async () => {
49
89
  const listFn = async () => [{ shardId: 'my-shard', path: 'readme.md', size: 100, lastModified: 0 }];
@@ -1,11 +1,40 @@
1
1
  import type { DocumentPickerApi, DocListFn } from './picker-api';
2
2
  /**
3
- * Create a document picker API bound to a document listing function.
4
- * The listFn is derived from the shard's document zone + browse permission
5
- * and baked in at construction time so callers don't pass their own scope.
6
- *
7
- * When an `anchor` element is provided the browser opens as a popup
8
- * (anchored near the element). Without an anchor it opens as a centered
9
- * modal (the expected default for file-browser dialogs).
3
+ * Folder mutation surface forwarded to the document browser modal.
4
+ * Mirrors the `handle` prop of `_DocumentBrowser.svelte`.
10
5
  */
11
- export declare function createDocumentPicker(listFn: DocListFn): DocumentPickerApi;
6
+ export interface PickerHandleOps {
7
+ mkdir: (shardId: string, path: string) => Promise<void>;
8
+ rmdir: (shardId: string, path: string, opts: {
9
+ recursive: boolean;
10
+ }) => Promise<void>;
11
+ renameFolder: (shardId: string, oldPath: string, newPath: string) => Promise<void>;
12
+ rename: (shardId: string, oldPath: string, newPath: string) => Promise<void>;
13
+ delete: (shardId: string, path: string) => Promise<void>;
14
+ }
15
+ /**
16
+ * Construction-time options for `createDocumentPicker`. All optional —
17
+ * absent values fall back to a read-only listing of `listFn`.
18
+ */
19
+ export interface DocumentPickerOptions {
20
+ /** Enumerates immediate child folders of `prefix` in `shardId`. When set,
21
+ * empty folders (with no documents underneath) appear in the browser. */
22
+ listFolders?: (shardId: string, prefix: string) => Promise<string[]>;
23
+ /** Folder/file mutation surface. When set, the browser exposes a toolbar
24
+ * with new-folder / rename / delete actions (gated per shard by
25
+ * `readOnlyShard`). */
26
+ handle?: PickerHandleOps;
27
+ /** Returns true for shards the caller cannot mutate. The browser hides
28
+ * the edit toolbar while navigated into such shards. */
29
+ readOnlyShard?: (shardId: string) => boolean;
30
+ /** When set, the browser opens pre-navigated into this shard's namespace
31
+ * instead of the (often empty) tenant-wide shard list. The "SH3" crumb
32
+ * still lets the user back out — useful in browse mode. */
33
+ initialShardId?: string;
34
+ /** When true, the browser is hard-scoped to `initialShardId`: the "SH3"
35
+ * breadcrumb is hidden and Backspace at the shard root is a no-op.
36
+ * Use this for non-browse shards that can only ever work within their
37
+ * own namespace, so the user can't navigate into a dead-end root. */
38
+ lockToShard?: boolean;
39
+ }
40
+ export declare function createDocumentPicker(listFn: DocListFn, options?: DocumentPickerOptions): DocumentPickerApi;