@notis_ai/cli 0.2.0-beta.16.1 → 0.2.0-beta.160.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.md +433 -133
  2. package/config/notis_app_boundary_rules.json +50 -0
  3. package/config/notis_app_design_rules.json +135 -0
  4. package/dist/agent-hooks/notis-agent-hook.mjs +18672 -0
  5. package/dist/base-skills/notis-apps/SKILL.md +70 -0
  6. package/dist/base-skills/notis-apps/references/architecture.md +164 -0
  7. package/dist/base-skills/notis-apps/references/context.md +81 -0
  8. package/dist/base-skills/notis-apps/references/design.md +165 -0
  9. package/dist/base-skills/notis-apps/references/reading.md +89 -0
  10. package/dist/base-skills/notis-apps/references/release.md +99 -0
  11. package/dist/base-skills/notis-apps/references/sdk.md +62 -0
  12. package/dist/base-skills/notis-apps/references/troubleshooting.md +23 -0
  13. package/dist/base-skills/notis-cli/SKILL.md +140 -0
  14. package/dist/base-skills/notis-cli/references/app-delivery.md +18 -0
  15. package/dist/base-skills/notis-cli/references/native-databases.md +20 -0
  16. package/dist/base-skills/notis-cli/references/tool-examples.md +56 -0
  17. package/dist/base-skills/notis-cli/references/troubleshooting.md +39 -0
  18. package/dist/base-skills/notis-query/SKILL.md +67 -0
  19. package/dist/base-skills/notis-query/references/database-discovery.md +59 -0
  20. package/dist/base-skills/notis-query/references/documents.md +50 -0
  21. package/dist/base-skills/notis-query/references/query.md +543 -0
  22. package/dist/skill-sync/index.js +1626 -0
  23. package/dist/skill-sync/index.js.map +7 -0
  24. package/dist/skill-sync-worker.mjs +2990 -0
  25. package/package.json +16 -6
  26. package/skills/notis-apps/cli.md +313 -0
  27. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
  28. package/skills/notis-onboarding/BRIEF.md +129 -0
  29. package/skills/notis-query/cli.md +39 -0
  30. package/src/agent-hook-entry.js +5 -0
  31. package/src/cli.js +294 -25
  32. package/src/command-specs/agents.js +392 -0
  33. package/src/command-specs/apps.js +1470 -202
  34. package/src/command-specs/auth.js +114 -137
  35. package/src/command-specs/diagnostics.js +716 -0
  36. package/src/command-specs/handover.js +374 -0
  37. package/src/command-specs/helpers.js +84 -82
  38. package/src/command-specs/index.js +25 -6
  39. package/src/command-specs/meta.js +150 -18
  40. package/src/command-specs/onboarding.js +290 -0
  41. package/src/command-specs/profile.js +358 -0
  42. package/src/command-specs/reports.js +86 -0
  43. package/src/command-specs/skills.js +75 -0
  44. package/src/command-specs/smoke.js +386 -0
  45. package/src/command-specs/tools.js +455 -139
  46. package/src/runtime/agent-browser.js +632 -0
  47. package/src/runtime/agent-memory-state.js +126 -0
  48. package/src/runtime/agent-setup.js +383 -0
  49. package/src/runtime/app-boundary-validator.js +404 -0
  50. package/src/runtime/app-changelog.js +79 -0
  51. package/src/runtime/app-platform.js +2633 -210
  52. package/src/runtime/app-registry-scaffolds.js +367 -0
  53. package/src/runtime/app-test-server.js +292 -0
  54. package/src/runtime/assets/store-screenshot-dark.png +0 -0
  55. package/src/runtime/auth-recovery.js +110 -0
  56. package/src/runtime/base-skills.d.ts +20 -0
  57. package/src/runtime/base-skills.js +167 -0
  58. package/src/runtime/channel.js +133 -0
  59. package/src/runtime/delegated-context.js +68 -0
  60. package/src/runtime/errors.js +1 -0
  61. package/src/runtime/git.js +233 -0
  62. package/src/runtime/login-listener.js +15 -0
  63. package/src/runtime/oauth.js +2622 -0
  64. package/src/runtime/output.js +37 -5
  65. package/src/runtime/ports.js +31 -0
  66. package/src/runtime/profiles.js +906 -55
  67. package/src/runtime/skill-sync/cloud-client.ts +99 -0
  68. package/src/runtime/skill-sync/index.ts +697 -0
  69. package/src/runtime/skill-sync/local-scanner.ts +1046 -0
  70. package/src/runtime/skill-sync/symlink-manager.ts +433 -0
  71. package/src/runtime/skill-sync/sync-plan.ts +22 -0
  72. package/src/runtime/skill-sync/types.ts +110 -0
  73. package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
  74. package/src/runtime/skill-sync-service.js +109 -0
  75. package/src/runtime/store-screenshot.js +143 -0
  76. package/src/runtime/sync-skills.d.ts +37 -0
  77. package/src/runtime/sync-skills.js +231 -0
  78. package/src/runtime/telemetry.js +92 -0
  79. package/src/runtime/transport.js +324 -45
  80. package/src/skill-sync-worker-entry.js +2 -0
  81. package/src/skill-sync-worker.js +50 -0
  82. package/template/.harness/index.html.tmpl +430 -0
  83. package/template/CHANGELOG.md +5 -0
  84. package/template/app/layout.tsx +5 -2
  85. package/template/app/page.tsx +49 -42
  86. package/template/components/page-heading.tsx +23 -0
  87. package/template/components/ui/badge.tsx +7 -4
  88. package/template/components/ui/card.tsx +24 -11
  89. package/template/components/ui/native-select.tsx +24 -0
  90. package/template/notis.config.ts +24 -6
  91. package/template/package-lock.json +4137 -0
  92. package/template/package.json +5 -5
  93. package/template/packages/{notis-sdk → sdk}/package.json +13 -3
  94. package/template/packages/sdk/src/agentContext.ts +36 -0
  95. package/template/packages/sdk/src/components/DocumentEditor.tsx +103 -0
  96. package/template/packages/sdk/src/components/Markdown.tsx +60 -0
  97. package/template/packages/sdk/src/components/MarkdownEditor.tsx +121 -0
  98. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +285 -0
  99. package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +97 -0
  100. package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  101. package/template/packages/sdk/src/components/NotisCommentBoundary.tsx +172 -0
  102. package/template/packages/sdk/src/components/NotisSelectionBoundary.tsx +59 -0
  103. package/template/packages/sdk/src/components/ShortcutHints.tsx +56 -0
  104. package/template/packages/sdk/src/components/Skeleton.tsx +24 -0
  105. package/template/packages/sdk/src/config.ts +257 -0
  106. package/template/packages/sdk/src/documents.ts +256 -0
  107. package/template/packages/sdk/src/hooks/useActiveResource.ts +19 -0
  108. package/template/packages/sdk/src/hooks/useAgentContext.ts +23 -0
  109. package/template/packages/sdk/src/hooks/useCloudComputer.ts +64 -0
  110. package/template/packages/sdk/src/hooks/useCollectionInteractions.ts +836 -0
  111. package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +49 -0
  112. package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  113. package/template/packages/sdk/src/hooks/useDocument.ts +43 -0
  114. package/template/packages/sdk/src/hooks/useDocuments.ts +84 -0
  115. package/template/packages/sdk/src/hooks/useHandover.ts +78 -0
  116. package/template/packages/sdk/src/hooks/useLongPressSelection.ts +79 -0
  117. package/template/packages/sdk/src/hooks/useMultiSelect.ts +95 -0
  118. package/template/packages/{notis-sdk → sdk}/src/hooks/useNotis.ts +10 -4
  119. package/template/packages/{notis-sdk → sdk}/src/hooks/useNotisNavigation.ts +11 -8
  120. package/template/packages/sdk/src/hooks/useQuery.ts +71 -0
  121. package/template/packages/sdk/src/hooks/useTool.ts +65 -0
  122. package/template/packages/sdk/src/hooks/useToolQuery.ts +12 -0
  123. package/template/packages/sdk/src/hooks/useTopBarSearch.ts +81 -0
  124. package/template/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
  125. package/template/packages/sdk/src/index.ts +161 -0
  126. package/template/packages/sdk/src/interactions/actions.ts +59 -0
  127. package/template/packages/sdk/src/interactions/shortcuts.tsx +694 -0
  128. package/template/packages/sdk/src/interactions/visibility.ts +13 -0
  129. package/template/packages/sdk/src/interactions.ts +45 -0
  130. package/template/packages/sdk/src/provider.tsx +44 -0
  131. package/template/packages/sdk/src/queryCache.ts +170 -0
  132. package/template/packages/sdk/src/runtime.ts +451 -0
  133. package/template/packages/sdk/src/styles.css +213 -0
  134. package/template/packages/sdk/src/tailwind.ts +56 -0
  135. package/template/packages/{notis-sdk → sdk}/src/vite.ts +5 -1
  136. package/template/tailwind.config.ts +1 -0
  137. package/src/command-specs/db.js +0 -163
  138. package/src/runtime/app-preview-server.js +0 -312
  139. package/template/packages/notis-sdk/src/config.ts +0 -48
  140. package/template/packages/notis-sdk/src/helpers.ts +0 -131
  141. package/template/packages/notis-sdk/src/hooks/useAppState.ts +0 -50
  142. package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +0 -58
  143. package/template/packages/notis-sdk/src/hooks/useDatabase.ts +0 -87
  144. package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
  145. package/template/packages/notis-sdk/src/hooks/useTool.ts +0 -49
  146. package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +0 -57
  147. package/template/packages/notis-sdk/src/index.ts +0 -47
  148. package/template/packages/notis-sdk/src/provider.tsx +0 -44
  149. package/template/packages/notis-sdk/src/runtime.ts +0 -159
  150. package/template/packages/notis-sdk/src/styles.css +0 -123
  151. /package/template/packages/{notis-sdk → sdk}/src/hooks/useBackend.ts +0 -0
  152. /package/template/packages/{notis-sdk → sdk}/src/hooks/useTools.ts +0 -0
  153. /package/template/packages/{notis-sdk → sdk}/src/ui.ts +0 -0
  154. /package/template/packages/{notis-sdk → sdk}/tsconfig.json +0 -0
