harebear-ui 0.2.2 → 0.3.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 (40) hide show
  1. package/dist/components/admin/app-shell.d.ts +4 -1
  2. package/dist/components/admin/app-shell.js +49 -46
  3. package/dist/components/admin/confirm-dialog.d.ts +17 -0
  4. package/dist/components/admin/confirm-dialog.js +20 -0
  5. package/dist/components/admin/copy-field.d.ts +17 -0
  6. package/dist/components/admin/copy-field.js +37 -0
  7. package/dist/components/admin/search-input.js +1 -1
  8. package/dist/components/admin/status.d.ts +12 -6
  9. package/dist/components/admin/status.js +8 -5
  10. package/dist/components/admin/user-chip.d.ts +1 -1
  11. package/dist/components/admin/user-chip.js +7 -4
  12. package/dist/components/ai/approval.d.ts +30 -0
  13. package/dist/components/ai/approval.js +81 -0
  14. package/dist/components/ai/chat-shell.d.ts +53 -0
  15. package/dist/components/ai/chat-shell.js +201 -0
  16. package/dist/components/ai/chat.d.ts +11 -3
  17. package/dist/components/ai/chat.js +21 -15
  18. package/dist/components/ai/code-block.js +1 -1
  19. package/dist/components/ai/conversation-list.d.ts +36 -3
  20. package/dist/components/ai/conversation-list.js +122 -13
  21. package/dist/components/ai/prompt-input.d.ts +28 -4
  22. package/dist/components/ai/prompt-input.js +52 -4
  23. package/dist/components/ai/tool-steps.d.ts +41 -0
  24. package/dist/components/ai/tool-steps.js +124 -0
  25. package/dist/components/ui/alert.d.ts +1 -1
  26. package/dist/components/ui/alert.js +5 -2
  27. package/dist/components/ui/badge.d.ts +2 -1
  28. package/dist/components/ui/badge.js +27 -14
  29. package/dist/components/ui/item.js +2 -2
  30. package/dist/components/ui/sidebar.d.ts +3 -1
  31. package/dist/components/ui/sidebar.js +7 -3
  32. package/dist/hooks/use-media-query.d.ts +2 -0
  33. package/dist/hooks/use-media-query.js +12 -0
  34. package/dist/index.d.ts +12 -6
  35. package/dist/index.js +10 -4
  36. package/dist/styles.css +24 -0
  37. package/docs/COMPONENTS.md +141 -72
  38. package/llms.txt +3 -3
  39. package/package.json +1 -1
  40. package/skills/harebear-ui/SKILL.md +4 -1
