@atmos.build/ui 0.1.3 → 0.1.4

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 CHANGED
@@ -13,30 +13,30 @@ import { Button, Command, Dialog, DialogContent, Table } from '@atmos/ui'
13
13
  MCP-specific host integration is available from a subpath of the same package:
14
14
 
15
15
  ```tsx
16
- import { useAtmosMcpApp } from '@atmos/ui/mcp-app'
16
+ import { useAtmosMcpApp } from '@atmos.build/ui/mcp-app'
17
17
  ```
18
18
 
19
19
  The build configuration and stylesheet use their own subpaths:
20
20
 
21
21
  ```ts
22
- import { atmosMcpAppViteConfig } from '@atmos/ui/mcp-app/vite'
22
+ import { atmosMcpAppViteConfig } from '@atmos.build/ui/mcp-app/vite'
23
23
  ```
24
24
 
25
25
  ```css
26
- @import '@atmos/ui/mcp-app/styles.css';
26
+ @import '@atmos.build/ui/mcp-app/styles.css';
27
27
 
28
28
  @source './**/*.{ts,tsx}';
29
29
  ```
30
30
 
31
- The stylesheet scans the complete component catalog, so any component exported by `@atmos/ui` receives its normal Tailwind styles without maintaining a second MCP-specific allowlist.
31
+ The stylesheet scans the complete component catalog, so any component exported by `@atmos.build/ui` receives its normal Tailwind styles without maintaining a second MCP-specific allowlist.
32
32
 
33
33
  Inside this repository, extension UI packages resolve `@atmos/ui` through the pnpm workspace. External extensions install an exact public npm version during authoring:
34
34
 
35
35
  ```sh
36
- npm install --save-exact @atmos/ui react@19 react-dom@19
36
+ npm install --save-exact @atmos.build/ui react@19 react-dom@19
37
37
  ```
38
38
 
39
- The compiled `dist/app.js` and `dist/app.css` are committed with the extension and inlined into its MCP App resource. Attaching or opening an extension therefore never installs packages or executes a package manager.
39
+ The compiled `app-build/app.js` and `app-build/app.css` are committed with the extension and inlined into its MCP App resource. Attaching or opening an extension therefore never installs packages or executes a package manager.
40
40
 
41
41
  Flow surfaces import their stylesheet explicitly so the JavaScript entry remains server-rendering compatible:
42
42
 
@@ -5,7 +5,7 @@ import { ChevronLeft, ChevronRight } from 'lucide-react';
5
5
  import { LayoutGroup, motion, useReducedMotion } from 'motion/react';
6
6
  import { Button } from './button.js';
7
7
  import { cn } from '../lib/cn.js';
8
- import { parseLooseDate } from "../lib/parse-loose.js";
8
+ import { parseLooseDate } from '../lib/parse-loose.js';
9
9
  import { resolveDateTimeFormat } from '../lib/date-time-format.js';
