@ciridae/ui 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/components/blocks/comment-composer.js +20 -2
  2. package/dist/components/blocks/comment-thread.d.ts +1 -1
  3. package/dist/components/blocks/comment-thread.js +32 -2
  4. package/dist/components/blocks/feedback-date-format.d.ts +2 -2
  5. package/dist/components/blocks/feedback-date-format.js +2 -2
  6. package/dist/components/blocks/feedback-detail-sheet.js +4 -4
  7. package/dist/components/blocks/feedback-tracking-types.d.ts +7 -1
  8. package/dist/components/blocks/feedback-triage-timeline.d.ts +11 -0
  9. package/dist/components/blocks/feedback-triage-timeline.js +17 -0
  10. package/dist/components/blocks/feedback-type-badge.d.ts +6 -1
  11. package/dist/components/blocks/feedback-type-badge.js +12 -6
  12. package/dist/components/blocks/feedback-unread-badge.d.ts +6 -0
  13. package/dist/components/blocks/feedback-unread-badge.js +9 -0
  14. package/dist/components/blocks/feedback-user-detail.js +1 -1
  15. package/dist/components/blocks/feedback-user-list.js +1 -1
  16. package/dist/components/blocks/mcp-access-grouped-tool-picker.d.ts +19 -0
  17. package/dist/components/blocks/mcp-access-grouped-tool-picker.js +116 -0
  18. package/dist/components/blocks/mcp-access-tool-picker-internals.d.ts +35 -0
  19. package/dist/components/blocks/mcp-access-tool-picker-internals.js +24 -0
  20. package/dist/components/blocks/mcp-access-tool-picker.d.ts +4 -17
  21. package/dist/components/blocks/mcp-access-tool-picker.js +4 -12
  22. package/dist/components/blocks/mcp-access-types.d.ts +2 -0
  23. package/dist/components/blocks/timestamp-format.d.ts +7 -1
  24. package/dist/components/blocks/timestamp-format.js +9 -1
  25. package/dist/components/ui/checkbox.js +2 -2
  26. package/dist/components/ui/data-table-facet-filter.d.ts +22 -6
  27. package/dist/components/ui/data-table-facet-filter.js +31 -19
  28. package/dist/components/ui/data-table-search-input.d.ts +14 -0
  29. package/dist/components/ui/data-table-search-input.js +15 -0
  30. package/dist/components/ui/item.js +7 -3
  31. package/dist/entries/data-table-search-input.d.ts +1 -0
  32. package/dist/entries/data-table-search-input.js +1 -0
  33. package/dist/entries/feedback-triage-timeline.d.ts +1 -0
  34. package/dist/entries/feedback-triage-timeline.js +1 -0
  35. package/dist/entries/feedback-unread-badge.d.ts +1 -0
  36. package/dist/entries/feedback-unread-badge.js +1 -0
  37. package/dist/entries/mcp-access-grouped-tool-picker.d.ts +1 -0
  38. package/dist/entries/mcp-access-grouped-tool-picker.js +1 -0
  39. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import * as React from "react";
3
3
  import { getCommentAuthorInitials } from "./comment-types";
4
+ import { cn } from "../../lib/utils";
4
5
  import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
5
6
  import { InputGroupButton, InputGroupTextarea } from "../ui/input-group";
6
7
  const COMMENT_TEXTAREA_MAX_HEIGHT = 128;
@@ -17,9 +18,17 @@ function CommentComposer({ actionsSlot, author, defaultExpanded = false, default
17
18
  });
18
19
  React.useEffect(() => {
19
20
  resizeCommentTextarea(textareaRef.current);
20
- }, [state.value]);
21
+ // showActions is a dep because each min-height bakes into the inline
22
+ // height this writes; a collapse without a value change must recompute.
23
+ }, [state.showActions, state.value]);
21
24
  const isDisabled = disabled || isSubmitting;
22
- return (_jsx("form", { onSubmit: state.onSubmit, children: _jsxs("div", { className: "grid w-full gap-1.5 rounded-md border border-input bg-background px-2.5 py-2 shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-[var(--focus-ring-width)] focus-within:ring-ring/50 dark:bg-input/30", "data-disabled": isDisabled ? "true" : undefined, children: [_jsxs("div", { className: "flex min-w-0 items-start gap-2", children: [_jsx(CommentComposerAvatar, { author: author }), _jsx(InputGroupTextarea, { "aria-label": "Comment", className: "max-h-32 min-h-7 overflow-y-hidden px-0 py-1 text-sm leading-5", disabled: isDisabled, onBlur: state.onBlur, onChange: state.onValueChange, onFocus: state.onFocus, placeholder: placeholder, ref: textareaRef, rows: 1, value: state.value })] }), _jsx(CommentComposerActions, { actionsSlot: actionsSlot, canSubmit: state.canSubmit, isSubmitting: isSubmitting, loadingLabel: loadingLabel, showActions: state.showActions, submitLabel: submitLabel })] }) }));
25
+ return (_jsx("form", { onSubmit: state.onSubmit, children: _jsxs("div", { className: "grid w-full gap-1.5 rounded-md border border-input bg-background px-2.5 py-2 shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-[var(--focus-ring-width)] focus-within:ring-ring/50 dark:bg-input/30", "data-disabled": isDisabled ? "true" : undefined, children: [_jsxs("div", { className: "flex min-w-0 items-start gap-2", children: [_jsx(CommentComposerAvatar, { author: author }), _jsx(InputGroupTextarea, { "aria-label": "Comment",
26
+ // An active composer opens a few lines of room; min-height beats
27
+ // the resize logic's inline height, so growth still works. The
28
+ // two min-heights are exclusive because cn resolves no conflicts,
29
+ // and important because the base Textarea's own min-h-16 is
30
+ // emitted after both and would otherwise win in every state.
31
+ className: cn("max-h-32 overflow-y-hidden px-0 py-1 text-sm leading-5", state.showActions ? "min-h-14!" : "min-h-7!"), disabled: isDisabled, onBlur: state.onBlur, onChange: state.onValueChange, onFocus: state.onFocus, onKeyDown: onComposerKeyDown, placeholder: placeholder, ref: textareaRef, rows: 1, value: state.value })] }), _jsx(CommentComposerActions, { actionsSlot: actionsSlot, canSubmit: state.canSubmit, isSubmitting: isSubmitting, loadingLabel: loadingLabel, showActions: state.showActions, submitLabel: submitLabel })] }) }));
23
32
  }