@@ -0,0 +1,49 @@
1
+ 'use client';
2
+
3
+ import { useQuery } from './useQuery';
4
+ import { useNotisRuntime } from '../provider';
5
+ import { normalizeDatabaseProperty, optionalString } from '../documents';
6
+ import type { DatabaseProperty } from '../runtime';
7
+
8
+ interface GetDatabaseResult {
9
+ database?: {
10
+ name?: string;
11
+ description?: string | null;
12
+ schema?: {
13
+ properties?: unknown[];
14
+ };
15
+ };
16
+ message?: string;
17
+ error?: string;
18
+ }
19
+
20
+ export interface UseDatabaseSchemaResult {
21
+ name: string | null;
22
+ description: string | null;
23
+ properties: DatabaseProperty[];
24
+ loading: boolean;
25
+ isFetching: boolean;
26
+ hasData: boolean;
27
+ error: Error | null;
28
+ refetch: () => void;
29
+ }
30
+
31
+ /** Fetch a database's schema (normalized properties + metadata). */
32
+ export function useDatabaseSchema(databaseSlug: string): UseDatabaseSchemaResult {
33
+ const runtime = useNotisRuntime();
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)),
44
+ };
45
+ }, { readOnly: true });
46
+ return { ...query, name: query.data?.name ?? null, description: query.data?.description ?? null,
47
+ properties: query.data?.properties ?? EMPTY_PROPERTIES };
48
+ }
49
+ const EMPTY_PROPERTIES: DatabaseProperty[] = [];
@@ -0,0 +1,76 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { useNotisRuntime } from '../provider';
5
+ import { useDocuments, type UseDocumentsOptions, type UseDocumentsResult } from './useDocuments';
6
+ import type { DocumentRecord } from '../runtime';
7
+
8
+ export interface UseDatabaseSubscriptionOptions extends UseDocumentsOptions {
9
+ /** Set to false to keep the query but skip the change feed. */
10
+ subscribe?: boolean;
11
+ }
12
+
13
+ export interface UseDatabaseSubscriptionResult extends UseDocumentsResult {
14
+ /** Alias of `documents`, for views that think in rows. */
15
+ rows: DocumentRecord[];
16
+ /** True while a live change feed is attached to this database. */
17
+ live: boolean;
18
+ }
19
+
20
+ /**
21
+ * Query a Notis database and keep it fresh without polling.
22
+ *
23
+ * ```tsx
24
+ * const { rows, live, refetch } = useDatabaseSubscription('workspaces');
25
+ * ```
26
+ *
27
+ * A change on the database wakes the hook, which then refetches through the
28
+ * usual `LOCAL_NOTIS_DATABASE_QUERY` path — the change feed is a signal only
29
+ * and never carries row data. Hosts without a change feed (the dev harness,
30
+ * the screenshot stub, the vite preview) still return rows; `live` is false
31
+ * there and the app should keep offering its manual refresh.
32
+ */
33
+ export function useDatabaseSubscription(
34
+ databaseSlug: string,
35
+ options: UseDatabaseSubscriptionOptions = {},
36
+ ): UseDatabaseSubscriptionResult {
37
+ const runtime = useNotisRuntime();
38
+ const { subscribe = true, ...documentOptions } = options;
39
+ const { documents, loading, isFetching, hasData, error, refetch } = useDocuments(databaseSlug, documentOptions);
40
+ const [live, setLive] = useState(false);
41
+
42
+ const refetchRef = useRef(refetch);
43
+ useEffect(() => {
44
+ refetchRef.current = refetch;
45
+ }, [refetch]);
46
+
47
+ const enabled = options.enabled !== false && subscribe;
48
+
49
+ useEffect(() => {
50
+ if (!runtime?.subscribeDatabase || !enabled || !databaseSlug) {
51
+ setLive(false);
52
+ return;
53
+ }
54
+
55
+ let cancelled = false;
56
+ const unsubscribe = runtime.subscribeDatabase(
57
+ databaseSlug,
58
+ () => {
59
+ refetchRef.current();
60
+ },
61
+ {
62
+ onStatusChange: (isLive) => {
63
+ if (!cancelled) setLive(isLive);
64
+ },
65
+ },
66
+ );
67
+
68
+ return () => {
69
+ cancelled = true;
70
+ setLive(false);
71
+ unsubscribe?.();
72
+ };
73
+ }, [runtime, databaseSlug, enabled]);
74
+
75
+ return { documents, rows: documents, loading, isFetching, hasData, error, refetch, live };
76
+ }
@@ -0,0 +1,43 @@
1
+ 'use client';
2
+
3
+ import { useQuery } from './useQuery';
4
+ import { useNotisRuntime } from '../provider';
5
+ import { normalizeDocumentRecord } from '../documents';
6
+ import type { DocumentRecord } from '../runtime';
7
+
8
+ interface GetDocumentResult {
9
+ status?: string;
10
+ document?: unknown;
11
+ message?: string;
12
+ error?: string;
13
+ }
14
+
15
+ export interface UseDocumentOptions {
16
+ enabled?: boolean;
17
+ }
18
+
19
+ export interface UseDocumentResult {
20
+ document: DocumentRecord | null;
21
+ loading: boolean;
22
+ isFetching: boolean;
23
+ hasData: boolean;
24
+ error: Error | null;
25
+ refetch: () => void;
26
+ }
27
+
28
+ /** Fetch a single document (with content) by id, normalized. */
29
+ export function useDocument(
30
+ documentId: string | null | undefined,
31
+ options: UseDocumentOptions = {},
32
+ ): UseDocumentResult {
33
+ const runtime = useNotisRuntime();
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 };
43
+ }
@@ -0,0 +1,84 @@
1
+ 'use client';
2
+
3
+ import { useQuery } from './useQuery';
4
+ import { useNotisRuntime } from '../provider';
5
+ import { normalizeDocumentRecord } from '../documents';
6
+ import type { DocumentRecord, QueryFilter } from '../runtime';
7
+
8
+ interface QueryDatabaseResult {
9
+ documents?: unknown[];
10
+ has_more?: boolean;
11
+ next_offset?: number | null;
12
+ message?: string;
13
+ error?: string;
14
+ }
15
+
16
+ export interface UseDocumentsOptions {
17
+ filter?: QueryFilter;
18
+ pageSize?: number;
19
+ offset?: number;
20
+ /** Fetch every page, starting at offset, instead of returning only one page. */
21
+ fetchAll?: boolean;
22
+ /** App-view lists can omit bodies until opening/searching a document. Default: true. */
23
+ includeContent?: boolean;
24
+ enabled?: boolean;
25
+ }
26
+
27
+ export interface UseDocumentsResult {
28
+ documents: DocumentRecord[];
29
+ loading: boolean;
30
+ isFetching: boolean;
31
+ hasData: boolean;
32
+ error: Error | null;
33
+ refetch: () => void;
34
+ }
35
+
36
+ /**
37
+ * Query a Notis database and get normalized `DocumentRecord`s.
38
+ *
39
+ * ```tsx
40
+ * const { documents, loading, refetch } = useDocuments('notes', {
41
+ * filter: { filters: [{ property: 'Folder', operator: 'contains', type: 'relation', value: folderId }] },
42
+ * pageSize: 250,
43
+ * });
44
+ * ```
45
+ */
46
+ export function useDocuments(
47
+ databaseSlug: string,
48
+ options: UseDocumentsOptions = {},
49
+ ): UseDocumentsResult {
50
+ const runtime = useNotisRuntime();
51
+ const query = useQuery<DocumentRecord[]>(
52
+ ['documents', databaseSlug, options.filter ?? null, options.pageSize ?? null, options.offset ?? 0, Boolean(options.fetchAll), options.includeContent !== false],
53
+ async () => {
54
+ if (!runtime) throw new Error('Notis runtime not available');
55
+ const allDocuments: unknown[] = [];
56
+ let offset = options.offset ?? 0;
57
+ while (true) {
58
+ const result = await runtime.callTool<QueryDatabaseResult>('LOCAL_NOTIS_DATABASE_QUERY', {
59
+ database_slug: databaseSlug,
60
+ query: {
61
+ ...(options.filter ?? {}),
62
+ ...(options.pageSize !== undefined ? { page_size: options.pageSize } : {}),
63
+ ...(options.includeContent === false ? { include_content: false } : {}),
64
+ },
65
+ ...(offset > 0 ? { offset } : {}),
66
+ }, { dedupe: true, readOnly: true });
67
+ const message = result.error ?? result.message;
68
+ if (!result.documents && message) throw new Error(message);
69
+ allDocuments.push(...(result.documents ?? []));
70
+ if (!options.fetchAll || !result.has_more) break;
71
+ const nextOffset = result.next_offset;
72
+ if (typeof nextOffset !== 'number' || nextOffset <= offset) {
73
+ throw new Error('Database query returned an invalid pagination offset');
74
+ }
75
+ offset = nextOffset;
76
+ }
77
+ return allDocuments.map(normalizeDocumentRecord).filter((document) => document.id);
78
+ },
79
+ { readOnly: true, enabled: options.enabled },
80
+ );
81
+ return { documents: query.data ?? EMPTY_DOCUMENTS, loading: query.loading, isFetching: query.isFetching, hasData: query.hasData, error: query.error, refetch: query.refetch };
82
+ }
83
+
84
+ const EMPTY_DOCUMENTS: DocumentRecord[] = [];
@@ -0,0 +1,78 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useState } from 'react';
4
+ import { useNotisRuntime } from '../provider';
5
+ import type { HandoverPayload, HandoverResult } from '../runtime';
6
+
7
+ export interface UseHandoverResult {
8
+ /** Hand the work over. Rejects when the host has no manager chat. */
9
+ handover: (payload: HandoverPayload) => Promise<HandoverResult>;
10
+ /** True while the manager chat is being prepared. */
11
+ pending: boolean;
12
+ error: Error | null;
13
+ /**
14
+ * False when the host cannot hand work over (dev harness, vite preview).
15
+ * Render the app's own fallback — a copyable prompt, say — when it is false.
16
+ */
17
+ available: boolean;
18
+ }
19
+
20
+ /**
21
+ * Hand a piece of work from app code to the Notis manager chat.
22
+ *
23
+ * An app displays work; the manager runs it. `handover` puts the message in
24
+ * the chat surface that already owns streaming progress, billing, cancellation
25
+ * and the transcript, and the app watches its own databases for the result.
26
+ *
27
+ * ```tsx
28
+ * const { handover, pending, available } = useHandover();
29
+ *
30
+ * return available ? (
31
+ * <Button
32
+ * disabled={pending}
33
+ * onClick={() => { void handover({ prompt: 'Create a workspace on notis to ...' }); }}
34
+ * >
35
+ * Send to Notis
36
+ * </Button>
37
+ * ) : (
38
+ * <CopyablePrompt prompt="Create a workspace on notis to ..." />
39
+ * );
40
+ * ```
41
+ *
42
+ * Omit `prompt` to open a context-only composer, for example when a visible
43
+ * feedback button should let the user write in their own words.
44
+ *
45
+ * Pass `skill` to bind the work to a skill declared in `notis.config.ts`; the
46
+ * host rejects a key the app does not declare. `autoSend` is accepted for
47
+ * forward compatibility; today's hosts always return `drafted` and let the
48
+ * user press send.
49
+ */
50
+ export function useHandover(): UseHandoverResult {
51
+ const runtime = useNotisRuntime();
52
+ const [pending, setPending] = useState(false);
53
+ const [error, setError] = useState<Error | null>(null);
54
+
55
+ const handover = useCallback(
56
+ async (payload: HandoverPayload): Promise<HandoverResult> => {
57
+ if (!runtime?.handover) {
58
+ throw new Error('This Notis host cannot hand work to the manager chat.');
59
+ }
60
+
61
+ setPending(true);
62
+ setError(null);
63
+
64
+ try {
65
+ return await runtime.handover(payload);
66
+ } catch (err) {
67
+ const e = err instanceof Error ? err : new Error(String(err));
68
+ setError(e);
69
+ throw e;
70
+ } finally {
71
+ setPending(false);
72
+ }
73
+ },
74
+ [runtime],
75
+ );
76
+
77
+ return { handover, pending, error, available: Boolean(runtime?.handover) };
78
+ }
@@ -0,0 +1,79 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef } from 'react';
4
+ import type {
5
+ MouseEvent as ReactMouseEvent,
6
+ PointerEvent as ReactPointerEvent,
7
+ } from 'react';
8
+
9
+ const LONG_PRESS_MS = 500;
10
+ const MOVE_TOLERANCE_PX = 10;
11
+ const INTERACTIVE_SELECTOR = [
12
+ 'button',
13
+ 'a[href]',
14
+ 'input',
15
+ 'textarea',
16
+ 'select',
17
+ 'summary',
18
+ '[contenteditable]:not([contenteditable="false"])',
19
+ '[role="button"]',
20
+ '[role="link"]',
21
+ '[role="menuitem"]',
22
+ '[role="option"]',
23
+ '[role="switch"]',
24
+ '[role="tab"]',
25
+ ].join(', ');
26
+
27
+ /** Adds a touch-only long-press entry point without rendering idle checkboxes. */
28
+ export function useLongPressSelection(onSelect: (id: string) => void) {
29
+ const onSelectRef = useRef(onSelect);
30
+ onSelectRef.current = onSelect;
31
+ const timerRef = useRef<number | null>(null);
32
+ const startRef = useRef<{ x: number; y: number } | null>(null);
33
+ const suppressClickRef = useRef(false);
34
+
35
+ const cancel = useCallback(() => {
36
+ if (timerRef.current !== null) window.clearTimeout(timerRef.current);
37
+ timerRef.current = null;
38
+ startRef.current = null;
39
+ }, []);
40
+
41
+ useEffect(() => cancel, [cancel]);
42
+
43
+ return useCallback((id: string) => ({
44
+ onPointerDown: (event: ReactPointerEvent<HTMLElement>) => {
45
+ if (event.pointerType !== 'touch' || event.button !== 0) return;
46
+ const target = event.target;
47
+ if (
48
+ target instanceof HTMLElement
49
+ && target !== event.currentTarget
50
+ && target.closest(INTERACTIVE_SELECTOR)
51
+ ) return;
52
+ cancel();
53
+ startRef.current = { x: event.clientX, y: event.clientY };
54
+ timerRef.current = window.setTimeout(() => {
55
+ timerRef.current = null;
56
+ startRef.current = null;
57
+ suppressClickRef.current = true;
58
+ onSelectRef.current(id);
59
+ }, LONG_PRESS_MS);
60
+ },
61
+ onPointerMove: (event: ReactPointerEvent<HTMLElement>) => {
62
+ const start = startRef.current;
63
+ if (!start) return;
64
+ if (
65
+ Math.abs(event.clientX - start.x) > MOVE_TOLERANCE_PX
66
+ || Math.abs(event.clientY - start.y) > MOVE_TOLERANCE_PX
67
+ ) cancel();
68
+ },
69
+ onPointerUp: cancel,
70
+ onPointerCancel: cancel,
71
+ onClickCapture: (event: ReactMouseEvent<HTMLElement>) => {
72
+ if (!suppressClickRef.current) return false;
73
+ suppressClickRef.current = false;
74
+ event.preventDefault();
75
+ event.stopPropagation();
76
+ return true;
77
+ },
78
+ }), [cancel]);
79
+ }
@@ -0,0 +1,95 @@
1
+ 'use client';
2
+
3
+ import { useCallback, type MouseEvent as ReactMouseEvent } from 'react';
4
+ import {
5
+ useCollectionInteractions,
6
+ type CollectionInteractionController,
7
+ type SelectionMarqueeRect,
8
+ } from './useCollectionInteractions';
9
+
10
+ const ROW_ATTR = 'data-notis-row-id';
11
+
12
+ export type DragRect = SelectionMarqueeRect;
13
+
14
+ export interface UseMultiSelectOptions<T> {
15
+ items: T[];
16
+ getId: (item: T) => string;
17
+ /** When false, suppresses the global Esc / Cmd+A / X / Shift+Arrow listeners. Default true. */
18
+ bindKeyboardShortcuts?: boolean;
19
+ /** When false, disables the rubber-band drag-select rectangle. Default true. */
20
+ enableDragSelect?: boolean;
21
+ /** Distance (px) the cursor must move past mousedown before a drag begins. Default 5. */
22
+ dragThreshold?: number;
23
+ /** Initial head id (the row that Shift+Arrow extends from). */
24
+ initialHeadId?: string | null;
25
+ /** Fires when the head moves via Shift+Arrow so the consumer can scroll the row into view. */
26
+ onHeadChange?: (id: string | null) => void;
27
+ }
28
+
29
+ export interface MultiSelectController<T> {
30
+ selectedIds: ReadonlySet<string>;
31
+ selectedCount: number;
32
+ lastClickedId: string | null;
33
+ headId: string | null;
34
+ dragRect: DragRect | null;
35
+ isSelected: (id: string) => boolean;
36
+ getSelectedItems: () => T[];
37
+ toggle: (id: string) => void;
38
+ select: (ids: string[]) => void;
39
+ clear: () => void;
40
+ selectAll: () => void;
41
+ selectRange: (anchorId: string, targetId: string) => void;
42
+ setHead: (id: string | null) => void;
43
+ onCheckboxClick: (id: string) => (event: ReactMouseEvent) => void;
44
+ onRowMouseDown: (id: string) => (event: ReactMouseEvent) => void;
45
+ getRowProps: (id: string) => { [ROW_ATTR]: string };
46
+ getItemProps: CollectionInteractionController<T>['getItemProps'];
47
+ getCheckboxProps: (id: string) => {
48
+ isSelected: boolean;
49
+ onClick: (event: ReactMouseEvent) => void;
50
+ };
51
+ getContainerProps: CollectionInteractionController<T>['getContainerProps'];
52
+ }
53
+
54
+ /**
55
+ * @deprecated Use `useCollectionInteractions` for controlled selection,
56
+ * active-item navigation, scoped shortcuts, and semantic actions. This bridge
57
+ * is retained for the 0.2 minor and scheduled for removal in 0.3.
58
+ */
59
+ export function useMultiSelect<T>(options: UseMultiSelectOptions<T>): MultiSelectController<T> {
60
+ const controller = useCollectionInteractions({
61
+ ...options,
62
+ defaultActiveId: options.initialHeadId,
63
+ onActiveItemChange: options.onHeadChange,
64
+ shortcuts: {
65
+ next: false,
66
+ previous: false,
67
+ activate: false,
68
+ },
69
+ });
70
+
71
+ const getRowProps = useCallback((id: string) => ({ [ROW_ATTR]: id }), []);
72
+ const getItemProps = controller.getItemProps;
73
+
74
+ return {
75
+ selectedIds: controller.selectedIds,
76
+ selectedCount: controller.selectedCount,
77
+ lastClickedId: controller.lastClickedId,
78
+ headId: controller.headId,
79
+ dragRect: controller.dragRect,
80
+ isSelected: controller.isSelected,
81
+ getSelectedItems: controller.getSelectedItems,
82
+ toggle: controller.toggle,
83
+ select: controller.select,
84
+ clear: controller.clear,
85
+ selectAll: controller.selectAll,
86
+ selectRange: controller.selectRange,
87
+ setHead: controller.setActive,
88
+ onCheckboxClick: controller.onCheckboxClick,
89
+ onRowMouseDown: controller.onRowMouseDown,
90
+ getRowProps,
91
+ getItemProps,
92
+ getCheckboxProps: controller.getCheckboxProps,
93
+ getContainerProps: controller.getContainerProps,
94
+ };
95
+ }
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useNotisRuntime } from '../provider';
4
- import type { AppDescriptor, RouteDescriptor, DatabaseDescriptor } from '../runtime';
4
+ import type { AppDescriptor, CollectionItemDetail, DatabaseDescriptor, RouteDescriptor } from '../runtime';
5
5
 
