@notis_ai/cli 0.2.0-beta.155.1 → 0.2.0-beta.157.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.
Files changed (47) hide show
  1. package/README.md +11 -45
  2. package/config/notis_app_design_rules.json +135 -0
  3. package/dist/agent-hooks/notis-agent-hook.mjs +5180 -7281
  4. package/dist/base-skills/notis-apps/SKILL.md +141 -224
  5. package/dist/base-skills/notis-cli/SKILL.md +64 -131
  6. package/package.json +1 -2
  7. package/skills/notis-apps/cli.md +34 -95
  8. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +1 -1
  9. package/src/command-specs/apps.js +326 -1562
  10. package/src/runtime/agent-browser.js +169 -1
  11. package/src/runtime/app-boundary-validator.js +221 -0
  12. package/src/runtime/app-platform.js +359 -233
  13. package/src/runtime/app-test-server.js +292 -0
  14. package/template/app/page.tsx +47 -45
  15. package/template/components/page-heading.tsx +23 -0
  16. package/template/components/ui/badge.tsx +7 -4
  17. package/template/components/ui/card.tsx +24 -11
  18. package/template/components/ui/native-select.tsx +24 -0
  19. package/template/notis.config.ts +0 -1
  20. package/template/package.json +2 -2
  21. package/template/packages/sdk/package.json +1 -2
  22. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +62 -8
  23. package/template/packages/sdk/src/components/Skeleton.tsx +24 -0
  24. package/template/packages/sdk/src/config.ts +0 -2
  25. package/template/packages/sdk/src/hooks/useCloudComputer.ts +15 -48
  26. package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +17 -53
  27. package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +2 -2
  28. package/template/packages/sdk/src/hooks/useDocument.ts +12 -47
  29. package/template/packages/sdk/src/hooks/useDocuments.ts +18 -58
  30. package/template/packages/sdk/src/hooks/useQuery.ts +71 -0
  31. package/template/packages/sdk/src/hooks/useToolQuery.ts +12 -0
  32. package/template/packages/sdk/src/hooks/useTopBarSearch.ts +15 -7
  33. package/template/packages/sdk/src/index.ts +8 -0
  34. package/template/packages/sdk/src/interactions.ts +2 -1
  35. package/template/packages/sdk/src/queryCache.ts +162 -0
  36. package/template/packages/sdk/src/runtime.ts +5 -0
  37. package/template/packages/sdk/src/styles.css +28 -1
  38. package/src/runtime/app-dev-build-supervisor.js +0 -47
  39. package/src/runtime/app-dev-build.js +0 -41
  40. package/src/runtime/app-dev-consumers.js +0 -154
  41. package/src/runtime/app-dev-host-lock.js +0 -80
  42. package/src/runtime/app-dev-process-identity.js +0 -111
  43. package/src/runtime/app-dev-roots.js +0 -284
  44. package/src/runtime/app-dev-server.js +0 -1136
  45. package/src/runtime/app-dev-sessions.js +0 -185
  46. package/src/runtime/cli-mode.generated.js +0 -5
  47. package/src/runtime/cli-mode.js +0 -34
@@ -1,7 +1,9 @@
1
1
  'use client';
2
2
 
