@notis_ai/cli 0.2.0-beta.159.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.
@@ -26,6 +26,10 @@ For source restoration, pull the current release into a fresh checkout and histo
26
26
  another folder. Replace source while retaining the current profile/app link and deployment base,
27
27
  change the package release label, check resource compatibility and deploy as a new release.
28
28
 
29
+ ## Reports
30
+
31
+ For a record-owned report, use `reports init → build → verify/preview → save` instead of the app deployment workflow above. Author exactly one route, select an existing app-owned database, and supply a readable context file. Revisions preserve the record ID and require its freshly read revision. See the product `notis-reports` skill for source recovery, ownership and readback. These commands do not deploy the owning app or publish a Store listing.
32
+
29
33
  ## Commands
30
34
 
31
35
  ### `npx --package @notis_ai/cli@latest -- notis doctor`
@@ -222,3 +226,88 @@ When to use: Diagnose issues with a Notis app project.
222
226
  Examples:
223
227
  - `npx --package @notis_ai/cli@latest -- notis apps doctor`
224
228
  - `npx --package @notis_ai/cli@latest -- notis apps doctor ./my-app`
229
+
230
+ ### `npx --package @notis_ai/cli@latest -- notis reports init <name> [dir]`
231
+
232
+ Init a record-owned SDK report locally.
233
+
234
+ When to use: Author an independent report without deploying its owning app.
235
+
236
+ Options:
237
+ - `--from <slug>` — Start from a published Store app listed by `notis apps scaffolds list`. Downloads its source from the public app registry.
238
+
239
+ Examples:
240
+ - `npx --package @notis_ai/cli@latest -- notis apps scaffolds list`
241
+ - `npx --package @notis_ai/cli@latest -- notis reports init "Mind the Flo"`
242
+ - `npx --package @notis_ai/cli@latest -- notis reports init "My CRM" --from databases`
243
+ - `npx --package @notis_ai/cli@latest -- notis reports init "My App" ~/code/my-app`
244
+
245
+ ### `npx --package @notis_ai/cli@latest -- notis reports build [dir]`
246
+
247
+ Build a record-owned SDK report locally.
248
+
249
+ When to use: Author an independent report without deploying its owning app.
250
+
251
+ Examples:
252
+ - `npx --package @notis_ai/cli@latest -- notis reports build`
253
+ - `npx --package @notis_ai/cli@latest -- notis reports build ./my-app`
254
+
255
+ ### `npx --package @notis_ai/cli@latest -- notis reports verify [dir]`
256
+
257
+ Verify a record-owned SDK report locally.
258
+
259
+ When to use: Author an independent report without deploying its owning app.
260
+
261
+ Options:
262
+ - `--routes <slugs>` — Comma-separated route slugs. Default: every route in manifest.
263
+ - `--port <n>` — Loopback port. Default: auto-pick.
264
+ - `--skip-build` — Skip notis apps build; reuse existing .notis/output/.
265
+ - `--mode <mode>` — stub | live. Default stub. Live posts to /portal_views/runtime_query with the CLI JWT and fails routes whose runtime calls all errored.
266
+ - `--listing` — Ignored for reports; saving a report does not publish a Store listing.
267
+ - `--no-browser` — Start the harness server and print URLs; do not drive agent-browser.
268
+ - `--keep-open` — Leave server + browser session running after report (for manual triage).
269
+
270
+ Examples:
271
+ - `npx --package @notis_ai/cli@latest -- notis reports verify`
272
+ - `npx --package @notis_ai/cli@latest -- notis reports verify --routes notes`
273
+ - `npx --package @notis_ai/cli@latest -- notis reports verify --mode live`
274
+ - `npx --package @notis_ai/cli@latest -- notis reports verify --no-browser # start the harness, drive agent-browser yourself`
275
+
276
+ ### `npx --package @notis_ai/cli@latest -- notis reports preview [dir]`
277
+
278
+ Preview a record-owned SDK report locally. Keeps the preview server and browser session open.
279
+
280
+ When to use: Author an independent report without deploying its owning app.
281
+
282
+ Options:
283
+ - `--routes <slugs>` — Comma-separated route slugs. Default: every route in manifest.
284
+ - `--port <n>` — Loopback port. Default: auto-pick.
285
+ - `--skip-build` — Skip notis apps build; reuse existing .notis/output/.
286
+ - `--mode <mode>` — stub | live. Default stub. Live posts to /portal_views/runtime_query with the CLI JWT and fails routes whose runtime calls all errored.
287
+ - `--listing` — Ignored for reports; saving a report does not publish a Store listing.
288
+ - `--no-browser` — Start the harness server and print URLs; do not drive agent-browser.
289
+ - `--keep-open` — Leave server + browser session running after report (for manual triage).
290
+
291
+ Examples:
292
+ - `npx --package @notis_ai/cli@latest -- notis reports preview`
293
+ - `npx --package @notis_ai/cli@latest -- notis reports preview --routes notes`
294
+ - `npx --package @notis_ai/cli@latest -- notis reports preview --mode live`
295
+ - `npx --package @notis_ai/cli@latest -- notis reports preview --no-browser # start the harness, drive agent-browser yourself`
296
+
297
+ ### `npx --package @notis_ai/cli@latest -- notis reports save [dir]`
298
+
299
+ Build, verify and save a report into an app-owned database record.
300
+
301
+ When to use: Persist an independently authored report, not an app release.
302
+
303
+ Options:
304
+ - `--database-id <id>` — Required. Owning app database.
305
+ - `--document-id <id>` — Existing record to update or attach to.
306
+ - `--attach` — Attach to an existing non-view record.
307
+ - `--expected-revision <revision>` — Fresh view revision (0 for a record without a view).
308
+ - `--title <title>` — Required, including updates. Record title.
309
+ - `--context-file <file>` — Required. UTF-8 readable report content and structure.
310
+ - `--properties-file <file>` — JSON database property values keyed by name.
311
+
312
+ Examples:
313
+ - `npx --package @notis_ai/cli@latest -- notis reports save ./weekly-report --database-id <id> --title "Weekly review" --context-file ./context.md`
@@ -48,12 +48,12 @@ The fields onboarding collects, wherever it runs. Both the conversational
48
48
  onboarding assistant and the CLI brief reference this partial so the two paths