24
33
  function useCommentComposerState({ defaultExpanded, defaultValue, disabled, isSubmitting, onSubmit, onValueChange, value, }) {
25
34
  const [localValue, setLocalValue] = React.useState(defaultValue);
@@ -108,6 +117,15 @@ function CommentComposerAvatar({ author }) {
108
117
  function CommentSubmitButton({ canSubmit, isSubmitting, loadingLabel, submitLabel, }) {
109
118
  return (_jsx(InputGroupButton, { disabled: !canSubmit, loading: isSubmitting, loadingLabel: loadingLabel, size: "sm", type: "submit", children: submitLabel }));
110
119
  }
120
+ /** Cmd/Ctrl+Enter posts; plain Enter keeps writing, as in every comment box. */
121
+ function onComposerKeyDown(event) {
122
+ if (event.key !== "Enter" || !(event.metaKey || event.ctrlKey))
123
+ return;
124
+ if (event.nativeEvent.isComposing)
125
+ return; // mid-IME Enter confirms the composition
126
+ event.preventDefault();
127
+ event.currentTarget.form?.requestSubmit();
128
+ }
111
129
  function resizeCommentTextarea(textarea) {
112
130
  if (!textarea)
113
131
  return;
@@ -1,4 +1,4 @@
1
- import type * as React from "react";
1
+ import * as React from "react";
2
2
  import type { CommentAuthor, CommentItemData } from "./comment-types";
3
3
  type CommentThreadProps = {
4
4
  comments: CommentItemData[];
@@ -1,11 +1,41 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as React from "react";
2
3
  import { MessageSquareTextIcon } from "lucide-react";
3
4
  import { CommentComposer } from "./comment-composer";
4
5
  import { CommentItem } from "./comment-item";
5
6
  import { Skeleton } from "../ui/skeleton";
6
7
  function CommentThread({ comments, composerAuthor, composerPlaceholder, composerSubmitLabel, emptyDescription = "Start the conversation with a short update or question.", emptyTitle = "No comments yet", isLoading = false, isSubmitting = false, onSubmit, readOnly = false, renderCommentActions, showHeader = true, title = "Comments", }) {
7
- const showComposer = !readOnly && Boolean(onSubmit);
8
- return (_jsxs("section", { className: "grid w-full gap-(--panel-gap)", "aria-label": title, children: [showHeader ? _jsx(CommentThreadHeader, { count: comments.length, title: title }) : null, _jsx(CommentThreadBody, { comments: comments, emptyDescription: emptyDescription, emptyTitle: emptyTitle, isLoading: isLoading, renderCommentActions: renderCommentActions }), showComposer && onSubmit ? (_jsx(CommentComposer, { author: composerAuthor, isSubmitting: isSubmitting, onSubmit: onSubmit, placeholder: composerPlaceholder, submitLabel: composerSubmitLabel })) : null] }));
8
+ const { armReveal, endRef } = useRevealThreadEnd(comments.length);
9
+ const submitAndReveal = !readOnly && onSubmit
10
+ ? async (body) => {
11
+ await onSubmit(body);
12
+ armReveal();
13
+ }
14
+ : undefined;
15
+ return (_jsxs("section", { className: "relative grid w-full gap-(--panel-gap)", "aria-label": title, children: [showHeader ? _jsx(CommentThreadHeader, { count: comments.length, title: title }) : null, _jsx(CommentThreadBody, { comments: comments, emptyDescription: emptyDescription, emptyTitle: emptyTitle, isLoading: isLoading, renderCommentActions: renderCommentActions }), submitAndReveal ? (_jsx(CommentComposer, { author: composerAuthor, isSubmitting: isSubmitting, onSubmit: submitAndReveal, placeholder: composerPlaceholder, submitLabel: composerSubmitLabel })) : null, _jsx("div", { "aria-hidden": true, className: "absolute bottom-0", ref: endRef })] }));
16
+ }
17
+ /**
18
+ * After a send, the thread follows its own tail: scroll once when the submit
19
+ * resolves, and again when the posted comment actually lands in `comments` —
20
+ * the app may only append it after a refetch.
21
+ */
22
+ function useRevealThreadEnd(commentCount) {
23
+ const endRef = React.useRef(null);
24
+ // A deadline, not a flag: the follow-up scroll only chases the send it
25
+ // belongs to, so a comment arriving minutes later cannot yank a reader
26
+ // who has scrolled away.
27
+ const armedUntil = React.useRef(0);
28
+ React.useEffect(() => {
29
+ if (armedUntil.current === 0 || Date.now() > armedUntil.current)
30
+ return;
31
+ armedUntil.current = 0;
32
+ endRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
33
+ }, [commentCount]);
34
+ const armReveal = React.useCallback(() => {
35
+ armedUntil.current = Date.now() + 5000;
36
+ endRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
37
+ }, []);
38
+ return { armReveal, endRef };
9
39
  }
10
40
  function CommentThreadHeader({ count, title }) {
11
41
  return (_jsxs("header", { className: "flex items-center justify-between gap-3", children: [_jsx("h3", { className: "text-sm font-medium text-foreground", children: title }), _jsx("span", { className: "font-mono text-xs text-muted-foreground tabular-nums", children: count })] }));
@@ -1,5 +1,5 @@
1
- import { type TimestampFormatOptions } from "./timestamp-format";
1
+ import { toUtcTimestamp, type TimestampFormatOptions } from "./timestamp-format";
2
2
  declare function formatFeedbackTimestamp(value: string | undefined, options?: TimestampFormatOptions): string;
3
3
  declare function formatFeedbackTimestampFull(value: string | undefined, options?: TimestampFormatOptions): string;
4
4
  declare function getFeedbackTimestampDateTime(value: string | undefined): string | undefined;
5
- export { formatFeedbackTimestamp, formatFeedbackTimestampFull, getFeedbackTimestampDateTime };
5
+ export { formatFeedbackTimestamp, formatFeedbackTimestampFull, getFeedbackTimestampDateTime, toUtcTimestamp, };
@@ -1,4 +1,4 @@
1
- import { formatTimestamp, formatTimestampFull, getTimestampDateTime, } from "./timestamp-format";
1
+ import { formatTimestamp, formatTimestampFull, getTimestampDateTime, toUtcTimestamp, } from "./timestamp-format";
2
2
  function formatFeedbackTimestamp(value, options = {}) {
3
3
  return formatTimestamp(value, options);
4
4
  }
@@ -8,4 +8,4 @@ function formatFeedbackTimestampFull(value, options = {}) {
8
8
  function getFeedbackTimestampDateTime(value) {
9
9
  return getTimestampDateTime(value);
10
10
  }
11
- export { formatFeedbackTimestamp, formatFeedbackTimestampFull, getFeedbackTimestampDateTime };
11
+ export { formatFeedbackTimestamp, formatFeedbackTimestampFull, getFeedbackTimestampDateTime, toUtcTimestamp, };
@@ -9,7 +9,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
9
9
  import { Button } from "../ui/button";
10
10
  import { Card } from "../ui/card";
11
11
  import { Item, ItemActions, ItemContent, ItemDescription, ItemGroup, ItemGroupItem, ItemMedia, ItemTitle, } from "../ui/item";
12
- import { NativeSelect, NativeSelectOption } from "../ui/native-select";
12
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
13
13
  import { PropertyList, PropertyListItem, PropertyListLabel, PropertyListValue, } from "../ui/property-list";
14
14
  import { Sheet, SheetBody, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, } from "../ui/sheet";
15
15
  function FeedbackDetailSheet({ conversation, feedback, onOpenChange, onStatusChange, open, pendingStatus, }) {
@@ -27,11 +27,11 @@ function FeedbackDetailContent({ feedback, onStatusChange, pendingStatus, }) {
27
27
  */
28
28
  function FeedbackDetailStatusSection({ onStatusChange, pendingStatus, status, }) {
29
29
  const selectId = React.useId();
30
- return (_jsxs("div", { className: "grid gap-2", children: [_jsx("label", { className: "text-meta text-muted-foreground", htmlFor: selectId, children: "Status" }), _jsx(NativeSelect, { disabled: pendingStatus !== undefined, id: selectId, onChange: (event) => {
31
- const next = feedbackStatusValues.find((value) => value === event.target.value);
30
+ return (_jsxs("div", { className: "grid gap-2", children: [_jsx("label", { className: "text-meta text-muted-foreground", htmlFor: selectId, children: "Status" }), _jsxs(Select, { disabled: pendingStatus !== undefined, onValueChange: (value) => {
31
+ const next = feedbackStatusValues.find((candidate) => candidate === value);
32
32
  if (next && next !== status)
33
33
  onStatusChange(next);
34
- }, value: pendingStatus ?? status, children: feedbackStatusValues.map((value) => (_jsx(NativeSelectOption, { value: value, children: feedbackStatusLabels[value] }, value))) })] }));
34
+ }, value: pendingStatus ?? status, children: [_jsx(SelectTrigger, { className: "w-full", id: selectId, children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: feedbackStatusValues.map((value) => (_jsx(SelectItem, { value: value, children: feedbackStatusLabels[value] }, value))) })] })] }));
35
35
  }
36
36
  function FeedbackDetailSummary({ feedback }) {
37
37
  const submitter = feedback.submittedBy;
@@ -49,6 +49,12 @@ type FeedbackMetrics = {
49
49
  received: number;
50
50
  total: number;
51
51
  };
52
+ type FeedbackTimelineEvent = {
53
+ actorEmail: string;
54
+ actorRole: "reporter" | "support";
55
+ at: string;
56
+ toStatus: FeedbackStatus;
57
+ };
52
58
  declare const feedbackStatusLabels: Record<FeedbackStatus, string>;
53
59
  declare const feedbackTypeLabels: Record<StakeholderFeedbackType, string>;
54
60
  declare const feedbackStatusOptions: readonly [{
@@ -84,4 +90,4 @@ declare const feedbackTypeOptions: readonly [{
84
90
  readonly value: "general";
85
91
  }];
86
92
  declare function getFeedbackInitials(name: string, email: string | undefined): string;
87
- export { feedbackStatusLabels, feedbackStatusOptions, feedbackStatusValues, feedbackTypeLabels, feedbackTypeOptions, getFeedbackInitials, type FeedbackAffectedScope, type FeedbackAttachment, type FeedbackMetrics, type FeedbackStatus, type FeedbackSubmittedBy, type FeedbackUrgency, type StakeholderFeedbackItem, type StakeholderFeedbackType, type UserFeedbackItem, };
93
+ export { feedbackStatusLabels, feedbackStatusOptions, feedbackStatusValues, feedbackTypeLabels, feedbackTypeOptions, getFeedbackInitials, type FeedbackAffectedScope, type FeedbackAttachment, type FeedbackMetrics, type FeedbackStatus, type FeedbackSubmittedBy, type FeedbackTimelineEvent, type FeedbackUrgency, type StakeholderFeedbackItem, type StakeholderFeedbackType, type UserFeedbackItem, };
@@ -0,0 +1,11 @@
1
+ import { type FeedbackTimelineEvent } from "./feedback-tracking-types";
2
+ type FeedbackTriageTimelineProps = {
3
+ events: FeedbackTimelineEvent[];
4
+ };
5
+ /**
6
+ * The item's triage history, oldest first: where it has been, who moved it,
7
+ * and when. Support identities read as "Support" -- one team voice, the same
8
+ * rule the conversation applies.
9
+ */
10
+ declare function FeedbackTriageTimeline({ events }: FeedbackTriageTimelineProps): import("@types/react").JSX.Element | null;
11
+ export { FeedbackTriageTimeline, type FeedbackTriageTimelineProps };
@@ -0,0 +1,17 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { formatFeedbackTimestamp } from "./feedback-date-format";
3
+ import { feedbackStatusLabels } from "./feedback-tracking-types";
4
+ /**
5
+ * The item's triage history, oldest first: where it has been, who moved it,
6
+ * and when. Support identities read as "Support" -- one team voice, the same
7
+ * rule the conversation applies.
8
+ */
9
+ function FeedbackTriageTimeline({ events }) {
10
+ if (events.length === 0)
11
+ return null;
12
+ return (_jsxs("section", { "aria-label": "Triage history", className: "grid gap-1.5", children: [_jsx("h3", { className: "text-sm font-medium", children: "Timeline" }), _jsx("ol", { className: "grid gap-1 border-l pl-3", children: events.map((event, index) => (_jsxs("li", { className: "text-xs text-muted-foreground", children: [_jsx("span", { className: "font-medium text-foreground", children: feedbackStatusLabels[event.toStatus] }), " · ", event.actorRole === "support" ? "Support" : event.actorEmail, " · ", formatFeedbackTimestamp(event.at)] }, getTimelineEventKey(event, index)))) })] }));
13
+ }
14
+ function getTimelineEventKey(event, index) {
15
+ return `${event.toStatus}-${event.at}-${index}`; // two transitions can share a status and timestamp
16
+ }
17
+ export { FeedbackTriageTimeline };
@@ -1,6 +1,11 @@
1
+ import type * as React from "react";
1
2
  import { type StakeholderFeedbackType } from "./feedback-tracking-types";
2
3
  type FeedbackTypeBadgeProps = {
3
4
  type: StakeholderFeedbackType;
4
5
  };
5
- declare function FeedbackTypeBadge({ type }: FeedbackTypeBadgeProps): import("@types/react").JSX.Element;
6
+ /**
7
+ * Quiet on purpose: status owns hue wherever the two badges share a row, so
8
+ * the type reads by its icon's silhouette instead of competing for color.
9
+ */
10
+ declare function FeedbackTypeBadge({ type }: FeedbackTypeBadgeProps): React.JSX.Element;
6
11
  export { FeedbackTypeBadge, type FeedbackTypeBadgeProps };
@@ -1,12 +1,18 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { BugIcon, LightbulbIcon, MessageSquareIcon } from "lucide-react";
2
3
  import { Badge } from "../ui/badge";
3
4
  import { feedbackTypeLabels } from "./feedback-tracking-types";
5
+ /**
6
+ * Quiet on purpose: status owns hue wherever the two badges share a row, so
7
+ * the type reads by its icon's silhouette instead of competing for color.
8
+ */
4
9
  function FeedbackTypeBadge({ type }) {
5
- return _jsx(Badge, { variant: feedbackTypeVariants[type], children: feedbackTypeLabels[type] });
10
+ const Icon = feedbackTypeIcons[type];
11
+ return (_jsxs(Badge, { variant: "outline", children: [_jsx(Icon, {}), feedbackTypeLabels[type]] }));
6
12
  }
7
- const feedbackTypeVariants = {
8
- bug: "warning",
9
- feature: "info",
10
- general: "outline",
13
+ const feedbackTypeIcons = {
14
+ bug: BugIcon,
15
+ feature: LightbulbIcon,
16
+ general: MessageSquareIcon,
11
17
  };
12
18
  export { FeedbackTypeBadge };
@@ -0,0 +1,6 @@
1
+ type FeedbackUnreadBadgeProps = {
2
+ count: number;
3
+ };
4
+ /** "N new reply/replies" pill for list rows; renders nothing at zero. */
5
+ declare function FeedbackUnreadBadge({ count }: FeedbackUnreadBadgeProps): import("@types/react").JSX.Element | null;
6
+ export { FeedbackUnreadBadge, type FeedbackUnreadBadgeProps };
@@ -0,0 +1,9 @@
1
+ import { jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Badge } from "../ui/badge";
3
+ /** "N new reply/replies" pill for list rows; renders nothing at zero. */
4
+ function FeedbackUnreadBadge({ count }) {
5
+ if (count <= 0)
6
+ return null;
7
+ return (_jsxs(Badge, { variant: "secondary", children: [count, " new ", count === 1 ? "reply" : "replies"] }));
8
+ }
9
+ export { FeedbackUnreadBadge };
@@ -18,7 +18,7 @@ function FeedbackUserDetailHeader({ feedback, onBack, }) {
18
18
  return (_jsx("header", { className: "grid gap-3", children: _jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [onBack ? _jsx(FeedbackUserDetailBackButton, { onBack: onBack }) : null, _jsx("div", { className: "min-w-0 flex-1", children: _jsxs("div", { className: "flex min-w-0 items-center justify-between gap-3", children: [_jsx("h3", { className: "truncate text-sm font-medium text-foreground", children: feedback.subject }), _jsx(FeedbackUserDetailBadges, { feedback: feedback })] }) })] }) }));
19
19
  }
20
20
  function FeedbackUserDetailBadges({ feedback }) {
21
- return (_jsxs("div", { className: "flex shrink-0 flex-wrap justify-end gap-2", children: [feedback.hasUnreadReply ? _jsx(Badge, { variant: "secondary", children: "New reply" }) : null, feedback.urgency === "emergency" ? (_jsxs(Badge, { variant: "destructive", children: [_jsx(AlertTriangleIcon, {}), "Emergency"] })) : null, _jsx(FeedbackTypeBadge, { type: feedback.type }), _jsx(FeedbackStatusBadge, { status: feedback.status })] }));
21
+ return (_jsxs("div", { className: "flex shrink-0 flex-wrap justify-end gap-2", children: [feedback.hasUnreadReply ? _jsx(Badge, { variant: "secondary", children: "New activity" }) : null, feedback.urgency === "emergency" ? (_jsxs(Badge, { variant: "destructive", children: [_jsx(AlertTriangleIcon, {}), "Emergency"] })) : null, _jsx(FeedbackTypeBadge, { type: feedback.type }), _jsx(FeedbackStatusBadge, { status: feedback.status })] }));
22
22
  }
23
23
  function FeedbackUserDetailBackButton({ onBack }) {
24
24
  return (_jsx(Button, { "aria-label": "Back to my feedback", className: "-ml-2 shrink-0", onClick: onBack, size: "icon", type: "button", variant: "ghost", children: _jsx(ArrowLeftIcon, {}) }));
@@ -26,7 +26,7 @@ function FeedbackUserListItem({ item, onSelectFeedback, selected, }) {
26
26
  return (_jsx(ItemGroupItem, { children: _jsx(Item, { "aria-current": selected ? "true" : undefined, asChild: true, className: cn("w-full cursor-pointer text-left", selected && "border-foreground/30 bg-muted/50"), size: "sm", variant: "outline", children: _jsx("button", { onClick: () => onSelectFeedback?.(item), type: "button", children: _jsx(FeedbackUserListItemContent, { item: item }) }) }) }));
27
27
  }
28
28
  function FeedbackUserListItemContent({ item }) {
29
- return (_jsxs(_Fragment, { children: [_jsxs(ItemContent, { className: "min-w-0", children: [_jsx(ItemTitle, { className: "max-w-full truncate", children: item.subject }), _jsx(ItemDescription, { className: "line-clamp-1", children: item.description })] }), _jsxs(ItemActions, { className: "shrink-0", children: [item.hasUnreadReply ? _jsx(Badge, { variant: "secondary", children: "New reply" }) : null, item.urgency === "emergency" ? (_jsxs(Badge, { variant: "destructive", children: [_jsx(AlertTriangleIcon, {}), "Emergency"] })) : null, _jsx(FeedbackTypeBadge, { type: item.type }), _jsx(FeedbackStatusBadge, { status: item.status })] }), _jsxs(ItemFooter, { children: [_jsx(FeedbackListDate, { label: "Submitted", value: item.submittedAt }), item.updatedAt ? _jsx(FeedbackListDate, { label: "Updated", value: item.updatedAt }) : null] })] }));
29
+ return (_jsxs(_Fragment, { children: [_jsxs(ItemContent, { className: "min-w-0", children: [_jsx(ItemTitle, { className: "max-w-full truncate", children: item.subject }), _jsx(ItemDescription, { className: "line-clamp-1", children: item.description })] }), _jsxs(ItemActions, { className: "shrink-0", children: [item.hasUnreadReply ? _jsx(Badge, { variant: "secondary", children: "New activity" }) : null, item.urgency === "emergency" ? (_jsxs(Badge, { variant: "destructive", children: [_jsx(AlertTriangleIcon, {}), "Emergency"] })) : null, _jsx(FeedbackTypeBadge, { type: item.type }), _jsx(FeedbackStatusBadge, { status: item.status })] }), _jsxs(ItemFooter, { children: [_jsx(FeedbackListDate, { label: "Submitted", value: item.submittedAt }), item.updatedAt ? _jsx(FeedbackListDate, { label: "Updated", value: item.updatedAt }) : null] })] }));
30
30
  }
31
31
  function FeedbackListDate({ label, value }) {
32
32
  return (_jsxs("span", { className: "font-mono text-xs text-muted-foreground tabular-nums", children: [label, " ", formatFeedbackTimestamp(value)] }));
@@ -0,0 +1,19 @@
1
+ import type { McpToolInventoryState } from "./mcp-access-tool-picker-internals";
2
+ import type { McpAccessToolSelector } from "./mcp-access-types";
3
+ /**
4
+ * The tool picker for inventories big enough to need navigation: one scroll
5
+ * region grouped by each tool's first OpenAPI tag, each group split into Read
6
+ * and Actions with a select-all per split, and a filter box on top. An
7
+ * inventory with no tags renders as a single unlabeled group, so the split,
8
+ * the select-alls, and the filter still apply.
9
+ *
10
+ * Every change is rebuilt from the full inventory against the one shared
11
+ * selection, so a tool selected but hidden by the filter is never dropped —
12
+ * only the rows on screen, and the select-alls over them, can change it.
13
+ */
14
+ export declare function McpAccessGroupedToolPicker({ busy, inventory, selected, onChange, }: {
15
+ busy: boolean;
16
+ inventory: McpToolInventoryState;
17
+ selected: McpAccessToolSelector[];
18
+ onChange: (tools: McpAccessToolSelector[]) => void;
19
+ }): import("@types/react").JSX.Element;
@@ -0,0 +1,116 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Checkbox } from "../ui/checkbox";
4
+ import { Input } from "../ui/input";
5
+ import { Spinner } from "../ui/spinner";
6
+ import { EmptySelectionWarning, InventoryUnavailableNote, keyOf, PickerRow, } from "./mcp-access-tool-picker-internals";
7
+ const UNTAGGED_LABEL = "Other";
8
+ /**
9
+ * The tool picker for inventories big enough to need navigation: one scroll
10
+ * region grouped by each tool's first OpenAPI tag, each group split into Read
11
+ * and Actions with a select-all per split, and a filter box on top. An
12
+ * inventory with no tags renders as a single unlabeled group, so the split,
13
+ * the select-alls, and the filter still apply.
14
+ *
15
+ * Every change is rebuilt from the full inventory against the one shared
16
+ * selection, so a tool selected but hidden by the filter is never dropped —
17
+ * only the rows on screen, and the select-alls over them, can change it.
18
+ */
19
+ export function McpAccessGroupedToolPicker({ busy, inventory, selected, onChange, }) {
20
+ const [query, setQuery] = useState("");
21
+ if (inventory.data === undefined) {
22
+ return _jsx(InventoryUnavailableNote, { isPending: inventory.isPending });
23
+ }
24
+ const tools = inventory.data.tools;
25
+ const selectedKeys = new Set(selected.map((tool) => keyOf(tool)));
26
+ const applyKeys = (keys, checked) => {
27
+ const next = new Set(selectedKeys);
28
+ for (const key of keys) {
29
+ if (checked)
30
+ next.add(key);
31
+ else
32
+ next.delete(key);
33
+ }
34
+ onChange(tools
35
+ .filter((tool) => next.has(keyOf(tool)))
36
+ .map((tool) => ({ method: tool.method, pathTemplate: tool.pathTemplate })));
37
+ };
38
+ const needle = query.trim().toLowerCase();
39
+ const groups = groupByFirstTag(tools);
40
+ const visibleGroups = groups
41
+ .map((group) => ({ ...group, visible: group.tools.filter((tool) => matches(tool, needle)) }))
42
+ .filter((group) => group.visible.length > 0);
43
+ const visibleCount = visibleGroups.reduce((count, group) => count + group.visible.length, 0);
44
+ const chosen = tools.filter((tool) => selectedKeys.has(keyOf(tool)));
45
+ return (_jsxs("div", { className: "grid grid-cols-[minmax(0,1fr)] gap-2", children: [_jsx(Input, { "aria-label": "Filter tools", className: "h-8 text-xs", onChange: (event) => {
46
+ setQuery(event.target.value);
47
+ }, placeholder: `Filter ${tools.length} tools…`, type: "search", value: query }), _jsx("div", { className: "max-h-96 overflow-y-auto rounded-md border border-border/60", children: visibleGroups.length === 0 ? (_jsx("p", { className: "text-muted-foreground px-3 py-2 text-xs", children: tools.length === 0 ? "This API exposes no tools." : "No tools match the filter." })) : (visibleGroups.map((group) => (_jsx(GroupSection, { busy: busy, group: group, onApply: applyKeys, selectedKeys: selectedKeys }, group.label ?? "")))) }), _jsx(PickerFooter, { busy: busy, chosen: chosen.length, matching: needle === "" ? null : visibleCount, total: tools.length })] }));
48
+ }
49
+ function PickerFooter({ busy, chosen, matching, total, }) {
50
+ return (_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [busy ? _jsx(Spinner, { "aria-label": "Saving selection", className: "text-muted-foreground" }) : null, _jsxs("p", { "aria-live": "polite", className: "text-muted-foreground text-xs", children: [chosen, " of ", total, " tools selected"] }), matching === null ? null : (_jsxs("p", { className: "text-muted-foreground text-xs", children: [matching, " match", matching === 1 ? "es" : "", " the filter"] })), chosen === 0 ? _jsx(EmptySelectionWarning, {}) : null] }));
51
+ }
52
+ function matches(tool, needle) {
53
+ if (needle === "")
54
+ return true;
55
+ return [tool.name, tool.description, `${tool.method} ${tool.pathTemplate}`].some((text) => text.toLowerCase().includes(needle));
56
+ }
57
+ /** One tag group: its sticky header with counts, then the Read and Actions runs. */
58
+ function GroupSection({ busy, group, selectedKeys, onApply, }) {
59
+ const chosenInGroup = group.tools.filter((tool) => selectedKeys.has(keyOf(tool))).length;
60
+ return (_jsxs("section", { children: [group.label === null ? null : (_jsxs("h4", { className: "bg-background sticky top-0 z-10 flex items-baseline justify-between gap-2 border-b border-border/60 px-3 py-1.5 font-mono text-xs font-semibold", children: [_jsx("span", { className: "truncate", children: group.label }), _jsxs("span", { className: "text-muted-foreground shrink-0 text-[10px] font-normal tabular-nums", children: [chosenInGroup, " / ", group.tools.length] })] })), [
61
+ { label: "Read", rows: group.visible.filter((tool) => tool.readOnly) },
62
+ { label: "Actions", rows: group.visible.filter((tool) => !tool.readOnly) },
63
+ ].map(({ label, rows }) => rows.length === 0 ? null : (_jsx(SubsectionRows, { busy: busy, groupLabel: group.label, label: label, onApply: onApply, rows: rows, selectedKeys: selectedKeys }, label)))] }));
64
+ }
65
+ /** One Read or Actions run within a group: its header with select-all, then rows. */
66
+ function SubsectionRows({ busy, groupLabel, label, rows, selectedKeys, onApply, }) {
67
+ const rowKeys = rows.map((tool) => keyOf(tool));
68
+ const picked = rowKeys.filter((key) => selectedKeys.has(key)).length;
69
+ const allState = picked === 0 ? false : picked === rowKeys.length ? true : "indeterminate";
70
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-3 pt-2 pb-1", children: [_jsx("span", { className: label === "Actions"
71
+ ? "text-primary text-[10px] font-semibold tracking-wide uppercase"
72
+ : "text-muted-foreground text-[10px] font-semibold tracking-wide uppercase", children: label }), _jsx(Checkbox, { "aria-label": `Select all ${label.toLowerCase()}${groupLabel === null ? "" : ` in ${groupLabel}`}`, checked: allState, disabled: busy, onCheckedChange: (next) => {
73
+ // The rows on screen only: with a filter active this is
74
+ // "select what I searched for", never the hidden rest.
75
+ onApply(rowKeys, next === true);
76
+ } })] }), _jsx("ul", { className: "divide-y divide-border/50", children: rows.map((tool) => (_jsx(PickerRow, { checked: selectedKeys.has(keyOf(tool)), disabled: busy,
77
+ // The subheader above already says Read or Actions.
78
+ kindBadge: false, onToggle: (key, checked) => {
79
+ onApply([key], checked);
80
+ }, tool: tool }, keyOf(tool)))) })] }));
81
+ }
82
+ /**
83
+ * Buckets by first tag in first-appearance order — a compiler emits tools in
84
+ * source order, so tag order is the API's own organization. Untagged tools
85
+ * trail as one "Other" bucket, unless nothing is tagged at all: then the one
86
+ * group is unlabeled rather than titled "Other" for no contrast.
87
+ */
88
+ function groupByFirstTag(tools) {
89
+ const buckets = new Map();
90
+ for (const tool of tools) {
91
+ const firstTag = tool.tags?.[0];
92
+ if (firstTag === undefined)
93
+ continue;
94
+ const bucket = buckets.get(firstTag);
95
+ if (bucket)
96
+ bucket.push(tool);
97
+ else
98
+ buckets.set(firstTag, [tool]);
99
+ }
100
+ if (buckets.size === 0)
101
+ return [{ label: null, tools }];
102
+ const untagged = tools.filter((tool) => (tool.tags?.length ?? 0) === 0);
103
+ if (untagged.length > 0) {
104
+ // Into the real "Other" tag's bucket when one exists, so two groups
105
+ // never share a label (and with it a React key).
106
+ const bucket = buckets.get(UNTAGGED_LABEL);
107
+ if (bucket)
108
+ bucket.push(...untagged);
109
+ else
110
+ buckets.set(UNTAGGED_LABEL, untagged);
111
+ }
112
+ return [...buckets.entries()].map(([label, bucketTools]) => ({
113
+ label,
114
+ tools: bucketTools,
115
+ }));
116
+ }
@@ -0,0 +1,35 @@
1
+ import type { McpAccessToolListing } from "./mcp-access-types";
2
+ /** The coordinate a selector and a compiled operation are matched on. */
3
+ export declare const keyOf: (tool: {
4
+ method: string;
5
+ pathTemplate: string;
6
+ }) => string;
7
+ /**
8
+ * The slice of the app's inventory query the pickers read, shaped so a
9
+ * TanStack Query result can be passed whole. `data` is `undefined` for both
10
+ * "still loading" and "failed to load"; `isPending` says which.
11
+ */
12
+ export type McpToolInventoryState = {
13
+ data: {
14
+ tools: McpAccessToolListing[];
15
+ } | undefined;
16
+ isPending: boolean;
17
+ };
18
+ /** The one line an absent inventory renders, in both pickers. */
19
+ export declare function InventoryUnavailableNote({ isPending }: {
20
+ isPending: boolean;
21
+ }): import("@types/react").JSX.Element;
22
+ /** Why an empty selection blocks the mint, in both pickers' footers. */
23
+ export declare function EmptySelectionWarning(): import("@types/react").JSX.Element;
24
+ /**
25
+ * One selectable tool row. `kindBadge` shows the read-only/action label — the
26
+ * flat list's only signal, redundant under the grouped picker's Read/Actions
27
+ * subheaders.
28
+ */
29
+ export declare function PickerRow({ checked, disabled, kindBadge, tool, onToggle, }: {
30
+ checked: boolean;
31
+ disabled: boolean;
32
+ kindBadge: boolean;
33
+ tool: McpAccessToolListing;
34
+ onToggle: (key: string, checked: boolean) => void;
35
+ }): import("@types/react").JSX.Element;
@@ -0,0 +1,24 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId } from "react";
3
+ import { Checkbox } from "../ui/checkbox";
4
+ /** The coordinate a selector and a compiled operation are matched on. */
5
+ export const keyOf = (tool) => `${tool.method} ${tool.pathTemplate}`;
6
+ /** The one line an absent inventory renders, in both pickers. */
7
+ export function InventoryUnavailableNote({ isPending }) {
8
+ return (_jsx("p", { className: "text-muted-foreground text-xs", children: isPending ? "Loading the tool list…" : "Couldn't load the tool list." }));
9
+ }
10
+ /** Why an empty selection blocks the mint, in both pickers' footers. */
11
+ export function EmptySelectionWarning() {
12
+ return (_jsx("p", { className: "text-destructive text-xs", children: "Select at least one tool to generate a connection code." }));
13
+ }
14
+ /**
15
+ * One selectable tool row. `kindBadge` shows the read-only/action label — the
16
+ * flat list's only signal, redundant under the grouped picker's Read/Actions
17
+ * subheaders.
18
+ */
19
+ export function PickerRow({ checked, disabled, kindBadge, tool, onToggle, }) {
20
+ const id = useId();
21
+ return (_jsxs("li", { className: "flex min-h-11 items-start gap-3 px-3 py-2", children: [_jsx(Checkbox, { checked: checked, disabled: disabled, id: id, onCheckedChange: (next) => {
22
+ onToggle(keyOf(tool), next === true);
23
+ } }), _jsxs("label", { className: "min-w-0 flex-1 cursor-pointer", htmlFor: id, children: [kindBadge ? (_jsxs("span", { className: "flex flex-wrap items-center gap-x-2", children: [_jsx("span", { className: "truncate font-mono text-xs", children: tool.name }), _jsx("span", { className: "text-muted-foreground text-[10px] uppercase", children: tool.readOnly ? "read-only" : "action" })] })) : (_jsx("span", { className: "block truncate font-mono text-xs", children: tool.name })), _jsx("span", { className: "text-muted-foreground block truncate text-xs", children: tool.description })] })] }));
24
+ }
@@ -1,20 +1,7 @@
1
- import type { McpAccessToolListing, McpAccessToolSelector } from "./mcp-access-types";
2
- /** The coordinate a selector and a compiled operation are matched on. */
3
- export declare const keyOf: (tool: {
4
- method: string;
5
- pathTemplate: string;
6
- }) => string;
7
- /**
8
- * The slice of the app's inventory query the picker reads, shaped so a
9
- * TanStack Query result can be passed whole. `data` is `undefined` for both
10
- * "still loading" and "failed to load"; `isPending` says which.
11
- */
12
- export type McpToolInventoryState = {
13
- data: {
14
- tools: McpAccessToolListing[];
15
- } | undefined;
16
- isPending: boolean;
17
- };
1
+ import type { McpToolInventoryState } from "./mcp-access-tool-picker-internals";
2
+ import type { McpAccessToolSelector } from "./mcp-access-types";
3
+ export { keyOf } from "./mcp-access-tool-picker-internals";
4
+ export type { McpToolInventoryState } from "./mcp-access-tool-picker-internals";
18
5
  /**
19
6
  * An exact tool selection, applied as it is clicked. There is nothing to
20
7
  * confirm: the list on screen is always what a code will be minted with, and an
@@ -1,9 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useId } from "react";
3
- import { Checkbox } from "../ui/checkbox";
4
2
  import { Spinner } from "../ui/spinner";
5
- /** The coordinate a selector and a compiled operation are matched on. */
6
- export const keyOf = (tool) => `${tool.method} ${tool.pathTemplate}`;
3
+ import { EmptySelectionWarning, InventoryUnavailableNote, keyOf, PickerRow, } from "./mcp-access-tool-picker-internals";
4
+ export { keyOf } from "./mcp-access-tool-picker-internals";
7
5
  /**
8
6
  * An exact tool selection, applied as it is clicked. There is nothing to
9
7
  * confirm: the list on screen is always what a code will be minted with, and an
@@ -17,7 +15,7 @@ export const keyOf = (tool) => `${tool.method} ${tool.pathTemplate}`;
17
15
  export function McpAccessToolPicker({ busy, inventory, selected, onChange, }) {
18
16
  const selectedKeys = new Set(selected.map((tool) => keyOf(tool)));
19
17
  if (inventory.data === undefined) {
20
- return (_jsx("p", { className: "text-muted-foreground text-xs", children: inventory.isPending ? "Loading the tool list…" : "Couldn't load the tool list." }));
18
+ return _jsx(InventoryUnavailableNote, { isPending: inventory.isPending });
21
19
  }
22
20
  const tools = inventory.data.tools;
23
21
  const toggle = (key, checked) => {
@@ -31,11 +29,5 @@ export function McpAccessToolPicker({ busy, inventory, selected, onChange, }) {
31
29
  .map((tool) => ({ method: tool.method, pathTemplate: tool.pathTemplate })));
32
30
  };
33
31
  const chosen = tools.filter((tool) => selectedKeys.has(keyOf(tool)));
34
- return (_jsxs("div", { className: "grid grid-cols-[minmax(0,1fr)] gap-2", children: [_jsx("ul", { className: "max-h-64 divide-y divide-border/50 overflow-y-auto rounded-md border border-border/60", children: tools.map((tool) => (_jsx(PickerRow, { checked: selectedKeys.has(keyOf(tool)), disabled: busy, onToggle: toggle, tool: tool }, keyOf(tool)))) }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [busy ? _jsx(Spinner, { "aria-label": "Saving selection", className: "text-muted-foreground" }) : null, _jsxs("p", { "aria-live": "polite", className: "text-muted-foreground text-xs", children: [chosen.length, " of ", tools.length, " tools selected"] }), chosen.length === 0 ? (_jsx("p", { className: "text-destructive text-xs", children: "Select at least one tool to generate a connection code." })) : null] })] }));
35
- }
36
- function PickerRow({ checked, disabled, tool, onToggle, }) {
37
- const id = useId();
38
- return (_jsxs("li", { className: "flex min-h-11 items-start gap-3 px-3 py-2", children: [_jsx(Checkbox, { checked: checked, disabled: disabled, id: id, onCheckedChange: (next) => {
39
- onToggle(keyOf(tool), next === true);
40
- } }), _jsxs("label", { className: "min-w-0 flex-1 cursor-pointer", htmlFor: id, children: [_jsxs("span", { className: "flex flex-wrap items-center gap-x-2", children: [_jsx("span", { className: "truncate font-mono text-xs", children: tool.name }), _jsx("span", { className: "text-muted-foreground text-[10px] uppercase", children: tool.readOnly ? "read-only" : "action" })] }), _jsx("span", { className: "block truncate text-muted-foreground text-xs", children: tool.description })] })] }));
32
+ return (_jsxs("div", { className: "grid grid-cols-[minmax(0,1fr)] gap-2", children: [_jsx("ul", { className: "max-h-64 divide-y divide-border/50 overflow-y-auto rounded-md border border-border/60", children: tools.map((tool) => (_jsx(PickerRow, { checked: selectedKeys.has(keyOf(tool)), disabled: busy, kindBadge: true, onToggle: toggle, tool: tool }, keyOf(tool)))) }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [busy ? _jsx(Spinner, { "aria-label": "Saving selection", className: "text-muted-foreground" }) : null, _jsxs("p", { "aria-live": "polite", className: "text-muted-foreground text-xs", children: [chosen.length, " of ", tools.length, " tools selected"] }), chosen.length === 0 ? _jsx(EmptySelectionWarning, {}) : null] })] }));
41
33
  }
@@ -21,6 +21,8 @@ export type McpAccessToolListing = {
21
21
  readOnly: boolean;
22
22
  method: McpHttpMethod;
23
23
  pathTemplate: string;
24
+ /** The operation's OpenAPI tags; the grouped picker buckets by the first. */
25
+ tags?: readonly string[];
24
26
  };
