@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.
- package/.turbo/turbo-build.log +118 -118
- package/CHANGELOG.md +12 -0
- package/dist/{chunk-MGIDYXOP.mjs → chunk-DWNLBUDC.mjs} +459 -67
- package/dist/{chunk-EFHPSKVF.mjs → chunk-EGM4DARZ.mjs} +110 -1
- package/dist/{chunk-R7M657QL.mjs → chunk-GIQGZFP6.mjs} +138 -46
- package/dist/{chunk-B5PSUONN.mjs → chunk-TF5TOVIM.mjs} +1 -1
- package/dist/{chunk-RRROLESJ.mjs → chunk-XHZONBL4.mjs} +1 -1
- package/dist/components/ui/ai-assistant-drawer.js +101 -0
- package/dist/components/ui/ai-assistant-drawer.mjs +2 -2
- package/dist/components/ui/ai-conversations/index.js +101 -0
- package/dist/components/ui/ai-conversations/index.mjs +2 -2
- package/dist/components/ui/chat-input-area.js +101 -0
- package/dist/components/ui/chat-input-area.mjs +1 -1
- package/dist/components/ui/policy-ai/index.js +818 -261
- package/dist/components/ui/policy-ai/index.mjs +11 -2
- package/dist/components/ui/support-agent/index.js +233 -45
- package/dist/components/ui/support-agent/index.mjs +2 -2
- package/dist/index.js +3521 -3330
- package/dist/index.mjs +5 -5
- package/dist/styles.css +1 -1
- package/package.json +1 -1
- package/src/components/ui/chat-input-area.tsx +181 -2
- package/src/components/ui/policy-ai/index.tsx +12 -0
- package/src/components/ui/policy-ai/policy-ai-context-sidebar.tsx +231 -0
- package/src/components/ui/policy-ai/policy-ai-history-panel.tsx +175 -0
- package/src/components/ui/policy-ai/policy-ai-page.tsx +243 -0
- package/src/components/ui/policy-ai/policy-ai-panel.tsx +64 -57
- package/src/components/ui/policy-ai/policy-ai-responses.tsx +8 -12
- package/src/components/ui/support-agent/support-agent-panel.tsx +170 -48
- package/src/styles/styles-css.ts +1 -1
package/package.json
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
import {
|
|
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
|
+
}
|