@wealthx/shadcn 1.5.39 → 1.5.41

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 (30) hide show
  1. package/.turbo/turbo-build.log +118 -118
  2. package/CHANGELOG.md +12 -0
  3. package/dist/{chunk-MGIDYXOP.mjs → chunk-DWNLBUDC.mjs} +459 -67
  4. package/dist/{chunk-EFHPSKVF.mjs → chunk-EGM4DARZ.mjs} +110 -1
  5. package/dist/{chunk-R7M657QL.mjs → chunk-GIQGZFP6.mjs} +138 -46
  6. package/dist/{chunk-B5PSUONN.mjs → chunk-TF5TOVIM.mjs} +1 -1
  7. package/dist/{chunk-RRROLESJ.mjs → chunk-XHZONBL4.mjs} +1 -1
  8. package/dist/components/ui/ai-assistant-drawer.js +101 -0
  9. package/dist/components/ui/ai-assistant-drawer.mjs +2 -2
  10. package/dist/components/ui/ai-conversations/index.js +101 -0
  11. package/dist/components/ui/ai-conversations/index.mjs +2 -2
  12. package/dist/components/ui/chat-input-area.js +101 -0
  13. package/dist/components/ui/chat-input-area.mjs +1 -1
  14. package/dist/components/ui/policy-ai/index.js +818 -261
  15. package/dist/components/ui/policy-ai/index.mjs +11 -2
  16. package/dist/components/ui/support-agent/index.js +233 -45
  17. package/dist/components/ui/support-agent/index.mjs +2 -2
  18. package/dist/index.js +3521 -3330
  19. package/dist/index.mjs +5 -5
  20. package/dist/styles.css +1 -1
  21. package/package.json +1 -1
  22. package/src/components/ui/chat-input-area.tsx +181 -2
  23. package/src/components/ui/policy-ai/index.tsx +12 -0
  24. package/src/components/ui/policy-ai/policy-ai-context-sidebar.tsx +231 -0
  25. package/src/components/ui/policy-ai/policy-ai-history-panel.tsx +175 -0
  26. package/src/components/ui/policy-ai/policy-ai-page.tsx +243 -0
  27. package/src/components/ui/policy-ai/policy-ai-panel.tsx +64 -57
  28. package/src/components/ui/policy-ai/policy-ai-responses.tsx +8 -12
  29. package/src/components/ui/support-agent/support-agent-panel.tsx +170 -48
  30. package/src/styles/styles-css.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wealthx/shadcn",
3
- "version": "1.5.39",
3
+ "version": "1.5.41",
4
4
  "main": "./dist/index.js",
5
5
  "module": "./dist/index.mjs",
6
6
  "types": "./src/index.ts",
@@ -1,5 +1,15 @@
1
1
  import * as React from "react";
2
- import { ImagePlus, Paperclip, Send } from "lucide-react";
2
+ import { flushSync } from "react-dom";
3
+ import {
4
+ Bold,
5
+ Code,
6
+ Code2,
7
+ ImagePlus,
8
+ Italic,
9
+ type LucideIcon,
10
+ Paperclip,
11
+ Send,
12
+ } from "lucide-react";
3
13
  import { cn } from "@/lib/utils";
4
14
  import { Button } from "@/components/ui/button";
5
15
  import { Textarea } from "@/components/ui/textarea";
@@ -14,6 +24,7 @@ import { Textarea } from "@/components/ui/textarea";
14
24
  * Features:
15
25
  * - Textarea with auto-resize up to `maxHeight`
16
26
  * - Enter to send / Shift+Enter for new line
27
+ * - Optional markdown formatting toolbar (`showMarkdownToolbar`)
17
28
  * - Optional file attachment (Paperclip) — shown only when `onAttachFile` is provided
18
29
  * - Optional image upload (ImagePlus) — shown only when `onAttachImage` is provided
19
30
  * - Focus ring on outer container via `focus-within`
@@ -26,6 +37,7 @@ import { Textarea } from "@/components/ui/textarea";
26
37
  * onSend={handleSend}
27
38
  * onAttachFile={handleFiles}
28
39
  * onAttachImage={handleImages}
40
+ * showMarkdownToolbar
29
41
  * placeholder="Ask anything…"
30
42
  * />
31
43
  */
