@notis_ai/cli 0.2.0-beta.145.1 → 0.2.0-beta.151.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  });
@@ -51,7 +51,7 @@ function DocumentEditorFallback({ documentId, variant = 'full', className }: Not
51
51
  );
52
52
  }
53
53
 
54
- const isMarkdown = document.contentType !== 'file';
54
+ const isMarkdown = document.contentType === 'markdown' || !document.contentType;
55
55
  return (
56
56
  <div className={className}>
57
57
  {variant === 'full' ? <h1 style={fallbackTitleStyle}>{document.title}</h1> : null}
@@ -62,6 +62,15 @@ function DocumentEditorFallback({ documentId, variant = 'full', className }: Not
62
62
  <p style={fallbackNoticeStyle}>Read-only preview — editing is available inside the Notis portal.</p>
63
63
  )}
64
64
  </>
65
+ ) : document.contentType === 'view' ? (
66
+ <div style={fallbackFrameStyle}>
67
+ {document.viewType === 'report'
68
+ ? 'Interactive report'
69
+ : document.viewType === 'html'
70
+ ? 'Interactive HTML document'
71
+ : 'View document'} — open it in Notis Documents for
72
+ the full native experience.
73
+ </div>
65
74
  ) : (
66
75
  <div style={fallbackFrameStyle}>
67
76
  {document.fileType ? `${document.fileType.toUpperCase()} document` : 'File document'} — open it in
@@ -76,8 +85,9 @@ function DocumentEditorFallback({ documentId, variant = 'full', className }: Not
76
85
  * Embeds the document editor for a document anywhere in an app view. The host
77
86
  * (portal) provides the implementation through `runtime.ui.DocumentEditor` and
78
87
  * dispatches on the document's content type (markdown -> rich text editor,
79
- * files -> matching viewer). Outside the portal this falls back to a read-only
80
- * preview.
88
+ * files -> matching viewer, views -> their trusted native Documents surface).
89
+ * Outside the portal this falls back to a read-only preview or an explicit
90
+ * open-in-Documents notice for native views.
81
91
  *
82
92
  * ```tsx
83
93
  * <DocumentEditor documentId={entry.id} variant="body" className="min-h-[320px]" />
@@ -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;
@@ -107,7 +107,7 @@ export function normalizePropertyValue(value: unknown): unknown {
107
107
  }
108
108
 
109
109
  function normalizeContentType(value: unknown): DocumentContentType | null {
110
- return value === 'markdown' || value === 'file' ? value : null;
110
+ return value === 'markdown' || value === 'file' || value === 'view' ? value : null;
111
111
  }
112
112
 
113
113
  /**
@@ -140,6 +140,12 @@ export function normalizeDocumentRecord(value: unknown): DocumentRecord {
140
140
  : null,
141
141
  contentMarkdown: optionalString(record.contentMarkdown) ?? optionalString(record.content_markdown),
142
142
  plainText: optionalString(record.plainText) ?? optionalString(record.plain_text),
143
+ viewType: optionalString(record.viewType) ?? optionalString(record.view_type),
144
+ viewState: asRecord(record.viewState) ?? asRecord(record.view_state),
145
+ viewRevision:
146
+ typeof (record.viewRevision ?? record.view_revision) === 'number'
147
+ ? Number(record.viewRevision ?? record.view_revision)
148
+ : null,
143
149
  createdAt:
144
150
  optionalString(record.createdAt)
145
151
  ?? optionalString(record.created_at)
@@ -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
 
@@ -71,7 +71,7 @@ export interface DatabaseDescriptor {
71
71
  * (pdf, xlsx, pptx, ...). Future content types (canvas, ...) extend this
72
72
  * union without changing the component contracts.
73
73
  */
74
- export type DocumentContentType = 'markdown' | 'file';
74
+ export type DocumentContentType = 'markdown' | 'file' | 'view';
75
75
 
76
76
  export interface DocumentRecord {
77
77
  id: string;
@@ -86,6 +86,9 @@ export interface DocumentRecord {
86
86
  contentBlocknote?: Array<Record<string, unknown>> | null;
87
87
  contentMarkdown?: string | null;
88
88
  plainText?: string | null;
89
+ viewType?: string | null;
90
+ viewState?: Record<string, unknown> | null;
91
+ viewRevision?: number | null;
89
92
  createdAt?: string | null;
90
93
  lastEditedTime?: string | null;
91
94
  }
@@ -121,6 +124,7 @@ export interface RouteDescriptor {
121
124
  icon?: string | null;
122
125
  parentSlug?: string | null;
123
126
  default?: boolean;
127
+ resourceDeepLinks?: boolean;
124
128
  collection?: {
125
129
  database: string;
126
130
  titleProperty: string;
@@ -169,6 +173,8 @@ export interface QueryFilter {
169
173
 
170
174
  export interface NotisRuntimeContext {
171
175
  collectionItem?: CollectionItemDetail | null;
176
+ /** App-owned resource requested through the route's `?resource=<id>` link. */
177
+ resourceId?: string | null;
172
178
  /**
173
179
  * Set when the app is being rendered by the screenshot harness (`notis apps
174
180
  * screenshot`) for the named listing scenario. Lets apps and SDK components