harebear-ui 0.8.0 → 0.9.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 (52) hide show
  1. package/AGENTS.md +2 -2
  2. package/dist/components/admin/app-nav.d.ts +4 -0
  3. package/dist/components/admin/app-nav.js +5 -2
  4. package/dist/components/admin/avatar-upload.d.ts +32 -0
  5. package/dist/components/admin/avatar-upload.js +121 -0
  6. package/dist/components/admin/category.d.ts +17 -0
  7. package/dist/components/admin/category.js +35 -0
  8. package/dist/components/admin/celebration.d.ts +7 -0
  9. package/dist/components/admin/celebration.js +61 -0
  10. package/dist/components/admin/color-swatch-picker.d.ts +15 -0
  11. package/dist/components/admin/color-swatch-picker.js +68 -0
  12. package/dist/components/admin/date-time-picker.d.ts +20 -0
  13. package/dist/components/admin/date-time-picker.js +97 -0
  14. package/dist/components/admin/detail-sheet.d.ts +18 -0
  15. package/dist/components/admin/detail-sheet.js +80 -0
  16. package/dist/components/admin/markdown-editor.d.ts +15 -0
  17. package/dist/components/admin/markdown-editor.js +42 -0
  18. package/dist/components/admin/user-chip.d.ts +34 -3
  19. package/dist/components/admin/user-chip.js +66 -7
  20. package/dist/components/admin/user-menu.d.ts +4 -1
  21. package/dist/components/admin/user-menu.js +2 -1
  22. package/dist/components/ai/ask-box.d.ts +14 -2
  23. package/dist/components/ai/ask-box.js +90 -54
  24. package/dist/components/ai/assistant.d.ts +13 -3
  25. package/dist/components/ai/assistant.js +37 -7
  26. package/dist/components/ai/code-block.d.ts +10 -3
  27. package/dist/components/ai/code-block.js +54 -5
  28. package/dist/components/ai/conversation-list.d.ts +13 -3
  29. package/dist/components/ai/conversation-list.js +15 -26
  30. package/dist/components/auth/auth.js +4 -6
  31. package/dist/components/data/agenda-row.d.ts +26 -0
  32. package/dist/components/data/agenda-row.js +62 -0
  33. package/dist/components/data/day-strip.d.ts +22 -0
  34. package/dist/components/data/day-strip.js +68 -0
  35. package/dist/components/data/sectioned-list.d.ts +21 -6
  36. package/dist/components/data/sectioned-list.js +44 -21
  37. package/dist/components/data/task-row.d.ts +3 -0
  38. package/dist/components/data/task-row.js +6 -14
  39. package/dist/components/ui/avatar.js +1 -1
  40. package/dist/components/ui/kbd.js +1 -1
  41. package/dist/components/ui/password-input.d.ts +8 -0
  42. package/dist/components/ui/password-input.js +32 -0
  43. package/dist/index.d.ts +15 -5
  44. package/dist/index.js +13 -3
  45. package/dist/lib/date.js +8 -0
  46. package/dist/lib/toast-clearance.js +22 -0
  47. package/dist/styles.css +48 -0
  48. package/docs/COMPONENTS.md +165 -15
  49. package/docs/ROADMAP.md +12 -11
  50. package/llms.txt +8 -7
  51. package/package.json +1 -1
  52. package/skills/harebear-ui/SKILL.md +2 -1
