@notis_ai/cli 0.2.0-beta.191.1 → 0.2.0-beta.196.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/README.md +479 -80
- package/config/portal-route-roots.json +48 -0
- package/dist/agent-hooks/notis-agent-hook.mjs +29072 -2784
- package/dist/base-skills/notis-apps/SKILL.md +88 -67
- package/dist/base-skills/notis-apps/references/architecture.md +114 -167
- package/dist/base-skills/notis-apps/references/design.md +26 -107
- package/dist/base-skills/notis-apps/references/memory.md +58 -0
- package/dist/base-skills/notis-apps/references/reading.md +52 -74
- package/dist/base-skills/notis-apps/references/release.md +104 -142
- package/dist/base-skills/notis-apps/references/sdk.md +113 -60
- package/dist/base-skills/notis-apps/references/troubleshooting.md +22 -21
- package/dist/base-skills/notis-apps/references/views.md +142 -0
- package/dist/base-skills/notis-cli/SKILL.md +4 -4
- package/dist/base-skills/notis-cli/references/app-delivery.md +12 -11
- package/dist/base-skills/notis-cli/references/native-databases.md +6 -6
- package/dist/base-skills/notis-cli/references/tool-examples.md +4 -4
- package/dist/base-skills/notis-query/SKILL.md +34 -60
- package/dist/base-skills/notis-query/references/database-discovery.md +59 -69
- package/dist/base-skills/notis-query/references/documents.md +63 -50
- package/dist/base-skills/notis-query/references/query.md +57 -524
- package/dist/sdk/src/bundleModule.ts +79 -0
- package/dist/sdk/src/components/DocumentEditor.tsx +6 -3
- package/dist/sdk/src/components/DocumentPage.tsx +141 -0
- package/dist/sdk/src/components/RecordComponents.tsx +33 -0
- package/dist/sdk/src/components/RenderChartData.tsx +19 -0
- package/dist/sdk/src/config.ts +4 -26
- package/dist/sdk/src/documents.ts +2 -0
- package/dist/sdk/src/hooks/useAction.ts +28 -0
- package/dist/sdk/src/hooks/useCloudComputer.ts +35 -4
- package/dist/sdk/src/hooks/useCloudComputerShell.ts +114 -0
- package/dist/sdk/src/hooks/useHandover.ts +5 -4
- package/dist/sdk/src/hooks/useNativeDocumentBody.ts +50 -0
- package/dist/sdk/src/hooks/useNativeDocuments.ts +83 -0
- package/dist/sdk/src/hooks/useNotis.ts +3 -0
- package/dist/sdk/src/hooks/useNotisNavigation.ts +26 -11
- package/dist/sdk/src/hooks/useQuery.ts +7 -4
- package/dist/sdk/src/hooks/useViewParams.ts +121 -0
- package/dist/sdk/src/hooks/useViewerReads.ts +129 -0
- package/dist/sdk/src/index.ts +36 -0
- package/dist/sdk/src/interactions/shortcuts.tsx +17 -3
- package/dist/sdk/src/nativeData.ts +39 -0
- package/dist/sdk/src/presentation.tsx +82 -0
- package/dist/sdk/src/provider.tsx +9 -4
- package/dist/sdk/src/queryCache.ts +5 -3
- package/dist/sdk/src/queryErrors.ts +51 -0
- package/dist/sdk/src/runtime.ts +146 -8
- package/dist/sdk/src/space.ts +425 -0
- package/dist/sdk/src/styles.css +96 -72
- package/dist/sdk/src/vite.ts +11 -1
- package/dist/skill-sync/index.js +458 -68
- package/dist/skill-sync/index.js.map +4 -4
- package/dist/skill-sync-worker.mjs +451 -77
- package/dist/space-harness/host.js +66 -0
- package/dist/view-renderer/dist/host.css +1 -0
- package/dist/view-renderer/dist/host.js +1042 -0
- package/dist/view-renderer/dist/theme.css +73 -0
- package/dist/view-renderer/src/dom.js +180 -0
- package/dist/view-renderer/src/errors.js +8 -0
- package/dist/view-renderer/src/host.tsx +173 -0
- package/dist/view-renderer/src/index.js +4 -0
- package/dist/view-renderer/src/pool.js +76 -0
- package/dist/view-renderer/src/remote-broker.js +59 -0
- package/dist/view-renderer/src/renderer.js +201 -0
- package/package.json +11 -5
- package/skills/notis-apps/cli.md +270 -163
- package/skills/notis-cli/AGENT_INSTRUCTIONS.md +2 -2
- package/skills/notis-onboarding/BRIEF.md +7 -3
- package/src/command-specs/apps.js +1 -8
- package/src/command-specs/files.js +29 -0
- package/src/command-specs/helpers.js +1 -0
- package/src/command-specs/index.js +10 -4
- package/src/command-specs/skills.js +229 -0
- package/src/command-specs/spaces.js +572 -0
- package/src/command-specs/views.js +69 -0
- package/src/runtime/agent-browser.js +30 -7
- package/src/runtime/app-boundary-validator.js +7 -4
- package/src/runtime/app-data-literals.js +82 -0
- package/src/runtime/app-platform.js +321 -253
- package/src/runtime/confined-space-build.js +74 -0
- package/src/runtime/confined-space-verify.js +49 -0
- package/src/runtime/native-record-files.js +47 -0
- package/src/runtime/retired-sdk-files.js +20 -0
- package/src/runtime/skill-sync/cloud-client.ts +26 -4
- package/src/runtime/skill-sync/index.ts +154 -35
- package/src/runtime/skill-sync/local-scanner.ts +125 -17
- package/src/runtime/skill-sync/native-identity.ts +102 -0
- package/src/runtime/skill-sync/native-plan.ts +59 -0
- package/src/runtime/skill-sync/sync-plan.ts +16 -9
- package/src/runtime/skill-sync/types.ts +32 -6
- package/src/runtime/source-root.js +32 -0
- package/src/runtime/space-collection-scaffold.js +66 -0
- package/src/runtime/space-database-structures.js +229 -0
- package/src/runtime/space-fixture-broker.js +50 -0
- package/src/runtime/space-lock.js +337 -0
- package/src/runtime/space-platform.js +105 -0
- package/src/runtime/space-source.js +281 -0
- package/src/runtime/space-test-server.js +327 -0
- package/src/runtime/space-verification.js +89 -0
- package/src/runtime/space-verify-browser-worker.js +51 -0
- package/src/runtime/space-view-manifest.js +254 -0
- package/src/runtime/sync-skills.js +1 -1
- package/src/runtime/transport.js +8 -1
- package/src/runtime/view-render.js +49 -0
- package/src/space-harness-host.ts +103 -0
- package/template/app/globals.css +1 -0
- package/src/command-specs/reports.js +0 -97
|
@@ -4,6 +4,7 @@ import type { CSSProperties, ReactElement } from 'react';
|
|
|
4
4
|
import { useNotisRuntime } from '../provider';
|
|
5
5
|
import { useDocument } from '../hooks/useDocument';
|
|
6
6
|
import { Markdown } from './Markdown';
|
|
7
|
+
import { RecordComponentUnavailable } from './RecordComponents';
|
|
7
8
|
import type { NotisDocumentEditorProps } from '../runtime';
|
|
8
9
|
|
|
9
10
|
const fallbackNoticeStyle: CSSProperties = {
|
|
@@ -35,7 +36,7 @@ const fallbackTitleStyle: CSSProperties = {
|
|
|
35
36
|
*/
|
|
36
37
|
function DocumentEditorFallback({ documentId, variant = 'full', className }: NotisDocumentEditorProps) {
|
|
37
38
|
const runtime = useNotisRuntime();
|
|
38
|
-
const { document, loading, error } = useDocument(documentId);
|
|
39
|
+
const { document, loading, error } = useDocument(documentId ?? null);
|
|
39
40
|
// Listing screenshots should show the app as it looks in the portal, so the
|
|
40
41
|
// harness-only editing notice stays out of scenario captures.
|
|
41
42
|
const isScreenshot = Boolean(runtime?.context?.screenshotScenario);
|
|
@@ -68,7 +69,7 @@ function DocumentEditorFallback({ documentId, variant = 'full', className }: Not
|
|
|
68
69
|
? 'Interactive report'
|
|
69
70
|
: document.viewType === 'html'
|
|
70
71
|
? 'Interactive HTML document'
|
|
71
|
-
: 'View document'} — open it in Notis
|
|
72
|
+
: 'View document'} — open it in its Notis Space for
|
|
72
73
|
the full native experience.
|
|
73
74
|
</div>
|
|
74
75
|
) : (
|
|
@@ -87,7 +88,7 @@ function DocumentEditorFallback({ documentId, variant = 'full', className }: Not
|
|
|
87
88
|
* dispatches on the document's content type (markdown -> rich text editor,
|
|
88
89
|
* files -> matching viewer, views -> their trusted native Documents surface).
|
|
89
90
|
* Outside the portal this falls back to a read-only preview or an explicit
|
|
90
|
-
* open-in-
|
|
91
|
+
* open-in-Space notice for native views.
|
|
91
92
|
*
|
|
92
93
|
* ```tsx
|
|
93
94
|
* <DocumentEditor documentId={entry.id} variant="body" className="min-h-[320px]" />
|
|
@@ -99,5 +100,7 @@ export function DocumentEditor(props: NotisDocumentEditorProps): ReactElement {
|
|
|
99
100
|
if (HostEditor) {
|
|
100
101
|
return <HostEditor {...props} />;
|
|
101
102
|
}
|
|
103
|
+
// A Space must never fall back to the legacy account-wide document reader.
|
|
104
|
+
if (runtime?.resource?.kind === 'space' || props.recordKey) return <RecordComponentUnavailable className={props.className} />;
|
|
102
105
|
return <DocumentEditorFallback {...props} />;
|
|
103
106
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef, type CSSProperties, type ReactElement } from 'react';
|
|
4
|
+
import { useNotisRuntime } from '../provider';
|
|
5
|
+
import { DocumentEditor } from './DocumentEditor';
|
|
6
|
+
import { ShareControl } from './RecordComponents';
|
|
7
|
+
import { Skeleton } from './Skeleton';
|
|
8
|
+
import type { NotisDocumentPageProps } from '../runtime';
|
|
9
|
+
|
|
10
|
+
const copy = {
|
|
11
|
+
en: { loading: 'Loading document', breadcrumb: 'Breadcrumb' },
|
|
12
|
+
fr: { loading: 'Chargement du document', breadcrumb: "Fil d'Ariane" },
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Column widths shared by the host page, its skeleton and the fallback, so nothing moves when the page arrives. */
|
|
16
|
+
export const DOCUMENT_PAGE_WIDTHS = { standard: 900, wide: 1200, full: undefined } as const;
|
|
17
|
+
/**
|
|
18
|
+
* The page's horizontal gutter: 16px, then 48px from 640px wide (the host page's px-4 sm:px-12), and its
|
|
19
|
+
* toolbar height (56px plus its 1px border, Beta's py-3 toolbar). The skeleton uses the same values.
|
|
20
|
+
*/
|
|
21
|
+
export const DOCUMENT_PAGE_GUTTER = { narrow: 16, wide: 48, breakpoint: 640, toolbar: 57 } as const;
|
|
22
|
+
|
|
23
|
+
const SKELETON = 'notis-document-page-skeleton';
|
|
24
|
+
// Inline styles cannot change with the width; this sheet (rendered with the skeleton, so it reaches a
|
|
25
|
+
// Space's ShadowRoot) gives the skeleton the page's own gutter and icon row at every width.
|
|
26
|
+
const skeletonCss = `.${SKELETON}__column{padding:8px ${DOCUMENT_PAGE_GUTTER.narrow}px 0}`
|
|
27
|
+
+ `.${SKELETON}__icon{width:44px;height:44px;margin-bottom:4px}`
|
|
28
|
+
+ `@media (min-width:${DOCUMENT_PAGE_GUTTER.breakpoint}px){.${SKELETON}__column{padding:8px ${DOCUMENT_PAGE_GUTTER.wide}px 0}`
|
|
29
|
+
+ `.${SKELETON}__icon{width:56px;height:56px}}`;
|
|
30
|
+
|
|
31
|
+
function useCopy() {
|
|
32
|
+
return copy[useNotisRuntime()?.context?.locale === 'fr' ? 'fr' : 'en'];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function columnStyle(width: NotisDocumentPageProps['width'] = 'standard'): CSSProperties {
|
|
36
|
+
const max = DOCUMENT_PAGE_WIDTHS[width];
|
|
37
|
+
return { width: '100%', maxWidth: max ? `${max}px` : undefined, margin: '0 auto', boxSizing: 'border-box' };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Placeholder shaped like the document page (toolbar, icon row, title, meta strip, body lines) at the
|
|
42
|
+
* same column width and gutter, so the page replaces it without a layout shift. Hosts show it while the
|
|
43
|
+
* record and the editor load; a Space can show it while its own data decides which record opens.
|
|
44
|
+
*/
|
|
45
|
+
export function DocumentPageSkeleton({ width = 'standard', className, label }: {
|
|
46
|
+
width?: NotisDocumentPageProps['width']; className?: string; label?: string;
|
|
47
|
+
}): ReactElement {
|
|
48
|
+
const t = useCopy();
|
|
49
|
+
return <div role="status" aria-busy="true" aria-label={label || t.loading} data-notis-skeleton="document-page" className={className}
|
|
50
|
+
style={{ width: '100%', display: 'flex', flexDirection: 'column' }}>
|
|
51
|
+
<style>{skeletonCss}</style>
|
|
52
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', height: DOCUMENT_PAGE_GUTTER.toolbar,
|
|
53
|
+
padding: `0 ${DOCUMENT_PAGE_GUTTER.narrow}px`, boxSizing: 'border-box' }}>
|
|
54
|
+
<Skeleton style={{ width: 180, height: 14 }} />
|
|
55
|
+
<Skeleton style={{ width: 48, height: 12 }} />
|
|
56
|
+
</div>
|
|
57
|
+
<div className={`${SKELETON}__column`} style={{ ...columnStyle(width), display: 'flex', flexDirection: 'column' }}>
|
|
58
|
+
<Skeleton className={`${SKELETON}__icon`} style={{ borderRadius: 12, height: undefined }} />
|
|
59
|
+
<Skeleton style={{ width: '58%', height: 36, margin: '2px 0 10px' }} />
|
|
60
|
+
<Skeleton style={{ width: '72%', height: 14, margin: '15px 0' }} />
|
|
61
|
+
<div style={{ display: 'grid', gap: 12, marginTop: 24 }}>
|
|
62
|
+
{[100, 96, 88, 100, 64].map((size, index) => <Skeleton key={index} style={{ width: `${size}%`, height: 16 }} />)}
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</div>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Shown by hosts that predate DocumentPage: the same record, with the editor these hosts already have.
|
|
70
|
+
* menuItems show as plain toolbar buttons and onSaved is called when a save finishes (title null);
|
|
71
|
+
* layout, header parts, meta and width options other than the column are not available there.
|
|
72
|
+
*/
|
|
73
|
+
function DocumentPageFallback(props: NotisDocumentPageProps) {
|
|
74
|
+
const t = useCopy();
|
|
75
|
+
const { recordKey, breadcrumb, actions, share = false, aboveBody, belowBody, width, readOnly, className, menuItems = [], onSaved, onSavingChange } = props;
|
|
76
|
+
const crumbs = breadcrumb === false ? [] : breadcrumb || [];
|
|
77
|
+
const showToolbar = crumbs.length > 0 || Boolean(actions) || share || menuItems.length > 0;
|
|
78
|
+
// DocumentEditor reports saving, not what it saved: a finished save tells the Space to refresh its lists.
|
|
79
|
+
const saving = useRef(false);
|
|
80
|
+
const savingChange = useCallback((value: boolean) => {
|
|
81
|
+
if (saving.current && !value) onSaved?.({ recordKey, title: null });
|
|
82
|
+
saving.current = value;
|
|
83
|
+
onSavingChange?.(value);
|
|
84
|
+
}, [onSaved, onSavingChange, recordKey]);
|
|
85
|
+
return <div className={className} style={{ width: '100%', display: 'flex', flexDirection: 'column' }}>
|
|
86
|
+
{showToolbar ? <div style={{ display: 'flex', alignItems: 'center', gap: 8, minHeight: 48, padding: '0 16px' }}>
|
|
87
|
+
<nav aria-label={t.breadcrumb} style={{ display: 'flex', alignItems: 'center', gap: 4, flex: 1, minWidth: 0, fontSize: 14 }}>
|
|
88
|
+
{crumbs.map((crumb, index) => <span key={`${crumb.label}-${index}`} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
|
89
|
+
{crumb.onSelect ? <button type="button" onClick={crumb.onSelect}
|
|
90
|
+
style={{ background: 'none', border: 0, padding: '4px 6px', borderRadius: 6, cursor: 'pointer', color: 'hsl(var(--muted-foreground))', font: 'inherit' }}>
|
|
91
|
+
{crumb.label}</button> : <span style={{ color: 'hsl(var(--muted-foreground))' }}>{crumb.label}</span>}
|
|
92
|
+
<span aria-hidden="true" style={{ color: 'hsl(var(--muted-foreground))' }}>/</span>
|
|
93
|
+
</span>)}
|
|
94
|
+
</nav>
|
|
95
|
+
{actions}
|
|
96
|
+
{share ? <ShareControl recordKey={recordKey} /> : null}
|
|
97
|
+
{/* The host's '...' menu is not available here: its entries show as plain buttons. */}
|
|
98
|
+
{menuItems.map((item, index) => <button key={`${item.label}-${index}`} type="button" onClick={item.onSelect} disabled={item.disabled}
|
|
99
|
+
style={{ background: 'none', border: 0, padding: '4px 8px', borderRadius: 6, cursor: item.disabled ? 'default' : 'pointer', font: 'inherit', fontSize: 13,
|
|
100
|
+
color: item.destructive ? 'hsl(var(--destructive))' : 'hsl(var(--muted-foreground))', opacity: item.disabled ? 0.5 : 1 }}>{item.label}</button>)}
|
|
101
|
+
</div> : null}
|
|
102
|
+
<div style={{ ...columnStyle(width), padding: '24px 16px 96px', display: 'flex', flexDirection: 'column', gap: 20 }}>
|
|
103
|
+
{aboveBody}
|
|
104
|
+
<DocumentEditor recordKey={recordKey} variant={props.header === false ? 'body' : 'full'} showProperties={props.properties !== 'hidden'}
|
|
105
|
+
readOnly={readOnly} onDirtyChange={props.onDirtyChange} onSavingChange={savingChange} />
|
|
106
|
+
{belowBody}
|
|
107
|
+
</div>
|
|
108
|
+
</div>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The full page of one record that is the main content of a Space view, such as a note: breadcrumb,
|
|
113
|
+
* Saving or Saved status, '...' menu, cover, icon, title, meta strip, properties that save
|
|
114
|
+
* automatically, and the collaborative body with presence. The host (Portal) draws it and keeps one
|
|
115
|
+
* save queue for the whole record. Files open in their viewer under the header; HTML and reports show
|
|
116
|
+
* only the toolbar (breadcrumb, actions, Share, '...' menu) above their frame, with no header, meta,
|
|
117
|
+
* properties, aboveBody or belowBody.
|
|
118
|
+
*
|
|
119
|
+
* ```tsx
|
|
120
|
+
* if (params.note) return <DocumentPage recordKey={params.note}
|
|
121
|
+
* breadcrumb={[{ label: 'All notes', onSelect: () => setParams({ note: null }) }]}
|
|
122
|
+
* actions={<TrashButton />} onSaved={() => notes.refetch()} />;
|
|
123
|
+
* ```
|
|
124
|
+
*
|
|
125
|
+
* Slots (`breadcrumb`, `actions`, `menuItems`, `aboveBody`, `belowBody`) and toggles (`share`,
|
|
126
|
+
* `header`, `meta`, `properties`, `width`) adjust it; `layout` rearranges its parts. Hosts without
|
|
127
|
+
* this component show the record with `DocumentEditor` instead.
|
|
128
|
+
*/
|
|
129
|
+
export function DocumentPage(props: NotisDocumentPageProps): ReactElement {
|
|
130
|
+
const Host = useNotisRuntime()?.ui?.DocumentPage;
|
|
131
|
+
return Host ? <Host {...props} /> : <DocumentPageFallback {...props} />;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Starts reading a record before it opens (for example on a list row's hover or focus), so its
|
|
136
|
+
* DocumentPage shows at once. Does nothing outside a host that supports it.
|
|
137
|
+
*/
|
|
138
|
+
export function usePrefetchRecord(): (recordKey: string) => void {
|
|
139
|
+
const prefetch = useNotisRuntime()?.ui?.prefetchRecord;
|
|
140
|
+
return useCallback((recordKey: string) => { if (recordKey) prefetch?.(recordKey); }, [prefetch]);
|
|
141
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ReactElement } from 'react';
|
|
4
|
+
import { useNotisRuntime } from '../provider';
|
|
5
|
+
import type { NotisHtmlFrameProps, NotisRecordPropertiesProps, NotisReportFrameProps, NotisShareControlProps } from '../runtime';
|
|
6
|
+
|
|
7
|
+
const copy = {
|
|
8
|
+
en: { unavailable: 'Open this record in Notis to use this component.' },
|
|
9
|
+
fr: { unavailable: 'Ouvre cet enregistrement dans Notis pour utiliser ce composant.' },
|
|
10
|
+
};
|
|
11
|
+
export function RecordComponentUnavailable({ className }: { className?: string }): ReactElement {
|
|
12
|
+
const runtime = useNotisRuntime();
|
|
13
|
+
const locale = runtime?.context?.locale === 'fr' ? 'fr' : 'en';
|
|
14
|
+
return <div role="status" className={className}>{copy[locale].unavailable}</div>;
|
|
15
|
+
}
|
|
16
|
+
export function RecordProperties(props: NotisRecordPropertiesProps): ReactElement {
|
|
17
|
+
const Host = useNotisRuntime()?.ui?.RecordProperties;
|
|
18
|
+
return Host ? <Host {...props} /> : <RecordComponentUnavailable className={props.className} />;
|
|
19
|
+
}
|
|
20
|
+
/** The host applies its viewer CSP and an opaque-origin sandbox; source cannot relax either. */
|
|
21
|
+
export function HtmlFrame(props: NotisHtmlFrameProps): ReactElement {
|
|
22
|
+
const Host = useNotisRuntime()?.ui?.HtmlFrame;
|
|
23
|
+
return Host ? <Host {...props} /> : <RecordComponentUnavailable className={props.className} />;
|
|
24
|
+
}
|
|
25
|
+
export function ReportFrame(props: NotisReportFrameProps): ReactElement {
|
|
26
|
+
const Host = useNotisRuntime()?.ui?.ReportFrame;
|
|
27
|
+
return Host ? <Host {...props} /> : <RecordComponentUnavailable className={props.className} />;
|
|
28
|
+
}
|
|
29
|
+
/** Private/shared state, copy link and revoke, backed by this record's Site. */
|
|
30
|
+
export function ShareControl(props: NotisShareControlProps): ReactElement {
|
|
31
|
+
const Host = useNotisRuntime()?.ui?.ShareControl;
|
|
32
|
+
return Host ? <Host {...props} /> : <RecordComponentUnavailable className={props.className} />;
|
|
33
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
/** A chart's data, available to view Markdown without adding a second visible table.
|
|
6
|
+
* Keep labels/values identical to the chart. The renderer recognizes this semantic
|
|
7
|
+
* table inside Shadow DOM; snapshots never infer numbers from SVG geometry.
|
|
8
|
+
*/
|
|
9
|
+
export function RenderChartData({ title, columns, rows, children }: {
|
|
10
|
+
title: string;
|
|
11
|
+
columns: readonly string[];
|
|
12
|
+
rows: readonly (readonly (string | number | boolean | null)[])[];
|
|
13
|
+
children?: ReactNode;
|
|
14
|
+
}) {
|
|
15
|
+
return <>{children}<table hidden data-notis-render="chart-data" aria-label={title}>
|
|
16
|
+
<thead><tr>{columns.map((column, index) => <th key={index}>{column}</th>)}</tr></thead>
|
|
17
|
+
<tbody>{rows.map((row, index) => <tr key={index}>{columns.map((_, column) => <td key={column}>{row[column] == null ? '' : String(row[column])}</td>)}</tr>)}</tbody>
|
|
18
|
+
</table></>;
|
|
19
|
+
}
|
package/dist/sdk/src/config.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
export { defineSpace, defineSpaces } from './space';
|
|
2
|
+
export type { SpaceDefinition, SpacesWorkspace, SpaceActionTemplate, SpaceResourceReference, SpaceDatabaseCreate } from './space';
|
|
3
|
+
export type { SpaceViewParam, SpaceViewParamType, SpaceShows, SpaceMemoryPolicy, SpaceMemorySnapshot, SpaceChrome, SpaceMarkdownInput } from './space';
|
|
4
|
+
|
|
1
5
|
/**
|
|
2
6
|
* Configuration utilities for notis.config.ts.
|
|
3
7
|
*
|
|
@@ -88,28 +92,6 @@ export interface NotisAppAuthor {
|
|
|
88
92
|
url?: string;
|
|
89
93
|
}
|
|
90
94
|
|
|
91
|
-
export interface NotisAppSkillConfig {
|
|
92
|
-
/** Stable source-owned key used by other app declarations. */
|
|
93
|
-
key: string;
|
|
94
|
-
/**
|
|
95
|
-
* Path to the skill, relative to notis.config.ts. Either a Markdown file
|
|
96
|
-
* (`./skills/onboarding.md`) or a directory holding SKILL.md plus its
|
|
97
|
-
* supporting files (`./skills/onboarding/`), which are packaged on deploy
|
|
98
|
-
* and materialized next to SKILL.md in the sandbox.
|
|
99
|
-
*/
|
|
100
|
-
path: string;
|
|
101
|
-
/** User-facing name used for the installed skill. */
|
|
102
|
-
name: string;
|
|
103
|
-
description?: string;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export interface NotisAppOnboardingConfig {
|
|
107
|
-
/** Key of a skill declared in `skills`. */
|
|
108
|
-
skill: string;
|
|
109
|
-
/** Editable message placed in Notis when onboarding is opened. */
|
|
110
|
-
prompt: string;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
95
|
export interface NotisAppScreenshotConfig {
|
|
114
96
|
/** Conventional metadata/screenshot-N.png source path. */
|
|
115
97
|
path: string;
|
|
@@ -245,10 +227,6 @@ export interface NotisAppConfig {
|
|
|
245
227
|
tools?: string[];
|
|
246
228
|
/** Optional execution bindings for provider-generated tool names. */
|
|
247
229
|
toolBindings?: NotisAppToolBinding[];
|
|
248
|
-
/** Skills shipped from this app's source tree. */
|
|
249
|
-
skills?: NotisAppSkillConfig[];
|
|
250
|
-
/** Optional onboarding entrypoint exposed from the app sidebar. */
|
|
251
|
-
onboarding?: NotisAppOnboardingConfig;
|
|
252
230
|
}
|
|
253
231
|
|
|
254
232
|
/**
|
|
@@ -125,6 +125,8 @@ export function normalizeDocumentRecord(value: unknown): DocumentRecord {
|
|
|
125
125
|
|
|
126
126
|
return {
|
|
127
127
|
id: optionalString(record.id) ?? '',
|
|
128
|
+
recordKey: optionalString(record.recordKey) ?? optionalString(record.record_key) ?? undefined,
|
|
129
|
+
revision: typeof record.revision === 'number' ? record.revision : undefined,
|
|
128
130
|
title: optionalString(record.title) ?? 'Untitled',
|
|
129
131
|
url: optionalString(record.url),
|
|
130
132
|
properties,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useState } from 'react';
|
|
4
|
+
import { useNotisRuntime } from '../provider';
|
|
5
|
+
import { useQuery, type UseQueryOptions, type UseQueryResult } from './useQuery';
|
|
6
|
+
import type { SpaceActionOptions } from '../space';
|
|
7
|
+
|
|
8
|
+
export function useAction<TInput extends Record<string, unknown> = Record<string, unknown>, TResult = unknown>(actionId: string): {
|
|
9
|
+
call: (inputs: TInput, options?: SpaceActionOptions) => Promise<TResult>; loading: boolean; error: Error | null;
|
|
10
|
+
} {
|
|
11
|
+
const runtime = useNotisRuntime();
|
|
12
|
+
const [pending, setPending] = useState(0);
|
|
13
|
+
const [error, setError] = useState<Error | null>(null);
|
|
14
|
+
const call = useCallback(async (inputs: TInput, options?: SpaceActionOptions) => {
|
|
15
|
+
if (runtime?.resource?.kind !== 'space' || !runtime.callAction) throw new Error('A Space action runtime is required.');
|
|
16
|
+
setPending((value) => value + 1); setError(null);
|
|
17
|
+
try { return await runtime.callAction<TResult>(actionId, inputs, options); }
|
|
18
|
+
catch (cause) { const failure = cause instanceof Error ? cause : new Error(String(cause)); setError(failure); throw failure; }
|
|
19
|
+
finally { setPending((value) => value - 1); }
|
|
20
|
+
}, [runtime, actionId]);
|
|
21
|
+
return { call, loading: pending > 0, error };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function useActionQuery<TResult = unknown>(actionId: string, inputs: Record<string, unknown>,
|
|
25
|
+
options: UseQueryOptions = { readOnly: true }): UseQueryResult<TResult> {
|
|
26
|
+
const { call } = useAction<Record<string, unknown>, TResult>(actionId);
|
|
27
|
+
return useQuery(['action', actionId, inputs], () => call(inputs, { readOnly: true, dedupe: true }), options);
|
|
28
|
+
}
|
|
@@ -3,7 +3,21 @@
|
|
|
3
3
|
import { useCallback, useRef } from 'react';
|
|
4
4
|
import { useQuery } from './useQuery';
|
|
5
5
|
import { useNotisRuntime } from '../provider';
|
|
6
|
-
import
|
|
6
|
+
import { queryKey } from '../queryCache';
|
|
7
|
+
import { spaceQueryKey } from '../space';
|
|
8
|
+
import type { CloudComputerFacts, NotisRuntime } from '../runtime';
|
|
9
|
+
|
|
10
|
+
/** Cache key of the facts read; re-read once the viewer allows a Space to use the cloud computer. */
|
|
11
|
+
export const CLOUD_COMPUTER_FACTS_KEY = ['cloud-computer-facts'] as const;
|
|
12
|
+
|
|
13
|
+
/** Marks the facts stale in the host cache so every mounted `useCloudComputer()` reads them again. */
|
|
14
|
+
export function invalidateCloudComputerFacts(runtime: NotisRuntime | null): void {
|
|
15
|
+
try {
|
|
16
|
+
runtime?.queryClient?.invalidate(queryKey(spaceQueryKey(runtime, CLOUD_COMPUTER_FACTS_KEY)));
|
|
17
|
+
} catch {
|
|
18
|
+
// A host without a Space access context has no shared facts cache to refresh.
|
|
19
|
+
}
|
|
20
|
+
}
|
|
7
21
|
|
|
8
22
|
export interface UseCloudComputerResult {
|
|
9
23
|
/**
|
|
@@ -16,6 +30,12 @@ export interface UseCloudComputerResult {
|
|
|
16
30
|
error: Error | null;
|
|
17
31
|
/** Re-read the facts. The platform caches them for a few minutes. */
|
|
18
32
|
refresh: () => Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Space only (`cloudComputer: 'read'` or `'shell'`): ask the host to show its approval prompt.
|
|
35
|
+
* Resolves true once the viewer allowed it (the facts are then read again), false when they did
|
|
36
|
+
* not, and null on hosts without the prompt (Beta Apps, Sites, previews offline).
|
|
37
|
+
*/
|
|
38
|
+
requestApproval: () => Promise<boolean | null>;
|
|
19
39
|
}
|
|
20
40
|
|
|
21
41
|
const UNAVAILABLE: CloudComputerFacts = {
|
|
@@ -40,7 +60,9 @@ const UNAVAILABLE: CloudComputerFacts = {
|
|
|
40
60
|
* ```
|
|
41
61
|
*
|
|
42
62
|
* Requires `capabilities.cloudComputer: 'read'` in `notis.config.ts` and the
|
|
43
|
-
* user's approval at install time.
|
|
63
|
+
* user's approval at install time. In a Space, declare `cloudComputer: 'read'`
|
|
64
|
+
* (or `'shell'`) in `defineSpace`; until the viewer allows it the facts answer
|
|
65
|
+
* unavailable, so offer `requestApproval()`. It answers with state the platform already
|
|
44
66
|
* holds: reading it never creates, resumes or commands a sandbox, and the GitHub
|
|
45
67
|
* probe only runs when the sandbox is already awake. `authenticated: null`
|
|
46
68
|
* therefore means *unknown*, not *signed out* — keep the app's own fallback for
|
|
@@ -50,7 +72,7 @@ const UNAVAILABLE: CloudComputerFacts = {
|
|
|
50
72
|
export function useCloudComputer(): UseCloudComputerResult {
|
|
51
73
|
const runtime = useNotisRuntime();
|
|
52
74
|
const explicitRefresh = useRef(false);
|
|
53
|
-
const query = useQuery<CloudComputerFacts>(
|
|
75
|
+
const query = useQuery<CloudComputerFacts>(CLOUD_COMPUTER_FACTS_KEY, async () => {
|
|
54
76
|
const refresh = explicitRefresh.current;
|
|
55
77
|
explicitRefresh.current = false;
|
|
56
78
|
return runtime?.cloudComputerFacts ? runtime.cloudComputerFacts(refresh ? { refresh: true } : undefined) : UNAVAILABLE;
|
|
@@ -59,6 +81,15 @@ export function useCloudComputer(): UseCloudComputerResult {
|
|
|
59
81
|
explicitRefresh.current = true;
|
|
60
82
|
await query.refetch();
|
|
61
83
|
}, [query.refetch]);
|
|
84
|
+
const transport = runtime?.spaceCloudComputer;
|
|
85
|
+
const requestApproval = useCallback(async () => {
|
|
86
|
+
if (!transport) return null;
|
|
87
|
+
const status = await transport.requestApproval();
|
|
88
|
+
if (status?.approved !== true) return false;
|
|
89
|
+
invalidateCloudComputerFacts(runtime);
|
|
90
|
+
await query.refetch();
|
|
91
|
+
return true;
|
|
92
|
+
}, [query.refetch, runtime, transport]);
|
|
62
93
|
return { facts: query.data ?? (query.error ? UNAVAILABLE : null), loading: query.loading,
|
|
63
|
-
isFetching: query.isFetching, error: query.error, refresh };
|
|
94
|
+
isFetching: query.isFetching, error: query.error, refresh, requestApproval };
|
|
64
95
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { useNotisRuntime } from '../provider';
|
|
5
|
+
import { invalidateCloudComputerFacts } from './useCloudComputer';
|
|
6
|
+
import type {
|
|
7
|
+
SpaceCloudComputerLevel,
|
|
8
|
+
SpaceCloudComputerRunResult,
|
|
9
|
+
SpaceCloudComputerStatus,
|
|
10
|
+
SpaceCloudComputerUploadInput,
|
|
11
|
+
SpaceCloudComputerUploadResult,
|
|
12
|
+
} from '../space';
|
|
13
|
+
|
|
14
|
+
export interface UseCloudComputerShellResult {
|
|
15
|
+
/**
|
|
16
|
+
* The level this Space declares (`'read'` or `'shell'`) when this host offers the cloud computer,
|
|
17
|
+
* else null (Sites, links, verification harnesses, older hosts): keep the page's own fallback.
|
|
18
|
+
*/
|
|
19
|
+
level: SpaceCloudComputerLevel | null;
|
|
20
|
+
/** True when `run` and `upload` are offered: the host offers the cloud computer and the Space declares `'shell'`. */
|
|
21
|
+
available: boolean;
|
|
22
|
+
/** The viewer's approval for this Space's current code (either level), or null while it is read. */
|
|
23
|
+
status: SpaceCloudComputerStatus | null;
|
|
24
|
+
approved: boolean;
|
|
25
|
+
loading: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Run one non-interactive command on the viewer's own cloud computer. Same result as the shell tool.
|
|
28
|
+
* Pass the same `requestId` when the page retries this run itself: a run is never repeated for one key.
|
|
29
|
+
*/
|
|
30
|
+
run(command: string, options?: RunOptions): Promise<SpaceCloudComputerRunResult>;
|
|
31
|
+
/** Write one file (at most 1 MiB) straight to the cloud computer; it never travels by URL or chat. */
|
|
32
|
+
upload(file: SpaceCloudComputerUploadInput): Promise<SpaceCloudComputerUploadResult>;
|
|
33
|
+
/**
|
|
34
|
+
* Ask the host to show its approval prompt, for either declared level. Only the person's click in
|
|
35
|
+
* that prompt approves; once allowed, every `useCloudComputer()` reads the facts again.
|
|
36
|
+
*/
|
|
37
|
+
requestApproval(): Promise<SpaceCloudComputerStatus | null>;
|
|
38
|
+
refresh(): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type RunOptions = { cwd?: string; timeoutMs?: number; maxOutputLength?: number; requestId?: string };
|
|
42
|
+
|
|
43
|
+
const unavailable = async (): Promise<never> => {
|
|
44
|
+
throw new Error('The cloud computer is not available in this Space.');
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The viewer's approval and, for `cloudComputer: 'shell'`, commands and file writes on the signed-in
|
|
49
|
+
* viewer's own cloud computer, from a Space page.
|
|
50
|
+
*
|
|
51
|
+
* ```tsx
|
|
52
|
+
* const shell = useCloudComputerShell();
|
|
53
|
+
* if (!shell.level) return <AskNotisFallback />;
|
|
54
|
+
* if (!shell.approved) return <Button onClick={() => shell.requestApproval()}>Allow</Button>;
|
|
55
|
+
* const result = await shell.run(`bash ${SCRIPTS}/workspace.sh sync ${quote(repo)} ${quote(name)}`);
|
|
56
|
+
* ```
|
|
57
|
+
*
|
|
58
|
+
* Requires `cloudComputer` in `defineSpace` and the viewer's approval, which the Portal asks for in
|
|
59
|
+
* its own prompt and which covers only the exact code the viewer allowed: any new release asks again.
|
|
60
|
+
*/
|
|
61
|
+
export function useCloudComputerShell(): UseCloudComputerShellResult {
|
|
62
|
+
const runtime = useNotisRuntime();
|
|
63
|
+
const transport = runtime?.spaceCloudComputer;
|
|
64
|
+
const declared = runtime?.space?.cloudComputer;
|
|
65
|
+
const level: SpaceCloudComputerLevel | null = transport && (declared === 'read' || declared === 'shell') ? declared : null;
|
|
66
|
+
const available = level === 'shell';
|
|
67
|
+
const [status, setStatus] = useState<SpaceCloudComputerStatus | null>(null);
|
|
68
|
+
const [loading, setLoading] = useState(Boolean(level));
|
|
69
|
+
const mounted = useRef(true);
|
|
70
|
+
useEffect(() => () => { mounted.current = false; }, []);
|
|
71
|
+
|
|
72
|
+
const refresh = useCallback(async () => {
|
|
73
|
+
if (!level || !transport) return;
|
|
74
|
+
setLoading(true);
|
|
75
|
+
try {
|
|
76
|
+
const next = await transport.status();
|
|
77
|
+
if (mounted.current) setStatus(next);
|
|
78
|
+
} catch {
|
|
79
|
+
if (mounted.current) setStatus(null);
|
|
80
|
+
} finally {
|
|
81
|
+
if (mounted.current) setLoading(false);
|
|
82
|
+
}
|
|
83
|
+
}, [level, transport]);
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
mounted.current = true;
|
|
87
|
+
void refresh();
|
|
88
|
+
}, [refresh]);
|
|
89
|
+
|
|
90
|
+
const run = useCallback(async (command: string, options: RunOptions = {}) => {
|
|
91
|
+
if (!available || !transport) return unavailable();
|
|
92
|
+
return transport.run({ command, ...(options.cwd ? { cwd: options.cwd } : {}),
|
|
93
|
+
...(options.timeoutMs ? { timeout_ms: options.timeoutMs } : {}),
|
|
94
|
+
...(options.maxOutputLength ? { max_output_length: options.maxOutputLength } : {}),
|
|
95
|
+
...(options.requestId ? { request_id: options.requestId } : {}) });
|
|
96
|
+
}, [available, transport]);
|
|
97
|
+
|
|
98
|
+
const upload = useCallback(async (file: SpaceCloudComputerUploadInput) => {
|
|
99
|
+
if (!available || !transport) return unavailable();
|
|
100
|
+
return transport.upload(file);
|
|
101
|
+
}, [available, transport]);
|
|
102
|
+
|
|
103
|
+
const requestApproval = useCallback(async () => {
|
|
104
|
+
if (!level || !transport) return null;
|
|
105
|
+
const next = await transport.requestApproval();
|
|
106
|
+
if (mounted.current) setStatus(next);
|
|
107
|
+
// A facts read refused before the approval must not keep showing "unavailable".
|
|
108
|
+
if (next?.approved === true) invalidateCloudComputerFacts(runtime);
|
|
109
|
+
return next;
|
|
110
|
+
}, [level, runtime, transport]);
|
|
111
|
+
|
|
112
|
+
return { level, available, status, approved: status?.approved === true, loading: Boolean(level) && loading,
|
|
113
|
+
run, upload, requestApproval, refresh };
|
|
114
|
+
}
|
|
@@ -48,12 +48,13 @@ export interface UseHandoverResult {
|
|
|
48
48
|
*/
|
|
49
49
|
export function useHandover(): UseHandoverResult {
|
|
50
50
|
const runtime = useNotisRuntime();
|
|
51
|
+
const dispatch = runtime?.resource?.kind === 'space' ? runtime.draftHandover : runtime?.handover;
|
|
51
52
|
const [pending, setPending] = useState(false);
|
|
52
53
|
const [error, setError] = useState<Error | null>(null);
|
|
53
54
|
|
|
54
55
|
const handover = useCallback(
|
|
55
56
|
async (payload: HandoverPayload): Promise<HandoverResult> => {
|
|
56
|
-
if (!
|
|
57
|
+
if (!dispatch) {
|
|
57
58
|
throw new Error('This Notis host cannot hand work to the manager chat.');
|
|
58
59
|
}
|
|
59
60
|
|
|
@@ -61,7 +62,7 @@ export function useHandover(): UseHandoverResult {
|
|
|
61
62
|
setError(null);
|
|
62
63
|
|
|
63
64
|
try {
|
|
64
|
-
return await
|
|
65
|
+
return await dispatch(payload);
|
|
65
66
|
} catch (err) {
|
|
66
67
|
const e = err instanceof Error ? err : new Error(String(err));
|
|
67
68
|
setError(e);
|
|
@@ -70,8 +71,8 @@ export function useHandover(): UseHandoverResult {
|
|
|
70
71
|
setPending(false);
|
|
71
72
|
}
|
|
72
73
|
},
|
|
73
|
-
[
|
|
74
|
+
[dispatch],
|
|
74
75
|
);
|
|
75
76
|
|
|
76
|
-
return { handover, pending, error, available: Boolean(
|
|
77
|
+
return { handover, pending, error, available: Boolean(dispatch) };
|
|
77
78
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
|
4
|
+
import { useNotisRuntime } from '../provider';
|
|
5
|
+
import { useQuery } from './useQuery';
|
|
6
|
+
import { SpaceActionError, spaceQueryKey, type SpaceDocumentBodyRead, type SpaceDocumentBodySave } from '../space';
|
|
7
|
+
|
|
8
|
+
/** Explicit textarea Save. The source owns the draft and retains its request ID
|
|
9
|
+
* until a receipt or unchanged result; background reads never replace that draft. */
|
|
10
|
+
export function useNativeDocumentBody(binding: string, recordKey: string | null, options: {
|
|
11
|
+
readAction: string; writeAction?: string; enabled?: boolean;
|
|
12
|
+
}) {
|
|
13
|
+
const runtime = useNotisRuntime();
|
|
14
|
+
const scope = JSON.stringify(spaceQueryKey(runtime, ['native-body', binding, recordKey, options.readAction, options.writeAction]));
|
|
15
|
+
const currentScope = useRef<string | null>(scope);
|
|
16
|
+
useLayoutEffect(() => {
|
|
17
|
+
currentScope.current = scope;
|
|
18
|
+
return () => { currentScope.current = null; };
|
|
19
|
+
}, [scope]);
|
|
20
|
+
const [state, setState] = useState<{ scope: string; pending: number; error: Error | null }>({ scope, pending: 0, error: null });
|
|
21
|
+
const query = useQuery<SpaceDocumentBodyRead>(['native-body', binding, recordKey, options.readAction], async () => {
|
|
22
|
+
if (!recordKey || runtime?.resource?.kind !== 'space' || !runtime.documentBody) throw new Error('Open a Space document first.');
|
|
23
|
+
const result = await runtime.documentBody({ operation: 'read', binding, recordKey, readAction: options.readAction });
|
|
24
|
+
if (!('content_markdown' in result)) throw new Error('The document response is incomplete.');
|
|
25
|
+
return result;
|
|
26
|
+
}, { readOnly: true, enabled: Boolean(recordKey) && options.enabled !== false });
|
|
27
|
+
const save = useCallback(async (draft: {
|
|
28
|
+
contentMarkdown: string; expectedRevision: number; schemaRevision: number; requestId: string;
|
|
29
|
+
}): Promise<SpaceDocumentBodySave> => {
|
|
30
|
+
if (!recordKey || !options.writeAction || runtime?.resource?.kind !== 'space' || !runtime.documentBody) {
|
|
31
|
+
throw new Error('This Space does not expose a body-edit action.');
|
|
32
|
+
}
|
|
33
|
+
setState(previous => ({ scope, pending: (previous.scope === scope ? previous.pending : 0) + 1, error: null }));
|
|
34
|
+
try {
|
|
35
|
+
const result = await runtime.documentBody({ operation: 'save', binding, recordKey,
|
|
36
|
+
readAction: options.readAction, writeAction: options.writeAction, ...draft });
|
|
37
|
+
if (currentScope.current !== scope) throw new SpaceActionError('This document view changed. Keep the original save request.', draft.requestId);
|
|
38
|
+
if (!('status' in result)) throw new Error('The document save receipt is incomplete.');
|
|
39
|
+
return result;
|
|
40
|
+
} catch (cause) {
|
|
41
|
+
const error = cause instanceof Error ? cause : new Error(String(cause));
|
|
42
|
+
if (currentScope.current === scope) setState(previous => ({ ...previous, error }));
|
|
43
|
+
throw error;
|
|
44
|
+
} finally {
|
|
45
|
+
if (currentScope.current === scope) setState(previous => ({ ...previous, pending: Math.max(0, previous.pending - 1) }));
|
|
46
|
+
}
|
|
47
|
+
}, [runtime, binding, recordKey, options.readAction, options.writeAction, scope]);
|
|
48
|
+
return { ...query, body: query.data, save, saving: state.scope === scope && state.pending > 0,
|
|
49
|
+
saveError: state.scope === scope ? state.error : null };
|
|
50
|
+
}
|