25
27
  /**
26
28
  * What a mint produced, as the fields these controls report: the access the
@@ -4,5 +4,11 @@ type TimestampFormatOptions = {
4
4
  declare function formatTimestamp(value: string | undefined, options?: TimestampFormatOptions): string;
5
5
  declare function formatTimestampFull(value: string | undefined, options?: TimestampFormatOptions): string;
6
6
  declare function getTimestampDateTime(value: string | undefined): string | undefined;
7
- export { formatTimestamp, formatTimestampFull, getTimestampDateTime };
7
+ /**
8
+ * Backends that store naive UTC timestamps serialize without a zone designator,
9
+ * and `Date.parse` reads a zoneless string as local time. Append the missing
10
+ * designator so timestamps render in the viewer's timezone.
11
+ */
12
+ declare function toUtcTimestamp(value: string): string;
13
+ export { formatTimestamp, formatTimestampFull, getTimestampDateTime, toUtcTimestamp };
8
14
  export type { TimestampFormatOptions };
@@ -28,6 +28,14 @@ function getTimestampDateTime(value) {
28
28
  return undefined;
29
29
  return date.toISOString();
30
30
  }
31
+ /**
32
+ * Backends that store naive UTC timestamps serialize without a zone designator,
33
+ * and `Date.parse` reads a zoneless string as local time. Append the missing
34
+ * designator so timestamps render in the viewer's timezone.
35
+ */
36
+ function toUtcTimestamp(value) {
37
+ return /(?:z|[+-]\d\d:?\d\d)$/i.test(value) ? value : `${value}Z`;
38
+ }
31
39
  function parseTimestamp(value) {
32
40
  if (!value)
33
41
  return undefined;
@@ -63,4 +71,4 @@ const dateTimeFormatter = new Intl.DateTimeFormat(undefined, {
63
71
  month: "short",
64
72
  year: "numeric",
65
73
  });
66
- export { formatTimestamp, formatTimestampFull, getTimestampDateTime };
74
+ export { formatTimestamp, formatTimestampFull, getTimestampDateTime, toUtcTimestamp };
@@ -1,9 +1,9 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
- import { CheckIcon } from "lucide-react";
3
+ import { CheckIcon, MinusIcon } from "lucide-react";
4
4
  import { Checkbox as CheckboxPrimitive } from "radix-ui";
5
5
  import { cn } from "../../lib/utils";
6
6
  function Checkbox({ className, ...props }) {
7
- return (_jsx(CheckboxPrimitive.Root, { "data-slot": "checkbox", className: cn("peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[var(--focus-ring-width)] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary", className), ...props, children: _jsx(CheckboxPrimitive.Indicator, { "data-slot": "checkbox-indicator", className: "grid place-content-center text-current transition-none", children: _jsx(CheckIcon, { className: "size-3.5" }) }) }));
7
+ return (_jsx(CheckboxPrimitive.Root, { "data-slot": "checkbox", className: cn("peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[var(--focus-ring-width)] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=indeterminate]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary dark:data-[state=indeterminate]:bg-primary", className), ...props, children: _jsx(CheckboxPrimitive.Indicator, { "data-slot": "checkbox-indicator", className: "grid place-content-center text-current transition-none", children: props.checked === "indeterminate" ? (_jsx(MinusIcon, { className: "size-3.5" })) : (_jsx(CheckIcon, { className: "size-3.5" })) }) }));
8
8
  }
9
9
  export { Checkbox };
@@ -7,17 +7,33 @@ type DataTableFacetOption = {
7
7
  value: string;
8
8
  };
9
9
  /**
10
- * Narrow a column to a set of its own values.
10
+ * The facet-filter popover, controlled: one button per facet that wears the
11
+ * current selection, checkable values inside. The caller owns the selection --
12
+ * URL search state, column filters, anything -- and receives the whole next
13
+ * selection on every toggle, so the popover can never disagree with the list
14
+ * it narrows and there is nothing to apply or discard on close.
15
+ */
16
+ declare function FacetFilter({ counts, onSelectionChange, options, selected, title, }: {
17
+ /** Rows each option would leave, shown when provided. */
18
+ counts?: ReadonlyMap<string, number>;
19
+ onSelectionChange: (values: string[]) => void;
20
+ options: DataTableFacetOption[];
21
+ selected: readonly string[];
22
+ title: string;
23
+ }): React.JSX.Element;
24
+ /**
25
+ * The column-bound binding: narrow a column to a set of its own values.
11
26
  *
12
- * Counts come from the table's faceted row model, which `TanStackDataTable`
13
- * already builds -- so an option can say how many rows it would leave without
14
- * the caller counting anything itself. Pair with the `arrIncludesSome` filter
15
- * function, which is what a multi-select selection means.
27
+ * Selection lives in the column's filter value, and counts come from the
28
+ * table's faceted row model, which `TanStackDataTable` already builds -- so an
29
+ * option can say how many rows it would leave without the caller counting
30
+ * anything itself. Pair with the `arrIncludesSome` filter function, which is
31
+ * what a multi-select selection means.
16
32
  */
17
33
  declare function DataTableFacetFilter({ columnId, options, title, }: {
18
34
  columnId: string;
19
35
  options: DataTableFacetOption[];
20
36
  title: string;
21
37
  }): React.JSX.Element;
22
- export { DataTableFacetFilter };
38
+ export { DataTableFacetFilter, FacetFilter };
23
39
  export type { DataTableFacetOption };
@@ -8,32 +8,44 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
8
8
  import { Popover, PopoverContent, PopoverTrigger } from "./popover";
9
9
  import { Separator } from "./separator";
10
10
  /**
11
- * Narrow a column to a set of its own values.
12
- *
13
- * Counts come from the table's faceted row model, which `TanStackDataTable`
14
- * already builds -- so an option can say how many rows it would leave without
15
- * the caller counting anything itself. Pair with the `arrIncludesSome` filter
16
- * function, which is what a multi-select selection means.
11
+ * The facet-filter popover, controlled: one button per facet that wears the
12
+ * current selection, checkable values inside. The caller owns the selection --
13
+ * URL search state, column filters, anything -- and receives the whole next
14
+ * selection on every toggle, so the popover can never disagree with the list
15
+ * it narrows and there is nothing to apply or discard on close.
17
16
  */
18
- function DataTableFacetFilter({ columnId, options, title, }) {
19
- const column = useDataTable().getColumn(columnId);
20
- const counts = column?.getFacetedUniqueValues();
21
- const selected = new Set(readSelection(column?.getFilterValue()));
22
- const select = (value) => {
23
- const next = new Set(selected);
17
+ function FacetFilter({ counts, onSelectionChange, options, selected, title, }) {
18
+ const selectedSet = new Set(selected);
19
+ const toggle = (value) => {
20
+ const next = new Set(selectedSet);
24
21
  if (next.has(value))
25
22
  next.delete(value);
26
23
  else
27
24
  next.add(value);
28
- // An empty selection is no filter at all, not a filter matching nothing.
29
- column?.setFilterValue(next.size ? [...next] : undefined);
25
+ onSelectionChange([...next]);
30
26
  };
31
- return (_jsxs(Popover, { children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { size: "sm", type: "button", variant: "outline", children: [_jsx(PlusCircleIcon, {}), title, selected.size > 0 ? (_jsxs(_Fragment, { children: [_jsx(Separator, { className: "mx-0.5 data-[orientation=vertical]:h-4", orientation: "vertical" }), _jsx(FacetSelectionSummary, { labels: options.filter((option) => selected.has(option.value)) })] })) : null] }) }), _jsx(PopoverContent, { align: "start", className: "w-52 p-0", children: _jsxs(Command, { children: [_jsx(CommandInput, { placeholder: title }), _jsxs(CommandList, { children: [_jsx(CommandEmpty, { children: "No matches." }), _jsx(CommandGroup, { children: options.map((option) => (_jsx(FacetOptionItem, { count: counts?.get(option.value), onSelect: () => {
32
- select(option.value);
33
- }, option: option, selected: selected.has(option.value) }, option.value))) }), selected.size > 0 ? (_jsxs(_Fragment, { children: [_jsx(CommandSeparator, {}), _jsx(CommandGroup, { children: _jsx(CommandItem, { className: "justify-center", onSelect: () => {
34
- column?.setFilterValue(undefined);
27
+ return (_jsxs(Popover, { children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { size: "sm", type: "button", variant: "outline", children: [_jsx(PlusCircleIcon, {}), title, selectedSet.size > 0 ? (_jsxs(_Fragment, { children: [_jsx(Separator, { className: "mx-0.5 data-[orientation=vertical]:h-4", orientation: "vertical" }), _jsx(FacetSelectionSummary, { labels: options.filter((option) => selectedSet.has(option.value)) })] })) : null] }) }), _jsx(PopoverContent, { align: "start", className: "w-52 p-0", children: _jsxs(Command, { children: [_jsx(CommandInput, { placeholder: title }), _jsxs(CommandList, { children: [_jsx(CommandEmpty, { children: "No matches." }), _jsx(CommandGroup, { children: options.map((option) => (_jsx(FacetOptionItem, { count: counts?.get(option.value), onSelect: () => {
28
+ toggle(option.value);
29
+ }, option: option, selected: selectedSet.has(option.value) }, option.value))) }), selectedSet.size > 0 ? (_jsxs(_Fragment, { children: [_jsx(CommandSeparator, {}), _jsx(CommandGroup, { children: _jsx(CommandItem, { className: "justify-center", onSelect: () => {
30
+ onSelectionChange([]);
35
31
  }, children: "Clear" }) })] })) : null] })] }) })] }));
36
32
  }
33
+ /**
34
+ * The column-bound binding: narrow a column to a set of its own values.
35
+ *
36
+ * Selection lives in the column's filter value, and counts come from the
37
+ * table's faceted row model, which `TanStackDataTable` already builds -- so an
38
+ * option can say how many rows it would leave without the caller counting
39
+ * anything itself. Pair with the `arrIncludesSome` filter function, which is
40
+ * what a multi-select selection means.
41
+ */
42
+ function DataTableFacetFilter({ columnId, options, title, }) {
43
+ const column = useDataTable().getColumn(columnId);
44
+ return (_jsx(FacetFilter, { counts: column?.getFacetedUniqueValues(), onSelectionChange: (values) => {
45
+ // An empty selection is no filter at all, not a filter matching nothing.
46
+ column?.setFilterValue(values.length ? values : undefined);
47
+ }, options: options, selected: readSelection(column?.getFilterValue()), title: title }));
48
+ }
37
49
  /** Names the choices while they still fit, then counts them. */
38
50
  function FacetSelectionSummary({ labels }) {
39
51
  if (labels.length > 2)
@@ -50,4 +62,4 @@ function readSelection(value) {
50
62
  return [];
51
63
  return value.filter((entry) => typeof entry === "string");
52
64
  }
53
- export { DataTableFacetFilter };
65
+ export { DataTableFacetFilter, FacetFilter };
@@ -0,0 +1,14 @@
1
+ type DataTableSearchInputProps = {
2
+ ariaLabel: string;
3
+ onChange: (value: string) => void;
4
+ placeholder: string;
5
+ value: string;
6
+ };
7
+ /**
8
+ * Shows what is being typed. Any debounce that turns the value into an address
9
+ * belongs in the caller's table state, next to everything else that decides
10
+ * what the table is looking at -- so a Clear that cancels a search cancels the
11
+ * write still in flight along with it.
12
+ */
13
+ declare function DataTableSearchInput({ ariaLabel, onChange, placeholder, value, }: DataTableSearchInputProps): import("@types/react").JSX.Element;
14
+ export { DataTableSearchInput, type DataTableSearchInputProps };
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { SearchIcon } from "lucide-react";
3
+ import { Input } from "./input";
4
+ /**
5
+ * Shows what is being typed. Any debounce that turns the value into an address
6
+ * belongs in the caller's table state, next to everything else that decides
7
+ * what the table is looking at -- so a Clear that cancels a search cancels the
8
+ * write still in flight along with it.
9
+ */
10
+ function DataTableSearchInput({ ariaLabel, onChange, placeholder, value, }) {
11
+ return (_jsxs("div", { className: "relative w-full sm:max-w-64", children: [_jsx(SearchIcon, { className: "pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { "aria-label": ariaLabel, className: "pl-8", onChange: (event) => {
12
+ onChange(event.target.value);
13
+ }, placeholder: placeholder, type: "search", value: value })] }));
14
+ }
15
+ export { DataTableSearchInput };
@@ -12,12 +12,16 @@ function ItemGroupItem({ role = "listitem", ...props }) {
12
12
  function ItemSeparator({ className, ...props }) {
13
13
  return (_jsx(Separator, { "data-slot": "item-separator", orientation: "horizontal", className: cn("my-0", className), ...props }));
14
14
  }
15
- const itemVariants = cva("group/item flex flex-wrap items-center rounded-md border border-transparent text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[var(--focus-ring-width)] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-accent/50", {
15
+ const itemVariants = cva(
16
+ // Each variant owns the border color outright: `cn` does not resolve
17
+ // Tailwind conflicts, so base + variant both setting it would leave the
18
+ // winner to the consuming app's CSS emission order.
19
+ "group/item flex flex-wrap items-center rounded-md border text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[var(--focus-ring-width)] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-accent/50", {
16
20
  variants: {
17
21
  variant: {
18
- default: "bg-transparent",
22
+ default: "border-transparent bg-transparent",
19
23
  outline: "border-border",
20
- muted: "bg-muted/50",
24
+ muted: "border-transparent bg-muted/50",
21
25
  },
22
26
  size: {
23
27
  default: "gap-(--panel-gap) p-(--panel-padding)",
@@ -0,0 +1 @@
1
+ export * from "../components/ui/data-table-search-input";
@@ -0,0 +1 @@
1
+ export * from "../components/ui/data-table-search-input";
@@ -0,0 +1 @@
1
+ export * from "../components/blocks/feedback-triage-timeline";
@@ -0,0 +1 @@
1
+ export * from "../components/blocks/feedback-triage-timeline";
@@ -0,0 +1 @@
1
+ export * from "../components/blocks/feedback-unread-badge";
@@ -0,0 +1 @@
1
+ export * from "../components/blocks/feedback-unread-badge";
@@ -0,0 +1 @@
1
+ export * from "../components/blocks/mcp-access-grouped-tool-picker";
@@ -0,0 +1 @@
1
+ export * from "../components/blocks/mcp-access-grouped-tool-picker";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ciridae/ui",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "engines": {