49
49
  can never quietly collect different things.
50
50
 
51
- All of these land through `LOCAL_NOTIS_SAVE_USER_SETTINGS`: `full_name` on the
51
+ All of these land through `LOCAL_NOTIS_SAVE_USER_SETTINGS`: `first_name` on the
52
52
  user row, the rest merged into the `settings` blob.
53
53
 
54
54
  | Field | Setting key | What it is for |
55
55
  |---|---|---|
56
- | First name | `full_name` | How Notis addresses the user. Deduce from the email before asking. |
56
+ | First name | `first_name` | How Notis addresses the user. Ask for their given name; preserve compound names and do not infer it from an email address. |
57
57
  | Occupation / role | `position` | Tailors examples and suggestions. |
58
58
  | Language | `language` | The language Notis replies in. |
59
59
  | Time zone | `timezone` | Anchors every scheduled and time-relative request. Ask; never guess silently. |
@@ -63,8 +63,9 @@ Rules that hold on every surface:
63
63
 
64
64
  * Ask one question at a time. A wall of questions reads as a form, and people
65
65
  abandon forms.
66
- * Skip anything already known or confidently deducible, and say what you deduced
67
- rather than asking the user to confirm a blank.
66
+ * For first name, skip only when already saved or provided by the user. Never
67
+ deduce a name. For other fields, skip known or confidently deducible values
68
+ and say what you deduced rather than asking the user to confirm a blank.
68
69
  * Save as soon as you have the basics rather than batching to the end — a user who
69
70
  drops out halfway should not lose what they already told you.
70
71
 
@@ -74,7 +75,7 @@ what you inferred, and let the user correct you. Then save:
74
75
 
75
76
  ```bash
76
77
  npx --package @notis_ai/cli@latest -- notis tools exec LOCAL_NOTIS_SAVE_USER_SETTINGS \
77
- --arguments '{"full_name":"...","position":"...","language":"...","timezone":"..."}'
78
+ --arguments '{"first_name":"...","position":"...","language":"...","timezone":"..."}'
78
79
  ```
79
80
 
80
81
  ## 2. Connect one app, then immediately read from it
@@ -786,7 +786,7 @@ async function appsVerifyHandler(ctx) {
786
786
  });
787
787
 
788
788
  if (keepOpen) {
789
- process.stderr.write(`[notis apps verify] harness open at ${baseUrl}. Press Ctrl-C to stop.\n`);
789
+ process.stderr.write(`[notis apps verify] harness open at ${urls[0]?.url || baseUrl}. Press Ctrl-C to stop.\n`);
790
790
  await new Promise(() => {});
791
791
  }
792
792
 
@@ -1,3 +1,4 @@
1
+ import { reportsCommandSpecs } from './reports.js';
1
2
  import { appsCommandSpecs } from './apps.js';
2
3
  import { toolsCommandSpecs } from './tools.js';
3
4
  import { metaCommandSpecs } from './meta.js';
@@ -11,6 +12,7 @@ import { agentsCommandSpecs } from './agents.js';
11
12
  import { skillsCommandSpecs } from './skills.js';
12
13
 