10
10
  const DEFAULT_LABELS = {
11
11
  aria: 'Date picker',
@@ -136,13 +136,14 @@ export function Calendar({ value, onSelect, selectionMode = 'day', locale, forma
136
136
  (selectionMode === 'week' ? sameWeek(d, previewDate) : sameDay(d, previewDate));
137
137
  const hostsSelection = isSelected && (selectionMode === 'week' ? isWeekStart : true);
138
138
  const isDisabled = isBeforeMin(d);
139
- return (_jsxs("button", { type: "button", onClick: () => handlePick(d), disabled: isDisabled, "aria-disabled": isDisabled || undefined, "aria-pressed": isSelected || undefined, "aria-label": d.toDateString(), className: cn('relative h-7 rounded-md text-[12px] tabular-nums transition-colors', 'hover:bg-accent hover:text-accent-foreground cursor-pointer', 'focus-visible:ring-ring/40 focus-visible:ring-2 focus-visible:outline-none', isToday && 'rounded-lg ring-1 ring-foreground/40', inMonth ? 'text-foreground/90' : 'text-muted-foreground/45', isPreview && 'bg-primary/10', isSelected &&
139
+ return (_jsxs("button", { type: "button", onClick: () => handlePick(d), disabled: isDisabled, "aria-disabled": isDisabled || undefined, "aria-pressed": isSelected || undefined, "aria-label": d.toDateString(), className: cn('relative h-7 rounded-md text-[12px] tabular-nums transition-colors', 'hover:bg-accent hover:text-accent-foreground cursor-pointer', 'focus-visible:ring-ring/40 focus-visible:ring-2 focus-visible:outline-none', isToday && 'ring-foreground/40 rounded-lg ring-1', inMonth ? 'text-foreground/90' : 'text-muted-foreground/45', isPreview && 'bg-primary/10', isSelected &&
140
140
  (selectionMode === 'week'
141
- ? 'text-foreground hover:bg-transparent hover:text-foreground'
142
- : 'text-primary-foreground hover:text-primary-foreground'), selectionMode === 'week' && (isSelected || isPreview) && 'rounded-none', (isSelected || isPreview) && isWeekStart && 'rounded-l-md', (isSelected || isPreview) && isWeekEnd && 'rounded-r-md', isDisabled && 'pointer-events-none cursor-default opacity-30 hover:bg-transparent'), children: [hostsSelection ? (_jsx(motion.div, { layoutId: "calendar-selection", "aria-hidden": true, "data-testid": "calendar-selection", transition: selectionTransition, className: cn('pointer-events-none absolute rounded-md', selectionMode === 'week'
141
+ ? 'text-foreground hover:text-foreground hover:bg-transparent'
142
+ : 'text-primary-foreground hover:text-primary-foreground'), selectionMode === 'week' && (isSelected || isPreview) && 'rounded-none', (isSelected || isPreview) && isWeekStart && 'rounded-l-md', (isSelected || isPreview) && isWeekEnd && 'rounded-r-md', isDisabled &&
143
+ 'pointer-events-none cursor-default opacity-30 hover:bg-transparent'), children: [hostsSelection ? (_jsx(motion.div, { layoutId: "calendar-selection", "aria-hidden": true, "data-testid": "calendar-selection", transition: selectionTransition, className: cn('pointer-events-none absolute rounded-md', selectionMode === 'week'
143
144
  ? 'bg-primary/15 inset-y-0 left-0 w-[700%]'
144
145
  : 'bg-primary inset-0') })) : null, _jsx("span", { className: "relative z-10", children: d.getDate() })] }, d.toISOString()));
145
- }) }) }), quickActions ? (_jsxs("div", { className: "mt-2 flex items-center gap-1 px-0.5", children: [_jsx(Button, { type: "button", size: "xs", variant: "ghost", onClick: () => onSelect(ymd(today)), children: t.today }), _jsx(Button, { type: "button", size: "xs", variant: "ghost", onClick: () => {
146
+ }) }) }), quickActions ? (_jsxs("div", { className: "mt-2 flex flex-wrap items-center gap-1 px-0.5", children: [_jsx(Button, { type: "button", size: "xs", variant: "ghost", onClick: () => onSelect(ymd(today)), children: t.today }), _jsx(Button, { type: "button", size: "xs", variant: "ghost", onClick: () => {
146
147
  const tomorrow = new Date(today);
147
148
  tomorrow.setDate(today.getDate() + 1);
148
149
  onSelect(ymd(tomorrow));
@@ -4,7 +4,7 @@ import type { Extension } from '@codemirror/state';
4
4
  * How every CodeMirror surface in atmOS looks.
5
5
  *
6
6
  * The colours are the app's own tokens rather than a packaged editor theme, so an editor
7
- * follows the workspace into dark mode and stays the same instrument whether it is holding
7
+ * follows the org into dark mode and stays the same instrument whether it is holding
8
8
  * a SQL statement, a formula, or a file off a machine. A packaged theme would be a second
9
9
  * palette to keep in sync with the first, and it would be wrong the moment the app's is.
10
10
  */
@@ -6,7 +6,7 @@ import { tags as t } from '@lezer/highlight';
6
6
  * How every CodeMirror surface in atmOS looks.
7
7
  *
8
8
  * The colours are the app's own tokens rather than a packaged editor theme, so an editor
9
- * follows the workspace into dark mode and stays the same instrument whether it is holding
9
+ * follows the org into dark mode and stays the same instrument whether it is holding
10
10
  * a SQL statement, a formula, or a file off a machine. A packaged theme would be a second
11
11
  * palette to keep in sync with the first, and it would be wrong the moment the app's is.
12
12
  */
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useId, useMemo, useState, } from 'react';
3
+ import { useId, useMemo, useState } from 'react';
4
4
  import { CalendarClock, Clock } from 'lucide-react';
5
5
  import { Calendar } from './calendar.js';
6
6
  import { PickerField } from './picker-field.js';
@@ -14,6 +14,11 @@ const DEFAULT_LABELS = {
14
14
  preview: (when) => `Preview: ${when}`,
15
15
  timeAria: 'Choose a time',
16
16
  };
17
+ function nextSlotAfterNow(step, now) {
18
+ const stride = Math.max(1, Math.round(step));
19
+ const minutes = now.getHours() * 60 + now.getMinutes();
20
+ return Math.min(Math.ceil((minutes + 1) / stride) * stride, 24 * 60 - 1);
21
+ }
17
22
  function ymd(d) {
18
23
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
19
24
  }
@@ -94,7 +99,11 @@ export function DateTimePicker({ value, onChange, mode = 'datetime', step = 15,
94
99
  const previewLabel = preview ? fmt(preview) : null;
95
100
  const gridValue = preview ? ymd(preview) : value ? ymd(value) : null;
96
101
  const columnMinutes = preview ? minutesOf(preview) : value ? minutesOf(value) : null;
97
- const slots = useMemo(() => buildTimeSlots(step, 0, 24 * 60 - 1), [step]);
102
+ const now = civilDateForTimeZone(new Date(), parseTimeZone);
103
+ const todayYmd = ymd(now);
104
+ const earliestMinutesOn = (day) => minDate && minDate <= todayYmd && day === todayYmd ? nextSlotAfterNow(step, now) : 0;
105
+ const earliestMinutes = earliestMinutesOn(gridValue ?? todayYmd);
106
+ const slots = useMemo(() => buildTimeSlots(step, earliestMinutes, 24 * 60 - 1), [step, earliestMinutes]);
98
107
  const { active, setActive, move } = useTimeNavigation({
99
108
  slots,
100
109
  selectedMinutes: value ? minutesOf(value) : null,
@@ -113,7 +122,7 @@ export function DateTimePicker({ value, onChange, mode = 'datetime', step = 15,
113
122
  const d = fromYmd(day);
114
123
  if (!d)
115
124
  return;
116
- commit(compose(d, columnMinutes ?? 9 * 60));
125
+ commit(compose(d, Math.max(columnMinutes ?? 9 * 60, earliestMinutesOn(day))));
117
126
  };
118
127
  const pickTime = (minutes) => {
119
128
  commit(compose(value ?? carrier(), minutes));
@@ -149,5 +158,5 @@ export function DateTimePicker({ value, onChange, mode = 'datetime', step = 15,
149
158
  setTyped(null);
150
159
  setActive(null);
151
160
  }
152
- }, display: display, typed: typed, onTypedChange: setTyped, onInputKeyDown: handleKeyDown, placeholder: placeholder ?? labels.placeholder, parseState: typed ? (preview ? 'ok' : 'unparseable') : 'empty', icon: mode === 'time' ? (_jsx(Clock, { className: "size-3.5", strokeWidth: 1.8 })) : (_jsx(CalendarClock, { className: "size-3.5", strokeWidth: 1.8 })), disabled: disabled, size: size, align: align, "aria-label": ariaLabel, className: cn(mode === 'time' ? 'w-[116px]' : 'w-[212px]', className), listboxId: listboxId, activeDescendantId: active !== null ? optionId(active) : undefined, children: [previewLabel ? (_jsx("p", { className: "text-muted-foreground border-border/60 border-b px-3 py-1.5 text-[11px] tabular-nums", children: labels.preview(previewLabel) })) : null, _jsxs("div", { className: "flex items-stretch", children: [mode !== 'time' && (_jsxs(_Fragment, { children: [_jsx("div", { className: "p-2.5", children: _jsx(Calendar, { value: gridValue, locale: resolvedLocale, format: format, typeable: false, minDate: minDate, onSelect: pickDay, className: "w-[244px]" }, gridValue ? gridValue.slice(0, 7) : 'none') }), _jsx("div", { className: "bg-border/60 w-px self-stretch" })] })), _jsx(TimeColumn, { id: listboxId, optionId: optionId, slots: slots, selectedMinutes: columnMinutes, activeMinutes: active, onPick: pickTime, locale: resolvedLocale, format: format, "aria-label": labels.timeAria })] })] }));
161
+ }, display: display, typed: typed, onTypedChange: setTyped, onInputKeyDown: handleKeyDown, placeholder: placeholder ?? labels.placeholder, parseState: typed ? (preview ? 'ok' : 'unparseable') : 'empty', icon: mode === 'time' ? (_jsx(Clock, { className: "size-3.5", strokeWidth: 1.8 })) : (_jsx(CalendarClock, { className: "size-3.5", strokeWidth: 1.8 })), disabled: disabled, size: size, align: align, "aria-label": ariaLabel, className: cn(mode === 'time' ? 'w-[116px]' : 'w-[212px]', className), listboxId: listboxId, activeDescendantId: active !== null ? optionId(active) : undefined, children: [previewLabel ? (_jsx("p", { className: "text-muted-foreground border-border/60 border-b px-3 py-1.5 text-[11px] tabular-nums", children: labels.preview(previewLabel) })) : null, _jsxs("div", { className: "flex items-stretch", children: [mode !== 'time' && (_jsxs(_Fragment, { children: [_jsx("div", { className: "p-2.5", children: _jsx(Calendar, { value: gridValue, locale: resolvedLocale, format: format, typeable: false, minDate: minDate, onSelect: pickDay }, gridValue ? gridValue.slice(0, 7) : 'none') }), _jsx("div", { className: "bg-border/60 w-px self-stretch" })] })), _jsx(TimeColumn, { id: listboxId, optionId: optionId, slots: slots, selectedMinutes: columnMinutes, activeMinutes: active, onPick: pickTime, locale: resolvedLocale, format: format, "aria-label": labels.timeAria })] })] }));
153
162
  }
@@ -1,11 +1,16 @@
1
1
  import * as React from 'react';
2
2
  import type { ReactNode } from 'react';
3
3
  export type InlineAlertTone = 'error' | 'warning' | 'info';
4
+ export type InlineAlertVariant = 'filled' | 'quiet';
4
5
  export interface InlineAlertProps extends Omit<React.ComponentProps<'div'>, 'children'> {
5
6
  tone?: InlineAlertTone;
7
+ variant?: InlineAlertVariant;
6
8
  children: ReactNode;
7
9
  onDismiss?: () => void;
8
10
  dismissLabel?: string;
11
+ detail?: string;
12
+ detailLabel?: string;
13
+ copyDetailLabel?: string;
9
14
  }
10
- export declare function InlineAlert({ tone, children, onDismiss, dismissLabel, className, ...props }: InlineAlertProps): React.JSX.Element;
15
+ export declare function InlineAlert({ tone, variant, children, onDismiss, dismissLabel, detail, detailLabel, copyDetailLabel, className, ...props }: InlineAlertProps): React.JSX.Element;
11
16
  export declare function InlineNotice({ tone, children, className, }: Pick<InlineAlertProps, 'tone' | 'children' | 'className'>): React.JSX.Element;
@@ -1,17 +1,34 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import * as React from 'react';
4
- import { AlertTriangle, Info, X } from 'lucide-react';
4
+ import { AlertTriangle, ChevronRight, Info, X } from 'lucide-react';
5
5
  import { cn } from '../lib/cn.js';
6
6
  import { Button } from './button.js';
7
+ import { CopyButton } from './copy-button.js';
7
8
  const toneClass = {
8
9
  error: 'border-destructive/30 bg-destructive/10 text-destructive',
9
10
  warning: 'border-warning/30 bg-warning/15 text-warning',
10
11
  info: 'border-info/30 bg-info/15 text-info',
11
12
  };
12
- export function InlineAlert({ tone = 'error', children, onDismiss, dismissLabel, className, ...props }) {
13
+ const quietRailClass = {
14
+ error: 'border-l-destructive',
15
+ warning: 'border-l-warning',
16
+ info: 'border-l-info',
17
+ };
18
+ const iconToneClass = {
19
+ error: 'text-destructive',
20
+ warning: 'text-warning',
21
+ info: 'text-info',
22
+ };
23
+ const quietClass = 'border-border/70 bg-muted/20 text-foreground border-l-2';
24
+ export function InlineAlert({ tone = 'error', variant = 'filled', children, onDismiss, dismissLabel, detail, detailLabel, copyDetailLabel, className, ...props }) {
13
25
  const Icon = tone === 'info' ? Info : AlertTriangle;
14
- return (_jsxs("div", { role: "alert", "data-slot": "inline-alert", "data-tone": tone, className: cn('flex items-start gap-2 rounded-md border px-3 py-2 text-xs', toneClass[tone], className), ...props, children: [_jsx(Icon, { className: "mt-0.5 size-3.5 shrink-0", strokeWidth: 1.5, "aria-hidden": true }), _jsx("span", { className: "flex-1 leading-relaxed", children: children }), onDismiss ? (_jsx(Button, { type: "button", variant: "ghost", size: "icon-xs", onClick: onDismiss, "aria-label": dismissLabel, className: "-mr-1 size-4 rounded text-current opacity-80 hover:bg-transparent hover:opacity-100", children: _jsx(X, { className: "size-3", strokeWidth: 1.5 }) })) : null] }));
26
+ const quiet = variant === 'quiet';
27
+ const [detailOpen, setDetailOpen] = React.useState(false);
28
+ const detailId = React.useId();
29
+ return (_jsxs("div", { role: "alert", "data-slot": "inline-alert", "data-tone": tone, "data-variant": variant, className: cn('flex items-start gap-2 rounded-md border px-3 py-2 text-xs', quiet ? cn(quietClass, quietRailClass[tone]) : toneClass[tone], className), ...props, children: [_jsx(Icon, { className: cn('mt-0.5 size-3.5 shrink-0', quiet && iconToneClass[tone]), strokeWidth: 1.5, "aria-hidden": true }), _jsxs("div", { className: "min-w-0 flex-1 leading-relaxed", children: [children, detail ? (_jsxs("div", { className: "mt-1", children: [_jsxs(Button, { type: "button", variant: "ghost", size: "xs", onClick: () => setDetailOpen((open) => !open), "aria-expanded": detailOpen, "aria-controls": detailOpen ? detailId : undefined, "data-slot": "inline-alert-detail-toggle", className: cn('-ml-2 h-5 opacity-80 hover:bg-transparent hover:opacity-100', quiet
30
+ ? 'text-muted-foreground hover:text-foreground'
31
+ : 'text-current hover:text-current'), children: [_jsx(ChevronRight, { className: cn('transition-transform duration-125 ease-[var(--ease-out-expo)] motion-reduce:transition-none', detailOpen && 'rotate-90'), strokeWidth: 1.5, "aria-hidden": true }), detailLabel] }), detailOpen ? (_jsxs("div", { className: "relative mt-1", children: [_jsx("pre", { id: detailId, "data-slot": "inline-alert-detail", className: "border-border/50 bg-background/70 text-foreground max-h-56 overflow-auto rounded-md border py-1.5 pr-9 pl-2 font-mono text-[11px] leading-4 whitespace-pre-wrap", children: detail }), copyDetailLabel ? (_jsx(CopyButton, { value: detail, label: copyDetailLabel, "data-slot": "inline-alert-detail-copy", className: "absolute top-1 right-1" })) : null] })) : null] })) : null] }), onDismiss ? (_jsx(Button, { type: "button", variant: "ghost", size: "icon-xs", onClick: onDismiss, "aria-label": dismissLabel, className: "-mr-1 size-4 rounded text-current opacity-80 hover:bg-transparent hover:opacity-100", children: _jsx(X, { className: "size-3", strokeWidth: 1.5 }) })) : null] }));
15
32
  }
16
33
  export function InlineNotice({ tone = 'info', children, className, }) {
17
34
  return (_jsx(InlineAlert, { tone: tone, className: className, children: children }));
@@ -3,10 +3,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useCallback, useId, useMemo, useRef, useState, } from 'react';
4
4
  import { Check, Plus } from 'lucide-react';
5
5
  import { Slot } from 'radix-ui';
6
- import { StackedPopover, StackedPopoverContent, StackedPopoverTrigger, } from './popover-stack.js';
6
+ import { StackedPopover, StackedPopoverContent, StackedPopoverTrigger } from './popover-stack.js';
7
7
  import { Kbd } from './kbd.js';
8
8
  import { EmptyState } from './empty-state.js';
9
9
  import { cn } from '../lib/cn.js';
10
+ import { useScrollLockEscape } from '../lib/scroll-lock-escape.js';
10
11
  const DEFAULT_LABELS = {
11
12
  noMatches: 'No matches',
12
13
  create: 'Create',
@@ -18,23 +19,8 @@ export function PropertySelector({ open, onOpenChange, children, items, onSelect
18
19
  const [activeOverride, setActiveOverride] = useState(null);
19
20
  const inputRef = useRef(null);
20
21
  const triggerRef = useRef(null);
21
- const wheelCleanupRef = useRef(null);
22
22
  const listId = useId();
23
- // The list is portalled to <body>, outside the scroll-lock (react-remove-scroll) of any
24
- // enclosing dialog. That lock's document-level, bubble-phase `wheel` handler cancels
25
- // mouse-wheel scrolling on portalled content (trackpad deltas slip its overscroll heuristic,
26
- // discrete wheel ticks don't). Stopping the wheel event at the list keeps it from reaching that
27
- // handler, so the list scrolls natively while the dialog's background stays locked. A callback
28
- // ref (re)attaches the listener every time the list mounts, since it only exists while open.
29
- const listRefCallback = useCallback((node) => {
30
- wheelCleanupRef.current?.();
31
- wheelCleanupRef.current = null;
32
- if (!node)
33
- return;
34
- const stop = (event) => event.stopPropagation();
35
- node.addEventListener('wheel', stop, { passive: true });
36
- wheelCleanupRef.current = () => node.removeEventListener('wheel', stop);
37
- }, []);
23
+ const listRefCallback = useScrollLockEscape();
38
24
  const filtered = useMemo(() => {
39
25
  const q = query.trim().toLowerCase();
40
26
  if (!q)
@@ -57,9 +43,7 @@ export function PropertySelector({ open, onOpenChange, children, items, onSelect
57
43
  ids.push(CREATE_ROW);
58
44
  return ids;
59
45
  }, [filtered, showCreate]);
60
- const activeId = activeOverride && rowIds.includes(activeOverride)
61
- ? activeOverride
62
- : (rowIds[0] ?? null);
46
+ const activeId = activeOverride && rowIds.includes(activeOverride) ? activeOverride : (rowIds[0] ?? null);
63
47
  const handleOpenChange = useCallback((next) => {
64
48
  if (next) {
65
49
  setQuery('');
@@ -106,11 +90,7 @@ export function PropertySelector({ open, onOpenChange, children, items, onSelect
106
90
  handleOpenChange(false);
107
91
  return;
108
92
  }
109
- if (e.key.length === 1 &&
110
- !e.metaKey &&
111
- !e.ctrlKey &&
112
- !e.altKey &&
113
- query.length === 0) {
93
+ if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey && query.length === 0) {
114
94
  const key = e.key.toLowerCase();
115
95
  const hit = items.find((i) => i.shortcut?.toLowerCase() === key);
116
96
  if (hit) {
@@ -120,7 +100,7 @@ export function PropertySelector({ open, onOpenChange, children, items, onSelect
120
100
  }
121
101
  }, [rowIds, activeId, filtered, items, query.length, handleSelect, handleCreate, handleOpenChange]);
122
102
  const activeDomId = activeId ? `${listId}-row-${activeId}` : undefined;
123
- return (_jsxs(StackedPopover, { open: open, onOpenChange: handleOpenChange, children: [_jsx(StackedPopoverTrigger, { asChild: true, children: triggerAsChild ? (_jsx(Slot.Root, { ref: triggerRef, "aria-haspopup": "listbox", "aria-expanded": open, children: children })) : (_jsx("div", { ref: triggerRef, className: "inline-flex w-full", "aria-haspopup": "listbox", "aria-expanded": open, children: children })) }), _jsxs(StackedPopoverContent, { className: cn('w-64 p-0 gap-0 overflow-hidden', contentClassName), align: "start", "aria-label": ariaLabel, onCloseAutoFocus: (e) => {
103
+ return (_jsxs(StackedPopover, { open: open, onOpenChange: handleOpenChange, children: [_jsx(StackedPopoverTrigger, { asChild: true, children: triggerAsChild ? (_jsx(Slot.Root, { ref: triggerRef, "aria-haspopup": "listbox", "aria-expanded": open, children: children })) : (_jsx("div", { ref: triggerRef, className: "inline-flex w-full", "aria-haspopup": "listbox", "aria-expanded": open, children: children })) }), _jsxs(StackedPopoverContent, { className: cn('w-64 gap-0 overflow-hidden p-0', contentClassName), align: "start", "aria-label": ariaLabel, onCloseAutoFocus: (e) => {
124
104
  e.preventDefault();
125
105
  const el = triggerRef.current;
126
106
  const focusable = el?.matches('button, [tabindex]:not([tabindex="-1"])')
@@ -130,12 +110,12 @@ export function PropertySelector({ open, onOpenChange, children, items, onSelect
130
110
  }, onOpenAutoFocus: (e) => {
131
111
  e.preventDefault();
132
112
  inputRef.current?.focus();
133
- }, children: [_jsxs("div", { className: "flex items-center gap-2 px-2.5 pt-2.5 pb-1.5 border-b border-border/60", children: [_jsx("input", { ref: inputRef, type: "text", role: "combobox", "aria-autocomplete": "list", "aria-controls": listId, "aria-expanded": true, "aria-activedescendant": activeDomId, value: query, placeholder: searchPlaceholder, onChange: (e) => setQuery(e.target.value), onKeyDown: handleKeyDown, className: "flex-1 min-w-0 bg-transparent outline-none text-[13px] placeholder:text-muted-foreground/70" }), triggerKey ? (_jsx(Kbd, { className: "text-[10.5px] shrink-0", children: triggerKey })) : null] }), _jsxs("ul", { ref: listRefCallback, id: listId, role: "listbox", "aria-label": ariaLabel, className: "max-h-[280px] overflow-y-auto overscroll-contain py-1", children: [filtered.length === 0 && !showCreate ? (_jsx("li", { children: _jsx(EmptyState, { title: labels.noMatches, className: "py-6" }) })) : (filtered.map((item) => {
113
+ }, children: [_jsxs("div", { className: "border-border/60 flex items-center gap-2 border-b px-2.5 pt-2.5 pb-1.5", children: [_jsx("input", { ref: inputRef, type: "text", role: "combobox", "aria-autocomplete": "list", "aria-controls": listId, "aria-expanded": true, "aria-activedescendant": activeDomId, value: query, placeholder: searchPlaceholder, onChange: (e) => setQuery(e.target.value), onKeyDown: handleKeyDown, className: "placeholder:text-muted-foreground/70 min-w-0 flex-1 bg-transparent text-[13px] outline-none" }), triggerKey ? _jsx(Kbd, { className: "shrink-0 text-[10.5px]", children: triggerKey }) : null] }), _jsxs("ul", { ref: listRefCallback, id: listId, role: "listbox", "aria-label": ariaLabel, className: "max-h-[280px] overflow-y-auto overscroll-contain py-1", children: [filtered.length === 0 && !showCreate ? (_jsx("li", { children: _jsx(EmptyState, { title: labels.noMatches, className: "py-6" }) })) : (filtered.map((item) => {
134
114
  const active = activeId === item.id;
135
115
  return (
136
116
  // eslint-disable-next-line jsx-a11y/click-events-have-key-events -- keyboard handled by the parent input via aria-activedescendant
137
- _jsxs("li", { id: `${listId}-row-${item.id}`, role: "option", "aria-selected": item.selected ?? false, "data-active": active || undefined, onMouseEnter: () => setActiveOverride(item.id), onClick: () => handleSelect(item), className: cn('mx-1 px-2 py-1.5 rounded-md cursor-pointer text-[13px] text-foreground', 'flex gap-2', item.description ? 'items-start' : 'items-center', active && 'bg-muted'), children: [item.icon ? (_jsx("span", { className: cn('shrink-0 inline-flex items-center justify-center', item.description && 'pt-0.5'), children: item.icon })) : null, _jsxs("span", { className: "flex-1 min-w-0", children: [_jsx("span", { className: "block truncate", children: item.label }), item.description ? (_jsx("span", { className: "block truncate text-[11px] leading-snug text-muted-foreground", children: item.description })) : null] }), _jsxs("span", { className: cn('shrink-0 inline-flex items-center gap-1 text-muted-foreground', item.description && 'pt-0.5'), children: [item.selected ? _jsx(Check, { className: "size-3.5", "aria-hidden": true }) : null, item.shortcut ? (_jsx(Kbd, { className: "text-[10.5px]", children: item.shortcut })) : null] })] }, item.id));
117
+ _jsxs("li", { id: `${listId}-row-${item.id}`, role: "option", "aria-selected": item.selected ?? false, "data-active": active || undefined, onMouseEnter: () => setActiveOverride(item.id), onClick: () => handleSelect(item), className: cn('text-foreground mx-1 cursor-pointer rounded-md px-2 py-1.5 text-[13px]', 'flex gap-2', item.description ? 'items-start' : 'items-center', active && 'bg-muted'), children: [item.icon ? (_jsx("span", { className: cn('inline-flex shrink-0 items-center justify-center', item.description && 'pt-0.5'), children: item.icon })) : null, _jsxs("span", { className: "min-w-0 flex-1", children: [_jsx("span", { className: "block truncate", children: item.label }), item.description ? (_jsx("span", { className: "text-muted-foreground block truncate text-[11px] leading-snug", children: item.description })) : null] }), _jsxs("span", { className: cn('text-muted-foreground inline-flex shrink-0 items-center gap-1', item.description && 'pt-0.5'), children: [item.selected ? _jsx(Check, { className: "size-3.5", "aria-hidden": true }) : null, item.shortcut ? _jsx(Kbd, { className: "text-[10.5px]", children: item.shortcut }) : null] })] }, item.id));
138
118
  })), showCreate ? (
139
119
  // eslint-disable-next-line jsx-a11y/click-events-have-key-events -- keyboard handled by the parent input via aria-activedescendant
140
- _jsxs("li", { id: `${listId}-row-${CREATE_ROW}`, role: "option", "aria-selected": false, "data-active": activeId === CREATE_ROW || undefined, onMouseEnter: () => setActiveOverride(CREATE_ROW), onClick: handleCreate, className: cn('mx-1 mt-1 px-2 py-1.5 rounded-md cursor-pointer text-[13px]', 'flex items-center gap-2', filtered.length > 0 && 'border-t border-border/60 pt-2', activeId === CREATE_ROW && 'bg-muted'), children: [_jsx(Plus, { className: "size-3.5 text-muted-foreground shrink-0", strokeWidth: 1.8, "aria-hidden": true }), _jsxs("span", { className: "text-foreground truncate", children: [createLabel ?? labels.create, ":", ' ', _jsxs("span", { className: "text-foreground/90", children: ["\"", trimmedQuery, "\""] })] })] })) : null] })] })] }));
120
+ _jsxs("li", { id: `${listId}-row-${CREATE_ROW}`, role: "option", "aria-selected": false, "data-active": activeId === CREATE_ROW || undefined, onMouseEnter: () => setActiveOverride(CREATE_ROW), onClick: handleCreate, className: cn('mx-1 mt-1 cursor-pointer rounded-md px-2 py-1.5 text-[13px]', 'flex items-center gap-2', filtered.length > 0 && 'border-border/60 border-t pt-2', activeId === CREATE_ROW && 'bg-muted'), children: [_jsx(Plus, { className: "text-muted-foreground size-3.5 shrink-0", strokeWidth: 1.8, "aria-hidden": true }), _jsxs("span", { className: "text-foreground truncate", children: [createLabel ?? labels.create, ":", ' ', _jsxs("span", { className: "text-foreground/90", children: ["\"", trimmedQuery, "\""] })] })] })) : null] })] })] }));
141
121
  }
@@ -3,7 +3,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import * as React from 'react';
4
4
  import { useMediaQuery } from '../hooks/use-media-query.js';
5
5
  import { cn } from '../lib/cn.js';
6
- const SHIMMER_FRAME_MS = 1000 / 8;
6
+ const SHIMMER_FRAME_MS = 1000 / 30;
7
7
  const shimmerEntries = new Set();
8
8
  let shimmerTimer = 0;
9
9
  function paintShimmers() {
@@ -3,6 +3,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { cn } from '../lib/cn.js';
5
5
  import { resolveDateTimeFormat } from '../lib/date-time-format.js';
6
+ import { useScrollLockEscape } from '../lib/scroll-lock-escape.js';
6
7
  export function buildTimeSlots(step, minMinutes, maxMinutes) {
7
8
  const stride = Math.max(1, Math.round(step));
8
9
  const out = [];
@@ -47,10 +48,11 @@ export function TimeColumn({ slots, selectedMinutes, activeMinutes = null, onPic
47
48
  }, [format, locale, hour12]);
48
49
  const scrollTarget = activeMinutes ?? selectedMinutes;
49
50
  const currentRef = useRef(null);
51
+ const listRefCallback = useScrollLockEscape();
50
52
  useEffect(() => {
51
53
  currentRef.current?.scrollIntoView({ block: 'center' });
52
54
  }, [scrollTarget]);
53
- return (_jsx("div", { id: id, role: "listbox", "aria-label": ariaLabel, className: cn('max-h-[316px] w-[92px] space-y-0.5 overflow-y-auto px-1.5 py-2', className), children: slots.map((minutes) => {
55
+ return (_jsx("div", { id: id, ref: listRefCallback, role: "listbox", "aria-label": ariaLabel, className: cn('max-h-[316px] w-[92px] space-y-0.5 overflow-y-auto px-1.5 py-2', className), children: slots.map((minutes) => {
54
56
  const selected = selectedMinutes === minutes;
55
57
  const active = activeMinutes === minutes;
56
58
  return (_jsx("button", { ref: minutes === scrollTarget ? currentRef : undefined, id: optionId?.(minutes), type: "button", role: "option", "aria-selected": selected, tabIndex: -1, onClick: () => onPick(minutes), className: cn('w-full cursor-pointer rounded-md px-2 py-1 text-left text-[12px] tabular-nums transition-colors', selected
@@ -10,7 +10,7 @@ export interface FlowEditorIssue {
10
10
  readonly severity?: FlowEditorIssueSeverity;
11
11
  }
12
12
  export interface FlowAdapterContext {
13
- readonly workspaceId: string | null | undefined;
13
+ readonly orgId: string | null | undefined;
14
14
  }
15
15
  export interface NodeConfigPanelProps<TData> {
16
16
  readonly node: RfNode<TData>;
@@ -23,7 +23,7 @@ export interface FlowEditorCanvasContext<TData, TEdge> {
23
23
  export type UseFlowEditorCanvasPresentation<TData, TEdge> = (context: FlowEditorCanvasContext<TData, TEdge>) => FlowEditorCanvasPresentation<TData, TEdge>;
24
24
  export interface FlowEditorProps<TData, TEdge, TDef, TSaveInput, TSaveResult, TPublishInput, TPublishResult> {
25
25
  readonly adapter: FlowDomainAdapter<TData, TEdge, TDef, TSaveInput, TSaveResult, TPublishInput, TPublishResult>;
26
- readonly workspaceId: string | null | undefined;
26
+ readonly orgId: string | null | undefined;
27
27
  readonly initialNodes: RfNode<TData>[];
28
28
  readonly initialEdges: RfEdge<TEdge>[];
29
29
  readonly settingsTitle: string;
@@ -50,5 +50,5 @@ export interface FlowEditorProps<TData, TEdge, TDef, TSaveInput, TSaveResult, TP
50
50
  onOpenChange: (open: boolean) => void;
51
51
  }) => ReactNode;
52
52
  }
53
- export declare function FlowEditor<TData, TEdge = unknown, TDef = unknown, TSaveInput = unknown, TSaveResult = unknown, TPublishInput = unknown, TPublishResult = unknown>({ adapter, workspaceId, initialNodes, initialEdges, settingsTitle, isNew, externalDirty, initiallyUnpublished, onSaved, onPublished, onFirstSave, onError, renderInsertPicker, renderDropPicker, dropPickerWidth, buildEdge, isSingleHandle, handleOrderOf, clipboardKind, headingFont, testId, renderHeaderStart, useCanvasPresentation, renderSettings, }: FlowEditorProps<TData, TEdge, TDef, TSaveInput, TSaveResult, TPublishInput, TPublishResult>): import("react").JSX.Element;
53
+ export declare function FlowEditor<TData, TEdge = unknown, TDef = unknown, TSaveInput = unknown, TSaveResult = unknown, TPublishInput = unknown, TPublishResult = unknown>({ adapter, orgId, initialNodes, initialEdges, settingsTitle, isNew, externalDirty, initiallyUnpublished, onSaved, onPublished, onFirstSave, onError, renderInsertPicker, renderDropPicker, dropPickerWidth, buildEdge, isSingleHandle, handleOrderOf, clipboardKind, headingFont, testId, renderHeaderStart, useCanvasPresentation, renderSettings, }: FlowEditorProps<TData, TEdge, TDef, TSaveInput, TSaveResult, TPublishInput, TPublishResult>): import("react").JSX.Element;
54
54
  export {};
@@ -14,9 +14,9 @@ import { SettingsDialog } from './settings-dialog.js';
14
14
  function useDefaultCanvasPresentation(_context) {
15
15
  return {};
16
16
  }
17
- export function FlowEditor({ adapter, workspaceId, initialNodes, initialEdges, settingsTitle, isNew = false, externalDirty = false, initiallyUnpublished = false, onSaved, onPublished, onFirstSave, onError, renderInsertPicker, renderDropPicker, dropPickerWidth, buildEdge, isSingleHandle, handleOrderOf, clipboardKind, headingFont, testId = 'flow-editor', renderHeaderStart, useCanvasPresentation = useDefaultCanvasPresentation, renderSettings, }) {
17
+ export function FlowEditor({ adapter, orgId, initialNodes, initialEdges, settingsTitle, isNew = false, externalDirty = false, initiallyUnpublished = false, onSaved, onPublished, onFirstSave, onError, renderInsertPicker, renderDropPicker, dropPickerWidth, buildEdge, isSingleHandle, handleOrderOf, clipboardKind, headingFont, testId = 'flow-editor', renderHeaderStart, useCanvasPresentation = useDefaultCanvasPresentation, renderSettings, }) {
18
18
  const t = useTranslations('flowEditor');
19
- const adapterCtx = useMemo(() => ({ workspaceId }), [workspaceId]);
19
+ const adapterCtx = useMemo(() => ({ orgId }), [orgId]);
20
20
  const groups = useMemo(() => adapter.nodeGroups(adapterCtx), [adapter, adapterCtx]);
21
21
  const dag = useDagEditor({
22
22
  makeNodeData: adapter.makeNodeData,
@@ -173,7 +173,7 @@ export function FlowEditor({ adapter, workspaceId, initialNodes, initialEdges, s
173
173
  lastSavedSigRef.current = debouncedSaveSig;
174
174
  return;
175
175
  }
176
- if (isNew || !workspaceId)
176
+ if (isNew || !orgId)
177
177
  return;
178
178
  if (debouncedSaveSig === lastSavedSigRef.current)
179
179
  return;
@@ -181,7 +181,7 @@ export function FlowEditor({ adapter, workspaceId, initialNodes, initialEdges, s
181
181
  void persistRef.current(true).catch(() => {
182
182
  lastSavedSigRef.current = null;
183
183
  });
184
- }, [debouncedSaveSig, isNew, workspaceId]);
184
+ }, [debouncedSaveSig, isNew, orgId]);
185
185
  const [settingsOpen, setSettingsOpen] = useState(false);
186
186
  const toolbarExtras = adapter.toolbarExtras?.(adapterCtx);
187
187
  const canvasPresentation = useCanvasPresentation({ dag, groups, invalidNodeIds });
package/dist/index.d.ts CHANGED
@@ -75,6 +75,7 @@ export { TimestampedTranscript, type TimestampedTranscriptLabels, type Timestamp
75
75
  export { MicrophonePriority, type MicrophonePriorityItem, type MicrophonePriorityLabels, type MicrophonePriorityProps, } from './components/microphone-priority.js';
76
76
  export { SpeechPlayback, type SpeechPlaybackLabels, type SpeechPlaybackProps, type SpeechPlaybackState, } from './components/speech-playback.js';
77
77
  export { cn } from './lib/cn.js';
78
+ export { useScrollLockEscape } from './lib/scroll-lock-escape.js';
78
79
  export { clearLocalUiState, readLocalUiState, subscribeLocalUiState, useLocalUiState, writeLocalUiState, } from './lib/local-ui-state.js';
79
80
  export { disclosureStateKey, useDisclosureState, type DisclosureState, } from './lib/disclosure-state.js';
80
81
  export * from './lib/view-query/index.js';
package/dist/index.js CHANGED
@@ -75,6 +75,7 @@ export { TimestampedTranscript, } from './components/timestamped-transcript.js';
75
75
  export { MicrophonePriority, } from './components/microphone-priority.js';
76
76
  export { SpeechPlayback, } from './components/speech-playback.js';
77
77
  export { cn } from './lib/cn.js';
78
+ export { useScrollLockEscape } from './lib/scroll-lock-escape.js';
78
79
  export { clearLocalUiState, readLocalUiState, subscribeLocalUiState, useLocalUiState, writeLocalUiState, } from './lib/local-ui-state.js';
79
80
  export { disclosureStateKey, useDisclosureState, } from './lib/disclosure-state.js';
80
81
  export * from './lib/view-query/index.js';
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Portalled content lands outside the scroll-lock (react-remove-scroll) of any enclosing dialog.
3
+ * That lock's document-level, bubble-phase `wheel` handler cancels mouse-wheel scrolling on
4
+ * portalled content (trackpad deltas slip its overscroll heuristic, discrete wheel ticks don't).
5
+ * Stopping the wheel event at the scroller keeps it from reaching that handler, so the content
6
+ * scrolls natively while the dialog's background stays locked. A callback ref (re)attaches the
7
+ * listener every time the scroller mounts, since it only exists while the surface is open.
8
+ */
9
+ export declare function useScrollLockEscape<T extends HTMLElement>(): (node: T | null) => void;
@@ -0,0 +1,22 @@
1
+ 'use client';
2
+ import { useCallback, useRef } from 'react';
3
+ /**
4
+ * Portalled content lands outside the scroll-lock (react-remove-scroll) of any enclosing dialog.
5
+ * That lock's document-level, bubble-phase `wheel` handler cancels mouse-wheel scrolling on
6
+ * portalled content (trackpad deltas slip its overscroll heuristic, discrete wheel ticks don't).
7
+ * Stopping the wheel event at the scroller keeps it from reaching that handler, so the content
8
+ * scrolls natively while the dialog's background stays locked. A callback ref (re)attaches the
9
+ * listener every time the scroller mounts, since it only exists while the surface is open.
10
+ */
11
+ export function useScrollLockEscape() {
12
+ const cleanupRef = useRef(null);
13
+ return useCallback((node) => {
14
+ cleanupRef.current?.();
15
+ cleanupRef.current = null;
16
+ if (!node)
17
+ return;
18
+ const stop = (event) => event.stopPropagation();
19
+ node.addEventListener('wheel', stop, { passive: true });
20
+ cleanupRef.current = () => node.removeEventListener('wheel', stop);
21
+ }, []);
22
+ }
@@ -0,0 +1,14 @@
1
+ export interface McpAppCheckoutVersionState {
2
+ reachable: boolean;
3
+ repositoryRoot: boolean;
4
+ branch: string | null;
5
+ headCommit: string | null;
6
+ detached: boolean;
7
+ }
8
+ export interface McpAppCheckoutVersionCopy {
9
+ checkout(id: string): string;
10
+ detachedHead: string;
11
+ detachedHeadAt(commit: string): string;
12
+ }
13
+ export declare function mcpAppCheckoutVersionLabel(checkoutId: string, state: McpAppCheckoutVersionState | null, copy: McpAppCheckoutVersionCopy): string;
14
+ export declare function shortCheckoutId(checkoutId: string): string;
@@ -0,0 +1,19 @@
1
+ export function mcpAppCheckoutVersionLabel(checkoutId, state, copy) {
2
+ if (state?.reachable && state.repositoryRoot) {
3
+ if (!state.detached && state.branch?.trim())
4
+ return state.branch.trim();
5
+ if (state.detached) {
6
+ const commit = shortCommit(state.headCommit);
7
+ return commit ? copy.detachedHeadAt(commit) : copy.detachedHead;
8
+ }
9
+ }
10
+ return copy.checkout(shortCheckoutId(checkoutId));
11
+ }
12
+ export function shortCheckoutId(checkoutId) {
13
+ const normalized = checkoutId.trim();
14
+ return normalized.length > 12 ? `${normalized.slice(0, 4)}…${normalized.slice(-4)}` : normalized;
15
+ }
16
+ function shortCommit(commit) {
17
+ const normalized = commit?.trim();
18
+ return normalized ? normalized.slice(0, 7) : null;
19
+ }
@@ -1,4 +1,8 @@
1
1
  export { App } from '@modelcontextprotocol/ext-apps';
2
2
  export type { McpUiHostContext, McpUiToolResultNotification } from '@modelcontextprotocol/ext-apps';
3
3
  export { applyAtmosHostContext } from './theme.js';
4
+ export { mcpAppCheckoutVersionLabel, shortCheckoutId, type McpAppCheckoutVersionCopy, type McpAppCheckoutVersionState, } from './checkout-version.js';
4
5
  export { useAtmosMcpApp, type AtmosMcpAppOptions } from './use-atmos-mcp-app.js';
6
+ export { useMcpResource, type McpResourceState } from './use-mcp-resource.js';
7
+ export { callMcpTool, useMcpTool, type McpToolState } from './use-mcp-tool.js';
8
+ export type { McpToolInput, McpToolOutput, RuntimeSchema, TypedMcpResource, TypedMcpTool, } from './typed-schema.js';
@@ -1,3 +1,6 @@
1
1
  export { App } from '@modelcontextprotocol/ext-apps';
2
2
  export { applyAtmosHostContext } from './theme.js';
3
+ export { mcpAppCheckoutVersionLabel, shortCheckoutId, } from './checkout-version.js';
3
4
  export { useAtmosMcpApp } from './use-atmos-mcp-app.js';
5
+ export { useMcpResource } from './use-mcp-resource.js';
6
+ export { callMcpTool, useMcpTool } from './use-mcp-tool.js';
@@ -0,0 +1,5 @@
1
+ import type { App } from '@modelcontextprotocol/ext-apps';
2
+ import { type ResourceUpdatedNotification } from '@modelcontextprotocol/sdk/types.js';
3
+ export declare function onMcpResourceUpdated(app: App, handler: (notification: ResourceUpdatedNotification) => void): void;
4
+ export declare function subscribeMcpResource(app: App, uri: string): Promise<void>;
5
+ export declare function unsubscribeMcpResource(app: App, uri: string): Promise<void>;
@@ -0,0 +1,13 @@
1
+ import { EmptyResultSchema, ResourceUpdatedNotificationSchema, SubscribeRequestSchema, UnsubscribeRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
2
+ export function onMcpResourceUpdated(app, handler) {
3
+ resourceProtocol(app).setNotificationHandler(ResourceUpdatedNotificationSchema, handler);
4
+ }
5
+ export async function subscribeMcpResource(app, uri) {
6
+ await resourceProtocol(app).request(SubscribeRequestSchema.parse({ method: 'resources/subscribe', params: { uri } }), EmptyResultSchema);
7
+ }
8
+ export async function unsubscribeMcpResource(app, uri) {
9
+ await resourceProtocol(app).request(UnsubscribeRequestSchema.parse({ method: 'resources/unsubscribe', params: { uri } }), EmptyResultSchema);
10
+ }
11
+ function resourceProtocol(app) {
12
+ return app;
13
+ }
@@ -0,0 +1,28 @@
1
+ export interface RuntimeSchema<T> {
2
+ parse(value: unknown): T;
3
+ }
4
+ export interface TypedMcpResource<T> {
5
+ readonly uri: string;
6
+ readonly schema: RuntimeSchema<T>;
7
+ }
8
+ export interface TypedMcpTool<ParsedInput = unknown, Output = unknown, Input = ParsedInput> {
9
+ readonly name: string;
10
+ readonly input: RuntimeSchema<ParsedInput>;
11
+ readonly output: RuntimeSchema<Output>;
12
+ readonly '~types'?: {
13
+ input: Input;
14
+ output: Output;
15
+ parsedInput: ParsedInput;
16
+ };
17
+ }
18
+ export type McpToolInput<Tool extends TypedMcpTool> = '~types' extends keyof Tool ? ToolTypeCarrier<Tool> extends {
19
+ input: infer Input;
20
+ } ? Input : ParsedSchema<Tool['input']> : ParsedSchema<Tool['input']>;
21
+ export type McpToolOutput<Tool extends TypedMcpTool> = '~types' extends keyof Tool ? ToolTypeCarrier<Tool> extends {
22
+ output: infer Output;
23
+ } ? Output : ParsedSchema<Tool['output']> : ParsedSchema<Tool['output']>;
24
+ type ToolTypeCarrier<Tool> = Tool extends {
25
+ readonly '~types'?: infer Types;
26
+ } ? NonNullable<Types> : never;
27
+ type ParsedSchema<Schema> = Schema extends RuntimeSchema<infer Value> ? Value : never;
28
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ import type { App } from '@modelcontextprotocol/ext-apps';
2
+ import type { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js';
3
+ import type { TypedMcpResource } from './typed-schema.js';
4
+ export interface McpResourceState<T> {
5
+ data: T | undefined;
6
+ error: Error | undefined;
7
+ loading: boolean;
8
+ refresh: () => void;
9
+ }
10
+ export declare function useMcpResource<T>(app: App | null | undefined, uri: string | null | undefined, select: (resource: ReadResourceResult) => T): McpResourceState<T>;
11
+ export declare function useMcpResource<T>(app: App | null | undefined, resource: TypedMcpResource<T> | null | undefined): McpResourceState<T>;
@@ -0,0 +1,206 @@
1
+ /* eslint-disable react-hooks/refs -- the latest selector must not become a store subscription dependency */
2
+ import { useMemo, useRef, useSyncExternalStore } from 'react';
3
+ import { onMcpResourceUpdated, subscribeMcpResource, unsubscribeMcpResource, } from './resource-protocol.js';
4
+ const EMPTY_SNAPSHOT = { loading: false };
5
+ const SUBSCRIPTION_RETRY_BASE_MS = 500;
6
+ const SUBSCRIPTION_RETRY_MAX_ATTEMPT = 6;
7
+ const SUBSCRIPTION_RETRY_MAX_MS = 30_000;
8
+ const registries = new WeakMap();
9
+ export function useMcpResource(app, resourceOrUri, select) {
10
+ const uri = typeof resourceOrUri === 'string' ? resourceOrUri : resourceOrUri?.uri;
11
+ const selectRef = useRef(() => {
12
+ throw new Error('An MCP resource selector is required');
13
+ });
14
+ selectRef.current =
15
+ typeof resourceOrUri === 'string'
16
+ ? (select ?? selectRef.current)
17
+ : (result) => parseTypedResource(result, resourceOrUri);
18
+ const entry = useMemo(() => (app && uri ? resourceEntry(app, uri) : undefined), [app, uri]);
19
+ const snapshot = useSyncExternalStore(entry?.subscribe ?? emptySubscribe, entry?.getSnapshot ?? emptySnapshot, emptySnapshot);
20
+ let data;
21
+ let error = snapshot.error;
22
+ if (snapshot.result) {
23
+ try {
24
+ data = selectRef.current(snapshot.result);
25
+ }
26
+ catch (cause) {
27
+ error = resourceError(cause);
28
+ }
29
+ }
30
+ return {
31
+ data,
32
+ error,
33
+ loading: snapshot.loading,
34
+ refresh: entry?.refresh ?? emptyRefresh,
35
+ };
36
+ }
37
+ function parseTypedResource(result, resource) {
38
+ if (!resource)
39
+ throw new Error('An MCP resource descriptor is required');
40
+ const content = result.contents.find((item) => item.uri === resource.uri);
41
+ if (!content || !('text' in content) || typeof content.text !== 'string') {
42
+ throw new Error(`MCP resource ${resource.uri} did not contain text content`);
43
+ }
44
+ return resource.schema.parse(JSON.parse(content.text));
45
+ }
46
+ class McpResourceRegistry {
47
+ app;
48
+ entries = new Map();
49
+ listening = false;
50
+ constructor(app) {
51
+ this.app = app;
52
+ }
53
+ listen() {
54
+ if (this.listening)
55
+ return;
56
+ this.listening = true;
57
+ onMcpResourceUpdated(this.app, ({ params }) => this.entries.get(params.uri)?.updated());
58
+ }
59
+ }
60
+ class McpResourceEntry {
61
+ registry;
62
+ uri;
63
+ listeners = new Set();
64
+ snapshot = { loading: true };
65
+ desired = false;
66
+ subscribed = false;
67
+ readVersion = 0;
68
+ retryAttempt = 0;
69
+ retryTimer;
70
+ transition = Promise.resolve();
71
+ constructor(registry, uri) {
72
+ this.registry = registry;
73
+ this.uri = uri;
74
+ }
75
+ getSnapshot = () => this.snapshot;
76
+ subscribe = (listener) => {
77
+ this.listeners.add(listener);
78
+ if (this.listeners.size === 1) {
79
+ const wasDesired = this.desired;
80
+ this.desired = true;
81
+ if (!wasDesired) {
82
+ this.retryAttempt = 0;
83
+ this.cancelRetry();
84
+ }
85
+ this.registry.listen();
86
+ this.reconcile();
87
+ }
88
+ return () => {
89
+ this.listeners.delete(listener);
90
+ queueMicrotask(() => {
91
+ if (this.listeners.size !== 0)
92
+ return;
93
+ this.desired = false;
94
+ this.cancelRetry();
95
+ this.reconcile();
96
+ });
97
+ };
98
+ };
99
+ refresh = () => {
100
+ if (this.subscribed) {
101
+ void this.read();
102
+ return;
103
+ }
104
+ this.retryAttempt = 0;
105
+ this.cancelRetry();
106
+ this.reconcile();
107
+ };
108
+ updated() {
109
+ if (this.desired && this.subscribed)
110
+ void this.read();
111
+ }
112
+ reconcile() {
113
+ this.transition = this.transition
114
+ .then(() => this.applyDesired())
115
+ .catch((cause) => {
116
+ if (this.desired) {
117
+ this.publish({ ...this.snapshot, error: resourceError(cause), loading: false });
118
+ if (retryableResourceError(cause))
119
+ this.scheduleRetry();
120
+ }
121
+ });
122
+ }
123
+ async applyDesired() {
124
+ if (this.desired && !this.subscribed) {
125
+ this.publish({ ...this.snapshot, error: undefined, loading: true });
126
+ await subscribeMcpResource(this.registry.app, this.uri);
127
+ this.subscribed = true;
128
+ this.retryAttempt = 0;
129
+ this.cancelRetry();
130
+ if (this.desired)
131
+ void this.read();
132
+ }
133
+ if (!this.desired && this.subscribed) {
134
+ this.subscribed = false;
135
+ this.readVersion += 1;
136
+ await unsubscribeMcpResource(this.registry.app, this.uri);
137
+ }
138
+ if (this.desired !== this.subscribed)
139
+ this.reconcile();
140
+ }
141
+ scheduleRetry() {
142
+ if (!this.desired || this.subscribed || this.retryTimer)
143
+ return;
144
+ const delay = Math.min(SUBSCRIPTION_RETRY_BASE_MS * 2 ** this.retryAttempt, SUBSCRIPTION_RETRY_MAX_MS);
145
+ this.retryAttempt = Math.min(this.retryAttempt + 1, SUBSCRIPTION_RETRY_MAX_ATTEMPT);
146
+ this.retryTimer = setTimeout(() => {
147
+ this.retryTimer = undefined;
148
+ if (this.desired && !this.subscribed)
149
+ this.reconcile();
150
+ }, delay);
151
+ }
152
+ cancelRetry() {
153
+ if (this.retryTimer === undefined)
154
+ return;
155
+ clearTimeout(this.retryTimer);
156
+ this.retryTimer = undefined;
157
+ }
158
+ async read() {
159
+ const version = ++this.readVersion;
160
+ this.publish({ ...this.snapshot, error: undefined, loading: true });
161
+ try {
162
+ const result = await this.registry.app.readServerResource({ uri: this.uri });
163
+ if (!this.desired || !this.subscribed || version !== this.readVersion)
164
+ return;
165
+ this.publish({ result, loading: false });
166
+ }
167
+ catch (cause) {
168
+ if (!this.desired || !this.subscribed || version !== this.readVersion)
169
+ return;
170
+ this.publish({ ...this.snapshot, error: resourceError(cause), loading: false });
171
+ }
172
+ }
173
+ publish(snapshot) {
174
+ this.snapshot = snapshot;
175
+ this.listeners.forEach((listener) => listener());
176
+ }
177
+ }
178
+ function resourceEntry(app, uri) {
179
+ let registry = registries.get(app);
180
+ if (!registry) {
181
+ registry = new McpResourceRegistry(app);
182
+ registries.set(app, registry);
183
+ }
184
+ let entry = registry.entries.get(uri);
185
+ if (!entry) {
186
+ entry = new McpResourceEntry(registry, uri);
187
+ registry.entries.set(uri, entry);
188
+ }
189
+ return entry;
190
+ }
191
+ function resourceError(cause) {
192
+ return cause instanceof Error ? cause : new Error(String(cause));
193
+ }
194
+ function retryableResourceError(cause) {
195
+ if (!cause || typeof cause !== 'object' || !('code' in cause))
196
+ return true;
197
+ const code = cause.code;
198
+ return code !== -32700 && code !== -32600 && code !== -32601 && code !== -32602;
199
+ }
200
+ function emptySubscribe() {
201
+ return emptyRefresh;
202
+ }
203
+ function emptySnapshot() {
204
+ return EMPTY_SNAPSHOT;
205
+ }
206
+ function emptyRefresh() { }
@@ -0,0 +1,9 @@
1
+ import type { App } from '@modelcontextprotocol/ext-apps';
2
+ import type { McpToolInput, McpToolOutput, TypedMcpTool } from './typed-schema.js';
3
+ export interface McpToolState<Input, Output> {
4
+ call: (input: Input) => Promise<Output>;
5
+ error: Error | undefined;
6
+ loading: boolean;
7
+ }
8
+ export declare function callMcpTool<Tool extends TypedMcpTool>(app: App, tool: Tool, input: McpToolInput<Tool>): Promise<McpToolOutput<Tool>>;
9
+ export declare function useMcpTool<Tool extends TypedMcpTool>(app: App | null | undefined, tool: Tool): McpToolState<McpToolInput<Tool>, McpToolOutput<Tool>>;
@@ -0,0 +1,73 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ export async function callMcpTool(app, tool, input) {
3
+ const parsedInput = tool.input.parse(input);
4
+ const result = await app.callServerTool({
5
+ name: tool.name,
6
+ arguments: asToolArguments(parsedInput),
7
+ });
8
+ if (result.isError)
9
+ throw toolCallError(tool.name, result);
10
+ return tool.output.parse(result.structuredContent);
11
+ }
12
+ export function useMcpTool(app, tool) {
13
+ const [status, setStatus] = useState({
14
+ app,
15
+ error: undefined,
16
+ pending: 0,
17
+ tool,
18
+ });
19
+ const mounted = useRef(true);
20
+ useEffect(() => {
21
+ mounted.current = true;
22
+ return () => {
23
+ mounted.current = false;
24
+ };
25
+ }, []);
26
+ const call = useCallback(async (input) => {
27
+ if (!app)
28
+ throw new Error(`Cannot call MCP tool ${tool.name}: the app is not connected`);
29
+ setStatus((current) => ({
30
+ app,
31
+ error: undefined,
32
+ pending: current.app === app && current.tool === tool ? current.pending + 1 : 1,
33
+ tool,
34
+ }));
35
+ try {
36
+ return await callMcpTool(app, tool, input);
37
+ }
38
+ catch (cause) {
39
+ const nextError = mcpToolError(cause);
40
+ if (mounted.current) {
41
+ setStatus((current) => current.app === app && current.tool === tool
42
+ ? { ...current, error: nextError }
43
+ : current);
44
+ }
45
+ throw nextError;
46
+ }
47
+ finally {
48
+ if (mounted.current) {
49
+ setStatus((current) => current.app === app && current.tool === tool
50
+ ? { ...current, pending: Math.max(0, current.pending - 1) }
51
+ : current);
52
+ }
53
+ }
54
+ }, [app, tool]);
55
+ const currentStatus = status.app === app && status.tool === tool ? status : undefined;
56
+ return { call, error: currentStatus?.error, loading: Boolean(currentStatus?.pending) };
57
+ }
58
+ function asToolArguments(input) {
59
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
60
+ throw new Error('MCP tool input must be an object');
61
+ }
62
+ return input;
63
+ }
64
+ function toolCallError(name, result) {
65
+ const detail = result.content
66
+ .filter((item) => Boolean(item.type === 'text' && item.text.trim()))
67
+ .map((item) => item.text.trim())
68
+ .join('\n');
69
+ return new Error(detail ? `MCP tool ${name} failed: ${detail}` : `MCP tool ${name} failed`);
70
+ }
71
+ function mcpToolError(cause) {
72
+ return cause instanceof Error ? cause : new Error(String(cause));
73
+ }
@@ -6,7 +6,7 @@ import { defineConfig } from 'vite';
6
6
  const MCP_APP_SOURCE_METADATA_PROPERTY = '__atmosMcpSource';
7
7
  const REACT_COMPONENT_FILE = /\.[jt]sx$/i;
8
8
  const NON_PRODUCTION_COMPONENT_FILE = /\.(?:stories|test|spec)\.[cm]?[jt]sx?$/i;
9
- export function atmosMcpAppViteConfig({ root, entry = 'src/main.tsx', outDir = '../dist', }) {
9
+ export function atmosMcpAppViteConfig({ root, entry = 'src/main.tsx', outDir = '../app-build', }) {
10
10
  return defineConfig({
11
11
  root,
12
12
  publicDir: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atmos.build/ui",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "The complete atmOS React component system and MCP App authoring helpers.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -64,6 +64,7 @@
64
64
  "@emoji-mart/data": "^1.2.1",
65
65
  "@lezer/highlight": "^1.2.3",
66
66
  "@modelcontextprotocol/ext-apps": "1.7.5",
67
+ "@modelcontextprotocol/sdk": "1.29.0",
67
68
  "@radix-ui/react-roving-focus": "^1.1.15",
68
69
  "@radix-ui/react-slot": "^1.3.0",
69
70
  "@tailwindcss/postcss": "^4.3.2",