@@ -0,0 +1,15 @@
1
+ import * as React from "react";
2
+ export interface MarkdownEditorProps extends Omit<React.ComponentProps<"textarea">, "value" | "defaultValue" | "onChange"> {
3
+ value: string;
4
+ onValueChange: (value: string) => void;
5
+ /**
6
+ * Turns the Markdown into elements for the Preview tab; the result is wrapped in Prose.
7
+ * With react-markdown: `(md) => <Markdown components={{ blockquote: ProseBlockquote }}>{md}</Markdown>`.
8
+ */
9
+ renderPreview: (markdown: string) => React.ReactNode;
10
+ /** Classes for the wrapping Tabs. `className` goes to the textarea. */
11
+ wrapperClassName?: string;
12
+ }
13
+ /** Markdown field with Write and Preview tabs. Other props go to the Textarea (id, placeholder, aria-*). */
14
+ declare function MarkdownEditor({ value, onValueChange, renderPreview, className, wrapperClassName, ...props }: MarkdownEditorProps): React.JSX.Element;
15
+ export { MarkdownEditor };
@@ -0,0 +1,42 @@
1
+ "use client";
2
+ import { Prose } from "../ai/code-block.js";
3
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs.js";
4
+ import { Textarea } from "../ui/textarea.js";
5
+ import { cn } from "../../lib/utils.js";
6
+ import { jsx, jsxs } from "react/jsx-runtime";
7
+ // src/components/admin/markdown-editor.tsx
8
+ /** Markdown field with Write and Preview tabs. Other props go to the Textarea (id, placeholder, aria-*). */
9
+ function MarkdownEditor({ value, onValueChange, renderPreview, className, wrapperClassName, ...props }) {
10
+ return /* @__PURE__ */ jsxs(Tabs, {
11
+ "data-slot": "markdown-editor",
12
+ defaultValue: "write",
13
+ className: cn("gap-3", wrapperClassName),
14
+ children: [
15
+ /* @__PURE__ */ jsxs(TabsList, { children: [/* @__PURE__ */ jsx(TabsTrigger, {
16
+ value: "write",
17
+ children: "Write"
18
+ }), /* @__PURE__ */ jsx(TabsTrigger, {
19
+ value: "preview",
20
+ children: "Preview"
21
+ })] }),
22
+ /* @__PURE__ */ jsx(TabsContent, {
23
+ value: "write",
24
+ children: /* @__PURE__ */ jsx(Textarea, {
25
+ value,
26
+ onChange: (e) => onValueChange(e.target.value),
27
+ className: cn("min-h-48", className),
28
+ ...props
29
+ })
30
+ }),
31
+ /* @__PURE__ */ jsx(TabsContent, {
32
+ value: "preview",
33
+ className: "min-h-48 rounded-md border px-3.5 py-3",
34
+ children: value.trim() ? /* @__PURE__ */ jsx(Prose, { children: renderPreview(value) }) : /* @__PURE__ */ jsx("p", {
35
+ className: "text-sm text-muted-foreground",
36
+ children: "Nothing to preview."
37
+ })
38
+ })
39
+ ]
40
+ });
41
+ }
42
+ export { MarkdownEditor };
@@ -1,14 +1,45 @@
1
+ import { CategoryColor } from "./category.js";
2
+ import { Avatar } from "../ui/avatar.js";
1
3
  import * as React from "react";
2
4
  /** Up to two initials from a name: "Maya Chen" -> "MC". */
3
5
  export declare function initials(name: string): string;
4
- export interface UserChipProps extends React.ComponentProps<"div"> {
6
+ export interface MemberAvatarProps extends Omit<React.ComponentProps<typeof Avatar>, "children" | "color"> {
7
+ name: string;
8
+ avatarUrl?: string;
9
+ /** Category colour behind the initials when there is no photo. Default: the soft primary tint. */
10
+ color?: CategoryColor;
11
+ }
12
+ /**
13
+ * A person's photo, or their initials on their category colour. It is an image named after the person; pass
14
+ * `aria-hidden` when the name is already shown next to it.
15
+ */
16
+ declare function MemberAvatar({ name, avatarUrl, color, ...props }: MemberAvatarProps): React.JSX.Element;
17
+ export interface AvatarStackProps extends React.ComponentProps<"div"> {
18
+ members: Pick<MemberAvatarProps, "name" | "avatarUrl" | "color">[];
19
+ /** Total slots, default 4. Past it, `max - 1` faces and a "+N" chip. */
20
+ max?: number;
21
+ }
22
+ /** Overlapping small avatars, e.g. who a list is shared with. Each face is named; the overflow reads "N more". */
23
+ declare function AvatarStack({ members, max, className, ...props }: AvatarStackProps): React.JSX.Element;
24
+ export interface VisibilityBadgeProps extends React.ComponentProps<"div"> {
25
+ visibility: "shared" | "private";
26
+ /** Faces shown when shared. */
27
+ members?: AvatarStackProps["members"];
28
+ /** Default "Shared with everyone" or "Only you". */
29
+ label?: React.ReactNode;
30
+ }
31
+ /** Who can see something: faces and "Shared with everyone", or a lock and "Only you". */
32
+ declare function VisibilityBadge({ visibility, members, label, className, ...props }: VisibilityBadgeProps): React.JSX.Element;
33
+ export interface UserChipProps extends Omit<React.ComponentProps<"div">, "color"> {
5
34
  name: string;
6
35
  subtitle?: React.ReactNode;
7
36
  avatarUrl?: string;
37
+ /** Category colour behind the initials when there is no photo. */
38
+ color?: CategoryColor;
8
39
  /** Trailing control, e.g. a menu button. */
9
40
  action?: React.ReactNode;
10
41
  size?: "sm" | "default" | "lg";
11
42
  }
12
43
  /** Avatar + name + secondary line. In a sidebar collapsed to its icon rail, only the avatar shows. */
13
- declare function UserChip({ name, subtitle, avatarUrl, action, size, className, ...props }: UserChipProps): React.JSX.Element;
14
- export { UserChip };
44
+ declare function UserChip({ name, subtitle, avatarUrl, color, action, size, className, ...props }: UserChipProps): React.JSX.Element;
45
+ export { AvatarStack, MemberAvatar, UserChip, VisibilityBadge };
@@ -1,25 +1,84 @@
1
1
  "use client";
2
+ import { categoryBg } from "./category.js";
2
3
  import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar.js";
3
4
  import { cn } from "../../lib/utils.js";
4
5
  import { jsx, jsxs } from "react/jsx-runtime";
6
+ import { LockIcon } from "lucide-react";
5
7
  // src/components/admin/user-chip.tsx
6
8
  /** Up to two initials from a name: "Maya Chen" -> "MC". */
7
9
  function initials(name) {
8
10
  return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]?.toUpperCase()).join("");
