@notis_ai/cli 0.2.15 → 0.2.17

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.
@@ -12,9 +12,20 @@
12
12
  */
13
13
 
14
14
  import { createServer } from 'node:http';
15
- import { spawn } from 'node:child_process';
16
- import { existsSync, readFileSync, statSync, watch as fsWatch } from 'node:fs';
15
+ import { execFileSync, spawn } from 'node:child_process';
16
+ import {
17
+ appendFileSync,
18
+ existsSync,
19
+ mkdirSync,
20
+ readFileSync,
21
+ renameSync,
22
+ rmSync,
23
+ statSync,
24
+ watch as fsWatch,
25
+ } from 'node:fs';
26
+ import { freemem, loadavg, totalmem } from 'node:os';
17
27
  import { dirname, join, resolve } from 'node:path';
28
+ import { setTimeout as delay } from 'node:timers/promises';
18
29
  import { fileURLToPath } from 'node:url';
19
30
 
20
31
  import {
@@ -32,6 +43,7 @@ import {
32
43
  linkAppDevSessionTarget,
33
44
  readAppDevSessions,
34
45
  } from './app-dev-sessions.js';
46
+ import { captureDesktopWatcherOwnership } from './app-dev-process-identity.js';
35
47
 
36
48
  const CONTENT_TYPES = {
37
49
  '.js': 'application/javascript; charset=utf-8',
@@ -44,6 +56,115 @@ const CLI_ROOT = resolve(RUNTIME_DIR, '../..');
44
56
  const REPO_ROOT = resolve(RUNTIME_DIR, '../../../..');
45
57
  const HARNESS_TEMPLATE_PATH = join(CLI_ROOT, 'template', '.harness', 'index.html.tmpl');
46
58
  const FALLBACK_REACT_VERSION = '19.0.0';
59
+ const BUILD_PROCESS_STOP_GRACE_MS = 1_000;
60
+ const DEV_DIAGNOSTIC_INTERVAL_MS = 30_000;
61
+ const DEV_DIAGNOSTIC_MAX_BYTES = 20 * 1024 * 1024;
62
+
63
+ function processGroupIsRunning(pid, signalProcess = process.kill) {
64
+ try {
65
+ signalProcess(-pid, 0);
66
+ return true;
67
+ } catch (error) {
68
+ return error?.code !== 'ESRCH';
69
+ }
70
+ }
71
+
72
+ function readProcessGroupRssBytes(groupPids) {
73
+ if (process.platform === 'win32' || groupPids.size === 0) return new Map();
74
+ try {
75
+ const output = execFileSync('ps', ['-axo', 'pgid=,rss='], {
76
+ encoding: 'utf8',
77
+ maxBuffer: 1024 * 1024,
78
+ stdio: ['ignore', 'pipe', 'ignore'],
79
+ });
80
+ const rssByGroup = new Map();
81
+ for (const line of output.split('\n')) {
82
+ const [rawGroupPid, rawRssKiB] = line.trim().split(/\s+/, 2);
83
+ const groupPid = Number.parseInt(rawGroupPid, 10);
84
+ if (!groupPids.has(groupPid)) continue;
85
+ const rssKiB = Number.parseInt(rawRssKiB, 10);
86
+ if (!Number.isFinite(rssKiB)) continue;
87
+ rssByGroup.set(groupPid, (rssByGroup.get(groupPid) || 0) + (rssKiB * 1024));
88
+ }
89
+ return rssByGroup;
90
+ } catch {
91
+ return new Map();
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Stop the npm wrapper and every Vite/esbuild descendant it launched.
97
+ *
98
+ * Killing only the npm PID leaves its watch process alive after a Desktop host
99
+ * restart. Each watcher therefore owns a separate POSIX process group. Windows
100
+ * uses taskkill's tree mode for the equivalent cleanup.
101
+ */
102
+ export async function terminateBuildProcessTree(child, {
103
+ platform = process.platform,
104
+ signalProcess = process.kill,
105
+ spawnProcess = spawn,
106
+ graceMs = BUILD_PROCESS_STOP_GRACE_MS,
107
+ } = {}) {
108
+ const pid = child?.pid;
109
+ if (!Number.isSafeInteger(pid) || pid <= 0) return;
110
+
111
+ if (platform === 'win32') {
112
+ await new Promise((resolvePromise) => {
113
+ let settled = false;
114
+ const finish = () => {
115
+ if (settled) return;
116
+ settled = true;
117
+ resolvePromise();
118
+ };
119
+ try {
120
+ const killer = spawnProcess('taskkill.exe', ['/pid', String(pid), '/t', '/f'], {
121
+ stdio: 'ignore',
122
+ windowsHide: true,
123
+ });
124
+ killer.once('error', () => {
125
+ try {
126
+ child.kill('SIGTERM');
127
+ } catch {
128
+ // The wrapper already exited.
129
+ }
130
+ finish();
131
+ });
132
+ killer.once('exit', finish);
133
+ setTimeout(finish, graceMs).unref?.();
134
+ } catch {
135
+ try {
136
+ child.kill('SIGTERM');
137
+ } catch {
138
+ // The wrapper already exited.
139
+ }
140
+ finish();
141
+ }
142
+ });
143
+ return;
144
+ }
145
+
146
+ try {
147
+ signalProcess(-pid, 'SIGTERM');
148
+ } catch (error) {
149
+ if (error?.code === 'ESRCH') return;
150
+ try {
151
+ child.kill('SIGTERM');
152
+ } catch {
153
+ return;
154
+ }
155
+ }
156
+
157
+ const deadline = Date.now() + graceMs;
158
+ while (processGroupIsRunning(pid, signalProcess) && Date.now() < deadline) {
159
+ await delay(25);
160
+ }
161
+ if (!processGroupIsRunning(pid, signalProcess)) return;
162
+ try {
163
+ signalProcess(-pid, 'SIGKILL');
164
+ } catch (error) {
165
+ if (error?.code !== 'ESRCH') throw error;
166
+ }
167
+ }
47
168
 
48
169
  function extFor(pathname) {
49
170
  const idx = pathname.lastIndexOf('.');
@@ -246,10 +367,11 @@ function buildHarnessDescriptor({ state, manifest, appConfig, route, scenario =
246
367
  icon: route.icon || null,
247
368
  parentSlug: route.parentSlug || null,
248
369
  default: Boolean(route.default),
370
+ resourceDeepLinks: route.resourceDeepLinks === true,
249
371
  collection: route.collection || null,
250
372
  },
251
373
  databases,
252
- context: { collectionItem: null, screenshotScenario: scenario },
374
+ context: { collectionItem: null, resourceId: null, screenshotScenario: scenario },
253
375
  tools,
254
376
  };
255
377
  }
@@ -300,7 +422,7 @@ function renderHarnessHtml({ state, manifest, appConfig, route, harnessOptions,
300
422
  /**
301
423
  * Start the dev server for one or more apps.
302
424
  *
303
- * @param {{apps: Array<{slug: string, projectDir: string, appId?: string, targetAppId?: string, userId?: string, profileKey?: string, sessionId?: string, mountNonce?: string}>, port: number, watch?: boolean, sessionsFilePath?: string, harness?: { mode?: string, apiBase?: string, jwt?: string }, log?: (m: string) => void, logError?: (m: string) => void}} options
425
+ * @param {{apps: Array<{slug: string, projectDir: string, appId?: string, targetAppId?: string, userId?: string, profileKey?: string, sessionId?: string, mountNonce?: string}>, port: number, watch?: boolean, sessionsFilePath?: string, harness?: { mode?: string, apiBase?: string, jwt?: string }, diagnosticsFile?: string | null, desktopOwnerId?: string | null, desktopOwnerScope?: string | null, terminateBuildProcess?: typeof terminateBuildProcessTree, log?: (m: string) => void, logError?: (m: string) => void}} options
304
426
  */
305
427
  export async function startAppDevServer({
306
428
  apps,
@@ -308,6 +430,10 @@ export async function startAppDevServer({
308
430
  watch = true,
309
431
  sessionsFilePath,
310
432
  harness = {},
433
+ diagnosticsFile = process.env.NOTIS_DEV_DIAGNOSTICS_FILE || null,
434
+ desktopOwnerId = process.env.NOTIS_APPS_DEV_DESKTOP_OWNER_ID || null,
435
+ desktopOwnerScope = process.env.NOTIS_APPS_DEV_DESKTOP_OWNER_SCOPE || null,
436
+ terminateBuildProcess = terminateBuildProcessTree,
311
437
  log = (msg) => process.stdout.write(`${msg}\n`),
312
438
  logError = (msg) => process.stderr.write(`${msg}\n`),
313
439
  }) {
@@ -316,6 +442,9 @@ export async function startAppDevServer({
316
442
  }
317
443
 
318
444
  const appState = new Map();
445
+ let diagnosticsTimer = null;
446
+ let diagnosticWriteFailed = false;
447
+ let serverClosing = false;
319
448
  // Sidebar reconciliation needs one stream for the shared host, not one
320
449
  // long-lived HTTP/1.1 connection per discovered app. Keeping a stream per
321
450
  // app exhausts Chromium's per-origin connection pool and can indefinitely
@@ -344,17 +473,68 @@ export async function startAppDevServer({
344
473
  prepareTimer: null,
345
474
  reloadTimer: null,
346
475
  buildProcess: null,
476
+ watcherOwnership: null,
347
477
  lastMtimeMs: 0,
348
478
  watchPollTimer: null,
349
479
  bundleReady: false,
350
480
  bundleReadyPromise,
351
481
  resolveBundleReady,
482
+ buildProcessStopPromise: null,
352
483
  };
353
484
  };
354
485
  for (const app of apps) {
355
486
  appState.set(app.slug, createAppState(app));
356
487
  }
357
488
 
489
+ function writeDevDiagnostic(event) {
490
+ if (!diagnosticsFile) return;
491
+ const memory = process.memoryUsage();
492
+ const watcherPids = new Set(
493
+ [...appState.values()]
494
+ .map((state) => state.buildProcess?.pid)
495
+ .filter((pid) => Number.isSafeInteger(pid) && pid > 0),
496
+ );
497
+ const watcherGroupRss = readProcessGroupRssBytes(watcherPids);
498
+ const record = {
499
+ at: new Date().toISOString(),
500
+ event,
501
+ host_pid: process.pid,
502
+ parent_pid: process.ppid,
503
+ rss_bytes: memory.rss,
504
+ heap_used_bytes: memory.heapUsed,
505
+ heap_total_bytes: memory.heapTotal,
506
+ external_bytes: memory.external,
507
+ system_free_bytes: freemem(),
508
+ system_total_bytes: totalmem(),
509
+ load_average_1m: loadavg()[0],
510
+ watcher_groups_rss_bytes: [...watcherGroupRss.values()].reduce((total, rss) => total + rss, 0),
511
+ apps: [...appState.values()].map((state) => ({
512
+ slug: state.slug,
513
+ project_dir: state.projectDir,
514
+ watcher_pid: state.buildProcess?.pid || null,
515
+ watcher_exit_code: state.buildProcess?.exitCode ?? null,
516
+ watcher_signal: state.buildProcess?.signalCode ?? null,
517
+ watcher_group_rss_bytes: watcherGroupRss.get(state.buildProcess?.pid) ?? null,
518
+ bundle_ready: state.bundleReady,
519
+ })),
520
+ };
521
+ try {
522
+ mkdirSync(dirname(diagnosticsFile), { recursive: true, mode: 0o700 });
523
+ if (existsSync(diagnosticsFile) && statSync(diagnosticsFile).size >= DEV_DIAGNOSTIC_MAX_BYTES) {
524
+ const previous = `${diagnosticsFile}.previous`;
525
+ rmSync(previous, { force: true });
526
+ renameSync(diagnosticsFile, previous);
527
+ }
528
+ appendFileSync(diagnosticsFile, `${JSON.stringify(record)}\n`, { mode: 0o600 });
529
+ diagnosticWriteFailed = false;
530
+ } catch (error) {
531
+ if (!diagnosticWriteFailed) {
532
+ diagnosticWriteFailed = true;
533
+ logError(`[notis apps dev] persistent diagnostics failed: ${error instanceof Error ? error.message : String(error)}`);
534
+ }
535
+ }
536
+ }
537
+
358
538
  function broadcastReload(slug) {
359
539
  const state = appState.get(slug);
360
540
  if (!state) return;
@@ -827,16 +1007,39 @@ export async function startAppDevServer({
827
1007
  watchManifestInputs(state);
828
1008
  pollForBundleAndWatch(state);
829
1009
 
830
- state.buildProcess = spawn('npm', ['run', 'build', '--', '--watch'], {
1010
+ const buildProcess = spawn('npm', ['run', 'build', '--', '--watch'], {
831
1011
  cwd: state.projectDir,
1012
+ detached: process.platform !== 'win32',
832
1013
  stdio: 'inherit',
833
1014
  env: { ...process.env, NOTIS_DEV: '1' },
834
1015
  });
1016
+ state.buildProcess = buildProcess;
1017
+ for (let attempt = 0; attempt < 5 && !state.watcherOwnership; attempt += 1) {
1018
+ state.watcherOwnership = captureDesktopWatcherOwnership({
1019
+ pid: buildProcess.pid,
1020
+ projectDir: state.projectDir,
1021
+ desktopOwnerId,
1022
+ desktopOwnerScope,
1023
+ });
1024
+ if (!state.watcherOwnership && attempt < 4) await delay(10);
1025
+ }
835
1026
 
836
- state.buildProcess.on('exit', (code) => {
1027
+ buildProcess.on('exit', (code) => {
837
1028
  if (code !== 0 && code !== null) {
838
1029
  logError(`[notis apps dev] ${state.slug}: vite build --watch exited with code ${code}`);
839
1030
  }
1031
+ if (!serverClosing) {
1032
+ if (state.buildProcess === buildProcess) state.buildProcess = null;
1033
+ const stopPromise = terminateBuildProcess(buildProcess).catch((error) => {
1034
+ logError(`[notis apps dev] ${state.slug}: watcher cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
1035
+ });
1036
+ state.buildProcessStopPromise = stopPromise;
1037
+ void stopPromise.finally(() => {
1038
+ if (state.buildProcessStopPromise === stopPromise) {
1039
+ state.buildProcessStopPromise = null;
1040
+ }
1041
+ });
1042
+ }
840
1043
  });
841
1044
  } else {
842
1045
  updateBundleDir(state, resolveBundleDir(state));
@@ -845,6 +1048,12 @@ export async function startAppDevServer({
845
1048
  log(`[notis apps dev] ${state.slug}: serving bundle at http://127.0.0.1:${port}/a/${state.slug}/bundle/app.js`);
846
1049
  }
847
1050
 
1051
+ writeDevDiagnostic('started');
1052
+ if (diagnosticsFile) {
1053
+ diagnosticsTimer = setInterval(() => writeDevDiagnostic('sample'), DEV_DIAGNOSTIC_INTERVAL_MS);
1054
+ diagnosticsTimer.unref?.();
1055
+ }
1056
+
848
1057
  return {
849
1058
  port,
850
1059
  updateApp(slug, updates = {}) {
@@ -866,7 +1075,19 @@ export async function startAppDevServer({
866
1075
  if (!state) return Promise.reject(new Error(`unknown app: ${slug}`));
867
1076
  return state.bundleReadyPromise;
868
1077
  },
1078
+ getWatcherOwnership(slug) {
1079
+ const state = appState.get(slug);
1080
+ if (!state) throw new Error(`unknown app: ${slug}`);
1081
+ return state.watcherOwnership ? { ...state.watcherOwnership } : null;
1082
+ },
869
1083
  async close() {
1084
+ serverClosing = true;
1085
+ if (diagnosticsTimer) {
1086
+ clearInterval(diagnosticsTimer);
1087
+ diagnosticsTimer = null;
1088
+ }
1089
+ writeDevDiagnostic('stopping');
1090
+ const buildProcessStops = [];
870
1091
  for (const state of appState.values()) {
871
1092
  if (state.prepareTimer) clearTimeout(state.prepareTimer);
872
1093
  if (state.reloadTimer) clearTimeout(state.reloadTimer);
@@ -894,10 +1115,16 @@ export async function startAppDevServer({
894
1115
  }
895
1116
  }
896
1117
  state.sseClients.clear();
897
- if (state.buildProcess && state.buildProcess.exitCode === null) {
898
- state.buildProcess.kill('SIGTERM');
1118
+ if (state.buildProcessStopPromise) {
1119
+ buildProcessStops.push(state.buildProcessStopPromise);
1120
+ }
1121
+ if (state.buildProcess) {
1122
+ const buildProcess = state.buildProcess;
1123
+ state.buildProcess = null;
1124
+ buildProcessStops.push(terminateBuildProcess(buildProcess));
899
1125
  }
900
1126
  }
1127
+ await Promise.allSettled(buildProcessStops);
901
1128
  for (const res of hostSseClients) {
902
1129
  try {
903
1130
  res.end();
@@ -907,6 +1134,7 @@ export async function startAppDevServer({
907
1134
  }
908
1135
  hostSseClients.clear();
909
1136
  await new Promise((resolvePromise) => server.close(() => resolvePromise()));
1137
+ writeDevDiagnostic('stopped');
910
1138
  },
911
1139
  };
912
1140
  }
@@ -680,6 +680,9 @@ function validateConfiguredRoutes(routes) {
680
680
  if (route.default) {
681
681
  defaultCount += 1;
682
682
  }
683
+ if (route.resourceDeepLinks !== undefined && typeof route.resourceDeepLinks !== 'boolean') {
684
+ throw usageError(`Route "${route.slug}" resourceDeepLinks must be a boolean.`);
685
+ }
683
686
  }
684
687
 
685
688
  if (defaultCount !== 1) {
@@ -799,6 +802,7 @@ export function generateManifest(appConfig, projectDir) {
799
802
  icon: route.icon || null,
800
803
  parentSlug: route.parentSlug || null,
801
804
  default: route.default || false,
805
+ resourceDeepLinks: route.resourceDeepLinks === true,
802
806
  export_name: route.exportName || route.export_name || exportNameFromPath(route.path),
803
807
  collection: route.collection || null,
804
808
  };
@@ -902,6 +906,24 @@ export function generateManifest(appConfig, projectDir) {
902
906
  };
903
907
  }
904
908
 
909
+ /**
910
+ * Keep the persisted app row's presentation metadata aligned with the bundle
911
+ * manifest. The app slug remains the stable identity; title/name is only the
912
+ * user-facing label.
913
+ */
914
+ export function appRowFieldsFromManifest(manifest) {
915
+ const app = manifest?.app && typeof manifest.app === 'object' ? manifest.app : {};
916
+ const displayName = typeof app.title === 'string' && app.title.trim()
917
+ ? app.title.trim()
918
+ : typeof app.name === 'string' && app.name.trim()
919
+ ? app.name.trim()
920
+ : null;
921
+ return {
922
+ ...(displayName ? { name: displayName } : {}),
923
+ accent: app.accent ?? null,
924
+ };
925
+ }
926
+
905
927
  export function normalizeAppToolBindings(bindings) {
906
928
  return (Array.isArray(bindings) ? bindings : [])
907
929
  .map((binding) => {
@@ -2818,7 +2840,7 @@ async function updateAppVersion(supabaseUrl, supabaseKey, appId, newVersion, man
2818
2840
  },
2819
2841
  body: JSON.stringify({
2820
2842
  manifest: { ...manifest, version: newVersion },
2821
- accent: manifest?.app?.accent ?? null,
2843
+ ...appRowFieldsFromManifest(manifest),
2822
2844
  updated_at: new Date().toISOString(),
2823
2845
  }),
2824
2846
  });
@@ -485,10 +485,15 @@ export function updateConfig(updater) {
485
485
  }
486
486
 
487
487
  /**
488
- * Remove only worktree-owned profiles without normalizing the rest of the
489
- * shared file. Archive cleanup can run before a packaged Desktop upgrade has
490
- * migrated its legacy `jwt`; preserving unknown/raw fields here keeps that
491
- * migrate-then-strip handoff intact.
488
+ * Remove every profile owned by one worktree, plus stale localhost stubs that
489
+ * use one of that worktree's generated profile names. Older builds sometimes
490
+ * lost `dev_workspace_root`, so requiring the ownership marker alone leaves a
491
+ * dead local profile behind after archive.
492
+ *
493
+ * Do this without normalizing the rest of the shared file. Archive cleanup can
494
+ * run before a packaged Desktop upgrade has migrated its legacy `jwt`;
495
+ * preserving unknown/raw fields here keeps that migrate-then-strip handoff
496
+ * intact.
492
497
  */
493
498
  export function removeOwnedDevProfiles(profileNames, workspaceRoot) {
494
499
  return withConfigWriteLock((configFile) => {
@@ -502,16 +507,19 @@ export function removeOwnedDevProfiles(profileNames, workspaceRoot) {
502
507
  return [];
503
508
  }
504
509
 
510
+ const generatedNames = new Set(profileNames);
505
511
  const removed = [];
506
- for (const name of profileNames) {
507
- const profile = Object.hasOwn(raw.profiles, name) ? raw.profiles[name] : null;
508
- if (
509
- !profile
510
- || typeof profile !== 'object'
511
- || profile.dev_workspace_root !== workspaceRoot
512
- ) {
512
+ for (const [name, profile] of Object.entries(raw.profiles)) {
513
+ if (!profile || typeof profile !== 'object') {
513
514
  continue;
514
515
  }
516
+
517
+ const ownedByWorkspace = profile.dev_workspace_root === workspaceRoot;
518
+ const staleGeneratedLocalProfile = generatedNames.has(name)
519
+ && !profile.dev_workspace_root
520
+ && isLocalApiBase(profile.api_base);
521
+ if (!ownedByWorkspace && !staleGeneratedLocalProfile) continue;
522
+
515
523
  delete raw.profiles[name];
516
524
  removed.push(name);
517
525
  if (raw.current_profile === name) raw.current_profile = DEFAULT_PROFILE;
@@ -84,6 +84,25 @@ async function writeLockOwnerAtomically(lockDirectory, owner) {
84
84
  await rename(temporaryOwnerPath, ownerPath);
85
85
  }
86
86
 
87
+ async function releaseOwnedLock(lockDirectory, ownerId) {
88
+ const owner = await lockSnapshot(lockDirectory);
89
+ if (owner?.id !== ownerId) return;
90
+
91
+ const quarantineRoot = join(dirname(lockDirectory), '.stale-operation-locks');
92
+ const releasedDirectory = join(quarantineRoot, `released.${ownerId}`);
93
+ await mkdir(quarantineRoot, { recursive: true, mode: 0o700 });
94
+ try {
95
+ // Moving the owned directory releases the shared pathname atomically.
96
+ // Delete only the private destination so a waiter can safely acquire a new
97
+ // lock without racing this owner's recursive cleanup.
98
+ await rename(lockDirectory, releasedDirectory);
99
+ } catch (error) {
100
+ if (error?.code === 'ENOENT') return;
101
+ throw error;
102
+ }
103
+ await rm(releasedDirectory, { recursive: true, force: true });
104
+ }
105
+
87
106
  /** Serialize Desktop and terminal skill sync across processes on one Mac. */
88
107
  export async function withSkillSyncLock(callback, {
89
108
  home = homedir(),
@@ -174,10 +193,7 @@ export async function withSkillSyncLock(callback, {
174
193
  heartbeatStopped = true;
175
194
  clearInterval(heartbeat);
176
195
  await heartbeatInFlight;
177
- const owner = await lockSnapshot(lockDirectory);
178
- if (owner?.id === ownerId) {
179
- await rm(lockDirectory, { recursive: true, force: true });
180
- }
196
+ await releaseOwnedLock(lockDirectory, ownerId);
181
197
  }
182
198
  }
183
199
 
@@ -49,10 +49,12 @@ const containerBaseStyle: CSSProperties = {
49
49
  };
50
50
 
51
51
  const countStyle: CSSProperties = {
52
+ flexShrink: 0,
52
53
  padding: '0 0.5rem',
53
54
  fontSize: '12px',
54
55
  fontWeight: 500,
55
56
  fontVariantNumeric: 'tabular-nums',
57
+ whiteSpace: 'nowrap',
56
58
  color: 'color-mix(in srgb, hsl(var(--background)) 70%, transparent)',
57
59
  };
58
60
 
@@ -64,6 +66,7 @@ const dividerStyle: CSSProperties = {
64
66
  };
65
67
 
66
68
  const baseButtonStyle: CSSProperties = {
69
+ flexShrink: 0,
67
70
  display: 'inline-flex',
68
71
  alignItems: 'center',
69
72
  gap: '0.375rem',
@@ -75,6 +78,7 @@ const baseButtonStyle: CSSProperties = {
75
78
  cursor: 'pointer',
76
79
  fontSize: '13px',
77
80
  fontFamily: 'inherit',
81
+ whiteSpace: 'nowrap',
78
82
  transition: 'background-color 120ms ease, color 120ms ease',
79
83
  };
80
84
 
@@ -188,7 +192,9 @@ function ActionButton({ action }: { action: MultiSelectAction }) {
188
192
  onBlur={() => setHover(false)}
189
193
  style={buttonStyle}
190
194
  >
191
- {action.icon ? <span aria-hidden style={iconSlotStyle}>{action.icon}</span> : null}
195
+ {!action.shortcut && action.icon ? (
196
+ <span aria-hidden style={iconSlotStyle}>{action.icon}</span>
197
+ ) : null}
192
198
  {display ? <kbd aria-hidden style={keycapStyle}>{display}</kbd> : null}
193
199
  <span>{action.pending ? `${action.label}…` : action.label}</span>
194
200
  </button>
@@ -40,6 +40,12 @@ export interface NotisRouteConfig {
40
40
  icon?: string;
41
41
  parentSlug?: string | null;
42
42
  default?: boolean;
43
+ /**
44
+ * Allow the host to address an app-owned resource on this route through the
45
+ * canonical `?resource=<id>` deep link. Read the incoming id with
46
+ * `useNotis().resourceId`.
47
+ */
48
+ resourceDeepLinks?: boolean;
43
49
  exportName?: string;
44
50
  collection?: {
45
51
  database: string;
@@ -64,8 +64,8 @@ export interface CollectionKeyboardShortcuts {
64
64
  clear: string | false;
65
65
  selectAll: string | false;
66
66
  toggle: string | false;
67
- extendNext: string | false;
68
- extendPrevious: string | false;
67
+ extendNext: string | string[] | false;
68
+ extendPrevious: string | string[] | false;
69
69
  next: string | string[] | false;
70
70
  previous: string | string[] | false;
71
71
  up: string | false;
@@ -158,8 +158,8 @@ const DEFAULT_SHORTCUTS: CollectionKeyboardShortcuts = {
158
158
  clear: 'Escape',
159
159
  selectAll: 'Mod+A',
160
160
  toggle: 'X',
161
- extendNext: 'Shift+ArrowDown',
162
- extendPrevious: 'Shift+ArrowUp',
161
+ extendNext: ['Shift+ArrowDown', 'Shift+ArrowRight'],
162
+ extendPrevious: ['Shift+ArrowUp', 'Shift+ArrowLeft'],
163
163
  next: 'J',
164
164
  previous: 'K',
165
165
  up: 'ArrowUp',
@@ -169,6 +169,16 @@ const DEFAULT_SHORTCUTS: CollectionKeyboardShortcuts = {
169
169
  activate: 'Enter',
170
170
  };
171
171
 
172
+ function filterShortcutKeys(
173
+ keys: string | string[] | false,
174
+ predicate: (key: string) => boolean,
175
+ ): string | string[] | false {
176
+ if (!keys) return false;
177
+ const filtered = (Array.isArray(keys) ? keys : [keys]).filter(predicate);
178
+ if (filtered.length === 0) return false;
179
+ return filtered.length === 1 ? filtered[0] : filtered;
180
+ }
181
+
172
182
  function setsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
173
183
  if (a.size !== b.size) return false;
174
184
  for (const id of a) if (!b.has(id)) return false;
@@ -442,14 +452,20 @@ export function useCollectionInteractions<T>(
442
452
  if (!keys) return;
443
453
  definitions.push({ id, label, keys, onTrigger });
444
454
  };
455
+ const extendRight = filterShortcutKeys(keyboard.extendNext, (key) => key.toLowerCase().endsWith('arrowright'));
456
+ const extendDown = filterShortcutKeys(keyboard.extendNext, (key) => !key.toLowerCase().endsWith('arrowright'));
457
+ const extendLeft = filterShortcutKeys(keyboard.extendPrevious, (key) => key.toLowerCase().endsWith('arrowleft'));
458
+ const extendUp = filterShortcutKeys(keyboard.extendPrevious, (key) => !key.toLowerCase().endsWith('arrowleft'));
445
459
  add('collection.clear', 'Clear selection', keyboard.clear, clear);
446
460
  add('collection.select-all', 'Select all visible items', selectionMode === 'none' ? false : keyboard.selectAll, selectAll);
447
461
  add('collection.toggle', 'Toggle active item', selectionMode === 'none' ? false : keyboard.toggle, () => {
448
462
  const id = activeIdRef.current ?? anchorIdRef.current;
449
463
  if (id) toggle(id);
450
464
  });
451
- add('collection.extend-next', 'Extend selection down', selectionMode !== 'multiple' ? false : keyboard.extendNext, () => moveActive('down', true));
452
- add('collection.extend-previous', 'Extend selection up', selectionMode !== 'multiple' ? false : keyboard.extendPrevious, () => moveActive('up', true));
465
+ add('collection.extend-down', 'Extend selection down', selectionMode !== 'multiple' ? false : extendDown, () => moveActive('down', true));
466
+ add('collection.extend-right', 'Extend selection right', selectionMode !== 'multiple' ? false : extendRight, () => moveActive('right', true));
467
+ add('collection.extend-up', 'Extend selection up', selectionMode !== 'multiple' ? false : extendUp, () => moveActive('up', true));
468
+ add('collection.extend-left', 'Extend selection left', selectionMode !== 'multiple' ? false : extendLeft, () => moveActive('left', true));
453
469
  add('collection.next', 'Next item', keyboard.next, () => moveActive('next'));
454
470
  add('collection.previous', 'Previous item', keyboard.previous, () => moveActive('previous'));
455
471
  add('collection.up', 'Move up', keyboard.up, () => moveActive('up'));
@@ -634,6 +650,7 @@ export function useCollectionInteractions<T>(
634
650
  },
635
651
  onKeyDown: (event: ReactKeyboardEvent) => {
636
652
  if (event.key !== ' ' || event.metaKey || event.ctrlKey || event.altKey) return;
653
+ if (event.target !== event.currentTarget) return;
637
654
  event.preventDefault();
638
655
  if (selectionMode === 'none') activate(id);
639
656
  else toggle(id);
@@ -12,6 +12,8 @@ interface NotisContext {
12
12
  databases: DatabaseDescriptor[];
13
13
  /** Selected collection item for the current route, when applicable. */
14
14
  collectionItem: CollectionItemDetail | null;
15
+ /** Resource requested through this route's canonical deep link. */
16
+ resourceId: string | null;
15
17
  /** Whether the runtime is loaded and available. */
16
18
  ready: boolean;
17
19
  }
@@ -29,6 +31,7 @@ export function useNotis(): NotisContext {
29
31
  route: runtime?.route ?? null,
30
32
  databases: runtime?.databases ?? [],
31
33
  collectionItem: runtime?.context?.collectionItem ?? null,
34
+ resourceId: runtime?.context?.resourceId ?? null,
32
35
  ready: runtime !== null,
33
36
  };
34
37
  }
@@ -5,7 +5,7 @@ import { useNotisRuntime } from '../provider';
5
5
 
6
6
  interface NavigationActions {
7
7
  /** Navigate to a route within the app by its path. */
8
- toRoute: (path: string) => void;
8
+ toRoute: (path: string, options?: { resourceId?: string | null }) => void;
9
9
  /** Navigate to a document detail view. */
10
10
  toDocument: (documentId: string, title?: string | null) => void;
11
11
  /** Navigate to the app's default route. */
@@ -25,11 +25,14 @@ interface NavigationActions {
25
25
  export function useNotisNavigation(): NavigationActions {
26
26
  const runtime = useNotisRuntime();
27
27
 
28
- const toRoute = useCallback((path: string) => {
28
+ const toRoute = useCallback((path: string, options?: { resourceId?: string | null }) => {
29
29
  if (runtime?.navigate) {
30
- runtime.navigate({ kind: 'route', path });
30
+ runtime.navigate({ kind: 'route', path, resourceId: options?.resourceId ?? null });
31
31
  } else if (typeof window !== 'undefined') {
32
- window.location.href = path;
32
+ const url = new URL(path, window.location.href);
33
+ if (options?.resourceId) url.searchParams.set('resource', options.resourceId);
34
+ else url.searchParams.delete('resource');
35
+ window.location.href = url.toString();
33
36
  }
34
37
  }, [runtime]);
35
38
 
@@ -182,7 +182,13 @@ export function isEditableShortcutEvent(event: Event): boolean {
182
182
  if (!(target instanceof HTMLElement)) return false;
183
183
  if (target.isContentEditable || target.getAttribute('contenteditable') === 'true') return true;
184
184
  const tag = target.tagName;
185
- return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
185
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || tag === 'BUTTON' || tag === 'SUMMARY') {
186
+ return true;
187
+ }
188
+ if (tag === 'A' && target.hasAttribute('href')) return true;
189
+ return ['button', 'link', 'menuitem', 'switch', 'tab'].includes(
190
+ target.getAttribute('role') || '',
191
+ );
186
192
  });
187
193
  }
188
194