@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.
- package/dist/agent-hooks/notis-agent-hook.mjs +209 -110
- package/dist/base-skills/notis-apps/SKILL.md +33 -30
- package/dist/skill-sync/index.js +134 -72
- package/dist/skill-sync/index.js.map +2 -2
- package/package.json +1 -1
- package/src/command-specs/apps.js +4 -2
- package/src/command-specs/skills.js +3 -2
- package/src/command-specs/tools.js +5 -0
- package/src/runtime/app-dev-build-supervisor.js +47 -0
- package/src/runtime/app-dev-build.js +41 -0
- package/src/runtime/app-dev-server.js +2 -6
- package/src/runtime/skill-sync/cloud-client.ts +5 -3
- package/src/runtime/skill-sync/index.ts +73 -65
- package/src/runtime/skill-sync/symlink-manager.ts +66 -16
- package/src/runtime/skill-sync/types.ts +5 -0
- package/template/app/page.tsx +8 -7
- package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +49 -8
- package/template/packages/sdk/src/components/Skeleton.tsx +24 -0
- package/template/packages/sdk/src/hooks/useCloudComputer.ts +15 -48
- package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +17 -53
- package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +2 -2
- package/template/packages/sdk/src/hooks/useDocument.ts +12 -47
- package/template/packages/sdk/src/hooks/useDocuments.ts +18 -58
- package/template/packages/sdk/src/hooks/useQuery.ts +71 -0
- package/template/packages/sdk/src/hooks/useToolQuery.ts +12 -0
- package/template/packages/sdk/src/hooks/useTopBarSearch.ts +15 -7
- package/template/packages/sdk/src/index.ts +8 -0
- package/template/packages/sdk/src/interactions/shortcuts.tsx +1 -1
- package/template/packages/sdk/src/queryCache.ts +162 -0
- package/template/packages/sdk/src/runtime.ts +5 -0
package/template/app/page.tsx
CHANGED
|
@@ -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 :
|
|
19
|
+
<CardTitle>{ready ? app?.name : <Skeleton style={{ width: 160 }} />}</CardTitle>
|
|
20
20
|
<CardDescription>
|
|
21
|
-
{ready ? app?.description :
|
|
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
|
-
<
|
|
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.
|
|
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
|
-
<
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
{
|
|
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,
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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 {
|
|
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 [
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
},
|
|
83
|
-
|
|
84
|
-
|
|
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 {
|
|
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,
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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 {
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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)
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
+
}
|