13
14
  export const GROUP_SUMMARIES = {
15
+ reports: 'Build and save independent SDK reports into app database records.',
14
16
  apps: 'Develop, deploy, and submit Notis Apps.',
15
17
  agents: 'Install Notis context into local coding agents.',
16
18
  handover: 'Hand the branch you are on to a Notis agent, hosted or your own Codex/Claude.',
@@ -28,6 +30,7 @@ export const COMMAND_SPECS = [
28
30
  ...agentsCommandSpecs,
29
31
  ...skillsCommandSpecs,
30
32
  ...appsCommandSpecs,
33
+ ...reportsCommandSpecs,
31
34
  ...handoverCommandSpecs,
32
35
  ...toolsCommandSpecs,
33
36
  ...diagnosticCommandSpecs,
@@ -157,7 +157,7 @@ async function authenticatedResult(ctx) {
157
157
  }
158
158
 
159
159
  if (onboardingComplete) {
160
- const name = state?.settings?.full_name;
160
+ const name = state?.settings?.first_name;
161
161
  const setupSummary = renderAgentSetup(base.agent_setup);
162
162
  return ctx.output.emitSuccess({
163
163
  command: 'start',
@@ -0,0 +1,86 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { appsCommandSpecs } from './apps.js';
4
+ import { buildArtifact, prepareAppRelease, resolveProjectDir } from '../runtime/app-platform.js';
5
+ import { nextIdempotencyKey, runToolCommand } from './helpers.js';
6
+ import { usageError, EXIT_CODES } from '../runtime/errors.js';
7
+
8
+ const reuse = (command, name = command) => {
9
+ const spec = appsCommandSpecs.find(item => item.command_path.join(' ') === `apps ${command}`);
10
+ return {
11
+ ...spec,
12
+ handler: async ctx => {
13
+ const output = new Proxy(ctx.output, {
14
+ get(target, key) {
15
+ if (key === 'emitSuccess') return result => {
16
+ const value = { ...result, warnings: (result.warnings || []).filter(warning => !warning.startsWith('Store readiness:')) };
17
+ if (value.data?.listing) { value.data = { ...value.data }; delete value.data.listing; }
18
+ return target.emitSuccess(value);
19
+ };
20
+ const value = Reflect.get(target, key);
21
+ return typeof value === 'function' ? value.bind(target) : value;
22
+ },
23
+ });
24
+ return spec.handler({ ...ctx, options: { ...ctx.options, listing: false, ...(name === 'preview' ? { keepOpen: true } : {}) }, output });
25
+ },
26
+ command_path: ['reports', name],
27
+ summary: `${name[0].toUpperCase() + name.slice(1)} a record-owned SDK report locally.${name === 'preview' ? ' Keeps the preview server and browser session open.' : ''}`,
28
+ args_schema: {
29
+ ...spec.args_schema,
30
+ options: (spec.args_schema?.options || []).map(option => option.flags === '--listing'
31
+ ? { ...option, description: 'Ignored for reports; saving a report does not publish a Store listing.' }
32
+ : option),
33
+ },
34
+ examples: (spec.examples || []).filter(example => !example.includes('--listing')).map(example => example.replace(`apps ${command}`, `reports ${name}`)),
35
+ when_to_use: 'Author an independent report without deploying its owning app.',
36
+ };
37
+ };
38
+ export const reportsCommandSpecs = [
39
+ reuse('init'), reuse('build'), reuse('verify'), reuse('verify', 'preview'),
40
+ {
41
+ command_path: ['reports', 'save'],
42
+ summary: 'Build, verify and save a report into an app-owned database record.',
43
+ when_to_use: 'Persist an independently authored report, not an app release.',
44
+ args_schema: {
45
+ arguments: [{ token: '[dir]', key: 'dir', description: 'Report source directory.' }],
46
+ options: [
47
+ { flags: '--database-id <id>', description: 'Required. Owning app database.' },
48
+ { flags: '--document-id <id>', description: 'Existing record to update or attach to.' },
49
+ { flags: '--attach', description: 'Attach to an existing non-view record.' },
50
+ { flags: '--expected-revision <revision>', description: 'Fresh view revision (0 for a record without a view).' },
51
+ { flags: '--title <title>', description: 'Required, including updates. Record title.' },
52
+ { flags: '--context-file <file>', description: 'Required. UTF-8 readable report content and structure.' },
53
+ { flags: '--properties-file <file>', description: 'JSON database property values keyed by name.' },
54
+ ],
55
+ },
56
+ examples: ['notis reports save ./weekly-report --database-id <id> --title \"Weekly review\" --context-file ./context.md'], mutates: true, idempotent: true,
57
+ backend_call: { type: 'tool', name: 'LOCAL_NOTIS_SAVE_REPORT' },
58
+ async handler(ctx) {
59
+ const dir = resolveProjectDir(ctx.args.dir || '.');
60
+ if (!ctx.options.databaseId || !ctx.options.title || !ctx.options.contextFile) throw usageError('--database-id, --title and --context-file are required.');
61
+ if (ctx.options.documentId && (!/^\d+$/.test(String(ctx.options.expectedRevision ?? '')) || !Number.isSafeInteger(Number(ctx.options.expectedRevision)))) throw usageError('--expected-revision is required for update/attach.');
62
+ if (ctx.options.attach && !ctx.options.documentId) throw usageError('--attach requires --document-id.');
63
+ const context = readFileSync(resolve(ctx.options.contextFile), 'utf8');
64
+ const properties = ctx.options.propertiesFile ? JSON.parse(readFileSync(resolve(ctx.options.propertiesFile), 'utf8')) : {};
65
+ await buildArtifact(dir, { stdio: ctx.output.isMachineMode() ? 'pipe' : 'inherit' });
66
+ const release = prepareAppRelease(dir);
67
+ try {
68
+ if (release.manifest.routes?.length !== 1) throw usageError('Reports require exactly one SDK route.');
69
+ let verification;
70
+ const verify = appsCommandSpecs.find(item => item.command_path.join(' ') === 'apps verify');
71
+ const exit = await verify.handler({ ...ctx, args: { dir: release.projectDir }, options: { skipBuild: true, mode: 'stub' }, output: { ...ctx.output, isMachineMode: () => true, emitSuccess: value => { verification = value; } } });
72
+ if (exit !== EXIT_CODES.ok || verification?.data?.status !== 'passed') throw usageError('Report verification failed; nothing saved.');
73
+ const files = { ...release.files, ...Object.fromEntries(Object.entries(release.sourceFiles).map(([path, data]) => [`source/${path}`, data])) };
74
+ const result = await runToolCommand({ runtime: { ...ctx.runtime, timeoutMs: Math.max(ctx.runtime.timeoutMs || 0, 90000) }, toolName: 'LOCAL_NOTIS_SAVE_REPORT', mutating: true,
75
+ idempotencyKey: nextIdempotencyKey(ctx.globalOptions), arguments_: {
76
+ operation: ctx.options.attach ? 'attach' : ctx.options.documentId ? 'update' : 'create',
77
+ database_id: ctx.options.databaseId, title: ctx.options.title, properties,
78
+ ...(ctx.options.documentId ? { document_id: ctx.options.documentId, expected_revision: Number(ctx.options.expectedRevision) } : {}),
79
+ report: { schema: 'notis-report/v2', context, artifact: { manifest: release.manifest, files, encoding: 'base64' } },
80
+ } });
81
+ if (!result?.payload?.document?.id || !result.payload.document.view_revision) throw usageError('Save returned no record identity. Read back before retrying; the outcome may be unknown.');
82
+ return ctx.output.emitSuccess({ command: 'reports save', data: result.payload });
83
+ } finally { release.close(); }
84
+ },
85
+ },
86
+ ];
@@ -80,6 +80,8 @@ function withoutBaseSkillState(state: NotisSyncState): NotisSyncState {
80
80
  }
81
81
 
82
82
  export interface MaterializeCloudSkillsResult {
83
+ /** Cloud skills verified present in the final scoped disk scan. */
84
+ materializedSkillNames: string[];
83
85
  pulled: number;
84
86
  downloaded: number;
85
87
  deleted: number;
@@ -89,6 +91,8 @@ export interface MaterializeCloudSkillsResult {
89
91
  }
90
92
 
91
93
  export interface MaterializeCloudSkillsOptions {
94
+ /** Server-verified Notis identity, when Desktop's auth subject differs. */
95
+ canonicalUserId?: string;
92
96
  /** Re-link only these cloud skills without removing or adopting other links. */
93
97
  relinkSkillNames?: readonly string[];
94
98
  }
@@ -293,6 +297,7 @@ async function writePulledSkillsToScopedMirror(
293
297
  "downloadSkillBundle" | "writeCloudSkillToDisk"
294
298
  >,
295
299
  failures: SkillSyncFailure[] = [],
300
+ writtenSkillNames: Set<string> = new Set(),
296
301
  ): Promise<number> {
297
302
  const localSkillMap = toSkillMap(localSkills);
298
303
  const warnSkillSync = (message: string, error: unknown): void => {
@@ -314,6 +319,7 @@ async function writePulledSkillsToScopedMirror(
314
319
  })
315
320
  ) {
316
321
  downloaded += 1;
322
+ writtenSkillNames.add(cloudSkill.name);
317
323
  } else {
318
324
  failures.push({ name: cloudSkill.name, error: "Skill content could not be downloaded or written; sync will retry" });
319
325
  }
@@ -367,13 +373,14 @@ export async function materializeCloudSkillsForLocalShell(
367
373
  );
368
374
  }
369
375
 
370
- const syncPaths = getSkillSyncPathsForUser(authUserId);
376
+ const syncPaths = getSkillSyncPathsForUser(options.canonicalUserId?.trim() || authUserId);
371
377
  const pullResponse = await deps.pullSkills(serverUrl, jwt);
372
378
  assertSkillsPullAuthorized(pullResponse);
373
379
  const previousState = await deps.readSyncState(syncPaths);
374
380
 
375
381
  const localSkills = await deps.scanLocalSkills(syncPaths);
376
382
  const failedDownloads: SkillSyncFailure[] = [];
383
+ const writtenSkillNames = new Set<string>();
377
384
  const downloaded = await writePulledSkillsToScopedMirror(
378
385
  pullResponse,
379
386
  localSkills,
@@ -381,6 +388,7 @@ export async function materializeCloudSkillsForLocalShell(
381
388
  syncPaths,
382
389
  deps,
383
390
  failedDownloads,
391
+ writtenSkillNames,
384
392
  );
385
393
  const finalLocalSkills = await deps.scanLocalSkills(syncPaths);
386
394
  const lastSyncedAt = pullResponse.last_synced_at || new Date().toISOString();
@@ -409,12 +417,31 @@ export async function materializeCloudSkillsForLocalShell(
409
417
  }
410
418
  for (const failure of failedDownloads) delete verifiedLinks[failure.name];
411
419
 
412
- await deps.writeSyncState(
413
- buildSyncState(pullResponse, finalLocalSkills, lastSyncedAt, verifiedLinks, new Set(failedDownloads.map(item => item.name))),
414
- syncPaths,
420
+ const materializedState = buildSyncState(
421
+ pullResponse, finalLocalSkills, lastSyncedAt, verifiedLinks,
422
+ new Set(failedDownloads.map(item => item.name)),
415
423
  );
424
+ // Pull-only refresh is not an upload acknowledgement. Keep content baselines
425
+ // unless we actually wrote cloud content, and retain cloud-missing entries so
426
+ // the next two-way sync can delete them instead of uploading them as new.
427
+ for (const [name, entry] of Object.entries(materializedState.skills)) {
428
+ if (writtenSkillNames.has(name)) continue;
429
+ const previous = previousState.skills[name];
430
+ if (previous) {
431
+ entry.folderHash = previous.folderHash;
432
+ entry.cloudContentHash = previous.cloudContentHash;
433
+ } else {
434
+ delete materializedState.skills[name];
435
+ }
436
+ }
437
+ materializedState.skills = { ...previousState.skills, ...materializedState.skills };
438
+ await deps.writeSyncState(materializedState, syncPaths);
416
439
 
417
440
  return {
441
+ materializedSkillNames: pullResponse.skills
442
+ .filter((skill) => finalLocalSkills.some((local) => local.name === skill.name)
443
+ && !failedDownloads.some((failure) => failure.name === skill.name))
444
+ .map((skill) => skill.name),
418
445
  pulled: pullResponse.skills.length,
419
446
  downloaded,
420
447
  deleted: 0,
@@ -0,0 +1,36 @@
1
+ /** Serializable context shared with the agent. Apps own annotation storage. */
2
+ export interface ContextAttachment {
3
+ /** Durable HTTPS URL supplied by the app. Sent through the host media pipeline. */
4
+ url: string;
5
+ name: string;
6
+ mimeType: string;
7
+ }
8
+
9
+ export interface AgentContextContent {
10
+ title?: string;
11
+ /** Emoji, Phosphor icon name, or HTTPS image URL. Never executable markup. */
12
+ icon?: string;
13
+ /** An app-defined kind, not a closed list of annotation types. */
14
+ kind?: string;
15
+ text?: string;
16
+ comment?: string;
17
+ preview?: { format: 'text' | 'markdown'; content: string };
18
+ /** Any JSON-serializable value: coordinates, chart state, loaded records, etc. */
19
+ data?: unknown;
20
+ attachments?: ContextAttachment[];
21
+ }
22
+
23
+ export interface AgentContextItem extends AgentContextContent {
24
+ /** Stable within this app. The host scopes IDs to the originating app. */
25
+ id: string;
26
+ resource?: import('./runtime').ContextResource | null;
27
+ }
28
+
29
+ /** Host-stamped origin. Apps describe resources; the host identifies the app/view. */
30
+ export interface AgentContextSource {
31
+ appId: string;
32
+ appName: string;
33
+ viewSlug: string;
34
+ viewName: string;
35
+ viewUrl?: string | null;
36
+ }
@@ -0,0 +1,172 @@
1
+ 'use client';
2
+ import React, { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
3
+ import type { ContextResource } from '../runtime';
4
+ import { NotisSelectionBoundary } from './NotisSelectionBoundary';
5
+ import { useAgentContext } from '../hooks/useAgentContext';
6
+
7
+ export interface NotisCommentBoxProps {
8
+ quote?: string;
9
+ value: string;
10
+ onChange(value: string): void;
11
+ onSubmit(): void;
12
+ onCancel(): void;
13
+ pending?: boolean;
14
+ error?: string;
15
+ className?: string;
16
+ }
17
+
18
+ /** Compact, chrome-themed presentation. Apps retain ownership of comment state. */
19
+ export function NotisCommentBox({ quote, value, onChange, onSubmit, onCancel, pending, error, className = '' }: NotisCommentBoxProps) {
20
+ return <div data-notis-comment-ui role="dialog" aria-label="Comment on selection"
21
+ style={{ colorScheme: 'dark', background: 'hsl(var(--sidebar-background, 0 0% 7.5%))', color: 'hsl(var(--sidebar-foreground, 240 4.8% 95.9%))', borderColor: 'hsl(var(--sidebar-border, 0 0% 23.5%))' }}
22
+ className={`flex min-w-0 flex-col gap-2 rounded-xl border p-2.5 text-sm shadow-lg ${className}`}>
23
+ <div className="flex min-w-0 items-center gap-2">
24
+ {quote && <blockquote title={quote} className="min-w-0 flex-1 truncate border-l-2 border-current/40 pl-2 opacity-70">{quote}</blockquote>}
25
+ <button type="button" aria-label="Cancel comment" onClick={onCancel} disabled={pending} className="ml-auto flex size-7 shrink-0 items-center justify-center rounded-lg opacity-70 hover:bg-white/10 hover:opacity-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current disabled:opacity-40 [@media(pointer:coarse)]:size-9">
26
+ <svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="m6 6 12 12M18 6 6 18" /></svg>
27
+ </button>
28
+ </div>
29
+ <textarea autoFocus rows={2} name="selection-comment" aria-label="Your comment" placeholder="Add a comment…" value={value} onChange={event => onChange(event.target.value)} disabled={pending}
30
+ onKeyDown={event => { if (event.nativeEvent.isComposing || pending) return; if (event.key === 'Escape') { event.preventDefault(); onCancel(); } if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { event.preventDefault(); if (value.trim()) onSubmit(); } }}
31
+ className="w-full min-w-0 resize-none rounded bg-transparent px-0.5 py-1 text-base text-inherit outline-none placeholder:text-current placeholder:opacity-60 sm:text-sm" />
32
+ {error && <p role="alert" className="text-sm text-red-300">{error}</p>}
33
+ <div className="flex items-center justify-between gap-3">
34
+ <span className="text-xs opacity-60">⌘ / Ctrl ↵ to add</span>
35
+ <button type="button" disabled={pending || !value.trim()} onClick={onSubmit}
36
+ style={{ background: 'hsl(var(--sidebar-foreground, 240 4.8% 95.9%))', color: 'hsl(var(--sidebar-background, 0 0% 7.5%))' }}
37
+ className="min-h-8 shrink-0 rounded-lg px-2.5 text-sm font-medium hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current disabled:opacity-40 [@media(pointer:coarse)]:min-h-9">{pending ? 'Adding…' : 'Add to chat'}</button>
38
+ </div>
39
+ </div>;
40
+ }
41
+
42
+ export interface NotisCommentBoundaryProps {
43
+ resource?: ContextResource | null;
44
+ children: ReactNode;
45
+ className?: string;
46
+ commentClassName?: string;
47
+ /** Replace the optional standard editor while retaining selection/context behavior. */
48
+ renderComment?: (props: NotisCommentBoxProps) => ReactNode;
49
+ }
50
+
51
+ type SelectionRect = Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom'>;
52
+
53
+ /** Place the tile after the final selected line, otherwise below (or above near the viewport bottom). */
54
+ export function getCommentActionPosition(rects: SelectionRect[], viewport: { width: number; height: number }, rightBoundary = viewport.width, trailing: SelectionRect[] = []) {
55
+ const last = rects[rects.length - 1];
56
+ const size = 32, gap = 8;
57
+ const clampX = (x: number) => Math.max(gap, Math.min(viewport.width - size - gap, x));
58
+ const clampY = (y: number) => Math.max(gap, Math.min(viewport.height - size - gap, y));
59
+ const beside = { left: last.right + gap, top: clampY((last.top + last.bottom - size) / 2) };
60
+ const overlapsText = trailing.some(rect => rect.left < beside.left + size && rect.right > beside.left && rect.top < beside.top + size && rect.bottom > beside.top);
61
+ if (beside.left + size <= Math.min(viewport.width, rightBoundary) - gap && !overlapsText) return beside;
62
+ return { left: clampX(last.right - size), top: clampY(last.bottom + gap + size <= viewport.height - gap ? last.bottom + gap : last.top - size - gap) };
63
+ }
64
+
65
+ /** Browser paragraph selection may include an adjacent block's empty starting boundary. */
66
+ export function getOwnedCommentRange(selected: Range, content: Element): Range | null {
67
+ const owned = content.ownerDocument.createRange();
68
+ owned.selectNodeContents(content);
69
+ const range = selected.cloneRange();
70
+ // Only clip whitespace outside the resource; actual neighboring content must
71
+ // never be silently attached to this resource's comment.
72
+ if (range.compareBoundaryPoints(Range.START_TO_START, owned) < 0) {
73
+ const before = range.cloneRange();
74
+ before.setEnd(owned.startContainer, owned.startOffset);
75
+ if (before.toString().trim()) return null;
76
+ range.setStart(owned.startContainer, owned.startOffset);
77
+ }
78
+ if (range.compareBoundaryPoints(Range.END_TO_END, owned) > 0) {
79
+ const after = range.cloneRange();
80
+ after.setStart(owned.endContainer, owned.endOffset);
81
+ if (after.toString().trim()) return null;
82
+ range.setEnd(owned.endContainer, owned.endOffset);
83
+ }
84
+ if (range.collapsed || !content.contains(range.startContainer) || !content.contains(range.endContainer)) return null;
85
+ return range;
86
+ }
87
+
88
+ /** Selection-to-chat convenience. No annotation database, polling or background attachment. */
89
+ export function NotisCommentBoundary({ resource, children, className, commentClassName, renderComment }: NotisCommentBoundaryProps) {
90
+ const context = useAgentContext();
91
+ const root = useRef<HTMLDivElement>(null);
92
+ const [selection, setSelection] = useState<{ id: string; text: string; resource?: ContextResource | null; top: number; left: number } | null>(null);
93
+ const [editing, setEditing] = useState(false);
94
+ const editingRef = useRef(false);
95
+ const [comment, setComment] = useState('');
96
+ const [pending, setPending] = useState(false);
97
+ const [error, setError] = useState('');
98
+ useEffect(() => {
99
+ const node = root.current;
100
+ if (!node) return;
101
+ const document = node.ownerDocument;
102
+ const scope = node.getRootNode() as ShadowRoot & { getSelection?: () => Selection | null };
103
+ const clearInvalidSelection = () => {
104
+ // Opening the editor focuses its input and collapses the browser range.
105
+ // Keep the captured quote/editor; only the unopened action follows live selection.
106
+ if (editingRef.current) return;
107
+ const selected = scope.getSelection?.() || document.defaultView?.getSelection();
108
+ const content = node.firstElementChild;
109
+ if (!selected?.rangeCount || selected.isCollapsed || !selected.toString().trim()
110
+ || !content || !getOwnedCommentRange(selected.getRangeAt(0), content)) setSelection(null);
111
+ };
112
+ document.addEventListener('selectionchange', clearInvalidSelection);
113
+ if (scope !== (document as unknown as ShadowRoot)) scope.addEventListener('selectionchange', clearInvalidSelection);
114
+ return () => {
115
+ document.removeEventListener('selectionchange', clearInvalidSelection);
116
+ if (scope !== (document as unknown as ShadowRoot)) scope.removeEventListener('selectionchange', clearInvalidSelection);
117
+ };
118
+ }, []);
119
+ const open = () => { editingRef.current = true; setEditing(true); };
120
+ const capture = (target: EventTarget | null) => {
121
+ // Clicking the action/editor must not unmount it before its click runs.
122
+ if (editing || (target instanceof Element && target.closest('[data-notis-comment-ui]'))) return;
123
+ const node = root.current;
124
+ const scope = node?.getRootNode() as ShadowRoot & { getSelection?: () => Selection | null };
125
+ const selected = scope?.getSelection?.() || window.getSelection();
126
+ if (!node || !selected?.rangeCount || selected.isCollapsed) { setSelection(null); return; }
127
+ const content = node.firstElementChild;
128
+ const range = content ? getOwnedCommentRange(selected.getRangeAt(0), content) : null;
129
+ if (!range) { setSelection(null); return; }
130
+ const start = range.startContainer.nodeType === Node.ELEMENT_NODE ? range.startContainer as Element : range.startContainer.parentElement;
131
+ const end = range.endContainer.nodeType === Node.ELEMENT_NODE ? range.endContainer as Element : range.endContainer.parentElement;
132
+ if (!node.contains(range.startContainer) || !node.contains(range.endContainer)
133
+ || start?.closest('input,textarea,button,[contenteditable],[data-notis-comment-ui]')
134
+ || end?.closest('input,textarea,button,[contenteditable],[data-notis-comment-ui]')) { setSelection(null); return; }
135
+ const rect = range.getBoundingClientRect();
136
+ const lines = Array.from(range.getClientRects?.() || []).filter(line => line.width > 0 && line.height > 0);
137
+ const boundary = node.firstElementChild?.getBoundingClientRect();
138
+ const trailing: DOMRect[] = [];
139
+ const endBlock = end?.closest('p,li,h1,h2,h3,h4,h5,h6,td,th,div');
140
+ if (endBlock && node.contains(endBlock)) {
141
+ const remainder = range.cloneRange();
142
+ remainder.selectNodeContents(endBlock);
143
+ remainder.setStart(range.endContainer, range.endOffset);
144
+ trailing.push(...Array.from(remainder.getClientRects?.() || []).filter(line => line.width > 0 && line.height > 0));
145
+ }
146
+ const position = getCommentActionPosition(lines.length ? lines : [rect], { width: window.innerWidth, height: window.innerHeight }, boundary?.width ? boundary.right : window.innerWidth, trailing);
147
+ setSelection({ id: crypto.randomUUID(), text: selected.toString().trim(), resource: resource ? JSON.parse(JSON.stringify(resource)) : null, ...position });
148
+ };
149
+ const close = () => { editingRef.current = false; setEditing(false); setSelection(null); setComment(''); setError(''); };
150
+ const submit = async () => {
151
+ if (!selection || !comment.trim() || pending) return;
152
+ setPending(true); setError('');
153
+ try {
154
+ const added = await context.add({ id: selection.id, kind: 'comment', title: selection.resource?.label || 'Comment', icon: 'phosphor:chat-text', text: selection.text, comment, resource: selection.resource });
155
+ if (!added) throw new Error('The comment could not be added to chat.');
156
+ close();
157
+ } catch (reason) { setError(reason instanceof Error ? reason.message : 'Could not add this comment.'); }
158
+ finally { setPending(false); }
159
+ };
160
+ const editorProps: NotisCommentBoxProps = { quote: selection?.text, value: comment, onChange: setComment, onSubmit: () => void submit(), onCancel: close, pending, error, className: commentClassName };
161
+ return <div ref={root} className="contents" onMouseUp={event => capture(event.target)} onDoubleClick={event => capture(event.target)} onKeyUp={event => { if (!editing && (event.shiftKey || event.key === 'Shift' || ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'a'))) capture(event.target); }}>
162
+ <NotisSelectionBoundary resource={resource} className={className} style={className ? undefined : { display: 'contents' }}>{children}</NotisSelectionBoundary>
163
+ {selection && <div data-notis-comment-ui className="fixed z-50 max-h-[calc(100dvh-1rem)]" style={{ overflow: editing ? 'auto' : 'visible', top: editing ? Math.min(selection.top, Math.max(8, window.innerHeight - 320)) : selection.top, left: `clamp(8px, ${selection.left}px, calc(100vw - ${editing ? 'min(20rem, calc(100vw - 1rem))' : '2rem'} - 8px))`, width: editing ? 'min(20rem, calc(100vw - 1rem))' : undefined } as CSSProperties}>
164
+ {editing ? (renderComment ? renderComment(editorProps) : <NotisCommentBox {...editorProps} />) : <button type="button" aria-label="Comment" title="Comment" onMouseDown={event => event.preventDefault()} onClick={open}
165
+ style={{ background: 'hsl(var(--sidebar-background, 0 0% 7.5%))', color: 'hsl(var(--sidebar-foreground, 240 4.8% 95.9%))' }}
166
+ className="relative flex size-8 items-center justify-center rounded-lg ring-1 ring-white/15 hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current">
167
+ <svg aria-hidden="true" width="18" height="18" viewBox="0 0 256 256" fill="currentColor"><path d="M216,48H40A16,16,0,0,0,24,64V224a15.85,15.85,0,0,0,9.24,14.5A16.13,16.13,0,0,0,40,240a15.89,15.89,0,0,0,10.25-3.78l.09-.07L83,208H216a16,16,0,0,0,16-16V64A16,16,0,0,0,216,48ZM40,224h0ZM216,192H80a8,8,0,0,0-5.23,1.95L40,224V64H216ZM88,112a8,8,0,0,1,8-8h64a8,8,0,0,1,0,16H96A8,8,0,0,1,88,112Zm0,32a8,8,0,0,1,8-8h64a8,8,0,1,1,0,16H96A8,8,0,0,1,88,144Z" /></svg>
168
+ <span aria-hidden="true" className="absolute left-1/2 top-1/2 hidden size-12 -translate-x-1/2 -translate-y-1/2 [@media(pointer:coarse)]:block" />
169
+ </button>}
170
+ </div>}
171
+ </div>;
172
+ }
@@ -1,5 +1,5 @@
1
1
  import type { CSSProperties, ClipboardEvent, ReactNode } from 'react';
2
- import { useCallback } from 'react';
2
+ import React, { useCallback } from 'react';
3
3
  import { useNotisRuntime } from '../provider';
4
4
  import type { ContextResource, ContextSelection } from '../runtime';
5
5
 
@@ -31,10 +31,13 @@ export function NotisSelectionBoundary({
31
31
  }: NotisSelectionBoundaryProps) {
32
32
  const runtime = useNotisRuntime();
33
33
  const onCopy = useCallback((event: ClipboardEvent<HTMLDivElement>) => {
34
- const selection = window.getSelection();
35
- const text = selection?.toString().trim() ?? '';
34
+ const target = event.target as Element | null;
35
+ if (target?.closest?.('input,textarea,[contenteditable="true"]')) return;
36
+ const scope = event.currentTarget.getRootNode() as ShadowRoot & { getSelection?: () => Selection | null };
37
+ const selection = scope?.getSelection?.() || window.getSelection();
38
+ const text = selection?.toString() ?? '';
36
39
  if (
37
- !text
40
+ !text.trim()
38
41
  || !selection
39
42
  || !event.currentTarget.contains(selection.anchorNode)
40
43
  || !event.currentTarget.contains(selection.focusNode)
@@ -42,7 +45,8 @@ export function NotisSelectionBoundary({
42
45
 
43
46
  const payload: ContextSelection = {
44
47
  id: selectionId(),
45
- text: text.slice(0, 12_000),
48
+ text,
49
+ ...(runtime?.contextSource ? { source: runtime.contextSource } : {}),
46
50
  ...(resource ? { resource: { ...resource, snapshot: undefined } } : {}),
47
51
  };
48
52
  event.clipboardData.setData('text/plain', text);
@@ -0,0 +1,23 @@
1
+ 'use client';
2
+ import { useMemo } from 'react';
3
+ import { useNotisRuntime } from '../provider';
4
+ import type { AgentContextItem } from '../agentContext';
5
+
6
+ /** Share context without owning storage, opening a new thread, or sending a message. */
7
+ export function useAgentContext() {
8
+ const runtime = useNotisRuntime();
9
+ return useMemo(() => ({
10
+ add(item: AgentContextItem): Promise<boolean> {
11
+ if (!runtime?.addContext) return Promise.reject(new Error('Chat context is unavailable in this host.'));
12
+ return runtime.addContext(item);
13
+ },
14
+ update(item: AgentContextItem): Promise<boolean> {
15
+ if (!runtime?.updateContext) return Promise.reject(new Error('Chat context is unavailable in this host.'));
16
+ return runtime.updateContext(item);
17
+ },
18
+ remove(id: string): Promise<boolean> {
19
+ if (!runtime?.removeContext) return Promise.reject(new Error('Chat context is unavailable in this host.'));
20
+ return runtime.removeContext(id);
21
+ },
22
+ }), [runtime]);
23
+ }
@@ -154,3 +154,8 @@ export { Skeleton, ViewSkeleton } from './components/Skeleton';
154
154
 
155
155
  export { useLongPressSelection } from './hooks/useLongPressSelection';
156
156
  export { isInteractionElementVisible } from './interactions/visibility';
157
+
158
+ export type { AgentContextContent, AgentContextItem, ContextAttachment } from './agentContext';
159
+ export { useAgentContext } from './hooks/useAgentContext';
160
+ export { NotisCommentBoundary, NotisCommentBox } from './components/NotisCommentBoundary';
161
+ export type { NotisCommentBoundaryProps, NotisCommentBoxProps } from './components/NotisCommentBoundary';