@@ -0,0 +1,201 @@
1
+ "use client";
2
+ import { ChatThread } from "./chat.js";
3
+ import { Alert, AlertDescription } from "../ui/alert.js";
4
+ import { Button } from "../ui/button.js";
5
+ import { Sheet, SheetContent, SheetHeader, SheetTitle } from "../ui/sheet.js";
6
+ import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarInset, SidebarProvider, SidebarTrigger, useSidebar, useSidebarOptional } from "../ui/sidebar.js";
7
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip.js";
8
+ import { useMediaQuery } from "../../hooks/use-media-query.js";
9
+ import { cn } from "../../lib/utils.js";
10
+ import * as React from "react";
11
+ import { jsx, jsxs } from "react/jsx-runtime";
12
+ import { PanelLeftCloseIcon, PanelLeftOpenIcon, PanelRightIcon, TriangleAlertIcon } from "lucide-react";
13
+ // src/components/ai/chat-shell.tsx
14
+ const WIDE = "(min-width: 1280px)";
15
+ const ChatShellContext = React.createContext(null);
16
+ function readStored(key) {
17
+ try {
18
+ const v = window.localStorage.getItem(key);
19
+ return v === null ? null : v !== "collapsed";
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+ const useIsomorphicLayoutEffect = typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;
25
+ /**
26
+ * Full-height chat app layout: a sidebar that collapses to an icon rail (a sheet on mobile), the main chat
27
+ * column, and an optional details panel. Put a ChatPanel inside.
28
+ */
29
+ function ChatShell({ brand, sidebarActions, sidebar, sidebarFooter, details, detailsTitle = "Details", defaultDetailsOpen = true, storageKey, defaultOpen = true, className, children }) {
30
+ const [open, setOpen] = React.useState(defaultOpen);
31
+ useIsomorphicLayoutEffect(() => {
32
+ if (!storageKey) return;
33
+ const stored = readStored(storageKey);
34
+ if (stored !== null) setOpen(stored);
35
+ }, [storageKey]);
36
+ const onOpenChange = (next) => {
37
+ setOpen(next);
38
+ if (!storageKey) return;
39
+ try {
40
+ window.localStorage.setItem(storageKey, next ? "open" : "collapsed");
41
+ } catch {}
42
+ };
43
+ const wide = useMediaQuery(WIDE);
44
+ const [inline, setInline] = React.useState(defaultDetailsOpen);
45
+ const [sheet, setSheet] = React.useState(false);
46
+ const hasDetails = details !== void 0 && details !== null && details !== false;
47
+ const ctx = React.useMemo(() => hasDetails ? {
48
+ detailsTitle,
49
+ detailsOpen: wide ? inline : sheet,
50
+ toggleDetails: () => wide ? setInline((v) => !v) : setSheet((v) => !v)
51
+ } : null, [
52
+ hasDetails,
53
+ detailsTitle,
54
+ wide,
55
+ inline,
56
+ sheet
57
+ ]);
58
+ return /* @__PURE__ */ jsx(TooltipProvider, { children: /* @__PURE__ */ jsx(SidebarProvider, {
59
+ open,
60
+ onOpenChange,
61
+ style: {
62
+ "--sidebar-width": "17rem",
63
+ "--sidebar-width-icon": "4.25rem"
64
+ },
65
+ children: /* @__PURE__ */ jsxs(ChatShellContext.Provider, {
66
+ value: ctx,
67
+ children: [/* @__PURE__ */ jsxs(Sidebar, {
68
+ collapsible: "icon",
69
+ children: [
70
+ /* @__PURE__ */ jsxs(SidebarHeader, {
71
+ className: "gap-5 px-3.5 pt-[max(1.25rem,env(safe-area-inset-top))] group-data-[collapsible=icon]:items-center group-data-[collapsible=icon]:gap-3 group-data-[collapsible=icon]:px-0",
72
+ children: [/* @__PURE__ */ jsxs("div", {
73
+ className: "flex min-w-0 items-center gap-2 group-data-[collapsible=icon]:flex-col group-data-[collapsible=icon]:gap-3",
74
+ children: [brand && /* @__PURE__ */ jsx("div", {
75
+ className: "min-w-0 flex-1 pl-2 group-data-[collapsible=icon]:pl-0",
76
+ children: brand
77
+ }), /* @__PURE__ */ jsx(SidebarCollapseButton, {})]
78
+ }), sidebarActions && /* @__PURE__ */ jsx("div", {
79
+ className: "flex flex-col gap-2.5 group-data-[collapsible=icon]:items-center",
80
+ children: sidebarActions
81
+ })]
82
+ }),
83
+ /* @__PURE__ */ jsx(SidebarContent, {
84
+ className: "px-3.5 pt-2",
85
+ children: sidebar
86
+ }),
87
+ sidebarFooter && /* @__PURE__ */ jsx(SidebarFooter, {
88
+ className: "px-3.5 pb-[max(1.25rem,env(safe-area-inset-bottom))] group-data-[collapsible=icon]:px-0",
89
+ children: /* @__PURE__ */ jsx("div", {
90
+ className: "px-2 group-data-[collapsible=icon]:px-0",
91
+ children: sidebarFooter
92
+ })
93
+ })
94
+ ]
95
+ }), /* @__PURE__ */ jsxs(SidebarInset, {
96
+ "data-slot": "chat-shell",
97
+ className: cn("h-dvh min-w-0 overflow-hidden", className),
98
+ children: [/* @__PURE__ */ jsxs("div", {
99
+ className: "flex h-full min-h-0 gap-3 p-2 sm:p-3 md:pl-0",
100
+ children: [children, hasDetails && wide && inline && /* @__PURE__ */ jsx("aside", {
101
+ "data-slot": "chat-shell-details",
102
+ "aria-label": detailsTitle,
103
+ className: "flex w-75 shrink-0 flex-col gap-3 overflow-y-auto",
104
+ children: details
105
+ })]
106
+ }), hasDetails && !wide && /* @__PURE__ */ jsx(Sheet, {
107
+ open: sheet,
108
+ onOpenChange: setSheet,
109
+ children: /* @__PURE__ */ jsxs(SheetContent, {
110
+ className: "overflow-y-auto bg-background p-4",
111
+ children: [/* @__PURE__ */ jsx(SheetHeader, {
112
+ className: "p-0",
113
+ children: /* @__PURE__ */ jsx(SheetTitle, { children: detailsTitle })
114
+ }), /* @__PURE__ */ jsx("div", {
115
+ className: "flex flex-col gap-3",
116
+ children: details
117
+ })]
118
+ })
119
+ })]
120
+ })]
121
+ })
122
+ }) });
123
+ }
124
+ /** Collapse / expand control for the desktop sidebar, with a tooltip. Hidden on mobile, where the sidebar is a sheet. */
125
+ function SidebarCollapseButton() {
126
+ const { state, isMobile, toggleSidebar } = useSidebar();
127
+ if (isMobile) return null;
128
+ const label = state === "collapsed" ? "Expand sidebar" : "Collapse sidebar";
129
+ return /* @__PURE__ */ jsxs(Tooltip, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
130
+ asChild: true,
131
+ children: /* @__PURE__ */ jsx(Button, {
132
+ variant: "ghost",
133
+ size: state === "collapsed" ? "icon" : "icon-sm",
134
+ "aria-label": label,
135
+ onClick: toggleSidebar,
136
+ children: state === "collapsed" ? /* @__PURE__ */ jsx(PanelLeftOpenIcon, {}) : /* @__PURE__ */ jsx(PanelLeftCloseIcon, {})
137
+ })
138
+ }), /* @__PURE__ */ jsx(TooltipContent, {
139
+ side: "right",
140
+ children: label
141
+ })] });
142
+ }
143
+ /**
144
+ * The chat column as a card: header, optional notice, scrolling thread, composer. Inside ChatShell it adds the
145
+ * mobile sidebar button and the details toggle to the header by itself.
146
+ */
147
+ function ChatPanel({ title, description, actions, notice, composer, conversationId, autoScroll, className, children, ...props }) {
148
+ const sidebar = useSidebarOptional();
149
+ const shell = React.useContext(ChatShellContext);
150
+ return /* @__PURE__ */ jsxs("section", {
151
+ "data-slot": "chat-panel",
152
+ className: cn("flex min-h-0 min-w-0 flex-1 flex-col rounded-xl border border-border bg-card shadow-card", className),
153
+ ...props,
154
+ children: [
155
+ /* @__PURE__ */ jsxs("header", {
156
+ className: "flex flex-wrap items-center gap-2.5 px-3.5 pt-[max(0.75rem,env(safe-area-inset-top))] pb-3 sm:pr-4 sm:pl-6",
157
+ children: [
158
+ sidebar && /* @__PURE__ */ jsx(SidebarTrigger, { className: "md:hidden" }),
159
+ /* @__PURE__ */ jsxs("div", {
160
+ className: "flex min-w-0 flex-1 basis-40 flex-col leading-snug",
161
+ children: [title && /* @__PURE__ */ jsx("h1", {
162
+ className: "truncate text-[15px] font-semibold",
163
+ children: title
164
+ }), description && /* @__PURE__ */ jsx("span", {
165
+ className: "truncate text-xs text-muted-foreground",
166
+ children: description
167
+ })]
168
+ }),
169
+ actions,
170
+ shell && /* @__PURE__ */ jsx(Button, {
171
+ variant: "ghost",
172
+ size: "icon",
173
+ "aria-label": `Toggle ${shell.detailsTitle.toLowerCase()}`,
174
+ "aria-pressed": shell.detailsOpen,
175
+ onClick: shell.toggleDetails,
176
+ children: /* @__PURE__ */ jsx(PanelRightIcon, {})
177
+ })
178
+ ]
179
+ }),
180
+ notice && /* @__PURE__ */ jsx("div", {
181
+ className: "px-3.5 pb-1 sm:px-6",
182
+ children: /* @__PURE__ */ jsxs(Alert, {
183
+ variant: "warning",
184
+ role: "status",
185
+ children: [/* @__PURE__ */ jsx(TriangleAlertIcon, {}), /* @__PURE__ */ jsx(AlertDescription, { children: notice })]
186
+ })
187
+ }),
188
+ /* @__PURE__ */ jsx(ChatThread, {
189
+ autoScroll,
190
+ className: "px-3.5 pt-4 sm:px-6",
191
+ contentClassName: "gap-7",
192
+ children
193
+ }, conversationId),
194
+ composer && /* @__PURE__ */ jsx("div", {
195
+ className: "px-3.5 pt-3 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6",
196
+ children: composer
197
+ })
198
+ ]
199
+ });
200
+ }
201
+ export { ChatPanel, ChatShell };
@@ -14,9 +14,11 @@ export interface ChatMessageProps extends React.ComponentProps<"div"> {
14
14
  avatar?: React.ReactNode;
15
15
  /** Rendered under assistant content, usually <MessageActions />. */
16
16
  actions?: React.ReactNode;
17
+ /** User bubble color: "muted" (gray, default) or "soft" (tinted with the accent). */
18
+ variant?: "muted" | "soft";
17
19
  }
