@notis_ai/cli 0.2.0-beta.154.1 → 0.2.0-beta.156.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 (30) hide show
  1. package/dist/agent-hooks/notis-agent-hook.mjs +209 -110
  2. package/dist/base-skills/notis-apps/SKILL.md +33 -30
  3. package/dist/skill-sync/index.js +134 -72
  4. package/dist/skill-sync/index.js.map +2 -2
  5. package/package.json +1 -1
  6. package/src/command-specs/apps.js +4 -2
  7. package/src/command-specs/skills.js +3 -2
  8. package/src/command-specs/tools.js +5 -0
  9. package/src/runtime/app-dev-build-supervisor.js +47 -0
  10. package/src/runtime/app-dev-build.js +41 -0
  11. package/src/runtime/app-dev-server.js +2 -6
  12. package/src/runtime/skill-sync/cloud-client.ts +5 -3
  13. package/src/runtime/skill-sync/index.ts +73 -65
  14. package/src/runtime/skill-sync/symlink-manager.ts +66 -16
  15. package/src/runtime/skill-sync/types.ts +5 -0
  16. package/template/app/page.tsx +8 -7
  17. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +49 -8
  18. package/template/packages/sdk/src/components/Skeleton.tsx +24 -0
  19. package/template/packages/sdk/src/hooks/useCloudComputer.ts +15 -48
  20. package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +17 -53
  21. package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +2 -2
  22. package/template/packages/sdk/src/hooks/useDocument.ts +12 -47
  23. package/template/packages/sdk/src/hooks/useDocuments.ts +18 -58
  24. package/template/packages/sdk/src/hooks/useQuery.ts +71 -0
  25. package/template/packages/sdk/src/hooks/useToolQuery.ts +12 -0
  26. package/template/packages/sdk/src/hooks/useTopBarSearch.ts +15 -7
  27. package/template/packages/sdk/src/index.ts +8 -0
  28. package/template/packages/sdk/src/interactions/shortcuts.tsx +1 -1
  29. package/template/packages/sdk/src/queryCache.ts +162 -0
  30. package/template/packages/sdk/src/runtime.ts +5 -0
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { getDocumentPreview, useDocuments, useNotis } from '@notis/sdk';
3
+ import { getDocumentPreview, useDocuments, useNotis, Skeleton, ViewSkeleton } from '@notis/sdk';
4
4
  import { Badge } from '@/components/ui/badge';
5
5
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
6
6
 