6
6
  interface NotisContext {
7
7
  /** App metadata (id, name, icon, description). Null before runtime loads. */
@@ -10,14 +10,18 @@ interface NotisContext {
10
10
  route: RouteDescriptor | null;
11
11
  /** Databases declared by this app. Empty before runtime loads. */
12
12
  databases: DatabaseDescriptor[];
13
+ /** Selected collection item for the current route, when applicable. */
14
+ collectionItem: CollectionItemDetail | null;
15
+ /** Resource requested through this route's canonical deep link. */
16
+ resourceId: string | null;
13
17
  /** Whether the runtime is loaded and available. */
14
18
  ready: boolean;
15
19
  }
16
20
 
17
21
  /**
18
- * Access app-level metadata: the app descriptor, current route, and declared
19
- * databases. Returns safe defaults when the runtime isn't available (SSR or
20
- * during `next dev` without preview).
22
+ * Access app-level metadata, the current route, declared databases, and the
23
+ * selected collection item. Returns safe defaults when the portal runtime is
24
+ * not available.
21
25
  */
22
26
  export function useNotis(): NotisContext {
23
27
  const runtime = useNotisRuntime();
@@ -26,6 +30,8 @@ export function useNotis(): NotisContext {
26
30
  app: runtime?.app ?? null,
27
31
  route: runtime?.route ?? null,
28
32
  databases: runtime?.databases ?? [],
33
+ collectionItem: runtime?.context?.collectionItem ?? null,
34
+ resourceId: runtime?.context?.resourceId ?? null,
29
35
  ready: runtime !== null,
30
36
  };
31
37
  }
@@ -5,9 +5,9 @@ import { useNotisRuntime } from '../provider';
5
5
 
6
6
  interface NavigationActions {
7
7
  /** Navigate to a route within the app by its path. */
8
- toRoute: (path: string) => void;
8
+ toRoute: (path: string, options?: { resourceId?: string | null }) => void;
9
9
  /** Navigate to a document detail view. */
10
- toDocument: (documentId: string) => void;
10
+ toDocument: (documentId: string, title?: string | null) => void;
11
11
  /** Navigate to the app's default route. */
12
12
  toApp: () => void;
13
13
  }
@@ -15,7 +15,7 @@ interface NavigationActions {
15
15
  /**
16
16
  * Navigation helpers for moving between routes and documents within the app.
17
17
  * When rendered inside the portal, uses the runtime.navigate() bridge.
18
- * In local preview, falls back to window.location.
18
+ * Without a portal runtime, falls back to window.location for route navigation.
19
19
  *
20
20
  * ```tsx
21
21
  * const nav = useNotisNavigation();
@@ -25,17 +25,20 @@ interface NavigationActions {
25
25
  export function useNotisNavigation(): NavigationActions {
26
26
  const runtime = useNotisRuntime();
27
27
 
28
- const toRoute = useCallback((path: string) => {
28
+ const toRoute = useCallback((path: string, options?: { resourceId?: string | null }) => {
29
29
  if (runtime?.navigate) {
30
- runtime.navigate({ kind: 'route', path });
30
+ runtime.navigate({ kind: 'route', path, resourceId: options?.resourceId ?? null });
31
31
  } else if (typeof window !== 'undefined') {
32
- window.location.href = path;
32
+ const url = new URL(path, window.location.href);
33
+ if (options?.resourceId) url.searchParams.set('resource', options.resourceId);
34
+ else url.searchParams.delete('resource');
35
+ window.location.href = url.toString();
33
36
  }
34
37
  }, [runtime]);
35
38
 
36
- const toDocument = useCallback((documentId: string) => {
39
+ const toDocument = useCallback((documentId: string, title?: string | null) => {
37
40
  if (runtime?.navigate) {
38
- runtime.navigate({ kind: 'document', documentId });
41
+ runtime.navigate({ kind: 'document', documentId, title: title ?? undefined });
39
42
  }
40
43
  }, [runtime]);
41
44
 
@@ -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
+ }