9
11
  }
12
+ /**
13
+ * A person's photo, or their initials on their category colour. It is an image named after the person; pass
14
+ * `aria-hidden` when the name is already shown next to it.
15
+ */
16
+ function MemberAvatar({ name, avatarUrl, color, ...props }) {
17
+ return /* @__PURE__ */ jsxs(Avatar, {
18
+ role: "img",
19
+ "aria-label": name,
20
+ "data-color": color,
21
+ ...props,
22
+ children: [avatarUrl && /* @__PURE__ */ jsx(AvatarImage, {
23
+ src: avatarUrl,
24
+ alt: ""
25
+ }), /* @__PURE__ */ jsx(AvatarFallback, {
26
+ "aria-hidden": "true",
27
+ className: cn(color && [categoryBg[color], "text-category-foreground"]),
28
+ children: initials(name)
29
+ })]
30
+ });
31
+ }
32
+ /** Overlapping small avatars, e.g. who a list is shared with. Each face is named; the overflow reads "N more". */
33
+ function AvatarStack({ members, max = 4, className, ...props }) {
34
+ const shown = members.length > max ? members.slice(0, max - 1) : members;
35
+ const extra = members.length - shown.length;
36
+ return /* @__PURE__ */ jsxs("div", {
37
+ "data-slot": "avatar-stack",
38
+ className: cn("flex -space-x-1.5", className),
39
+ ...props,
40
+ children: [shown.map((m, i) => /* @__PURE__ */ jsx(MemberAvatar, {
41
+ size: "sm",
42
+ ...m,
43
+ className: "ring-2 ring-background"
44
+ }, `${m.name}-${i}`)), extra > 0 && /* @__PURE__ */ jsxs("span", {
45
+ role: "img",
46
+ "aria-label": `${extra} more`,
47
+ className: "relative flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground ring-2 ring-background",
48
+ children: ["+", extra]
49
+ })]
50
+ });
51
+ }
52
+ /** Who can see something: faces and "Shared with everyone", or a lock and "Only you". */
53
+ function VisibilityBadge({ visibility, members, label, className, ...props }) {
54
+ const shared = visibility === "shared";
55
+ return /* @__PURE__ */ jsxs("div", {
56
+ "data-slot": "visibility-badge",
57
+ "data-visibility": visibility,
58
+ className: cn("inline-flex items-center gap-2 text-[13px] text-muted-foreground", className),
59
+ ...props,
60
+ children: [shared ? members?.length ? /* @__PURE__ */ jsx(AvatarStack, {
61
+ members,
62
+ "aria-hidden": "true"
63
+ }) : null : /* @__PURE__ */ jsx(LockIcon, {
64
+ "aria-hidden": "true",
65
+ className: "size-4 shrink-0"
66
+ }), /* @__PURE__ */ jsx("span", { children: label ?? (shared ? "Shared with everyone" : "Only you") })]
67
+ });
68
+ }
10
69
  /** Avatar + name + secondary line. In a sidebar collapsed to its icon rail, only the avatar shows. */