@@ -8,7 +8,7 @@ export default function HomePage() {
8
8
  const { app, ready } = useNotis();
9
9
  // Documents come back normalized: plain property values, camelCase fields,
10
10
  // and typed content (contentMarkdown / contentBlocknote / plainText).
11
- const { documents, loading } = useDocuments('items', { pageSize: 25 });
11
+ const { documents, loading, hasData, error, refetch } = useDocuments('items', { pageSize: 25 });
12
12
 
13
13
  return (
14
14
  <main className="notis-app-shell space-y-6">
@@ -16,9 +16,9 @@ export default function HomePage() {
16
16
  <CardHeader className="space-y-3">
17
17
  <Badge variant="secondary" className="w-fit">Installed app</Badge>
18
18
  <div className="space-y-2">
19
- <CardTitle>{ready ? app?.name : 'Loading...'}</CardTitle>
19
+ <CardTitle>{ready ? app?.name : <Skeleton style={{ width: 160 }} />}</CardTitle>
20
20
  <CardDescription>
21
- {ready ? app?.description : 'Loading app metadata...'}
21
+ {ready ? app?.description : <Skeleton style={{ width: 240 }} />}
22
22
  </CardDescription>
23
23
  </div>
24
24
  </CardHeader>
@@ -30,11 +30,12 @@ export default function HomePage() {
30
30
  <CardDescription>Use shadcn surfaces and portal tokens so the app feels native inside Notis.</CardDescription>
31
31
  </CardHeader>
32
32
  <CardContent>
33
+ {error && <p role="alert" className="mb-3 text-sm text-destructive">{error.message} <button onClick={refetch}>Retry</button></p>}
33
34
  {loading ? (
34
- <p className="text-sm text-muted-foreground">Loading...</p>
35
- ) : documents.length === 0 ? (
35
+ <ViewSkeleton variant="table" rows={4} />
36
+ ) : !hasData ? null : documents.length === 0 ? (
36
37
  <div className="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground">
37
- No items yet. Deploy the app and create some.
38
+ No items yet. Create your first item.
38
39
  </div>
39
40
  ) : (
40
41
  <div className="space-y-3">
@@ -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,
@@ -29,7 +31,7 @@ export interface MultiSelectActionBarProps {
29
31
 
30
32
  const containerBaseStyle: CSSProperties = {
31
33
  position: 'fixed',
32
- bottom: '1rem',
34
+ bottom: 'calc(var(--notis-viewport-bottom, 0px) + max(1rem, var(--notis-safe-area-bottom, env(safe-area-inset-bottom, 0px))))',
33
35
  left: '50%',
34
36
  transform: 'translateX(-50%)',
35
37
  zIndex: 60,
@@ -46,6 +48,8 @@ const containerBaseStyle: CSSProperties = {
46
48
  pointerEvents: 'auto',
47
49
  fontSize: '13px',
48
50
  lineHeight: 1.2,
51
+ width: 'max-content',
52
+ maxWidth: 'calc(100% - 2rem)',
49
53
  };
50
54
 
51
55
  const countStyle: CSSProperties = {
@@ -115,6 +119,26 @@ export function MultiSelectActionBar({
115
119
  shortcutsEnabled = true,
116
120
  shortcutScope = 'collection',
117
121
  }: MultiSelectActionBarProps): ReactElement | null {
122
+ const barRef = useRef<HTMLDivElement>(null);
123
+ const visible = selectedCount > 0;
124
+ useEffect(() => {
125
+ const bar = barRef.current;
126
+ if (!visible || !bar) return;
127
+ // Also reaches a Portal launcher when an app renders inside a shadow root.
128
+ const root = document.documentElement;
129
+ const property = '--notis-bulk-actions-height';
130
+ const previous = root.style.getPropertyValue(property);
131
+ const update = () => root.style.setProperty(property, `${bar.getBoundingClientRect().height + 12}px`);
132
+ update();
133
+ const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update);
134
+ observer?.observe(bar);
135
+ return () => {
136
+ observer?.disconnect();
137
+ if (previous) root.style.setProperty(property, previous);
138
+ else root.style.removeProperty(property);
139
+ };
140
+ }, [visible]);
141
+
118
142
  const actionShortcuts = useMemo<ShortcutDefinition[]>(() => {
119
143
  return actions.flatMap((action): ShortcutDefinition[] => {
120
144
  if (!action.shortcut || action.disabled || action.pending) return [];
@@ -147,16 +171,33 @@ export function MultiSelectActionBar({
147
171
 
148
172
  return (
149
173
  <div
174
+ ref={barRef}
175
+ data-notis-bulk-actions
150
176
  role="toolbar"
151
177
  aria-label={`Bulk actions for ${selectedCount} selected ${countWord}`}
152
178
  className={className}
153
179
  style={composedStyle}
154
180
  >
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
- ))}
181
+ <style>{`
182
+ [data-notis-bulk-action-icon][data-has-shortcut] { display: none !important; }
183
+ @media (max-width: 639px) {
184
+ [data-notis-bulk-actions] { flex-wrap: wrap; width: calc(100% - 2rem) !important; }
185
+ [data-notis-bulk-count] { flex-basis: 100%; padding: 0.375rem 0.5rem !important; font-size: 14px !important; }
186
+ [data-notis-bulk-divider] { display: none; }
187
+ [data-notis-bulk-action-list] { width: 100%; }
188
+ [data-notis-bulk-actions] button { min-height: 48px; font-size: 16px !important; }
189
+ [data-notis-bulk-actions] kbd { display: none !important; }
190
+ [data-notis-bulk-action-icon][data-has-shortcut] { display: inline-flex !important; }
191
+ }
192
+ @media (hover: none) { [data-notis-bulk-actions] kbd { display: none !important; } }
193
+ `}</style>
194
+ <span data-notis-bulk-count style={countStyle}>{countLabel}</span>
195
+ {actions.length > 0 ? <span data-notis-bulk-divider aria-hidden style={dividerStyle} /> : null}
196
+ <div data-notis-bulk-action-list style={{ display: 'flex', minWidth: 0, overflowX: 'auto', overscrollBehaviorX: 'contain', gap: '0.25rem' }}>
197
+ {actions.map((action) => (
198
+ <ActionButton key={action.id} action={action} />
199
+ ))}
200
+ </div>
160
201
  </div>
161
202
  );
162
203
  }
@@ -192,8 +233,8 @@ function ActionButton({ action }: { action: MultiSelectAction }) {
192
233
  onBlur={() => setHover(false)}
193
234
  style={buttonStyle}
194
235
  >
195
- {!action.shortcut && action.icon ? (
196
- <span aria-hidden style={iconSlotStyle}>{action.icon}</span>
236
+ {action.icon ? (
237
+ <span data-notis-bulk-action-icon data-has-shortcut={action.shortcut ? '' : undefined} aria-hidden style={iconSlotStyle}>{action.icon}</span>
197
238
  ) : null}
198
239
  {display ? <kbd aria-hidden style={keycapStyle}>{display}</kbd> : null}
199
240
  <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
+ }
@@ -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
+ }