@@ -66,11 +78,168 @@ export interface ChatInputAreaProps {
66
78
  maxHeight?: number;
67
79
  /** Focus the textarea on mount. */
68
80
  autoFocus?: boolean;
81
+ /**
82
+ * Show a markdown formatting toolbar (Bold, Italic, Code, Code block)
83
+ * above the textarea. Wraps selected text or inserts a placeholder.
84
+ * @default false
85
+ */
86
+ showMarkdownToolbar?: boolean;
69
87
  className?: string;
70
88
  }
71
89
 
72
90
  const DEFAULT_HINT = "Enter to send · Shift+Enter for new line";
73
91
 
92
+ // ---------------------------------------------------------------------------
93
+ // Markdown toolbar
94
+ // ---------------------------------------------------------------------------
95
+
96
+ type ToolbarItem =
97
+ | {
98
+ type: "button";
99
+ icon: LucideIcon;
100
+ label: string;
101
+ title: string;
102
+ before: string;
103
+ after: string;
104
+ placeholder: string;
105
+ }
106
+ | { type: "divider" };
107
+
108
+ /** Static config — defined once, no per-render allocation. */
109
+ const TOOLBAR_ITEMS: ToolbarItem[] = [
110
+ {
111
+ type: "button",
112
+ icon: Bold,
113
+ label: "Bold",
114
+ title: "Bold (Ctrl+B)",
115
+ before: "**",
116
+ after: "**",
117
+ placeholder: "bold text",
118
+ },
119
+ {
120
+ type: "button",
121
+ icon: Italic,
122
+ label: "Italic",
123
+ title: "Italic (Ctrl+I)",
124
+ before: "*",
125
+ after: "*",
126
+ placeholder: "italic text",
127
+ },
128
+ {
129
+ type: "button",
130
+ icon: Code,
131
+ label: "Inline code",
132
+ title: "Inline code",
133
+ before: "`",
134
+ after: "`",
135
+ placeholder: "code",
136
+ },
137
+ { type: "divider" },
138
+ {
139
+ type: "button",
140
+ icon: Code2,
141
+ label: "Code block",
142
+ title: "Code block",
143
+ before: "```\n",
144
+ after: "\n```",
145
+ placeholder: "code block",
146
+ },
147
+ ];
148
+
149
+ /**
150
+ * Wraps the current selection (or inserts a placeholder) with markdown syntax.
151
+ * Uses flushSync so the selection can be restored synchronously after the
152
+ * controlled value update — no setTimeout timing hack needed.
153
+ */
154
+ function applyMarkdown(
155
+ textarea: HTMLTextAreaElement,
156
+ before: string,
157
+ after: string,
158
+ placeholder: string,
159
+ onChange: (value: string) => void,
160
+ ) {
161
+ const start = textarea.selectionStart;
162
+ const end = textarea.selectionEnd;
163
+ const selected = textarea.value.slice(start, end);
164
+ const insertion = selected || placeholder;
165
+ const next =
166
+ textarea.value.slice(0, start) +
167
+ before +
168
+ insertion +
169
+ after +
170
+ textarea.value.slice(end);
171
+
172
+ const newStart = start + before.length;
173
+ const newEnd = newStart + insertion.length;
174
+
175
+ // flushSync forces React to flush the state update synchronously so we can
176
+ // restore selection immediately — React-blessed alternative to setTimeout.
177
+ flushSync(() => onChange(next));
178
+ textarea.focus();
179
+ textarea.setSelectionRange(newStart, newEnd);
180
+ }
181
+
182
+ interface MarkdownToolbarProps {
183
+ textareaRef: React.RefObject<HTMLTextAreaElement | null>;
184
+ onChange: (value: string) => void;
185
+ disabled?: boolean;
186
+ }
187
+
188
+ /** Memoised — does not re-render on every parent keystroke. */
189
+ const MarkdownToolbar = React.memo(function MarkdownToolbar({
190
+ textareaRef,
191
+ onChange,
192
+ disabled,
193
+ }: MarkdownToolbarProps) {
194
+ // Single stable handler — reads format tokens from data attributes.
195
+ const handleFormat = React.useCallback(
196
+ (e: React.MouseEvent<HTMLButtonElement>) => {
197
+ if (!textareaRef.current) return;
198
+ const { before, after, placeholder } = e.currentTarget.dataset as {
199
+ before: string;
200
+ after: string;
201
+ placeholder: string;
202
+ };
203
+ applyMarkdown(textareaRef.current, before, after, placeholder, onChange);
204
+ },
205
+ [textareaRef, onChange],
206
+ );
207
+
208
+ return (
209
+ <div className="flex items-center gap-0.5 border-b border-border px-2 py-1">
210
+ {TOOLBAR_ITEMS.map((item, i) =>
211
+ item.type === "divider" ? (
212
+ <span
213
+ key={i}
214
+ className="mx-0.5 h-3.5 w-px bg-border"
215
+ aria-hidden="true"
216
+ />
217
+ ) : (
218
+ <Button
219
+ key={item.label}
220
+ variant="ghost"
221
+ size="icon-sm"
222
+ type="button"
223
+ title={item.title}
224
+ aria-label={item.label}
225
+ disabled={disabled}
226
+ data-before={item.before}
227
+ data-after={item.after}
228
+ data-placeholder={item.placeholder}
229
+ onClick={handleFormat}
230
+ >
231
+ <item.icon className="size-3.5" aria-hidden="true" />
232
+ </Button>
233
+ ),
234
+ )}
235
+ </div>
236
+ );
237
+ });
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // ChatInputArea
241
+ // ---------------------------------------------------------------------------
242
+
74
243
  export function ChatInputArea({
75
244
  value,
76
245
  onChange,
@@ -82,6 +251,7 @@ export function ChatInputArea({
82
251
  hint = DEFAULT_HINT,
83
252
  maxHeight = 160,
84
253
  autoFocus = false,
254
+ showMarkdownToolbar = false,
85
255
  className,
86
256
  }: ChatInputAreaProps) {
87
257
  const textareaRef = React.useRef<HTMLTextAreaElement>(null);
@@ -154,8 +324,17 @@ export function ChatInputArea({
154
324
  data-slot="chat-input-area"
155
325
  className={cn("flex flex-col gap-1.5", className)}
156
326
  >
157
- {/* Unified input box — textarea + action bar share one border */}
327
+ {/* Unified input box — toolbar + textarea + action bar share one border */}
158
328
  <div className="border border-border bg-background flex flex-col focus-within:ring-1 focus-within:ring-ring">
329
+ {/* Markdown toolbar — optional, shown at top of the input box */}
330
+ {showMarkdownToolbar && (
331
+ <MarkdownToolbar
332
+ textareaRef={textareaRef}
333
+ onChange={onChange}
334
+ disabled={disabled}
335
+ />
336
+ )}
337
+
159
338
  <Textarea
160
339
  ref={textareaRef}
161
340
  value={value}
@@ -39,3 +39,15 @@ export type {
39
39
  export { PolicyAIFAB, PolicyAIPanel } from "./policy-ai-panel";
40
40
 
41
41
  export type { PolicyAIFABProps, PolicyAIPanelProps } from "./policy-ai-panel";
42
+
43
+ export { PolicyAIHistoryPanel } from "./policy-ai-history-panel";
44
+
45
+ export type { PolicyAIHistoryPanelProps } from "./policy-ai-history-panel";
46
+
47
+ export { PolicyAIContextSidebar } from "./policy-ai-context-sidebar";
48
+
49
+ export type { PolicyAIContextSidebarProps } from "./policy-ai-context-sidebar";
50
+
51
+ export { PolicyAIPage } from "./policy-ai-page";
52
+
53
+ export type { PolicyAIPageProps } from "./policy-ai-page";
@@ -0,0 +1,231 @@
1
+ import * as React from "react";
2
+ import { type LucideIcon, BrainCircuit, Database, Layers } from "lucide-react";
3
+ import { cn } from "@/lib/utils";
4
+ import { Badge } from "@/components/ui/badge";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Card } from "@/components/ui/card";
7
+ import { Separator } from "@/components/ui/separator";
8
+ import type {
9
+ PolicyQueryContext,
10
+ PolicyQueryType,
11
+ } from "./policy-ai-primitives";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Props
15
+ // ---------------------------------------------------------------------------
16
+
17
+ export interface PolicyAIContextSidebarProps {
18
+ /** Context detected from the most recent query (absent in home state). */
19
+ lastQueryContext?: PolicyQueryContext;
20
+ /** Follow-up question suggestions shown below the query context. */
21
+ suggestedFollowUps?: string[];
22
+ /** Called when user clicks a suggested follow-up question. */
23
+ onFollowUpClick?: (question: string) => void;
24
+ /** Number of banks indexed. Defaults to 42. */
25
+ bankCount?: number;
26
+ /** Number of policy categories indexed. Defaults to 86. */
27
+ categoryCount?: number;
28
+ /** Human-readable date of last policy update. */
29
+ lastUpdated?: string;
30
+ className?: string;
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Internal helpers
35
+ // ---------------------------------------------------------------------------
36
+
37
+ const QUERY_TYPE_DESCRIPTIONS: Record<PolicyQueryType, string> = {
38
+ single_bank: "Answered from a single bank's policy documents.",
39
+ cross_bank_comparison: "Compared across all indexed banks simultaneously.",
40
+ threshold_filter: "Filtered banks against a specific threshold or limit.",
41
+ ranking: "Ranked banks using TOPSIS multi-criteria scoring.",
42
+ scenario_match: "Matched your scenario against all bank lending criteria.",
43
+ general: "General policy question answered from the knowledge base.",
44
+ };
45
+
46
+ const QUERY_TYPE_LABELS: Record<PolicyQueryType, string> = {
47
+ single_bank: "Single bank",
48
+ cross_bank_comparison: "Cross-bank",
49
+ threshold_filter: "Threshold filter",
50
+ ranking: "Ranked list",
51
+ scenario_match: "Scenario match",
52
+ general: "General",
53
+ };
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Sub-components
57
+ // ---------------------------------------------------------------------------
58
+
59
+ function SidebarSection({
60
+ title,
61
+ children,
62
+ className,
63
+ }: {
64
+ title: string;
65
+ children: React.ReactNode;
66
+ className?: string;
67
+ }) {
68
+ return (
69
+ <div className={cn("flex flex-col gap-2", className)}>
70
+ <p className="text-overline text-muted-foreground px-3">{title}</p>
71
+ {children}
72
+ </div>
73
+ );
74
+ }
75
+
76
+ function StatRow({
77
+ icon: Icon,
78
+ children,
79
+ }: {
80
+ icon: LucideIcon;
81
+ children: React.ReactNode;
82
+ }) {
83
+ return (
84
+ <div className="flex items-center gap-2.5">
85
+ <Icon
86
+ className="size-3.5 text-muted-foreground shrink-0"
87
+ aria-hidden="true"
88
+ />
89
+ {children}
90
+ </div>
91
+ );
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // PolicyAIContextSidebar (Organism)
96
+ // ---------------------------------------------------------------------------
97
+
98
+ /**
99
+ * Right sidebar for the Policy AI dedicated page.
100
+ * Displays:
101
+ * - Coverage stats (banks + categories indexed)
102
+ * - Last query context as a source reference card
103
+ * - Suggested follow-up questions
104
+ *
105
+ * @example
106
+ * <PolicyAIContextSidebar
107
+ * lastQueryContext={lastMessage?.queryContext}
108
+ * suggestedFollowUps={["What about NAB?", "Show me ranked list"]}
109
+ * onFollowUpClick={handleSend}
110
+ * />
111
+ */
112
+ export function PolicyAIContextSidebar({
113
+ lastQueryContext,
114
+ suggestedFollowUps,
115
+ onFollowUpClick,
116
+ bankCount = 42,
117
+ categoryCount = 86,
118
+ lastUpdated = "June 2026",
119
+ className,
120
+ }: PolicyAIContextSidebarProps) {
121
+ return (
122
+ <div
123
+ data-slot="policy-ai-context-sidebar"
124
+ className={cn(
125
+ "flex flex-col h-full border-l border-border bg-card shrink-0 overflow-y-auto",
126
+ className,
127
+ )}
128
+ >
129
+ <div className="flex flex-col gap-5 px-0 py-4">
130
+ {/* ── Coverage stats ── */}
131
+ <SidebarSection title="Coverage">
132
+ <div className="flex flex-col gap-1.5 px-3">
133
+ <StatRow icon={BrainCircuit}>
134
+ <span className="text-sm text-foreground">
135
+ <span className="font-semibold">{bankCount}</span> banks indexed
136
+ </span>
137
+ </StatRow>
138
+ <StatRow icon={Layers}>
139
+ <span className="text-sm text-foreground">
140
+ <span className="font-semibold">{categoryCount}</span> policy
141
+ categories
142
+ </span>
143
+ </StatRow>
144
+ <StatRow icon={Database}>
145
+ <span className="text-caption text-muted-foreground">
146
+ Updated {lastUpdated}
147
+ </span>
148
+ </StatRow>
149
+ </div>
150
+ </SidebarSection>
151
+
152
+ {/* ── Last query context — source reference card ── */}
153
+ {lastQueryContext && (
154
+ <>
155
+ <Separator />
156
+ <SidebarSection title="Last Query">
157
+ {/*
158
+ * Styled as a "source" card — three zones:
159
+ * Header → policy type + query type badge
160
+ * Body → bank name + category badges
161
+ * Footer → one-line method description
162
+ */}
163
+ <Card className="gap-0 py-0 shadow-none overflow-hidden mx-3">
164
+ {/* Header */}
165
+ <div className="flex items-center justify-between gap-2 px-3 py-2 border-b border-border bg-muted/30">
166
+ <span className="text-sm font-semibold text-foreground">
167
+ {lastQueryContext.policyType}
168
+ </span>
169
+ <Badge variant="outline" className="text-xs shrink-0">
170
+ {QUERY_TYPE_LABELS[lastQueryContext.queryType]}
171
+ </Badge>
172
+ </div>
173
+
174
+ {/* Body */}
175
+ <div className="flex flex-col gap-2 px-3 py-2.5">
176
+ {lastQueryContext.bankName && (
177
+ <p className="text-sm font-semibold text-foreground leading-none">
178
+ {lastQueryContext.bankName}
179
+ </p>
180
+ )}
181
+ {lastQueryContext.categories.length > 0 && (
182
+ <div className="flex flex-wrap gap-1">
183
+ {lastQueryContext.categories.map((cat) => (
184
+ <Badge
185
+ key={cat}
186
+ variant="secondary"
187
+ className="text-xs font-normal"
188
+ >
189
+ {cat}
190
+ </Badge>
191
+ ))}
192
+ </div>
193
+ )}
194
+ </div>
195
+
196
+ {/* Footer — method description */}
197
+ <div className="border-t border-border px-3 py-2">
198
+ <p className="text-caption text-muted-foreground leading-snug">
199
+ {QUERY_TYPE_DESCRIPTIONS[lastQueryContext.queryType]}
200
+ </p>
201
+ </div>
202
+ </Card>
203
+ </SidebarSection>
204
+ </>
205
+ )}
206
+
207
+ {/* ── Suggested follow-ups ── */}
208
+ {!!suggestedFollowUps?.length && (
209
+ <>
210
+ <Separator />
211
+ <SidebarSection title="Follow-ups">
212
+ <div className="flex flex-col gap-1 px-3">
213
+ {suggestedFollowUps.map((q) => (
214
+ <Button
215
+ key={q}
216
+ variant="outline"
217
+ size="sm"
218
+ className="w-full justify-start h-auto py-1.5 text-muted-foreground font-normal leading-snug whitespace-normal text-left"
219
+ onClick={() => onFollowUpClick?.(q)}
220
+ >
221
+ {q}
222
+ </Button>
223
+ ))}
224
+ </div>
225
+ </SidebarSection>
226
+ </>
227
+ )}
228
+ </div>
229
+ </div>
230
+ );
231
+ }
@@ -0,0 +1,175 @@
1
+ import * as React from "react";
2
+ import { BrainCircuit, MessageSquarePlus, Search } from "lucide-react";
3
+ import { cn } from "@/lib/utils";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Input } from "@/components/ui/input";
6
+ import type { PolicyConversationItem } from "./policy-ai-primitives";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Props
10
+ // ---------------------------------------------------------------------------
11
+
12
+ export interface PolicyAIHistoryPanelProps {
13
+ conversations: PolicyConversationItem[];
14
+ activeId?: string;
15
+ onSelect?: (id: string) => void;
16
+ onNewChat?: () => void;
17
+ searchQuery?: string;
18
+ onSearchChange?: (q: string) => void;
19
+ className?: string;
20
+ }
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // PolicyAIHistoryItem — mirrors ConversationListItem visual pattern
24
+ // ---------------------------------------------------------------------------
25
+
26
+ function PolicyAIHistoryItem({
27
+ conv,
28
+ isActive,
29
+ onClick,
30
+ }: {
31
+ conv: PolicyConversationItem;
32
+ isActive: boolean;
33
+ onClick: () => void;
34
+ }) {
35
+ return (
36
+ <button
37
+ type="button"
38
+ onClick={onClick}
39
+ className={cn(
40
+ "w-full flex items-start gap-3 px-3 py-3 text-left transition-colors",
41
+ "border-b border-border last:border-b-0",
42
+ isActive ? "bg-muted" : "hover:bg-muted/40",
43
+ )}
44
+ >
45
+ <div className="min-w-0 flex-1">
46
+ {/* Row 1 — query title + timestamp (mirrors ConversationListItem row 1) */}
47
+ <div className="flex items-start justify-between gap-2">
48
+ <span
49
+ className={cn(
50
+ "text-sm leading-snug line-clamp-2 min-w-0",
51
+ isActive ? "font-semibold text-foreground" : "text-foreground",
52
+ )}
53
+ >
54
+ {conv.title}
55
+ </span>
56
+ {conv.timestamp && (
57
+ <span className="shrink-0 text-caption text-muted-foreground whitespace-nowrap">
58
+ {conv.timestamp}
59
+ </span>
60
+ )}
61
+ </div>
62
+ </div>
63
+ </button>
64
+ );
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // PolicyAIHistoryPanel (Organism)
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /**
72
+ * Left sidebar for the Policy AI dedicated page.
73
+ * Visual pattern mirrors ConversationList + ConversationListItem:
74
+ * - Header bar with "New Chat" button
75
+ * - Search input (same style as ConversationList)
76
+ * - List of Policy AI sessions using avatar + title + timestamp layout
77
+ *
78
+ * @example
79
+ * <PolicyAIHistoryPanel
80
+ * conversations={conversations}
81
+ * activeId={activeId}
82
+ * onSelect={setActiveId}
83
+ * onNewChat={() => setMessages([])}
84
+ * />
85
+ */
86
+ export function PolicyAIHistoryPanel({
87
+ conversations,
88
+ activeId,
89
+ onSelect,
90
+ onNewChat,
91
+ searchQuery = "",
92
+ onSearchChange,
93
+ className,
94
+ }: PolicyAIHistoryPanelProps) {
95
+ const filtered = searchQuery
96
+ ? conversations.filter((c) =>
97
+ c.title.toLowerCase().includes(searchQuery.toLowerCase()),
98
+ )
99
+ : conversations;
100
+
101
+ return (
102
+ <div
103
+ data-slot="policy-ai-history-panel"
104
+ className={cn(
105
+ "flex flex-col h-full border-r border-border bg-background shrink-0",
106
+ className,
107
+ )}
108
+ >
109
+ {/* ── Header — New Chat + Search (mirrors ConversationList header) ── */}
110
+ <div className="shrink-0 flex flex-col">
111
+ {/* New chat */}
112
+ <div className="flex items-center gap-2 border-b border-border px-3 py-2.5">
113
+ <Button
114
+ size="sm"
115
+ className="w-full justify-start gap-2"
116
+ onClick={onNewChat}
117
+ >
118
+ <MessageSquarePlus
119
+ className="size-3.5 shrink-0"
120
+ aria-hidden="true"
121
+ />
122
+ New Chat
123
+ </Button>
124
+ </div>
125
+
126
+ {/* Search */}
127
+ <div className="flex items-center border-b border-border px-3 py-2.5">
128
+ <div className="relative flex-1">
129
+ <Search
130
+ className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none"
131
+ aria-hidden="true"
132
+ />
133
+ <Input
134
+ value={searchQuery}
135
+ onChange={(e) => onSearchChange?.(e.target.value)}
136
+ placeholder="Search conversations..."
137
+ className="h-8 pl-8 text-sm"
138
+ aria-label="Search conversations"
139
+ />
140
+ </div>
141
+ </div>
142
+ </div>
143
+
144
+ {/* ── Conversation list ── */}
145
+ <div className="flex-1 overflow-y-auto min-h-0" tabIndex={0}>
146
+ {filtered.length === 0 ? (
147
+ <div className="flex flex-col items-center justify-center gap-2 p-8 text-muted-foreground">
148
+ <BrainCircuit className="size-8 opacity-30" aria-hidden="true" />
149
+ <p className="text-sm">
150
+ {searchQuery ? "No conversations found." : "No recent sessions."}
151
+ </p>
152
+ {searchQuery && (
153
+ <Button
154
+ variant="outline"
155
+ size="sm"
156
+ onClick={() => onSearchChange?.("")}
157
+ >
158
+ Clear search
159
+ </Button>
160
+ )}
161
+ </div>
162
+ ) : (
163
+ filtered.map((conv) => (
164
+ <PolicyAIHistoryItem
165
+ key={conv.id}
166
+ conv={conv}
167
+ isActive={conv.id === activeId}
168
+ onClick={() => onSelect?.(conv.id)}
169
+ />
170
+ ))
171
+ )}
172
+ </div>
173
+ </div>
174
+ );
175
+ }