@notis_ai/cli 0.2.0-beta.155.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 +6 -4
- package/dist/base-skills/notis-apps/SKILL.md +33 -30
- package/package.json +1 -1
- package/src/command-specs/apps.js +4 -2
- 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/queryCache.ts +162 -0
- package/template/packages/sdk/src/runtime.ts +5 -0
|
@@ -13830,7 +13830,7 @@ function runtimeCallLabel(call) {
|
|
|
13830
13830
|
}
|
|
13831
13831
|
return call?.op || "runtime call";
|
|
13832
13832
|
}
|
|
13833
|
-
function assertHarnessResult(result, route, databaseSlugs, mode = "stub") {
|
|
13833
|
+
function assertHarnessResult(result, route, databaseSlugs, mode = "stub", capabilities = {}) {
|
|
13834
13834
|
const assertions = [];
|
|
13835
13835
|
if (result.tool_error) {
|
|
13836
13836
|
assertions.push({
|
|
@@ -13869,7 +13869,7 @@ function assertHarnessResult(result, route, databaseSlugs, mode = "stub") {
|
|
|
13869
13869
|
);
|
|
13870
13870
|
for (const call of databaseQueries) {
|
|
13871
13871
|
const databaseSlug = call?.args?.arguments?.database_slug;
|
|
13872
|
-
if (databaseSlug && !declaredDatabaseSet.has(databaseSlug)) {
|
|
13872
|
+
if (databaseSlug && !declaredDatabaseSet.has(databaseSlug) && capabilities.workspaceDatabases !== "read") {
|
|
13873
13873
|
assertions.push({
|
|
13874
13874
|
ok: false,
|
|
13875
13875
|
code: "undeclared_database_query",
|
|
@@ -14963,7 +14963,8 @@ ${listing.errors.map((error) => ` - ${error}`).join("\n")}`);
|
|
|
14963
14963
|
result,
|
|
14964
14964
|
route,
|
|
14965
14965
|
declaredDatabaseSlugs(appConfig, manifest, route),
|
|
14966
|
-
mode
|
|
14966
|
+
mode,
|
|
14967
|
+
manifest.capabilities || appConfig.capabilities || {}
|
|
14967
14968
|
);
|
|
14968
14969
|
return {
|
|
14969
14970
|
...result,
|
|
@@ -14988,7 +14989,8 @@ ${listing.errors.map((error) => ` - ${error}`).join("\n")}`);
|
|
|
14988
14989
|
result,
|
|
14989
14990
|
route,
|
|
14990
14991
|
declaredDatabaseSlugs(appConfig, manifest, route),
|
|
14991
|
-
mode
|
|
14992
|
+
mode,
|
|
14993
|
+
manifest.capabilities || appConfig.capabilities || {}
|
|
14992
14994
|
);
|
|
14993
14995
|
results.push({
|
|
14994
14996
|
route: route.slug,
|
|
@@ -104,7 +104,7 @@ App code never accesses the runtime directly -- it uses SDK hooks (`useTool`, `u
|
|
|
104
104
|
|
|
105
105
|
1. **React + Vite only** -- No Next.js, no custom server
|
|
106
106
|
2. **ES module bundle** -- Vite builds a library-mode bundle with React externalized
|
|
107
|
-
3. **Component rendering** -- Apps render as React components directly in the portal.
|
|
107
|
+
3. **Component rendering** -- Apps render as React components directly in the portal. Do not create your own iframe; the host chooses the trusted shadow-root or isolated Store rendering path.
|
|
108
108
|
The portal owns the `ShadowRoot`, theme tokens, and runtime provider.
|
|
109
109
|
4. **HTTP bridge** -- Runtime calls use fetch to `/portal_views/runtime_query`
|
|
110
110
|
5. **Declarative tools** -- Tool access is declared in `notis.config.ts` by the final names returned by tool discovery and enforced server-side. Views can call native Notis, connected integrations, PostForMe, and MCP tools directly; metered calls use the same credit-cap and usage-billing path as the CLI.
|
|
@@ -293,40 +293,43 @@ For arbitrary app-owned resources that are not Notis collection rows, set `resou
|
|
|
293
293
|
Standard React pages in `app/`. Use generic SDK tool hooks for data and build on top of the scaffolded shadcn components and portal shell classes (`notis-app-shell`, `notis-app-surface`):
|
|
294
294
|
|
|
295
295
|
```tsx
|
|
296
|
-
|
|
297
|
-
import { useEffect, useState } from 'react';
|
|
298
|
-
import { useTool } from '@notis/sdk';
|
|
296
|
+
import { useDocuments, ViewSkeleton } from '@notis/sdk';
|
|
299
297
|
import { Card } from '@/components/ui/card';
|
|
300
298
|
|
|
301
|
-
type QueryTasksArgs = { database_id?: string; database_slug?: string; query: { page_size?: number } };
|
|
302
|
-
type TaskDoc = { document_id?: string; id?: string; title?: string; properties?: Record<string, unknown> };
|
|
303
|
-
type QueryTasksResult = { documents?: TaskDoc[] };
|
|
304
|
-
|
|
305
299
|
export default function TasksPage() {
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
.
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
return (
|
|
318
|
-
<div className="p-6 space-y-4">
|
|
319
|
-
{documents.map((doc) => (
|
|
320
|
-
<Card key={doc.id || doc.document_id} className="p-4">
|
|
321
|
-
<h3>{doc.title || 'Untitled'}</h3>
|
|
322
|
-
<p className="text-muted-foreground">{String(doc.properties?.status || '')}</p>
|
|
323
|
-
</Card>
|
|
324
|
-
))}
|
|
325
|
-
</div>
|
|
326
|
-
);
|
|
300
|
+
const tasks = useDocuments('tasks', { pageSize: 25 });
|
|
301
|
+
return <section className="space-y-4 p-6">
|
|
302
|
+
<h1 className="text-xl font-semibold">Tasks</h1>
|
|
303
|
+
{tasks.error && <p role="alert">{tasks.error.message} <button onClick={tasks.refetch}>Retry</button></p>}
|
|
304
|
+
{tasks.loading ? <ViewSkeleton variant="table" rows={5} /> : tasks.hasData ? (
|
|
305
|
+
tasks.documents.length ? tasks.documents.map((task) => (
|
|
306
|
+
<Card key={task.id} className="p-4"><h2>{task.title || 'Untitled'}</h2></Card>
|
|
307
|
+
)) : <p>No tasks yet.</p>
|
|
308
|
+
) : null}
|
|
309
|
+
</section>;
|
|
327
310
|
}
|
|
328
311
|
```
|
|
329
312
|
|
|
313
|
+
### Instant-view loading contract (required)
|
|
314
|
+
|
|
315
|
+
Build a client-side, multi-route app with one persistent `app/layout.tsx` shell. Navigate with `useNotisNavigation`; never use a document reload for an internal route. “SPA” means preserving that shell and reusing reads, **not** mounting every page or fetching every database at startup.
|
|
316
|
+
|
|
317
|
+
| State | Required UI |
|
|
318
|
+
| --- | --- |
|
|
319
|
+
| First read, no successful data | Keep headings/navigation/layout visible; use content-shaped skeletons only in missing regions. No page spinner or whole-page `Loading...`. |
|
|
320
|
+
| Cached view / successful empty result | Render synchronously from the shared SDK cache. Empty results are real cached results. |
|
|
321
|
+
| Background refresh | Keep current content and selection. Never replace populated content with a skeleton; do not drive the top-bar spinner from mount/refetch state. |
|
|
322
|
+
| Explicit Save / Upload / submitted search | Progress belongs in that button or affected section. Disable only the conflicting action. |
|
|
323
|
+
| Failed read | Show a scoped error and Retry; keep usable cached content. Never show an empty-state message before `hasData` is true. |
|
|
324
|
+
|
|
325
|
+
Use `useDocuments`, `useDocument`, `useDatabaseSchema`, and `useDatabaseSubscription` for native reads. `loading` means no first successful response; `isFetching` includes silent refresh. Do not copy their data into mount-only state, clear rows on error, or gate the entire app on `isFetching`.
|
|
326
|
+
|
|
327
|
+
For another **explicitly identified idempotent read**, use `useToolQuery<Result>(toolName, exactArguments, { readOnly: true })`, or `useQuery(keyArray, readCallback, { readOnly: true })`. Include every filter, selected resource, pagination option, and other input in the key. Call tools within a custom read with `{ readOnly: true, dedupe: true }`; the same SQL/shell tool can also perform writes, so never mark a whole toolkit read-only. Leave mutations as ordinary `useTool` actions.
|
|
328
|
+
|
|
329
|
+
`useQueryClient().prefetch(keyArray, readCallback, { readOnly: true })` prepares small known reads after the current view has rendered or on hover/focus. It shares the host's two-request speculative budget. Match the exact foreground query key. Never prefetch a mutation, login/polling action, provider sweep, `fetchAll` query, or an aggregate that fans out into more requests. Do not invent tool names to prepare a view. Older hosts safely fall back to uncached hook-local reads and skip prefetch.
|
|
330
|
+
|
|
331
|
+
Caches belong to the host's in-memory account/environment/app/version/effective-permission scope. Do not add module-global or `localStorage` caches of user data. Writes and realtime events invalidate reads; logout, access loss, and updates retire scopes. Preserve the last successful snapshot on an ordinary network failure.
|
|
332
|
+
|
|
330
333
|
### Discovering database schema
|
|
331
334
|
|
|
332
335
|
Before writing app code, inspect the database schema to know what properties exist:
|
|
@@ -367,7 +370,7 @@ Do NOT pass Notion-style wrappers (`{select: {name: "Todo"}}`) when upserting.
|
|
|
367
370
|
- If a screen looks like a standalone microsite instead of a portal tool, it is too custom.
|
|
368
371
|
- For Notes-style apps, the folder tree belongs to the portal sidebar when configured via `collection.sidebar`. The page content should complement that chrome, not duplicate or replace it.
|
|
369
372
|
- Never indicate selected items with a heavy left-border bar (e.g. `border-l-2 border-l-foreground` paired with a muted background). It looks dated and clashes with the portal chrome. Use a single subtle background change (`bg-muted` for selected, `hover:bg-muted/50` for hover) and let typography or an icon carry the rest of the state.
|
|
370
|
-
- Do not render any search input inside the app (in-page search rails, "Ask Notis…" pills, command-palette-style bars, etc.). The portal already owns the top-bar search field. Wire your view to it with `useTopBarSearch({ value, onChange, placeholder, onSubmit })` from `@notis/sdk` and let the page filter or refetch on the values it receives.
|
|
373
|
+
- Do not render any search input inside the app (in-page search rails, "Ask Notis…" pills, command-palette-style bars, etc.). The portal already owns the top-bar search field. Wire your view to it with `useTopBarSearch({ value, onChange, placeholder, onSubmit })` from `@notis/sdk` and let the page filter or refetch on the values it receives. Use its `setLoading` only for an explicit submitted search, never initial view loading or background refresh.
|
|
371
374
|
|
|
372
375
|
### Sidebar invariants
|
|
373
376
|
|
package/package.json
CHANGED
|
@@ -516,7 +516,7 @@ function runtimeCallLabel(call) {
|
|
|
516
516
|
return call?.op || 'runtime call';
|
|
517
517
|
}
|
|
518
518
|
|
|
519
|
-
function assertHarnessResult(result, route, databaseSlugs, mode = 'stub') {
|
|
519
|
+
function assertHarnessResult(result, route, databaseSlugs, mode = 'stub', capabilities = {}) {
|
|
520
520
|
const assertions = [];
|
|
521
521
|
if (result.tool_error) {
|
|
522
522
|
assertions.push({
|
|
@@ -557,7 +557,7 @@ function assertHarnessResult(result, route, databaseSlugs, mode = 'stub') {
|
|
|
557
557
|
);
|
|
558
558
|
for (const call of databaseQueries) {
|
|
559
559
|
const databaseSlug = call?.args?.arguments?.database_slug;
|
|
560
|
-
if (databaseSlug && !declaredDatabaseSet.has(databaseSlug)) {
|
|
560
|
+
if (databaseSlug && !declaredDatabaseSet.has(databaseSlug) && capabilities.workspaceDatabases !== 'read') {
|
|
561
561
|
assertions.push({
|
|
562
562
|
ok: false,
|
|
563
563
|
code: 'undeclared_database_query',
|
|
@@ -1842,6 +1842,7 @@ async function appsVerifyHandler(ctx) {
|
|
|
1842
1842
|
route,
|
|
1843
1843
|
declaredDatabaseSlugs(appConfig, manifest, route),
|
|
1844
1844
|
mode,
|
|
1845
|
+
manifest.capabilities || appConfig.capabilities || {},
|
|
1845
1846
|
);
|
|
1846
1847
|
return {
|
|
1847
1848
|
...result,
|
|
@@ -1867,6 +1868,7 @@ async function appsVerifyHandler(ctx) {
|
|
|
1867
1868
|
route,
|
|
1868
1869
|
declaredDatabaseSlugs(appConfig, manifest, route),
|
|
1869
1870
|
mode,
|
|
1871
|
+
manifest.capabilities || appConfig.capabilities || {},
|
|
1870
1872
|
);
|
|
1871
1873
|
results.push({
|
|
1872
1874
|
route: route.slug,
|
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
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useCallback, useEffect } from 'react';
|
|
3
|
+
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
|
|
4
4
|
import { useNotisRuntime } from '../provider';
|
|
5
5
|
|
|
6
6
|
interface TopBarSearchOptions {
|
|
@@ -24,8 +24,9 @@ interface TopBarSearchActions {
|
|
|
24
24
|
* While the component is mounted, typing in the top bar calls `onChange`,
|
|
25
25
|
* pressing Enter calls `onSubmit`, and the `value` prop drives the input.
|
|
26
26
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
27
|
+
* Use `setLoading(true)` only for an explicitly submitted search action, then
|
|
28
|
+
* restore the icon with `setLoading(false)`. Automatic reads use content
|
|
29
|
+
* skeletons initially and refresh populated content silently.
|
|
29
30
|
*
|
|
30
31
|
* Without a portal runtime, this hook is a safe no-op.
|
|
31
32
|
*
|
|
@@ -42,21 +43,28 @@ interface TopBarSearchActions {
|
|
|
42
43
|
* });
|
|
43
44
|
* ```
|
|
44
45
|
*
|
|
45
|
-
*
|
|
46
|
-
* on every render.
|
|
46
|
+
* Callback refs retain the latest committed handlers without re-registering
|
|
47
|
+
* search on every app render. Inline callbacks are safe.
|
|
47
48
|
*/
|
|
48
49
|
export function useTopBarSearch(opts: TopBarSearchOptions): TopBarSearchActions {
|
|
49
50
|
const runtime = useNotisRuntime();
|
|
50
51
|
const { value, onChange, placeholder, onSubmit } = opts;
|
|
52
|
+
const callbacks = useRef({ onChange, onSubmit });
|
|
53
|
+
useLayoutEffect(() => { callbacks.current = { onChange, onSubmit }; });
|
|
54
|
+
const hasSubmit = Boolean(onSubmit);
|
|
51
55
|
|
|
52
56
|
useEffect(() => {
|
|
53
57
|
const register = runtime?.registerTopBarSearch;
|
|
54
58
|
if (!register) return;
|
|
55
|
-
register({
|
|
59
|
+
register({
|
|
60
|
+
onChange: (next) => callbacks.current.onChange(next),
|
|
61
|
+
placeholder,
|
|
62
|
+
onSubmit: hasSubmit ? () => callbacks.current.onSubmit?.() : undefined,
|
|
63
|
+
});
|
|
56
64
|
return () => {
|
|
57
65
|
register(null);
|
|
58
66
|
};
|
|
59
|
-
}, [runtime,
|
|
67
|
+
}, [runtime, placeholder, hasSubmit]);
|
|
60
68
|
|
|
61
69
|
useEffect(() => {
|
|
62
70
|
runtime?.setTopBarSearchValue?.(value);
|
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
export { NotisProvider, useNotisRuntime } from './provider';
|
|
11
11
|
|
|
12
12
|
// Hooks
|
|
13
|
+
export { useQuery, useQueryClient } from './hooks/useQuery';
|
|
14
|
+
export type { UseQueryOptions, UseQueryResult } from './hooks/useQuery';
|
|
15
|
+
export { createQueryClient, createPrefetchQueue, queryKey } from './queryCache';
|
|
16
|
+
export type { NotisQueryClient, QuerySnapshot, QueryFetchOptions } from './queryCache';
|
|
13
17
|
export { useNotis } from './hooks/useNotis';
|
|
14
18
|
export { useDocuments } from './hooks/useDocuments';
|
|
15
19
|
export type { UseDocumentsOptions, UseDocumentsResult } from './hooks/useDocuments';
|
|
@@ -143,3 +147,7 @@ export type {
|
|
|
143
147
|
ToolCallOptions,
|
|
144
148
|
ToolInputSchema,
|
|
145
149
|
} from './runtime';
|
|
150
|
+
|
|
151
|
+
export { useToolQuery } from './hooks/useToolQuery';
|
|
152
|
+
|
|
153
|
+
export { Skeleton, ViewSkeleton } from './components/Skeleton';
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/** In-memory read snapshots. The host owns the lifetime and authorization scope. */
|
|
2
|
+
export interface QuerySnapshot<T = unknown> {
|
|
3
|
+
data: T | undefined;
|
|
4
|
+
hasData: boolean;
|
|
5
|
+
isFetching: boolean;
|
|
6
|
+
error: Error | null;
|
|
7
|
+
updatedAt: number;
|
|
8
|
+
invalidation: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface QueryFetchOptions {
|
|
12
|
+
staleTimeMs?: number;
|
|
13
|
+
force?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface NotisQueryClient {
|
|
17
|
+
getSnapshot<T>(key: string): QuerySnapshot<T>;
|
|
18
|
+
subscribe(key: string, listener: () => void): () => void;
|
|
19
|
+
fetch<T>(key: string, read: () => Promise<T>, options?: QueryFetchOptions): Promise<T>;
|
|
20
|
+
prefetch<T>(key: string, read: () => Promise<T>, options?: QueryFetchOptions): Promise<T>;
|
|
21
|
+
invalidate(key?: string): void;
|
|
22
|
+
clear(): void;
|
|
23
|
+
/** Host-only: permanently retire a revoked security scope. */
|
|
24
|
+
dispose?(): void;
|
|
25
|
+
getRevision?(): number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const EMPTY: QuerySnapshot = Object.freeze({
|
|
29
|
+
data: undefined, hasData: false, isFetching: false, error: null, updatedAt: 0, invalidation: 0,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** Stable keys distinguish filters, pagination, and resource identities. */
|
|
33
|
+
export function queryKey(value: unknown): string {
|
|
34
|
+
if (value instanceof Date) return JSON.stringify(value.toISOString());
|
|
35
|
+
if (Array.isArray(value)) return `[${value.map(queryKey).join(',')}]`;
|
|
36
|
+
if (value && typeof value === 'object') {
|
|
37
|
+
const record = value as Record<string, unknown>;
|
|
38
|
+
return `{${Object.keys(record).filter((key) => record[key] !== undefined).sort()
|
|
39
|
+
.map((key) => `${JSON.stringify(key)}:${queryKey(record[key])}`).join(',')}}`;
|
|
40
|
+
}
|
|
41
|
+
return JSON.stringify(value) ?? 'null';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** One shared queue can bound descriptor and data speculation together. */
|
|
45
|
+
export function createPrefetchQueue(concurrency = 2) {
|
|
46
|
+
let active = 0;
|
|
47
|
+
const waiting: Array<() => void> = [];
|
|
48
|
+
function drain() {
|
|
49
|
+
while (active < concurrency && waiting.length) waiting.shift()!();
|
|
50
|
+
}
|
|
51
|
+
return <T>(read: () => Promise<T>): Promise<T> => new Promise<T>((resolve, reject) => {
|
|
52
|
+
waiting.push(() => {
|
|
53
|
+
active += 1;
|
|
54
|
+
Promise.resolve().then(read).then(resolve, reject).finally(() => { active -= 1; drain(); });
|
|
55
|
+
});
|
|
56
|
+
drain();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type Entry = {
|
|
61
|
+
snapshot: QuerySnapshot;
|
|
62
|
+
listeners: Set<() => void>;
|
|
63
|
+
generation: number;
|
|
64
|
+
pending?: Promise<unknown>;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export function createQueryClient(options: {
|
|
68
|
+
maxEntries?: number;
|
|
69
|
+
now?: () => number;
|
|
70
|
+
schedule?: ReturnType<typeof createPrefetchQueue>;
|
|
71
|
+
} = {}): NotisQueryClient {
|
|
72
|
+
const entries = new Map<string, Entry>();
|
|
73
|
+
const now = options.now ?? Date.now;
|
|
74
|
+
const maxEntries = options.maxEntries ?? 100;
|
|
75
|
+
const schedule = options.schedule ?? createPrefetchQueue(2);
|
|
76
|
+
let epoch = 0;
|
|
77
|
+
let disposed = false;
|
|
78
|
+
let revision = 0;
|
|
79
|
+
|
|
80
|
+
function prune(keep?: string) {
|
|
81
|
+
for (const [oldKey, old] of entries) {
|
|
82
|
+
if (entries.size <= maxEntries) break;
|
|
83
|
+
if (oldKey !== keep && !old.listeners.size && !old.pending) entries.delete(oldKey);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function entry(key: string): Entry {
|
|
87
|
+
let value = entries.get(key);
|
|
88
|
+
if (!value) {
|
|
89
|
+
value = { snapshot: EMPTY, listeners: new Set(), generation: 0 };
|
|
90
|
+
entries.set(key, value);
|
|
91
|
+
}
|
|
92
|
+
// Only operations, not getSnapshot during render, update the LRU.
|
|
93
|
+
entries.delete(key);
|
|
94
|
+
entries.set(key, value);
|
|
95
|
+
prune(key);
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
function publish(value: Entry, snapshot: QuerySnapshot) {
|
|
99
|
+
value.snapshot = snapshot;
|
|
100
|
+
for (const listener of value.listeners) listener();
|
|
101
|
+
}
|
|
102
|
+
const client: NotisQueryClient = {
|
|
103
|
+
getSnapshot<T>(key: string) { return (entries.get(key)?.snapshot ?? EMPTY) as QuerySnapshot<T>; },
|
|
104
|
+
subscribe(key, listener) {
|
|
105
|
+
const value = entry(key);
|
|
106
|
+
value.listeners.add(listener);
|
|
107
|
+
return () => { value.listeners.delete(listener); prune(); };
|
|
108
|
+
},
|
|
109
|
+
fetch<T>(key: string, read: () => Promise<T>, fetchOptions: QueryFetchOptions = {}) {
|
|
110
|
+
if (disposed) return Promise.reject(new Error('App query scope is no longer available.'));
|
|
111
|
+
const value = entry(key);
|
|
112
|
+
if (value.pending) return value.pending as Promise<T>;
|
|
113
|
+
if (!fetchOptions.force && value.snapshot.hasData && value.snapshot.updatedAt > 0
|
|
114
|
+
&& now() - value.snapshot.updatedAt < (fetchOptions.staleTimeMs ?? 30_000)) {
|
|
115
|
+
return Promise.resolve(value.snapshot.data as T);
|
|
116
|
+
}
|
|
117
|
+
const generation = ++value.generation;
|
|
118
|
+
const requestEpoch = epoch;
|
|
119
|
+
const canCommit = () => epoch === requestEpoch && value.generation === generation && entries.get(key) === value;
|
|
120
|
+
const request = Promise.resolve().then(read).then((data) => {
|
|
121
|
+
if (canCommit()) publish(value, { ...value.snapshot, data, hasData: true, isFetching: false, error: null, updatedAt: now() });
|
|
122
|
+
return data;
|
|
123
|
+
}, (reason) => {
|
|
124
|
+
const error = reason instanceof Error ? reason : new Error(String(reason));
|
|
125
|
+
if (canCommit()) publish(value, { ...value.snapshot, isFetching: false, error });
|
|
126
|
+
throw error;
|
|
127
|
+
}).finally(() => { if (value.pending === request) value.pending = undefined; prune(); });
|
|
128
|
+
value.pending = request;
|
|
129
|
+
publish(value, { ...value.snapshot, isFetching: true, error: null });
|
|
130
|
+
return request;
|
|
131
|
+
},
|
|
132
|
+
prefetch<T>(key: string, read: () => Promise<T>, fetchOptions?: QueryFetchOptions) {
|
|
133
|
+
const requestEpoch = epoch;
|
|
134
|
+
return schedule(() => {
|
|
135
|
+
if (epoch !== requestEpoch) throw new Error('App query scope was cleared.');
|
|
136
|
+
return client.fetch(key, read, fetchOptions);
|
|
137
|
+
});
|
|
138
|
+
},
|
|
139
|
+
getRevision: () => revision,
|
|
140
|
+
invalidate(key) {
|
|
141
|
+
revision += 1;
|
|
142
|
+
for (const [entryKey, value] of entries) {
|
|
143
|
+
if (key !== undefined && entryKey !== key) continue;
|
|
144
|
+
value.generation += 1;
|
|
145
|
+
value.pending = undefined;
|
|
146
|
+
publish(value, { ...value.snapshot, updatedAt: 0, isFetching: false, invalidation: value.snapshot.invalidation + 1 });
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
dispose() { disposed = true; client.clear(); },
|
|
150
|
+
clear() {
|
|
151
|
+
revision += 1;
|
|
152
|
+
epoch += 1;
|
|
153
|
+
for (const value of entries.values()) {
|
|
154
|
+
value.generation += 1;
|
|
155
|
+
value.pending = undefined;
|
|
156
|
+
publish(value, { ...EMPTY, invalidation: value.snapshot.invalidation + 1 });
|
|
157
|
+
}
|
|
158
|
+
for (const [key, value] of entries) if (!value.listeners.size) entries.delete(key);
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
return client;
|
|
162
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { ComponentType } from 'react';
|
|
11
|
+
import type { NotisQueryClient } from './queryCache';
|
|
11
12
|
|
|
12
13
|
// ---------------------------------------------------------------------------
|
|
13
14
|
// Database types
|
|
@@ -106,6 +107,8 @@ export interface ToolDescriptor {
|
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
export interface ToolCallOptions {
|
|
110
|
+
/** Explicitly identifies an idempotent read; never set on a mutation. */
|
|
111
|
+
readOnly?: boolean;
|
|
109
112
|
/**
|
|
110
113
|
* Coalesce an identical in-flight call. Only opt in for idempotent reads;
|
|
111
114
|
* mutations must execute once per invocation.
|
|
@@ -359,6 +362,8 @@ export interface CloudComputerFacts {
|
|
|
359
362
|
}
|
|
360
363
|
|
|
361
364
|
export interface NotisRuntime {
|
|
365
|
+
/** Optional host-scoped in-memory read cache. Older hosts remain supported. */
|
|
366
|
+
queryClient?: NotisQueryClient;
|
|
362
367
|
app: AppDescriptor;
|
|
363
368
|
route: RouteDescriptor;
|
|
364
369
|
databases: DatabaseDescriptor[];
|