11
- function UserChip({ name, subtitle, avatarUrl, action, size = "default", className, ...props }) {
70
+ function UserChip({ name, subtitle, avatarUrl, color, action, size = "default", className, ...props }) {
12
71
  return /* @__PURE__ */ jsxs("div", {
13
72
  "data-slot": "user-chip",
14
73
  className: cn("flex min-w-0 items-center gap-3 group-data-[collapsible=icon]:justify-center", className),
15
74
  ...props,
16
75
  children: [
17
- /* @__PURE__ */ jsxs(Avatar, {
76
+ /* @__PURE__ */ jsx(MemberAvatar, {
77
+ name,
78
+ avatarUrl,
79
+ color,
18
80
  size,
19
- children: [avatarUrl && /* @__PURE__ */ jsx(AvatarImage, {
20
- src: avatarUrl,
21
- alt: ""
22
- }), /* @__PURE__ */ jsx(AvatarFallback, { children: initials(name) })]
81
+ "aria-hidden": "true"
23
82
  }),
24
83
  /* @__PURE__ */ jsxs("div", {
25
84
  className: "min-w-0 flex-1 leading-snug group-data-[collapsible=icon]:hidden",
@@ -38,4 +97,4 @@ function UserChip({ name, subtitle, avatarUrl, action, size = "default", classNa
38
97
  ]
39
98
  });
40
99
  }
41
- export { UserChip, initials };
100
+ export { AvatarStack, MemberAvatar, UserChip, VisibilityBadge, initials };
@@ -1,9 +1,12 @@
1
+ import { CategoryColor } from "./category.js";
1
2
  import * as React from "react";
2
3
  export interface UserMenuProps {
3
4
  name: string;
4
5
  /** Second line, e.g. the email or "Signed in". */
5
6
  subtitle?: React.ReactNode;
6
7
  avatarUrl?: string;
8
+ /** Category colour behind the initials when there is no photo. */
9
+ color?: CategoryColor;
7
10
  /** Extra items between the header and the theme choice, e.g. a Settings DropdownMenuItem. */
8
11
  children?: React.ReactNode;
9
12
  /** Show the Light / Dark / System choice. Default true. Needs ThemeProvider. */
@@ -18,5 +21,5 @@ export interface UserMenuProps {
18
21
  * The signed-in user as a menu button, for a sidebar footer or top bar: theme choice, app items such as
19
22
  * Settings, and sign out. In a sidebar icon rail the trigger is just the avatar and the menu opens to the side.
20
23
  */
21
- declare function UserMenu({ name, subtitle, avatarUrl, children, themeSwitch, onSignOut, signOutLabel, className }: UserMenuProps): React.JSX.Element;
24
+ declare function UserMenu({ name, subtitle, avatarUrl, color, children, themeSwitch, onSignOut, signOutLabel, className }: UserMenuProps): React.JSX.Element;
22
25
  export { UserMenu };
@@ -28,7 +28,7 @@ const themes = [
28
28
  * The signed-in user as a menu button, for a sidebar footer or top bar: theme choice, app items such as
29
29
  * Settings, and sign out. In a sidebar icon rail the trigger is just the avatar and the menu opens to the side.
30
30
  */
31
- function UserMenu({ name, subtitle, avatarUrl, children, themeSwitch = true, onSignOut, signOutLabel = "Sign out", className }) {
31
+ function UserMenu({ name, subtitle, avatarUrl, color, children, themeSwitch = true, onSignOut, signOutLabel = "Sign out", className }) {
32
32
  const { mode, setMode } = useTheme();
33
33
  const sidebar = useSidebarOptional();
34
34
  const rail = sidebar?.state === "collapsed" && !sidebar.isMobile;
@@ -40,6 +40,7 @@ function UserMenu({ name, subtitle, avatarUrl, children, themeSwitch = true, onS
40
40
  name,
41
41
  subtitle,
42
42
  avatarUrl,
43
+ color,
43
44
  className: "flex-1"
44
45
  }), /* @__PURE__ */ jsx(ChevronsUpDownIcon, { className: "size-4 shrink-0 text-muted-foreground group-data-[collapsible=icon]:hidden" })]
45
46
  }), /* @__PURE__ */ jsxs(DropdownMenuContent, {
@@ -18,10 +18,22 @@ export interface AskBoxProps extends Omit<React.ComponentProps<"form">, "onSubmi
18
18
  lang?: string;
19
19
  disabled?: boolean;
20
20
  autoFocus?: boolean;
21
+ /** The assistant is working: sending is blocked and, with `onStop`, the send button becomes Stop. */
22
+ pending?: boolean;
23
+ /** Stops the assistant. Shown as a Stop button while `pending`. */
24
+ onStop?: () => void;
25
+ /**
26
+ * The answer, shown under the box (text, Prose, ActionReceipts). Passing it, even `null`, turns the reply area on:
27
+ * while `pending` with no answer yet it shows a typing indicator. `useAssistant().reply` fits here.
28
+ */
29
+ reply?: React.ReactNode;
30
+ /** Link under the reply to continue in the full chat, labelled "Open chat". */
31
+ chatHref?: string;
21
32
  }
22
33
  /**
23
34
  * The large rounded input an assistant starts from: auto-growing text, chips while typing, suggestion pills and the
24
- * DictationButton (mic, stop, send). The focus ring is on the whole box.
35
+ * DictationButton (mic, stop, send). The focus ring is on the whole box. With `reply` the answer shows under the box;
36
+ * with `pending` and `onStop` the send button becomes Stop.
25
37
  */
26
- declare function AskBox({ value, onValueChange, onSubmit, placeholder, chips, suggestions, onSuggestion, dictation, lang, disabled, autoFocus, className, ...props }: AskBoxProps): React.JSX.Element;
38
+ declare function AskBox({ value, onValueChange, onSubmit, placeholder, chips, suggestions, onSuggestion, dictation, lang, disabled, autoFocus, pending, onStop, reply, chatHref, className, ...props }: AskBoxProps): React.JSX.Element;
27
39
  export { AskBox };
@@ -1,9 +1,11 @@
1
1
  "use client";
2
+ import { TypingIndicator } from "./chat.js";
2
3
  import { DictationButton } from "./dictation.js";
3
4
  import { Button } from "../ui/button.js";
4
5
  import { cn } from "../../lib/utils.js";
5
6
  import * as React from "react";
6
7
  import { jsx, jsxs } from "react/jsx-runtime";
8
+ import { ArrowRightIcon, SquareIcon } from "lucide-react";
7
9
  // src/components/ai/ask-box.tsx
8
10
  const noSpeech = {
9
11
  isSupported: () => false,
@@ -11,15 +13,20 @@ const noSpeech = {
11
13
  };
12
14
  /**
13
15
  * The large rounded input an assistant starts from: auto-growing text, chips while typing, suggestion pills and the
14
- * DictationButton (mic, stop, send). The focus ring is on the whole box.
16
+ * DictationButton (mic, stop, send). The focus ring is on the whole box. With `reply` the answer shows under the box;
17
+ * with `pending` and `onStop` the send button becomes Stop.
15
18
  */
16
- function AskBox({ value, onValueChange, onSubmit, placeholder = "Ask anything", chips, suggestions, onSuggestion, dictation, lang, disabled, autoFocus, className, ...props }) {
19
+ function AskBox({ value, onValueChange, onSubmit, placeholder = "Ask anything", chips, suggestions, onSuggestion, dictation, lang, disabled, autoFocus, pending, onStop, reply, chatHref, className, ...props }) {
17
20
  const id = React.useId();
18
21
  const submit = () => {
19
22
  const text = value.trim();
20
- if (text && !disabled) onSubmit(text);
23
+ if (!text || disabled || pending) return;
24
+ onSubmit(text);
25
+ document.getElementById(id)?.focus();
21
26
  };
22
27
  const hasText = value.trim().length > 0;
28
+ const hasReply = reply != null && reply !== false;
29
+ const showReply = hasReply || pending && reply !== void 0;
23
30
  return /* @__PURE__ */ jsxs("form", {
24
31
  "data-slot": "ask-box",
25
32
  className: cn("flex w-full min-w-0 flex-col gap-3", className),
@@ -28,58 +35,87 @@ function AskBox({ value, onValueChange, onSubmit, placeholder = "Ask anything",
28
35
  submit();
29
36
  },
30
37
  ...props,
31
- children: [/* @__PURE__ */ jsxs("div", {
32
- className: "focus-box flex flex-col gap-2 rounded-3xl border border-transparent bg-muted py-2.5 pr-2.5 pl-5",
33
- children: [/* @__PURE__ */ jsxs("div", {
34
- className: "flex items-end gap-2",
35
- children: [
36
- /* @__PURE__ */ jsx("label", {
37
- htmlFor: id,
38
- className: "sr-only",
39
- children: placeholder
40
- }),
41
- /* @__PURE__ */ jsx("textarea", {
42
- id,
43
- rows: 1,
44
- value,
45
- disabled,
46
- autoFocus,
47
- placeholder,
48
- onChange: (e) => onValueChange(e.target.value),
49
- onKeyDown: (e) => {
50
- if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
51
- e.preventDefault();
52
- submit();
53
- }
54
- },
55
- className: "field-sizing-content max-h-48 min-h-10 min-w-0 flex-1 resize-none border-0 bg-transparent py-2 text-base leading-6 text-foreground outline-none placeholder:text-muted-foreground"
56
- }),
57
- /* @__PURE__ */ jsx(DictationButton, {
58
- value,
59
- disabled,
60
- source: dictation === false ? noSpeech : dictation,
61
- lang,
62
- onTranscript: (text) => onValueChange(value ? `${value.replace(/\s+$/, "")} ${text}` : text)
38
+ children: [
39
+ /* @__PURE__ */ jsxs("div", {
40
+ className: "focus-box flex flex-col gap-2 rounded-3xl border border-transparent bg-muted py-2.5 pr-2.5 pl-5",
41
+ children: [/* @__PURE__ */ jsxs("div", {
42
+ className: "flex items-end gap-2",
43
+ children: [
44
+ /* @__PURE__ */ jsx("label", {
45
+ htmlFor: id,
46
+ className: "sr-only",
47
+ children: placeholder
48
+ }),
49
+ /* @__PURE__ */ jsx("textarea", {
50
+ id,
51
+ rows: 1,
52
+ value,
53
+ disabled,
54
+ autoFocus,
55
+ placeholder,
56
+ onChange: (e) => onValueChange(e.target.value),
57
+ onKeyDown: (e) => {
58
+ if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
59
+ e.preventDefault();
60
+ submit();
61
+ }
62
+ },
63
+ className: "field-sizing-content max-h-48 min-h-10 min-w-0 flex-1 resize-none border-0 bg-transparent py-2 text-base leading-6 text-foreground outline-none placeholder:text-muted-foreground"
64
+ }),
65
+ pending && onStop ? /* @__PURE__ */ jsx(Button, {
66
+ type: "button",
67
+ size: "icon",
68
+ shape: "pill",
69
+ className: "size-10 shrink-0",
70
+ "aria-label": "Stop",
71
+ onClick: () => {
72
+ onStop();
73
+ document.getElementById(id)?.focus();
74
+ },
75
+ children: /* @__PURE__ */ jsx(SquareIcon, { className: "size-3.5 fill-current" })
76
+ }) : /* @__PURE__ */ jsx(DictationButton, {
77
+ value,
78
+ disabled: disabled || pending,
79
+ source: dictation === false ? noSpeech : dictation,
80
+ lang,
81
+ onTranscript: (text) => onValueChange(value ? `${value.replace(/\s+$/, "")} ${text}` : text)
82
+ })
83
+ ]
84
+ }), hasText && chips && /* @__PURE__ */ jsx("div", {
85
+ "data-slot": "ask-box-chips",
86
+ className: "flex flex-wrap gap-1.5 pb-1",
87
+ children: chips
88
+ })]
89
+ }),
90
+ showReply && /* @__PURE__ */ jsxs("div", {
91
+ "data-slot": "ask-box-reply",
92
+ "aria-live": "polite",
93
+ "aria-busy": pending || void 0,
94
+ className: "flex min-w-0 flex-col gap-3 px-2 text-[15px] leading-relaxed",
95
+ children: [hasReply ? reply : /* @__PURE__ */ jsx(TypingIndicator, { label: "Working" }), chatHref && /* @__PURE__ */ jsx(Button, {
96
+ asChild: true,
97
+ variant: "link",
98
+ className: "self-start px-0 max-md:min-h-11",
99
+ children: /* @__PURE__ */ jsxs("a", {
100
+ href: chatHref,
101
+ children: ["Open chat", /* @__PURE__ */ jsx(ArrowRightIcon, {})]
63
102
  })
64
- ]
65
- }), hasText && chips && /* @__PURE__ */ jsx("div", {
66
- "data-slot": "ask-box-chips",
67
- className: "flex flex-wrap gap-1.5 pb-1",
68
- children: chips
69
- })]
70
- }), suggestions && suggestions.length > 0 && /* @__PURE__ */ jsx("div", {
71
- "data-slot": "ask-box-suggestions",
72
- className: "flex flex-wrap gap-2",
73
- children: suggestions.map((s) => /* @__PURE__ */ jsx(Button, {
74
- type: "button",
75
- variant: "outline",
76
- size: "sm",
77
- shape: "pill",
78
- disabled,
79
- onClick: () => onSuggestion ? onSuggestion(s) : onSubmit(s),
80
- children: s
81
- }, s))
82
- })]
103
+ })]
104
+ }),
105
+ suggestions && suggestions.length > 0 && /* @__PURE__ */ jsx("div", {
106
+ "data-slot": "ask-box-suggestions",
107
+ className: "flex flex-wrap gap-2",
108
+ children: suggestions.map((s) => /* @__PURE__ */ jsx(Button, {
109
+ type: "button",
110
+ variant: "outline",
111
+ size: "sm",
112
+ shape: "pill",
113
+ disabled,
114
+ onClick: () => onSuggestion ? onSuggestion(s) : onSubmit(s),
115
+ children: s
116
+ }, s))
117
+ })
118
+ ]
83
119
  });
84
120
  }
85
121
  export { AskBox };
@@ -25,6 +25,12 @@ export interface AssistantSendContext {
25
25
  }) => string;
26
26
  /** Replace a message's content, e.g. while a reply streams in. */
27
27
  update: (id: string, content: React.ReactNode) => void;
28
+ /** Aborted when the user presses Stop. Pass it to `fetch`, or listen for `abort` to tell your server to stop. */
29
+ signal: AbortSignal;
30
+ }
31
+ export interface AssistantSendOptions {
32
+ /** Open the dock. Default true; false for an AskBox that shows the answer inline. */
33
+ open?: boolean;
28
34
  }
29
35
  export interface AssistantContextValue {
30
36
  open: boolean;
@@ -33,10 +39,14 @@ export interface AssistantContextValue {
33
39
  messages: AssistantMessage[];
34
40
  /** True while the send handler runs. */
35
41
  pending: boolean;
42
+ /** The assistant messages after the latest user message, ready for `AskBox` `reply`; null until there is one. */
43
+ reply: React.ReactNode;
36
44
  page: AssistantPage | null;
37
45
  setPage: (page: AssistantPage | null) => void;
38
- /** Adds the user message, opens the dock and runs the app's onSend. */
39
- send: (text: string) => Promise<void>;
46
+ /** Adds the user message, opens the dock (unless `{ open: false }`) and runs the app's onSend. */
47
+ send: (text: string, options?: AssistantSendOptions) => Promise<void>;
48
+ /** Stops the running send: aborts its `signal`, ends `pending` and drops its return value and later `append`/`update` calls. */
49
+ stop: () => void;
40
50
  append: AssistantSendContext["append"];
41
51
  update: AssistantSendContext["update"];
42
52
  clear: () => void;
@@ -44,7 +54,7 @@ export interface AssistantContextValue {
44
54
  export interface AssistantProviderProps {
45
55
  /**
46
56
  * The app's send handler. Return the reply (text or nodes) to append it, or call `append` / `update` yourself
47
- * (for streaming or receipts) and return nothing. A thrown error is shown as the reply.
57
+ * (for streaming or receipts) and return nothing. A thrown error is shown as the reply. Stop aborts `signal`.
48
58
  */
49
59
  onSend: (text: string, context: AssistantSendContext) => React.ReactNode | Promise<React.ReactNode>;
50
60
  initialMessages?: AssistantMessage[];
@@ -2,6 +2,7 @@
2
2
  import { AskBox } from "./ask-box.js";
3
3
  import { AssistantAvatar, ChatMessage, ChatThread, TypingIndicator } from "./chat.js";
4
4
  import { Button } from "../ui/button.js";
5
+ import { useToastClearance } from "../../lib/toast-clearance.js";
5
6
  import { cn } from "../../lib/utils.js";
6
7
  import * as React from "react";
7
8
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -25,6 +26,7 @@ function AssistantProvider({ onSend, initialMessages = [], defaultOpen = false,
25
26
  pageRef.current = page;
26
27
  const onSendRef = React.useRef(onSend);
27
28
  onSendRef.current = onSend;
29
+ const runRef = React.useRef(null);
28
30
  const append = React.useCallback((m) => {
29
31
  const id = m.id ?? newId();
30
32
  setMessages((list) => [...list, {
@@ -39,43 +41,66 @@ function AssistantProvider({ onSend, initialMessages = [], defaultOpen = false,
39
41
  content
40
42
  } : m));
41
43
  }, []);
42
- const send = React.useCallback(async (text) => {
44
+ const send = React.useCallback(async (text, options) => {
43
45
  const before = messagesRef.current;
46
+ runRef.current?.abort();
47
+ const run = new AbortController();
48
+ runRef.current = run;
44
49
  append({
45
50
  role: "user",
46
51
  content: text
47
52
  });
48
- setOpen(true);
53
+ if (options?.open !== false) setOpen(true);
49
54
  setPending(true);
50
55
  try {
51
56
  const reply = await onSendRef.current(text, {
52
57
  page: pageRef.current,
53
58
  messages: before,
54
- append,
55
- update
59
+ append: (m) => run.signal.aborted ? "" : append(m),
60
+ update: (id, content) => {
61
+ if (!run.signal.aborted) update(id, content);
62
+ },
63
+ signal: run.signal
56
64
  });
65
+ if (run.signal.aborted) return;
57
66
  if (reply !== void 0 && reply !== null && reply !== false) append({
58
67
  role: "assistant",
59
68
  content: reply
60
69
  });
61
70
  } catch (e) {
71
+ if (run.signal.aborted) return;
62
72
  append({
63
73
  role: "assistant",
64
74
  content: e instanceof Error ? e.message : "Something went wrong."
65
75
  });
66
76
  } finally {
67
- setPending(false);
77
+ if (runRef.current === run) {
78
+ runRef.current = null;
79
+ setPending(false);
80
+ }
68
81
  }
69
82
  }, [append, update]);
83
+ const stop = React.useCallback(() => {
84
+ runRef.current?.abort();
85
+ runRef.current = null;
86
+ setPending(false);
87
+ }, []);
88
+ const reply = React.useMemo(() => {
89
+ const start = messages.findLastIndex((m) => m.role === "user");
90
+ const answer = start === -1 ? [] : messages.slice(start + 1);
91
+ return answer.length ? answer.map((m) => /* @__PURE__ */ jsx("div", { children: m.content }, m.id)) : null;
92
+ }, [messages]);
70
93
  const value = React.useMemo(() => ({
71
94
  open,
72
95
  setOpen,
73
96
  toggle: () => setOpen((v) => !v),
74
97
  messages,
75
98
  pending,
99
+ reply,
76
100
  page,
77
101
  setPage,
78
102
  send,
103
+ stop,
79
104
  append,
80
105
  update,
81
106
  clear: () => setMessages([])
@@ -83,8 +108,10 @@ function AssistantProvider({ onSend, initialMessages = [], defaultOpen = false,
83
108
  open,
84
109
  messages,
85
110
  pending,
111
+ reply,
86
112
  page,
87
113
  send,
114
+ stop,
88
115
  append,
89
116
  update
90
117
  ]);
@@ -118,7 +145,7 @@ function useAssistantPage(page) {
118
145
  * Sits above AppNav's phone tab bar.
119
146
  */
120
147
  function AssistantDock({ title = "Assistant", icon = /* @__PURE__ */ jsx(SparklesIcon, {}), fullScreenHref, emptyTitle = "How can I help?", emptyDescription, suggestions, placeholder, dictation, launcher = true, launcherLabel, className, ...props }) {
121
- const { open, setOpen, messages, pending, page, send } = useAssistant();
148
+ const { open, setOpen, messages, pending, page, send, stop } = useAssistant();
122
149
  const [draft, setDraft] = React.useState("");
123
150
  const titleId = React.useId();
124
151
  const panelRef = React.useRef(null);
@@ -136,6 +163,7 @@ function AssistantDock({ title = "Assistant", icon = /* @__PURE__ */ jsx(Sparkle
136
163
  panelRef.current?.querySelector("textarea")?.focus();
137
164
  send(text);
138
165
  };
166
+ useToastClearance("--hb-toast-launcher", "3.5rem", !open && launcher);
139
167
  if (!open) {
140
168
  if (!launcher) return null;
141
169
  return /* @__PURE__ */ jsxs("button", {
@@ -220,7 +248,9 @@ function AssistantDock({ title = "Assistant", icon = /* @__PURE__ */ jsx(Sparkle
220
248
  onSubmit: submit,
221
249
  placeholder: placeholder ?? `Ask ${title}`,
222
250
  suggestions: messages.length === 0 ? pills : void 0,
223
- dictation
251
+ dictation,
252
+ pending,
253
+ onStop: stop
224
254
  })
225
255
  })
226
256
  ]
@@ -22,10 +22,17 @@ export interface InlineCodeProps extends React.ComponentProps<"code"> {
22
22
  truncate?: number;
23
23
  }
24
24
  declare function InlineCode({ truncate, className, children, title, ...props }: InlineCodeProps): React.JSX.Element;
25
+ /**
26
+ * A blockquote that becomes a `Callout` when its first word is "Tip:" (the word is dropped).
27
+ * Prose applies it to blockquote children; pass it to react-markdown as `components={{ blockquote: ProseBlockquote }}`.
28
+ */
29
+ declare function ProseBlockquote({ children, node: _node, ...props }: React.ComponentProps<"blockquote"> & {
30
+ node?: unknown;
31
+ }): React.JSX.Element;
25
32
  /**
26
33
  * Typography for long-form and model output. Styles plain HTML children
27
34
  * (p, ul, ol, li, strong, a, code, pre, h1-h4, blockquote, table), so it works
28
- * with react-markdown or any HTML renderer.
35
+ * with react-markdown or any HTML renderer. A blockquote starting "Tip:" renders as a Callout.
29
36
  */
30
- declare function Prose({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
31
- export { CodeSnippet, InlineCode, Prose };
37
+ declare function Prose({ className, children, ...props }: React.ComponentProps<"div">): React.JSX.Element;
38
+ export { CodeSnippet, InlineCode, Prose, ProseBlockquote };