@atmos.build/ui 0.1.2 → 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 +6 -6
- package/dist/components/calendar.js +6 -5
- package/dist/components/code-editor-theme.d.ts +1 -1
- package/dist/components/code-editor-theme.js +1 -1
- package/dist/components/date-time-picker.js +13 -4
- package/dist/components/inline-alert.d.ts +9 -4
- package/dist/components/inline-alert.js +21 -3
- package/dist/components/metric-grid.d.ts +3 -1
- package/dist/components/metric-grid.js +3 -2
- package/dist/components/property-selector.js +9 -29
- package/dist/components/shimmer-text.js +1 -1
- package/dist/components/spec-strip.d.ts +5 -0
- package/dist/components/spec-strip.js +3 -2
- package/dist/components/time-column.js +3 -1
- package/dist/flow/editor/adapter.d.ts +1 -1
- package/dist/flow/editor/flow-editor.d.ts +2 -2
- package/dist/flow/editor/flow-editor.js +4 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lib/scroll-lock-escape.d.ts +9 -0
- package/dist/lib/scroll-lock-escape.js +22 -0
- package/dist/mcp-app/checkout-version.d.ts +14 -0
- package/dist/mcp-app/checkout-version.js +19 -0
- package/dist/mcp-app/index.d.ts +4 -0
- package/dist/mcp-app/index.js +3 -0
- package/dist/mcp-app/resource-protocol.d.ts +5 -0
- package/dist/mcp-app/resource-protocol.js +13 -0
- package/dist/mcp-app/typed-schema.d.ts +28 -0
- package/dist/mcp-app/typed-schema.js +1 -0
- package/dist/mcp-app/use-mcp-resource.d.ts +11 -0
- package/dist/mcp-app/use-mcp-resource.js +206 -0
- package/dist/mcp-app/use-mcp-tool.d.ts +9 -0
- package/dist/mcp-app/use-mcp-tool.js +73 -0
- package/dist/mcp-app/vite.js +1 -1
- package/package.json +2 -1
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 `
|
|
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
|
|
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
|
|
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:
|
|
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 &&
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
import * as React from 'react';
|
|
1
2
|
import type { ReactNode } from 'react';
|
|
2
3
|
export type InlineAlertTone = 'error' | 'warning' | 'info';
|
|
3
|
-
export
|
|
4
|
+
export type InlineAlertVariant = 'filled' | 'quiet';
|
|
5
|
+
export interface InlineAlertProps extends Omit<React.ComponentProps<'div'>, 'children'> {
|
|
4
6
|
tone?: InlineAlertTone;
|
|
7
|
+
variant?: InlineAlertVariant;
|
|
5
8
|
children: ReactNode;
|
|
6
9
|
onDismiss?: () => void;
|
|
7
10
|
dismissLabel?: string;
|
|
8
|
-
|
|
11
|
+
detail?: string;
|
|
12
|
+
detailLabel?: string;
|
|
13
|
+
copyDetailLabel?: string;
|
|
9
14
|
}
|
|
10
|
-
export declare function InlineAlert({ tone, children, onDismiss, dismissLabel, className, }: InlineAlertProps):
|
|
11
|
-
export declare function InlineNotice({ tone, children, className, }: Pick<InlineAlertProps, 'tone' | 'children' | 'className'>):
|
|
15
|
+
export declare function InlineAlert({ tone, variant, children, onDismiss, dismissLabel, detail, detailLabel, copyDetailLabel, className, ...props }: InlineAlertProps): React.JSX.Element;
|
|
16
|
+
export declare function InlineNotice({ tone, children, className, }: Pick<InlineAlertProps, 'tone' | 'children' | 'className'>): React.JSX.Element;
|
|
@@ -1,16 +1,34 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { AlertTriangle, ChevronRight, Info, X } from 'lucide-react';
|
|
4
5
|
import { cn } from '../lib/cn.js';
|
|
5
6
|
import { Button } from './button.js';
|
|
7
|
+
import { CopyButton } from './copy-button.js';
|
|
6
8
|
const toneClass = {
|
|
7
9
|
error: 'border-destructive/30 bg-destructive/10 text-destructive',
|
|
8
10
|
warning: 'border-warning/30 bg-warning/15 text-warning',
|
|
9
11
|
info: 'border-info/30 bg-info/15 text-info',
|
|
10
12
|
};
|
|
11
|
-
|
|
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 }) {
|
|
12
25
|
const Icon = tone === 'info' ? Info : AlertTriangle;
|
|
13
|
-
|
|
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] }));
|
|
14
32
|
}
|
|
15
33
|
export function InlineNotice({ tone = 'info', children, className, }) {
|
|
16
34
|
return (_jsx(InlineAlert, { tone: tone, className: className, children: children }));
|
|
@@ -21,8 +21,10 @@ export interface MetricTileProps extends Omit<React.ComponentProps<'div'>, 'titl
|
|
|
21
21
|
suffix?: React.ReactNode;
|
|
22
22
|
hint?: React.ReactNode;
|
|
23
23
|
tone?: MetricTone;
|
|
24
|
+
/** What the value is changing from, rendered as "before → after". See SpecStripItem.from. */
|
|
25
|
+
from?: React.ReactNode;
|
|
24
26
|
}
|
|
25
|
-
export declare function MetricTile({ label, value, suffix, hint, tone, className, ...props }: MetricTileProps): React.JSX.Element;
|
|
27
|
+
export declare function MetricTile({ label, value, suffix, hint, from, tone, className, ...props }: MetricTileProps): React.JSX.Element;
|
|
26
28
|
export interface MetricBreakdownItem {
|
|
27
29
|
key: string;
|
|
28
30
|
label: React.ReactNode;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import * as React from 'react';
|
|
4
|
+
import { ArrowRight } from 'lucide-react';
|
|
4
5
|
import { cn } from '../lib/cn.js';
|
|
5
6
|
const TONE_TEXT = {
|
|
6
7
|
neutral: 'text-foreground',
|
|
@@ -18,8 +19,8 @@ const PANEL_PADDING = { none: '', sm: 'p-3', md: 'p-4' };
|
|
|
18
19
|
export function MetricPanel({ title, action, padding = 'md', className, children, ...props }) {
|
|
19
20
|
return (_jsxs("div", { "data-slot": "metric-panel", className: cn('bg-muted min-w-0', PANEL_PADDING[padding], className), ...props, children: [title || action ? (_jsxs("div", { className: cn('mb-3 flex items-center justify-between gap-2', padding === 'none' && 'px-3 pt-3'), children: [title ? _jsx("p", { className: "text-xs font-medium", children: title }) : _jsx("span", {}), action] })) : null, children] }));
|
|
20
21
|
}
|
|
21
|
-
export function MetricTile({ label, value, suffix, hint, tone = 'neutral', className, ...props }) {
|
|
22
|
-
return (_jsxs("div", { "data-slot": "metric-tile", "data-tone": tone, className: cn('bg-muted min-w-0 p-3', className), ...props, children: [label ? _jsx("p", { className: "text-muted-foreground truncate text-xs", children: label }) : null, _jsxs("p", { className: cn('text-2xl font-semibold tracking-tight tabular-nums', label ? 'mt-2' : '', TONE_TEXT[tone]), children: [value, suffix ? (_jsx("span", { className: "text-muted-foreground ml-1 text-xs font-normal", children: suffix })) : null] }), hint ? _jsx("p", { className: "text-muted-foreground mt-1 text-[11px] leading-snug", children: hint }) : null] }));
|
|
22
|
+
export function MetricTile({ label, value, suffix, hint, from, tone = 'neutral', className, ...props }) {
|
|
23
|
+
return (_jsxs("div", { "data-slot": "metric-tile", "data-tone": tone, className: cn('bg-muted min-w-0 p-3', className), ...props, children: [label ? _jsx("p", { className: "text-muted-foreground truncate text-xs", children: label }) : null, _jsxs("p", { className: cn('text-2xl font-semibold tracking-tight tabular-nums', label ? 'mt-2' : '', TONE_TEXT[tone]), children: [from !== undefined ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "text-muted-foreground font-normal", children: from }), _jsx(ArrowRight, { className: "text-muted-foreground mx-1.5 inline size-4 align-[-0.05em]", strokeWidth: 1.5, "aria-hidden": true })] })) : null, value, suffix ? (_jsx("span", { className: "text-muted-foreground ml-1 text-xs font-normal", children: suffix })) : null] }), hint ? _jsx("p", { className: "text-muted-foreground mt-1 text-[11px] leading-snug", children: hint }) : null] }));
|
|
23
24
|
}
|
|
24
25
|
const TONE_BAR = {
|
|
25
26
|
neutral: 'bg-foreground/35',
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 /
|
|
6
|
+
const SHIMMER_FRAME_MS = 1000 / 30;
|
|
7
7
|
const shimmerEntries = new Set();
|
|
8
8
|
let shimmerTimer = 0;
|
|
9
9
|
function paintShimmers() {
|
|
@@ -4,6 +4,11 @@ export interface SpecStripItem {
|
|
|
4
4
|
label: string;
|
|
5
5
|
value: React.ReactNode;
|
|
6
6
|
suffix?: React.ReactNode;
|
|
7
|
+
/**
|
|
8
|
+
* What the value is changing from. Present, the item reads "before → after", which is what a
|
|
9
|
+
* shape being upgraded is: the new number alone says what you get without saying what moved.
|
|
10
|
+
*/
|
|
11
|
+
from?: React.ReactNode;
|
|
7
12
|
}
|
|
8
13
|
export interface SpecStripProps extends Omit<React.ComponentProps<'div'>, 'children'> {
|
|
9
14
|
items: SpecStripItem[];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import * as React from 'react';
|
|
4
|
+
import { ArrowRight } from 'lucide-react';
|
|
4
5
|
import { cn } from '../lib/cn.js';
|
|
5
6
|
import { Separator } from './separator.js';
|
|
6
7
|
/**
|
|
@@ -11,5 +12,5 @@ import { Separator } from './separator.js';
|
|
|
11
12
|
* stops meaning anything.
|
|
12
13
|
*/
|
|
13
14
|
export function SpecStrip({ items, className, ...props }) {
|
|
14
|
-
return (_jsx("div", { "data-slot": "spec-strip", className: cn('flex items-stretch gap-3', className), ...props, children: items.map((item, index) => (_jsxs(React.Fragment, { children: [index > 0 ? _jsx(Separator, { orientation: "vertical", className: "bg-border/60" }) : null, _jsxs("div", { className: "flex min-w-0 flex-1 flex-col items-center gap-1.5 py-1", children: [_jsx("span", { className: "text-muted-foreground [&_svg]:size-5", "aria-hidden": true, children: item.icon }), _jsxs("span", { className: "truncate text-[17px] font-medium tabular-nums", children: [item.value, item.suffix ? (_jsx("span", { className: "text-muted-foreground ml-1 text-[12px] font-normal", children: item.suffix })) : null] }), _jsx("span", { className: "text-muted-foreground truncate text-[11.5px]", children: item.label })] })] }, item.label))) }));
|
|
15
|
+
return (_jsx("div", { "data-slot": "spec-strip", className: cn('flex items-stretch gap-3', className), ...props, children: items.map((item, index) => (_jsxs(React.Fragment, { children: [index > 0 ? _jsx(Separator, { orientation: "vertical", className: "bg-border/60" }) : null, _jsxs("div", { className: "flex min-w-0 flex-1 flex-col items-center gap-1.5 py-1", children: [_jsx("span", { className: "text-muted-foreground [&_svg]:size-5", "aria-hidden": true, children: item.icon }), _jsxs("span", { className: "truncate text-[17px] font-medium tabular-nums", children: [item.from !== undefined ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "text-muted-foreground font-normal", children: item.from }), _jsx(ArrowRight, { className: "text-muted-foreground mx-1 inline size-3.5 align-[-0.1em]", strokeWidth: 1.5, "aria-hidden": true })] })) : null, item.value, item.suffix ? (_jsx("span", { className: "text-muted-foreground ml-1 text-[12px] font-normal", children: item.suffix })) : null] }), _jsx("span", { className: "text-muted-foreground truncate text-[11.5px]", children: item.label })] })] }, item.label))) }));
|
|
15
16
|
}
|
|
@@ -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
|
|
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
|
|
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,
|
|
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,
|
|
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(() => ({
|
|
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 || !
|
|
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,
|
|
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
|
+
}
|
package/dist/mcp-app/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/mcp-app/index.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/mcp-app/vite.js
CHANGED
|
@@ -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 = '../
|
|
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
|
+
"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",
|