3
3
  import React, {
4
+ useEffect,
4
5
  useMemo,
6
+ useRef,
5
7
  useState,
6
8
  type CSSProperties,
7
9
  type ReactElement,
@@ -12,6 +14,13 @@ import type { ShortcutScope } from '../interactions/shortcuts';
12
14
 
13
15
  export type MultiSelectAction = ResolvedCollectionAction;
14
16
 
17
+ /** The Portal owns global launcher positioning; app code only reports its own bar. */
18
+ export const BULK_ACTION_LAYOUT_EVENT = 'notis:bulk-actions-layout';
19
+ export interface BulkActionLayoutDetail {
20
+ bar: HTMLElement;
21
+ active: boolean;
22
+ }
23
+
15
24
  export interface MultiSelectActionBarProps {
16
25
  selectedCount: number;
17
26
  actions: MultiSelectAction[];
@@ -29,7 +38,7 @@ export interface MultiSelectActionBarProps {
29
38
 
30
39
  const containerBaseStyle: CSSProperties = {
31
40
  position: 'fixed',
32
- bottom: '1rem',
41
+ bottom: 'calc(var(--notis-viewport-bottom, 0px) + max(1rem, var(--notis-safe-area-bottom, env(safe-area-inset-bottom, 0px))))',
33
42
  left: '50%',
34
43
  transform: 'translateX(-50%)',
35
44
  zIndex: 60,
@@ -46,6 +55,8 @@ const containerBaseStyle: CSSProperties = {
46
55
  pointerEvents: 'auto',
47
56
  fontSize: '13px',
48
57
  lineHeight: 1.2,
58
+ width: 'max-content',
59
+ maxWidth: 'calc(100% - 2rem)',
49
60
  };
50
61
 
51
62
  const countStyle: CSSProperties = {
@@ -115,6 +126,32 @@ export function MultiSelectActionBar({
115
126
  shortcutsEnabled = true,
116
127
  shortcutScope = 'collection',
117
128
  }: MultiSelectActionBarProps): ReactElement | null {
129
+ const barRef = useRef<HTMLDivElement>(null);
130
+ const visible = selectedCount > 0;
131
+ useEffect(() => {
132
+ const bar = barRef.current;
133
+ if (!visible || !bar) return;
134
+ // Notify the owning document, including from a shadow root or after unmount.
135
+ // Only the Portal host may measure this bar and change global layout styles.
136
+ const report = (active: boolean) => {
137
+ const owner = bar.ownerDocument;
138
+ const LayoutEvent = owner.defaultView?.CustomEvent;
139
+ if (LayoutEvent) {
140
+ owner.dispatchEvent(new LayoutEvent<BulkActionLayoutDetail>(BULK_ACTION_LAYOUT_EVENT, {
141
+ detail: { bar, active },
142
+ }));
143
+ }
144
+ };
145
+ const update = () => report(true);
146
+ update();
147
+ const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update);
148
+ observer?.observe(bar);
149
+ return () => {
150
+ observer?.disconnect();
151
+ report(false);
152
+ };
153
+ }, [visible]);
154
+
118
155
  const actionShortcuts = useMemo<ShortcutDefinition[]>(() => {
119
156
  return actions.flatMap((action): ShortcutDefinition[] => {
120
157
  if (!action.shortcut || action.disabled || action.pending) return [];
@@ -147,16 +184,33 @@ export function MultiSelectActionBar({
147
184
 
148
185
  return (
149
186
  <div
187
+ ref={barRef}
188
+ data-notis-bulk-actions
150
189
  role="toolbar"
151
190
  aria-label={`Bulk actions for ${selectedCount} selected ${countWord}`}
152
191
  className={className}
153
192
  style={composedStyle}
154
193
  >
155
- <span style={countStyle}>{countLabel}</span>
156
- {actions.length > 0 ? <span aria-hidden style={dividerStyle} /> : null}
157
- {actions.map((action) => (
158
- <ActionButton key={action.id} action={action} />
159
- ))}
194
+ <style>{`
195
+ [data-notis-bulk-action-icon][data-has-shortcut] { display: none !important; }
196
+ @media (max-width: 639px) {
197
+ [data-notis-bulk-actions] { flex-wrap: wrap; width: calc(100% - 2rem) !important; }
198
+ [data-notis-bulk-count] { flex-basis: 100%; padding: 0.375rem 0.5rem !important; font-size: 14px !important; }
199
+ [data-notis-bulk-divider] { display: none; }
200
+ [data-notis-bulk-action-list] { width: 100%; }
201
+ [data-notis-bulk-actions] button { min-height: 48px; font-size: 16px !important; }
202
+ [data-notis-bulk-actions] kbd { display: none !important; }
203
+ [data-notis-bulk-action-icon][data-has-shortcut] { display: inline-flex !important; }
204
+ }
205
+ @media (hover: none) { [data-notis-bulk-actions] kbd { display: none !important; } }
206
+ `}</style>
207
+ <span data-notis-bulk-count style={countStyle}>{countLabel}</span>
208
+ {actions.length > 0 ? <span data-notis-bulk-divider aria-hidden style={dividerStyle} /> : null}
209
+ <div data-notis-bulk-action-list style={{ display: 'flex', minWidth: 0, overflowX: 'auto', overscrollBehaviorX: 'contain', gap: '0.25rem' }}>
210
+ {actions.map((action) => (
211
+ <ActionButton key={action.id} action={action} />
212
+ ))}
213
+ </div>
160
214
  </div>
161
215
  );
162
216
  }
@@ -192,8 +246,8 @@ function ActionButton({ action }: { action: MultiSelectAction }) {
192
246
  onBlur={() => setHover(false)}
193
247
  style={buttonStyle}
194
248
  >
195
- {!action.shortcut && action.icon ? (
196
- <span aria-hidden style={iconSlotStyle}>{action.icon}</span>
249
+ {action.icon ? (
250
+ <span data-notis-bulk-action-icon data-has-shortcut={action.shortcut ? '' : undefined} aria-hidden style={iconSlotStyle}>{action.icon}</span>
197
251
  ) : null}
198
252
  {display ? <kbd aria-hidden style={keycapStyle}>{display}</kbd> : null}
199
253
  <span>{action.pending ? `${action.label}…` : action.label}</span>
@@ -0,0 +1,24 @@
1
+ import type { CSSProperties } from 'react';
2
+
3
+ /** Content-shaped placeholder. Never use it to replace a successful cached result. */
4
+ export function Skeleton({ className, style }: { className?: string; style?: CSSProperties }) {
5
+ return <span aria-hidden="true" className={className} style={{ display: 'block', height: 16,
6
+ // Apps may define --muted as HSL channels or a complete CSS color. A
7
+ // neutral default remains visible with either token format and no theme.
8
+ borderRadius: 6, background: 'rgba(128,128,128,.18)', ...style }} />;
9
+ }
10
+
11
+ export function ViewSkeleton({ variant = 'table', rows = 5 }: {
12
+ variant?: 'table' | 'cards' | 'graph' | 'detail'; rows?: number;
13
+ }) {
14
+ return <div role="status" aria-label="Loading content" aria-busy="true" data-notis-content-skeleton
15
+ style={{ width: '100%', display: 'grid', gap: 16, padding: 24 }}>
16
+ <Skeleton style={{ width: '32%', height: 28 }} />
17
+ {variant === 'graph' ? <Skeleton style={{ height: 420 }} /> :
18
+ <div style={{ display: 'grid', gap: 12, gridTemplateColumns: variant === 'cards' ? 'repeat(auto-fit,minmax(180px,1fr))' : '1fr' }}>
19
+ {Array.from({ length: rows }, (_, index) => <Skeleton key={index}
20
+ style={{ height: variant === 'cards' ? 112 : variant === 'detail' ? 20 : 44,
21
+ width: variant === 'detail' && index === rows - 1 ? '65%' : '100%' }} />)}
22
+ </div>}
23
+ </div>;
24
+ }
@@ -198,8 +198,6 @@ export interface NotisAppToolBinding {
198
198
  export interface NotisAppConfig {
199
199
  /** URL-safe app slug. Existing apps may still use a display name here. */
200
200
  name: string;
201
- /** Stable local-development slug. Defaults to a slug derived from `name`. */
202
- devSlug?: string;
203
201
  /** Human display title, Raycast-style. Falls back to `name`. */
204
202
  title?: string;
205
203
  description?: string;
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
- import { useCallback, useEffect, useRef, useState } from 'react';
3
+ import { useCallback, useRef } from 'react';
4
+ import { useQuery } from './useQuery';
4
5
  import { useNotisRuntime } from '../provider';
5
6
  import type { CloudComputerFacts } from '../runtime';
6
7
 
@@ -11,6 +12,7 @@ export interface UseCloudComputerResult {
11
12
  */
12
13
  facts: CloudComputerFacts | null;
13
14
  loading: boolean;
15
+ isFetching: boolean;
14
16
  error: Error | null;
15
17
  /** Re-read the facts. The platform caches them for a few minutes. */
16
18
  refresh: () => Promise<void>;
@@ -47,51 +49,16 @@ const UNAVAILABLE: CloudComputerFacts = {
47
49
  */
48
50
  export function useCloudComputer(): UseCloudComputerResult {
49
51
  const runtime = useNotisRuntime();
50
- const [facts, setFacts] = useState<CloudComputerFacts | null>(null);
51
- // True from the first committed render: the initial read is already queued
52
- // in an effect, and `{ loading: false, facts: null }` would flash a
53
- // consumer's fallback branch before the answer arrives.
54
- const [loading, setLoading] = useState(true);
55
- const [error, setError] = useState<Error | null>(null);
56
- const mounted = useRef(true);
57
-
58
- useEffect(() => {
59
- mounted.current = true;
60
- return () => {
61
- mounted.current = false;
62
- };
63
- }, []);
64
-
65
- const read = useCallback(async (options?: { refresh?: boolean }) => {
66
- if (!runtime?.cloudComputerFacts) {
67
- setFacts(UNAVAILABLE);
68
- setLoading(false);
69
- return;
70
- }
71
-
72
- setLoading(true);
73
- setError(null);
74
- try {
75
- const next = await runtime.cloudComputerFacts(options);
76
- if (mounted.current) setFacts(next);
77
- } catch (err) {
78
- const e = err instanceof Error ? err : new Error(String(err));
79
- if (mounted.current) {
80
- setError(e);
81
- // A refused or failed read is the same product state as a host that
82
- // cannot answer: the app shows its fallback instead of an error.
83
- setFacts(UNAVAILABLE);
84
- }
85
- } finally {
86
- if (mounted.current) setLoading(false);
87
- }
88
- }, [runtime]);
89
-
90
- useEffect(() => {
91
- void read();
92
- }, [read]);
93
-
94
- const refresh = useCallback(() => read({ refresh: true }), [read]);
95
-
96
- return { facts, loading, error, refresh };
52
+ const explicitRefresh = useRef(false);
53
+ const query = useQuery<CloudComputerFacts>(['cloud-computer-facts'], async () => {
54
+ const refresh = explicitRefresh.current;
55
+ explicitRefresh.current = false;
56
+ return runtime?.cloudComputerFacts ? runtime.cloudComputerFacts(refresh ? { refresh: true } : undefined) : UNAVAILABLE;
57
+ }, { readOnly: true });
58
+ const refresh = useCallback(async () => {
59
+ explicitRefresh.current = true;
60
+ await query.refetch();
61
+ }, [query.refetch]);
62
+ return { facts: query.data ?? (query.error ? UNAVAILABLE : null), loading: query.loading,
63
+ isFetching: query.isFetching, error: query.error, refresh };
97
64
  }
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useCallback, useEffect, useState } from 'react';
3
+ import { useQuery } from './useQuery';
4
4
  import { useNotisRuntime } from '../provider';
5
5
  import { normalizeDatabaseProperty, optionalString } from '../documents';
6
6
  import type { DatabaseProperty } from '../runtime';
@@ -22,6 +22,8 @@ export interface UseDatabaseSchemaResult {
22
22
  description: string | null;
23
23
  properties: DatabaseProperty[];
24
24
  loading: boolean;
25
+ isFetching: boolean;
26
+ hasData: boolean;
25
27
  error: Error | null;
26
28
  refetch: () => void;
27
29
  }
@@ -29,57 +31,19 @@ export interface UseDatabaseSchemaResult {
29
31
  /** Fetch a database's schema (normalized properties + metadata). */
30
32
  export function useDatabaseSchema(databaseSlug: string): UseDatabaseSchemaResult {
31
33
  const runtime = useNotisRuntime();
32
- const [name, setName] = useState<string | null>(null);
33
- const [description, setDescription] = useState<string | null>(null);
34
- const [properties, setProperties] = useState<DatabaseProperty[]>([]);
35
- const [loading, setLoading] = useState(true);
36
- const [error, setError] = useState<Error | null>(null);
37
- const [fetchKey, setFetchKey] = useState(0);
38
-
39
- const refetch = useCallback(() => {
40
- setFetchKey((key) => key + 1);
41
- }, []);
42
-
43
- useEffect(() => {
44
- if (!runtime) {
45
- setLoading(false);
46
- return;
47
- }
48
-
49
- let cancelled = false;
50
- setLoading(true);
51
- setError(null);
52
-
53
- runtime
54
- .callTool<GetDatabaseResult>('LOCAL_NOTIS_DATABASE_GET_DATABASE', {
55
- database_slug: databaseSlug,
56
- })
57
- .then((result) => {
58
- if (cancelled) return;
59
- const message = result.error ?? result.message;
60
- if (!result.database && message) {
61
- throw new Error(message);
62
- }
63
- setName(optionalString(result.database?.name));
64
- setDescription(optionalString(result.database?.description));
65
- const rawProperties = result.database?.schema?.properties ?? [];
66
- setProperties(
67
- rawProperties
68
- .map(normalizeDatabaseProperty)
69
- .filter((property): property is DatabaseProperty => Boolean(property)),
70
- );
71
- setLoading(false);
72
- })
73
- .catch((err) => {
74
- if (cancelled) return;
75
- setError(err instanceof Error ? err : new Error(String(err)));
76
- setLoading(false);
77
- });
78
-
79
- return () => {
80
- cancelled = true;
34
+ const query = useQuery(['database-schema', databaseSlug], async () => {
35
+ const result = await runtime!.callTool<GetDatabaseResult>('LOCAL_NOTIS_DATABASE_GET_DATABASE', {
36
+ database_slug: databaseSlug,
37
+ }, { dedupe: true, readOnly: true });
38
+ if (!result.database) throw new Error(result.error || result.message || 'Database not found');
39
+ return {
40
+ name: optionalString(result.database.name),
41
+ description: optionalString(result.database.description),
42
+ properties: (result.database.schema?.properties ?? []).map(normalizeDatabaseProperty)
43
+ .filter((property): property is DatabaseProperty => Boolean(property)),
81
44
  };
82
- }, [runtime, databaseSlug, fetchKey]);
83
-
84
- return { name, description, properties, loading, error, refetch };
45
+ }, { readOnly: true });
46
+ return { ...query, name: query.data?.name ?? null, description: query.data?.description ?? null,
47
+ properties: query.data?.properties ?? EMPTY_PROPERTIES };
85
48
  }
49
+ const EMPTY_PROPERTIES: DatabaseProperty[] = [];
@@ -36,7 +36,7 @@ export function useDatabaseSubscription(
36
36
  ): UseDatabaseSubscriptionResult {
37
37
  const runtime = useNotisRuntime();
38
38
  const { subscribe = true, ...documentOptions } = options;
39
- const { documents, loading, error, refetch } = useDocuments(databaseSlug, documentOptions);
39
+ const { documents, loading, isFetching, hasData, error, refetch } = useDocuments(databaseSlug, documentOptions);
40
40
  const [live, setLive] = useState(false);
41
41
 
42
42
  const refetchRef = useRef(refetch);
@@ -72,5 +72,5 @@ export function useDatabaseSubscription(
72
72
  };
73
73
  }, [runtime, databaseSlug, enabled]);
74
74
 
75
- return { documents, rows: documents, loading, error, refetch, live };
75
+ return { documents, rows: documents, loading, isFetching, hasData, error, refetch, live };
76
76
  }
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useCallback, useEffect, useState } from 'react';
3
+ import { useQuery } from './useQuery';
4
4
  import { useNotisRuntime } from '../provider';
5
5
  import { normalizeDocumentRecord } from '../documents';
6
6
  import type { DocumentRecord } from '../runtime';
@@ -19,6 +19,8 @@ export interface UseDocumentOptions {
19
19
  export interface UseDocumentResult {
20
20
  document: DocumentRecord | null;
21
21
  loading: boolean;
22
+ isFetching: boolean;
23
+ hasData: boolean;
22
24
  error: Error | null;
23
25
  refetch: () => void;
24
26
  }
@@ -29,50 +31,13 @@ export function useDocument(
29
31
  options: UseDocumentOptions = {},
30
32
  ): UseDocumentResult {
31
33
  const runtime = useNotisRuntime();
32
- const [document, setDocument] = useState<DocumentRecord | null>(null);
33
- const [loading, setLoading] = useState(Boolean(documentId));
34
- const [error, setError] = useState<Error | null>(null);
35
- const [fetchKey, setFetchKey] = useState(0);
36
-
37
- const enabled = options.enabled !== false && Boolean(documentId);
38
-
39
- const refetch = useCallback(() => {
40
- setFetchKey((key) => key + 1);
41
- }, []);
42
-
43
- useEffect(() => {
44
- if (!runtime || !enabled || !documentId) {
45
- setDocument(null);
46
- setLoading(false);
47
- return;
48
- }
49
-
50
- let cancelled = false;
51
- setLoading(true);
52
- setError(null);
53
-
54
- runtime
55
- .callTool<GetDocumentResult>('LOCAL_NOTIS_DATABASE_GET_DOCUMENT', {
56
- document_id: documentId,
57
- })
58
- .then((result) => {
59
- if (cancelled) return;
60
- if (!result.document) {
61
- throw new Error(result.error ?? result.message ?? 'Document not found');
62
- }
63
- setDocument(normalizeDocumentRecord(result.document));
64
- setLoading(false);
65
- })
66
- .catch((err) => {
67
- if (cancelled) return;
68
- setError(err instanceof Error ? err : new Error(String(err)));
69
- setLoading(false);
70
- });
71
-
72
- return () => {
73
- cancelled = true;
74
- };
75
- }, [runtime, documentId, enabled, fetchKey]);
76
-
77
- return { document, loading, error, refetch };
34
+ const query = useQuery<DocumentRecord>(['document', documentId ?? null], async () => {
35
+ if (!runtime || !documentId) throw new Error('Document not found');
36
+ const result = await runtime.callTool<GetDocumentResult>('LOCAL_NOTIS_DATABASE_GET_DOCUMENT', {
37
+ document_id: documentId,
38
+ }, { dedupe: true, readOnly: true });
39
+ if (!result.document) throw new Error(result.error ?? result.message ?? 'Document not found');
40
+ return normalizeDocumentRecord(result.document);
41
+ }, { readOnly: true, enabled: options.enabled !== false && Boolean(documentId) });
42
+ return { document: query.data ?? null, loading: query.loading, isFetching: query.isFetching, hasData: query.hasData, error: query.error, refetch: query.refetch };
78
43
  }
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useCallback, useEffect, useState } from 'react';
3
+ import { useQuery } from './useQuery';
4
4
  import { useNotisRuntime } from '../provider';
5
5
  import { normalizeDocumentRecord } from '../documents';
6
6
  import type { DocumentRecord, QueryFilter } from '../runtime';
@@ -25,6 +25,8 @@ export interface UseDocumentsOptions {
25
25
  export interface UseDocumentsResult {
26
26
  documents: DocumentRecord[];
27
27
  loading: boolean;
28
+ isFetching: boolean;
29
+ hasData: boolean;
28
30
  error: Error | null;
29
31
  refetch: () => void;
30
32
  }
@@ -44,78 +46,36 @@ export function useDocuments(
44
46
  options: UseDocumentsOptions = {},
45
47
  ): UseDocumentsResult {
46
48
  const runtime = useNotisRuntime();
47
- const [documents, setDocuments] = useState<DocumentRecord[]>([]);
48
- const [loading, setLoading] = useState(true);
49
- const [error, setError] = useState<Error | null>(null);
50
- const [fetchKey, setFetchKey] = useState(0);
51
-
52
- const enabled = options.enabled !== false;
53
- const filterKey = JSON.stringify(options.filter ?? null);
54
-
55
- const refetch = useCallback(() => {
56
- setFetchKey((key) => key + 1);
57
- }, []);
58
-
59
- useEffect(() => {
60
- if (!runtime || !enabled) {
61
- setLoading(false);
62
- return;
63
- }
64
-
65
- let cancelled = false;
66
- setLoading(true);
67
- setError(null);
68
-
69
- const filter = filterKey === 'null' ? null : (JSON.parse(filterKey) as QueryFilter);
70
-
71
- const fetchDocuments = async (): Promise<unknown[]> => {
49
+ const query = useQuery<DocumentRecord[]>(
50
+ ['documents', databaseSlug, options.filter ?? null, options.pageSize ?? null, options.offset ?? 0, Boolean(options.fetchAll)],
51
+ async () => {
52
+ if (!runtime) throw new Error('Notis runtime not available');
72
53
  const allDocuments: unknown[] = [];
73
54
  let offset = options.offset ?? 0;
74
-
75
55
  while (true) {
76
56
  const result = await runtime.callTool<QueryDatabaseResult>('LOCAL_NOTIS_DATABASE_QUERY', {
77
57
  database_slug: databaseSlug,
78
58
  query: {
79
- ...(filter ?? {}),
59
+ ...(options.filter ?? {}),
80
60
  ...(options.pageSize !== undefined ? { page_size: options.pageSize } : {}),
81
61
  },
82
62
  ...(offset > 0 ? { offset } : {}),
83
- });
63
+ }, { dedupe: true, readOnly: true });
84
64
  const message = result.error ?? result.message;
85
- if (!result.documents && message) {
86
- throw new Error(message);
87
- }
65
+ if (!result.documents && message) throw new Error(message);
88
66
  allDocuments.push(...(result.documents ?? []));
89
- if (!options.fetchAll || !result.has_more) return allDocuments;
90
-
67
+ if (!options.fetchAll || !result.has_more) break;
91
68
  const nextOffset = result.next_offset;
92
69
  if (typeof nextOffset !== 'number' || nextOffset <= offset) {
93
70
  throw new Error('Database query returned an invalid pagination offset');
94
71
  }
95
72
  offset = nextOffset;
96
73
  }
97
- };
98
-
99
- fetchDocuments()
100
- .then((result) => {
101
- if (cancelled) return;
102
- setDocuments(
103
- result
104
- .map(normalizeDocumentRecord)
105
- .filter((document) => document.id),
106
- );
107
- setLoading(false);
108
- })
109
- .catch((err) => {
110
- if (cancelled) return;
111
- setError(err instanceof Error ? err : new Error(String(err)));
112
- setLoading(false);
113
- });
114
-
115
- return () => {
116
- cancelled = true;
117
- };
118
- }, [runtime, databaseSlug, enabled, fetchKey, filterKey, options.fetchAll, options.offset, options.pageSize]);
119
-
120
- return { documents, loading, error, refetch };
74
+ return allDocuments.map(normalizeDocumentRecord).filter((document) => document.id);
75
+ },
76
+ { readOnly: true, enabled: options.enabled },
77
+ );
78
+ return { documents: query.data ?? EMPTY_DOCUMENTS, loading: query.loading, isFetching: query.isFetching, hasData: query.hasData, error: query.error, refetch: query.refetch };
121
79
  }
80
+
81
+ const EMPTY_DOCUMENTS: DocumentRecord[] = [];
@@ -0,0 +1,71 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react';
4
+ import { useNotisRuntime } from '../provider';
5
+ import { createQueryClient, queryKey } from '../queryCache';
6
+
7
+ export interface UseQueryOptions {
8
+ /** Required acknowledgement: only idempotent reads may run on mount or prefetch. */
9
+ readOnly: true;
10
+ enabled?: boolean;
11
+ staleTimeMs?: number;
12
+ }
13
+
14
+ export interface UseQueryResult<T> {
15
+ data: T | undefined;
16
+ hasData: boolean;
17
+ loading: boolean;
18
+ isFetching: boolean;
19
+ error: Error | null;
20
+ refetch: () => Promise<void>;
21
+ }
22
+
23
+ /** Cached read callback; scope and lifetime are provided by the host, never by app globals. */
24
+ export function useQuery<T>(key: readonly unknown[], read: () => Promise<T>, options: UseQueryOptions): UseQueryResult<T> {
25
+ const runtime = useNotisRuntime();
26
+ // Older hosts deliberately get a hook-local cache: no cross-runtime/account reuse.
27
+ const fallback = useMemo(() => createQueryClient(), [runtime]);
28
+ const client = runtime?.queryClient ?? fallback;
29
+ const cacheKey = queryKey(key);
30
+ const enabled = Boolean(runtime) && options.enabled !== false && options.readOnly === true;
31
+ const readRef = useRef(read);
32
+ useLayoutEffect(() => { readRef.current = read; });
33
+ const subscribe = useCallback((listener: () => void) => enabled ? client.subscribe(cacheKey, listener) : () => {}, [cacheKey, client, enabled]);
34
+ const snapshot = useSyncExternalStore(subscribe, () => client.getSnapshot<T>(cacheKey), () => client.getSnapshot<T>(cacheKey));
35
+ const fetch = useCallback(() => {
36
+ const capturedRead = readRef.current;
37
+ return client.fetch(cacheKey, capturedRead, { staleTimeMs: options.staleTimeMs });
38
+ }, [cacheKey, client, options.staleTimeMs]);
39
+ useEffect(() => {
40
+ if (!enabled) return;
41
+ void fetch().catch(() => {}); // Error belongs to the snapshot; no unhandled rejection.
42
+ }, [enabled, fetch, snapshot.invalidation]);
43
+ const refetch = useCallback(async () => {
44
+ if (!enabled) return;
45
+ client.invalidate(cacheKey);
46
+ await fetch().catch(() => {});
47
+ }, [cacheKey, client, enabled, fetch]);
48
+ return {
49
+ data: enabled ? snapshot.data : undefined,
50
+ hasData: enabled && snapshot.hasData,
51
+ loading: enabled && !snapshot.hasData && !snapshot.error,
52
+ isFetching: enabled && snapshot.isFetching,
53
+ error: enabled ? snapshot.error : null,
54
+ refetch,
55
+ };
56
+ }
57
+
58
+ /** Opt-in preparation for small, known read queries. Never use for writes or full-database crawls. */
59
+ export function useQueryClient(): {
60
+ invalidate(key?: readonly unknown[]): void;
61
+ prefetch<T>(key: readonly unknown[], read: () => Promise<T>, options: UseQueryOptions): Promise<T | undefined>;
62
+ } {
63
+ const runtime = useNotisRuntime();
64
+ return useMemo(() => ({
65
+ invalidate: (key?: readonly unknown[]) => runtime?.queryClient?.invalidate(key ? queryKey(key) : undefined),
66
+ prefetch: <T,>(key: readonly unknown[], read: () => Promise<T>, options: UseQueryOptions) => {
67
+ if (!runtime?.queryClient || options.enabled === false || options.readOnly !== true) return Promise.resolve(undefined);
68
+ return runtime.queryClient.prefetch(queryKey(key), read, { staleTimeMs: options.staleTimeMs });
69
+ },
70
+ }), [runtime]);
71
+ }
@@ -0,0 +1,12 @@
1
+ 'use client';
2
+
3
+ import { useNotisRuntime } from '../provider';
4
+ import { useQuery, type UseQueryOptions } from './useQuery';
5
+
6
+ /** An explicitly identified read-only tool, cached by tool name and exact arguments. */
7
+ export function useToolQuery<T = unknown>(name: string, args: Record<string, unknown> = {}, options: UseQueryOptions) {
8
+ const runtime = useNotisRuntime();
9
+ return useQuery<T>(['tool', name, args], () => runtime!.callTool<T>(name, args, {
10
+ readOnly: true, dedupe: true,
11
+ }), options);
12
+ }