18
20
  /** One chat turn. User: right-aligned bubble. Assistant: avatar + free-flowing content. */
19
- declare function ChatMessage({ role, avatar, actions, className, children, ...props }: ChatMessageProps): React.JSX.Element;
21
+ declare function ChatMessage({ role, avatar, actions, variant, className, children, ...props }: ChatMessageProps): React.JSX.Element;
20
22
  /** Tinted circle with an icon. Default assistant avatar. */
21
23
  declare function AssistantAvatar({ icon, className }: {
22
24
  icon?: React.ReactNode;
@@ -32,8 +34,14 @@ export interface MessageActionsProps extends React.ComponentProps<"div"> {
32
34
  }
33
35
  /** Copy / regenerate / thumbs row under an assistant message. Add more buttons as children. */
34
36
  declare function MessageActions({ copyText, onRegenerate, onFeedback, feedback, className, children, ...props }: MessageActionsProps): React.JSX.Element;
35
- /** Three pulsing dots while the assistant is generating. */
36
- declare function TypingIndicator({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
37
+ export interface TypingIndicatorProps extends React.ComponentProps<"div"> {
38
+ /** Text after the dots, e.g. "Working". Also used as the accessible label. */
39
+ label?: React.ReactNode;
40
+ /** Dot color: "muted" (default) or "primary". */
41
+ variant?: "muted" | "primary";
42
+ }
43
+ /** Three pulsing dots while the assistant is generating, with an optional label. */
44
+ declare function TypingIndicator({ label, variant, className, ...props }: TypingIndicatorProps): React.JSX.Element;
37
45
  export interface ToolCallProps extends React.ComponentProps<typeof Badge> {
38
46
  /** "running" shows a spinner; "error" tints red. */
39
47
  status?: "running" | "done" | "error";
@@ -42,14 +42,14 @@ function ChatThread({ autoScroll = true, className, contentClassName, children,
42
42
  });
43
43
  }
44
44
  /** One chat turn. User: right-aligned bubble. Assistant: avatar + free-flowing content. */
45
- function ChatMessage({ role, avatar, actions, className, children, ...props }) {
45
+ function ChatMessage({ role, avatar, actions, variant = "muted", className, children, ...props }) {
46
46
  if (role === "user") return /* @__PURE__ */ jsx("div", {
47
47
  "data-slot": "chat-message",
48
48
  "data-role": "user",
49
49
  className: cn("flex justify-end", className),
50
50
  ...props,
51
51
  children: /* @__PURE__ */ jsx("div", {
52
- className: "max-w-[80%] rounded-[calc(var(--radius-xl)*1.2)] rounded-br-sm bg-muted px-4.5 py-3 text-[15px] whitespace-pre-wrap",
52
+ className: cn("max-w-[80%] min-w-0 rounded-[calc(var(--radius-xl)*1.2)] rounded-br-sm px-4.5 py-3 text-[15px] whitespace-pre-wrap wrap-anywhere", variant === "soft" ? "bg-primary-soft" : "bg-muted"),
53
53
  children
54
54
  })
55
55
  });
@@ -119,22 +119,28 @@ function MessageActions({ copyText, onRegenerate, onFeedback, feedback, classNam
119
119
  ]
120
120
  });
121
121
  }
122
- /** Three pulsing dots while the assistant is generating. */
123
- function TypingIndicator({ className, ...props }) {
124
- return /* @__PURE__ */ jsx("div", {
122
+ /** Three pulsing dots while the assistant is generating, with an optional label. */
123
+ function TypingIndicator({ label, variant = "muted", className, ...props }) {
124
+ return /* @__PURE__ */ jsxs("div", {
125
125
  "data-slot": "typing-indicator",
126
126
  role: "status",
127
- "aria-label": "Assistant is typing",
128
- className: cn("flex gap-1.25 py-1", className),
127
+ "aria-label": typeof label === "string" ? label : "Assistant is typing",
128
+ className: cn("flex items-center gap-2 py-1", className),
129
129
  ...props,
130
- children: [
131
- 0,
132
- 150,
133
- 300
134
- ].map((d) => /* @__PURE__ */ jsx("span", {
135
- className: "inline-block size-1.5 animate-typing rounded-full bg-muted-foreground",
136
- style: { animationDelay: `${d}ms` }
137
- }, d))
130
+ children: [/* @__PURE__ */ jsx("span", {
131
+ className: "flex gap-1.25",
132
+ children: [
133
+ 0,
134
+ 150,
135
+ 300
136
+ ].map((d) => /* @__PURE__ */ jsx("span", {
137
+ className: cn("inline-block size-1.5 animate-typing rounded-full", variant === "primary" ? "bg-primary" : "bg-muted-foreground"),
138
+ style: { animationDelay: `${d}ms` }
139
+ }, d))
140
+ }), label && /* @__PURE__ */ jsx("span", {
141
+ className: "text-xs text-muted-foreground",
142
+ children: label
143
+ })]
138
144
  });
139
145
  }
140
146
  /** Outlined chip summarizing a tool call, e.g. "Read 2 notes from homelab/". */
@@ -56,7 +56,7 @@ function InlineCode({ className, ...props }) {
56
56
  function Prose({ className, ...props }) {
57
57
  return /* @__PURE__ */ jsx("div", {
58
58
  "data-slot": "prose",
59
- className: cn("flex flex-col gap-3.5 text-[15px] leading-[1.65] text-foreground", "[&_ol]:flex [&_ol]:list-decimal [&_ol]:flex-col [&_ol]:gap-2 [&_ol]:pl-5.5", "[&_ul]:flex [&_ul]:list-disc [&_ul]:flex-col [&_ul]:gap-2 [&_ul]:pl-5.5", "[&_strong]:font-semibold [&_a]:text-primary [&_a]:underline-offset-4 hover:[&_a]:underline", "[&_h1]:text-xl [&_h1]:font-semibold [&_h2]:text-lg [&_h2]:font-semibold [&_h3]:font-semibold [&_h4]:font-semibold", "[&_blockquote]:border-l-2 [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground", "[&_:not(pre)>code]:rounded-[6px] [&_:not(pre)>code]:bg-muted [&_:not(pre)>code]:px-1.5 [&_:not(pre)>code]:py-0.5 [&_:not(pre)>code]:font-mono [&_:not(pre)>code]:text-[12.5px]", "[&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-muted [&_pre]:p-4.5 [&_pre]:font-mono [&_pre]:text-[13px]", "[&_table]:w-full [&_table]:text-sm [&_td]:border-b [&_td]:py-2 [&_th]:border-b [&_th]:py-2 [&_th]:text-left [&_th]:font-medium", className),
59
+ className: cn("flex min-w-0 flex-col gap-3.5 text-[15px] leading-[1.65] wrap-anywhere text-foreground", "[&_ol]:flex [&_ol]:list-decimal [&_ol]:flex-col [&_ol]:gap-2 [&_ol]:pl-5.5", "[&_ul]:flex [&_ul]:list-disc [&_ul]:flex-col [&_ul]:gap-2 [&_ul]:pl-5.5", "[&_strong]:font-semibold [&_a]:text-primary [&_a]:underline-offset-4 hover:[&_a]:underline", "[&_h1]:text-xl [&_h1]:font-semibold [&_h2]:text-lg [&_h2]:font-semibold [&_h3]:font-semibold [&_h4]:font-semibold", "[&_blockquote]:border-l-2 [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground", "[&_:not(pre)>code]:rounded-[6px] [&_:not(pre)>code]:bg-muted [&_:not(pre)>code]:px-1.5 [&_:not(pre)>code]:py-0.5 [&_:not(pre)>code]:font-mono [&_:not(pre)>code]:text-[12.5px]", "[&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-muted [&_pre]:p-4.5 [&_pre]:font-mono [&_pre]:text-[13px]", "[&_table]:w-full [&_table]:text-sm [&_td]:border-b [&_td]:py-2 [&_th]:border-b [&_th]:py-2 [&_th]:text-left [&_th]:font-medium", className),
60
60
  ...props
61
61
  });
62
62
  }
@@ -1,7 +1,15 @@
1
+ import { StatusTone } from "../admin/status.js";
2
+ import { Button } from "../ui/button.js";
1
3
  import * as React from "react";
2
4
  export interface Conversation {
3
5
  id: string;
4
6
  title: string;
7
+ /** Second line under the title, e.g. the last-updated time or "Working". */
8
+ description?: React.ReactNode;
9
+ /** Shows a status dot before the description, for live states. */
10
+ tone?: StatusTone;
11
+ /** Pulse the status dot. */
12
+ pulse?: boolean;
5
13
  }
6
14
  export interface ConversationGroup {
7
15
  /** e.g. "Today", "Yesterday", "Previous 7 days". */
@@ -12,7 +20,32 @@ export interface ConversationListProps extends Omit<React.ComponentProps<"nav">,
12
20
  groups: ConversationGroup[];
13
21
  activeId?: string;
14
22
  onSelect?: (id: string) => void;
23
+ /** Trailing control per row, e.g. a delete button. On devices that can hover it shows on hover and focus; on touch it is always visible. */
24
+ renderAction?: (conversation: Conversation) => React.ReactNode;
25
+ /** Shown when there are no conversations. Default "No conversations yet". */
26
+ empty?: React.ReactNode;
15
27
  }
16
- /** Grouped list of chat threads for a sidebar. */
17
- declare function ConversationList({ groups, activeId, onSelect, className, ...props }: ConversationListProps): React.JSX.Element;
18
- export { ConversationList };
28
+ /**
29
+ * Grouped list of chat threads for a sidebar. Inside a Sidebar it closes the mobile sheet on select,
30
+ * and hides itself when the sidebar is collapsed to its icon rail.
31
+ */
32
+ declare function ConversationList({ groups, activeId, onSelect, renderAction, empty, className, ...props }: ConversationListProps): React.JSX.Element;
33
+ /**
34
+ * Buckets items into Today / Yesterday / Previous 7 days / Earlier by a timestamp (ms or Date),
35
+ * newest first, and drops empty groups. Pass `now` for deterministic tests.
36
+ */
37
+ declare function groupByDate<T>(items: T[], getDate: (item: T) => number | Date, toConversation: (item: T) => Conversation, now?: number | Date): ConversationGroup[];
38
+ export interface NewConversationButtonProps extends React.ComponentProps<typeof Button> {
39
+ /** Default "New conversation". */
40
+ label?: string;
41
+ /** Default a plus icon. */
42
+ icon?: React.ReactNode;
43
+ /** Trailing content in the expanded button, e.g. <Kbd>⌘N</Kbd>. */
44
+ shortcut?: React.ReactNode;
45
+ }
46
+ /**
47
+ * Full-width "New conversation" button for a sidebar header. In the icon rail it shrinks to an icon with a
48
+ * tooltip; on mobile it closes the sidebar sheet after the click.
49
+ */
50
+ declare function NewConversationButton({ label, icon, shortcut, variant, onClick, className, ...props }: NewConversationButtonProps): React.JSX.Element;
51
+ export { ConversationList, NewConversationButton, groupByDate };
@@ -1,30 +1,139 @@
1
1
  "use client";
2
+ import { StatusDot } from "../admin/status.js";
3
+ import { Button } from "../ui/button.js";
4
+ import { Empty, EmptyDescription } from "../ui/empty.js";
5
+ import { useSidebarOptional } from "../ui/sidebar.js";
6
+ import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip.js";
2
7
  import { cn } from "../../lib/utils.js";
3
8
  import { jsx, jsxs } from "react/jsx-runtime";
9
+ import { PlusIcon } from "lucide-react";
4
10
  // src/components/ai/conversation-list.tsx
5
- /** Grouped list of chat threads for a sidebar. */
6
- function ConversationList({ groups, activeId, onSelect, className, ...props }) {
7
- return /* @__PURE__ */ jsx("nav", {
11
+ /**
12
+ * Grouped list of chat threads for a sidebar. Inside a Sidebar it closes the mobile sheet on select,
13
+ * and hides itself when the sidebar is collapsed to its icon rail.
14
+ */
15
+ function ConversationList({ groups, activeId, onSelect, renderAction, empty = "No conversations yet", className, ...props }) {
16
+ const sidebar = useSidebarOptional();
17
+ const select = (id) => {
18
+ onSelect?.(id);
19
+ sidebar?.setOpenMobile(false);
20
+ };
21
+ const visible = groups.filter((g) => g.items.length);
22
+ return /* @__PURE__ */ jsxs("nav", {
8
23
  "data-slot": "conversation-list",
9
24
  "aria-label": "Conversations",
10
- className: cn("flex flex-col gap-4.5", className),
25
+ className: cn("flex flex-col gap-4.5 group-data-[collapsible=icon]:hidden", className),
11
26
  ...props,
12
- children: groups.map((g) => /* @__PURE__ */ jsxs("div", {
27
+ children: [visible.map((g) => /* @__PURE__ */ jsxs("div", {
13
28
  className: "flex flex-col gap-0.5",
14
29
  children: [/* @__PURE__ */ jsx("div", {
15
30
  className: "px-3 pb-1.5 text-xs font-medium text-muted-foreground",
16
31
  children: g.label
17
32
  }), g.items.map((c) => {
18
33
  const active = c.id === activeId;
19
- return /* @__PURE__ */ jsx("button", {
20
- type: "button",
21
- "aria-current": active ? "page" : void 0,
22
- onClick: () => onSelect?.(c.id),
23
- className: cn("w-full truncate rounded-md px-3 py-2.25 text-left font-medium text-muted-foreground transition-colors outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/40", active && "bg-card text-foreground shadow-card hover:bg-card"),
24
- children: c.title
34
+ const action = renderAction?.(c);
35
+ return /* @__PURE__ */ jsxs("div", {
36
+ "data-slot": "conversation-item",
37
+ "data-active": active || void 0,
38
+ className: cn("group/conversation flex min-w-0 items-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground", active && "bg-card text-foreground shadow-card hover:bg-card"),
39
+ children: [/* @__PURE__ */ jsxs("button", {
40
+ type: "button",
41
+ "aria-current": active ? "page" : void 0,
42
+ onClick: () => select(c.id),
43
+ className: cn("flex min-w-0 flex-1 flex-col rounded-md py-2.25 pl-3 text-left font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring/40", action ? "pr-1" : "pr-3"),
44
+ children: [/* @__PURE__ */ jsx("span", {
45
+ className: "truncate",
46
+ children: c.title
47
+ }), c.description !== void 0 && /* @__PURE__ */ jsxs("span", {
48
+ className: "flex min-w-0 items-center gap-1.5 text-xs font-normal text-muted-foreground tabular-nums",
49
+ children: [c.tone && /* @__PURE__ */ jsx(StatusDot, {
50
+ tone: c.tone,
51
+ pulse: c.pulse
52
+ }), /* @__PURE__ */ jsx("span", {
53
+ className: "truncate",
54
+ children: c.description
55
+ })]
56
+ })]
57
+ }), action && /* @__PURE__ */ jsx("div", {
58
+ className: "mr-1 flex shrink-0 items-center [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover/conversation:opacity-100 [@media(hover:hover)]:group-focus-within/conversation:opacity-100",
59
+ children: action
60
+ })]
25
61
  }, c.id);
26
62
  })]
27
- }, g.label))
63
+ }, g.label)), !visible.length && empty && /* @__PURE__ */ jsx(Empty, { children: /* @__PURE__ */ jsx(EmptyDescription, { children: empty }) })]
28
64
  });
29
65
  }
30
- export { ConversationList };
66
+ /**
67
+ * Buckets items into Today / Yesterday / Previous 7 days / Earlier by a timestamp (ms or Date),
68
+ * newest first, and drops empty groups. Pass `now` for deterministic tests.
69
+ */
70
+ function groupByDate(items, getDate, toConversation, now = Date.now()) {
71
+ const d = new Date(now);
72
+ const midnight = (daysAgo) => new Date(d.getFullYear(), d.getMonth(), d.getDate() - daysAgo).getTime();
73
+ const [today, yesterday, weekAgo] = [
74
+ midnight(0),
75
+ midnight(1),
76
+ midnight(7)
77
+ ];
78
+ const groups = [
79
+ {
80
+ label: "Today",
81
+ items: []
82
+ },
83
+ {
84
+ label: "Yesterday",
85
+ items: []
86
+ },
87
+ {
88
+ label: "Previous 7 days",
89
+ items: []
90
+ },
91
+ {
92
+ label: "Earlier",
93
+ items: []
94
+ }
95
+ ];
96
+ const sorted = [...items].sort((a, b) => +getDate(b) - +getDate(a));
97
+ for (const item of sorted) {
98
+ const t = +getDate(item);
99
+ groups[t >= today ? 0 : t >= yesterday ? 1 : t >= weekAgo ? 2 : 3]?.items.push(toConversation(item));
100
+ }
101
+ return groups.filter((g) => g.items.length);
102
+ }
103
+ /**
104
+ * Full-width "New conversation" button for a sidebar header. In the icon rail it shrinks to an icon with a
105
+ * tooltip; on mobile it closes the sidebar sheet after the click.
106
+ */
107
+ function NewConversationButton({ label = "New conversation", icon = /* @__PURE__ */ jsx(PlusIcon, {}), shortcut, variant = "secondary", onClick, className, ...props }) {
108
+ const sidebar = useSidebarOptional();
109
+ const rail = sidebar?.state === "collapsed" && !sidebar.isMobile;
110
+ const button = /* @__PURE__ */ jsxs(Button, {
111
+ "data-slot": "new-conversation-button",
112
+ variant,
113
+ size: rail ? "icon" : "default",
114
+ "aria-label": rail ? label : void 0,
115
+ className: cn(!rail && "w-full justify-start", className),
116
+ onClick: (e) => {
117
+ onClick?.(e);
118
+ sidebar?.setOpenMobile(false);
119
+ },
120
+ ...props,
121
+ children: [
122
+ icon,
123
+ !rail && /* @__PURE__ */ jsx("span", {
124
+ className: "flex-1 truncate text-left",
125
+ children: label
126
+ }),
127
+ !rail && shortcut
128
+ ]
129
+ });
130
+ if (!rail) return button;
131
+ return /* @__PURE__ */ jsxs(Tooltip, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
132
+ asChild: true,
133
+ children: button
134
+ }), /* @__PURE__ */ jsx(TooltipContent, {
135
+ side: "right",
136
+ children: label
137
+ })] });
138
+ }
139
+ export { ConversationList, NewConversationButton, groupByDate };
@@ -20,8 +20,32 @@ export interface PromptInputProps extends Omit<React.ComponentProps<"form">, "on
20
20
  }
21
21
  /** Chat composer: auto-growing textarea with attach, tool chips, and send/stop. */
22
22
  declare function PromptInput({ value, onValueChange, onSubmit, placeholder, loading, onStop, onAttach, tools, footer, disabled, autoFocus, className, ...props }: PromptInputProps): React.JSX.Element;
23
- /** Toggleable pill for the composer toolbar (web search, a knowledge source…). */
24
- declare function PromptInputChip({ active, className, ...props }: React.ComponentProps<typeof Button> & {
23
+ export interface PromptInputChipProps extends React.ComponentProps<typeof Button> {
25
24
  active?: boolean;
26
- }): React.JSX.Element;
27
- export { PromptInput, PromptInputChip };
25
+ /** "warning" outlines the chip in the warning color, e.g. for a risky mode that is on. */
26
+ tone?: "default" | "warning";
27
+ }
28
+ /** Toggleable pill for the composer toolbar (web search, a knowledge source…). */
29
+ declare function PromptInputChip({ active, tone, className, ...props }: PromptInputChipProps): React.JSX.Element;
30
+ export interface PromptInputSelectOption {
31
+ value: string;
32
+ label: React.ReactNode;
33
+ description?: React.ReactNode;
34
+ icon?: React.ReactNode;
35
+ /** "warning" shows the chip in the warning color while this option is selected. */
36
+ tone?: "default" | "warning";
37
+ }
38
+ export interface PromptInputSelectProps {
39
+ options: PromptInputSelectOption[];
40
+ value: string;
41
+ onValueChange: (value: string) => void;
42
+ /** Heading inside the menu, e.g. "Permissions for this conversation". */
43
+ label?: React.ReactNode;
44
+ disabled?: boolean;
45
+ /** Tooltip text on the chip. */
46
+ title?: string;
47
+ className?: string;
48
+ }
49
+ /** Composer chip that picks one of a few modes, each with an icon and a one-line description. */
50
+ declare function PromptInputSelect({ options, value, onValueChange, label, disabled, title, className }: PromptInputSelectProps): React.JSX.Element;
51
+ export { PromptInput, PromptInputChip, PromptInputSelect };
@@ -1,9 +1,10 @@
1
1
  "use client";
2
2
  import { Button } from "../ui/button.js";
3
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger } from "../ui/dropdown-menu.js";
3
4
  import { cn } from "../../lib/utils.js";
4
5
  import * as React from "react";
5
6
  import { jsx, jsxs } from "react/jsx-runtime";
6
- import { ArrowUpIcon, PaperclipIcon, SquareIcon } from "lucide-react";
7
+ import { ArrowUpIcon, ChevronDownIcon, PaperclipIcon, SquareIcon } from "lucide-react";
7
8
  // src/components/ai/prompt-input.tsx
8
9
  /** Chat composer: auto-growing textarea with attach, tool chips, and send/stop. */
9
10
  function PromptInput({ value, onValueChange, onSubmit, placeholder = "Send a message…", loading, onStop, onAttach, tools, footer, disabled, autoFocus, className, ...props }) {
@@ -84,16 +85,63 @@ function PromptInput({ value, onValueChange, onSubmit, placeholder = "Send a mes
84
85
  })]
85
86
  });
86
87
  }
88
+ const chipClass = (tone) => cn("h-8", tone === "warning" && "border-warning/50 bg-transparent text-warning");
87
89
  /** Toggleable pill for the composer toolbar (web search, a knowledge source…). */
88
- function PromptInputChip({ active, className, ...props }) {
90
+ function PromptInputChip({ active, tone = "default", className, ...props }) {
89
91
  return /* @__PURE__ */ jsx(Button, {
90
92
  type: "button",
91
93
  size: "sm",
92
94
  shape: "pill",
93
95
  variant: active ? "soft" : "outline",
94
96
  "aria-pressed": active,
95
- className: cn("h-8", className),
97
+ "data-tone": tone,
98
+ className: cn(chipClass(tone), className),
96
99
  ...props
97
100
  });
98
101
  }
99
- export { PromptInput, PromptInputChip };
102
+ /** Composer chip that picks one of a few modes, each with an icon and a one-line description. */
103
+ function PromptInputSelect({ options, value, onValueChange, label, disabled, title, className }) {
104
+ const current = options.find((o) => o.value === value) ?? options[0];
105
+ const tone = current?.tone ?? "default";
106
+ return /* @__PURE__ */ jsxs(DropdownMenu, { children: [/* @__PURE__ */ jsx(DropdownMenuTrigger, {
107
+ asChild: true,
108
+ disabled,
109
+ children: /* @__PURE__ */ jsxs(Button, {
110
+ type: "button",
111
+ size: "sm",
112
+ shape: "pill",
113
+ variant: tone === "warning" ? "outline" : "soft",
114
+ "data-slot": "prompt-input-select",
115
+ "data-tone": tone,
116
+ title,
117
+ className: cn(chipClass(tone), className),
118
+ children: [
119
+ current?.icon,
120
+ current?.label,
121
+ /* @__PURE__ */ jsx(ChevronDownIcon, { className: "opacity-60" })
122
+ ]
123
+ })
124
+ }), /* @__PURE__ */ jsxs(DropdownMenuContent, {
125
+ align: "start",
126
+ className: "w-72",
127
+ children: [label && /* @__PURE__ */ jsx(DropdownMenuLabel, { children: label }), /* @__PURE__ */ jsx(DropdownMenuRadioGroup, {
128
+ value,
129
+ onValueChange,
130
+ children: options.map((o) => /* @__PURE__ */ jsx(DropdownMenuRadioItem, {
131
+ value: o.value,
132
+ className: "items-start py-2",
133
+ children: /* @__PURE__ */ jsxs("div", {
134
+ className: "flex min-w-0 flex-col",
135
+ children: [/* @__PURE__ */ jsx("span", {
136
+ className: "font-medium",
137
+ children: o.label
138
+ }), o.description && /* @__PURE__ */ jsx("span", {
139
+ className: "text-xs text-muted-foreground",
140
+ children: o.description
141
+ })]
142
+ })
143
+ }, o.value))
144
+ })]
145
+ })] });
146
+ }
147
+ export { PromptInput, PromptInputChip, PromptInputSelect };