Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
3c588e1
feat: feat: build Nvidia NIM-powered financial briefing chat inter (#73)
hardeyshorlar12 Aug 31, 2026
0cb268d
feat: feat: build Nvidia NIM-powered financial briefing chat inter (#73)
hardeyshorlar12 Aug 31, 2026
840c32f
feat: feat: build Nvidia NIM-powered financial briefing chat inter (#73)
hardeyshorlar12 Aug 31, 2026
b523334
feat: feat: build Nvidia NIM-powered financial briefing chat inter (#73)
hardeyshorlar12 Aug 31, 2026
6f3b41e
feat: feat: build Nvidia NIM-powered financial briefing chat inter (#73)
hardeyshorlar12 Aug 31, 2026
d60bbbd
feat: feat: build Nvidia NIM-powered financial briefing chat inter (#73)
hardeyshorlar12 Aug 31, 2026
c9da4f9
feat: feat: build Nvidia NIM-powered financial briefing chat inter (#73)
hardeyshorlar12 Aug 31, 2026
a2a7715
fix(ci): resolve failing checks for #73
hardeyshorlar12 Aug 31, 2026
0f0a2b3
fix(ci): resolve failing checks for #73
hardeyshorlar12 Aug 31, 2026
0ba6b6a
fix(ci): resolve failing checks for #73
hardeyshorlar12 Aug 31, 2026
84da769
fix(ci): resolve failing checks for #73
hardeyshorlar12 Aug 31, 2026
6c351ad
fix(ci): resolve failing checks for #73
hardeyshorlar12 Aug 31, 2026
59e01b7
fix(ci): resolve failing checks for #73
hardeyshorlar12 Aug 31, 2026
5144a52
fix(ci): resolve failing checks for #73
hardeyshorlar12 Aug 31, 2026
6151617
fix(ci): resolve failing checks for #73
hardeyshorlar12 Aug 31, 2026
bd27da3
fix(ci): resolve failing checks for #73
hardeyshorlar12 Aug 31, 2026
5f363b8
fix(ci): resolve failing checks for #73
hardeyshorlar12 Aug 31, 2026
8fa69e8
fix(ci): resolve failing checks for #73
hardeyshorlar12 Aug 31, 2026
bd2f9d7
fix(ci): resolve failing checks for #73
hardeyshorlar12 Aug 31, 2026
5350cf4
fix(ci): resolve failing checks for #73
hardeyshorlar12 Aug 31, 2026
ae6b331
fix(ci): resolve failing checks for #73
hardeyshorlar12 Aug 31, 2026
979924f
fix(ci): resolve failing checks for #73
hardeyshorlar12 Aug 31, 2026
ebe0bd1
fix(ci): resolve failing checks for #73
hardeyshorlar12 Aug 31, 2026
bb6066d
fix(ci): resolve failing checks for #73
hardeyshorlar12 Aug 31, 2026
5b96e98
fix(ci): resolve failing checks for #73
hardeyshorlar12 Aug 31, 2026
e7f2e4c
Merge remote-tracking branch 'origin/main' into pr131
Cjay-Cyber-2 Sep 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 101 additions & 39 deletions src/components/shell/assistant-drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,24 @@ import { useAssistantSeed, useBriefing } from '@/hooks/use-queries';
import { Avatar } from '@/components/ui/avatar';
import { cn } from '@/lib/cn';
import { env, isMockMode } from '@/lib/env';
import { motion, AnimatePresence } from 'framer-motion';
import ReactMarkdown from 'react-markdown';
import type { ChatMessage } from '@/types/domain';

const defaultSuggestions = [
{ label: 'Financial summary', prompt: 'Summarize treasury health and any anomaly alerts.' },
{ label: 'Budget check', prompt: 'Check budget health and flag any risks before close.' },
{ label: 'Financial briefing', prompt: "Provide today's financial briefing and treasury health summary." },
{ label: 'Portfolio health', prompt: 'Summarize portfolio health and highlight any risk signals.' },
{ label: 'Agent activity log', prompt: 'What did agents do recently? Summarize the latest activity log.' },
{ label: 'Stellar transfer explainer', prompt: 'Explain the most recent high-value Stellar transfer in plain English.' },
];


/**
* Slide-over AI assistant. Seeds from the mock conversation and the daily
* briefing's suggested prompts. Composer is local-only (no backend in mock
* mode) β€” sending appends an optimistic user turn plus a canned acknowledgement
* so the interaction reads end-to-end.
* briefing's suggested prompts. The live path streams from NVIDIA NIM.
* Composer is local-only (no backend in mock mode) β€” sending appends an
* optimistic user turn plus a canned acknowledgement so the interaction reads
* end-to-end.
*/
export function AssistantDrawer() {
const open = useAssistantStore((s) => s.open);
Expand All @@ -32,6 +36,7 @@ export function AssistantDrawer() {
const [draft, setDraft] = useState('');
const [seeded, setSeeded] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const composerRef = useRef<HTMLTextAreaElement>(null);

useEffect(() => {
if (typeof window === 'undefined') return;
Expand Down Expand Up @@ -68,6 +73,10 @@ export function AssistantDrawer() {
if (open) scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
}, [open, messages]);

useEffect(() => {
if (open) composerRef.current?.focus();
}, [open]);

useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
Expand Down Expand Up @@ -95,23 +104,62 @@ export function AssistantDrawer() {
return;
}

const assistantId = `a-${Date.now()}`;
let accumulated = '';
setMessages((prev) => [
...prev,
{ id: assistantId, role: 'assistant', content: '', createdAt: new Date().toISOString() },
]);

try {
const res = await fetch(`${env.apiUrl}${env.apiVersion}/ai/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: trimmed }),
body: JSON.stringify({ message: trimmed, stream: true }),
});
const body = await res.json();
const reply = body?.data?.reply ?? body?.reply ?? 'No response from AI.';
setMessages((prev) => [
...prev,
{ id: `a-${Date.now()}`, role: 'assistant', content: reply, createdAt: new Date().toISOString() },
]);
if (!res.ok || !res.body) throw new Error('AI request failed');

const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const event = line.trim();
if (!event.startsWith('data:')) continue;
const data = event.slice(5).trim();
if (data === '[DONE]') continue;
try {
const chunk = JSON.parse(data);
const delta = chunk?.choices?.[0]?.delta?.content;
if (delta) {
accumulated += delta;
setMessages((prev) =>
prev.map((m) => (m.id === assistantId ? { ...m, content: accumulated } : m)),
);
}
} catch {
// Ignore partial JSON frames and keep reading the stream.
}
}
}
if (!accumulated) {
setMessages((prev) =>
prev.map((m) => (m.id === assistantId ? { ...m, content: 'No response from AI.' } : m)),
);
}
} catch {
setMessages((prev) => [
...prev,
{ id: `a-err-${Date.now()}`, role: 'assistant', content: 'Failed to reach the AI service. Check the API connection.', createdAt: new Date().toISOString() },
]);
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? { ...m, content: 'Failed to reach the AI service. Check the API connection.' }
: m,
),
);
}
};

Expand All @@ -122,17 +170,18 @@ export function AssistantDrawer() {
);

return (
<>
{/* Floating Bottom-Right AI Widget */}
<div
className={cn(
'fixed bottom-6 right-6 sm:bottom-8 sm:right-8 z-50 flex flex-col items-end justify-end transition-all duration-base ease-astroid',
open ? 'opacity-100 scale-100 pointer-events-auto origin-bottom-right' : 'opacity-0 scale-95 pointer-events-none origin-bottom-right',
)}
>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: 24, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 24, scale: 0.95 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="fixed bottom-6 right-6 sm:bottom-8 sm:right-8 z-50 flex flex-col items-end justify-end"
>
<div
role="dialog"
aria-label="AI Executive Command Terminal"
aria-label="Financial Briefing Assistant"
aria-modal="false"
className="relative flex h-[580px] max-h-[calc(100vh-120px)] w-[400px] max-w-[calc(100vw-48px)] flex-col rounded-card border border-border-strong bg-surface/95 shadow-raised backdrop-blur-xl overflow-hidden"
>
Expand All @@ -142,30 +191,33 @@ export function AssistantDrawer() {
<Sparkles className="h-4 w-4" aria-hidden />
</span>
<div>
<h3 className="font-display text-sm font-semibold leading-tight">Command Terminal</h3>
<p className="text-2xs text-foreground-secondary">Autonomous AI Copilot</p>
<h3 className="font-display text-sm font-semibold leading-tight">Financial Briefing</h3>
<p className="text-2xs text-foreground-secondary">Powered by NVIDIA NIM</p>
</div>
</span>
<button
type="button"
onClick={() => setOpen(false)}
className="grid h-8 w-8 place-items-center rounded-button text-foreground-secondary transition-colors duration-fast hover:bg-surface-secondary hover:text-foreground"
className="grid h-8 w-8 place-items-center rounded-button text-foreground-secondary transition-colors duration-fast hover:bg-surface-secondary hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gold focus-visible:ring-offset-2"
aria-label="Close assistant"
>
<X className="h-4 w-4" aria-hidden />
</button>
</header>

<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto p-5">
<div ref={scrollRef} role="log" aria-live="polite" aria-relevant="additions text" className="flex-1 space-y-4 overflow-y-auto p-5">
{messages.length === 0 && (
<div className="rounded-card border border-dashed border-border bg-surface-secondary/40 p-4 text-sm leading-relaxed text-foreground-secondary">
Ask for a treasury summary, a budget-risk review, or a plain-English explanation of a Stellar transfer.
</div>
)}

{messages.map((msg) => (
<div
<motion.div
key={msg.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.15 }}
className={cn('flex gap-3', msg.role === 'user' && 'flex-row-reverse')}
>
{msg.role === 'assistant' ? (
Expand All @@ -183,23 +235,30 @@ export function AssistantDrawer() {
: 'border border-border bg-surface-secondary/60 text-foreground',
)}
>
{msg.content}
{msg.role === 'assistant' ? (
<ReactMarkdown>{msg.content || '_Thinking…_'}</ReactMarkdown>
) : (
<ReactMarkdown>{msg.content}</ReactMarkdown>
)}
</div>
</div>
</motion.div>
))}

{suggestions.length > 0 && (
<div className="space-y-2.5 pt-2">
<p className="text-2xs font-semibold uppercase tracking-wider text-foreground-muted">
Suggested Actions
</p>
<div className="flex flex-col gap-2">
<div className="flex flex-wrap gap-2">
{suggestions.map((s) => (
<button
key={s.label}
type="button"
onClick={() => send(s.prompt)}
className="group flex items-center justify-between gap-2 rounded-button border border-border bg-surface p-3 text-left text-xs text-foreground-secondary transition-all duration-fast hover:border-gold hover:text-foreground"
onClick={() => {
setDraft(s.prompt);
composerRef.current?.focus();
}}
className="group inline-flex items-center gap-1.5 rounded-full border border-border bg-surface px-3 py-1.5 text-xs text-foreground-secondary transition-all duration-fast hover:border-gold hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gold focus-visible:ring-offset-2"
>
<span className="truncate">{s.label}</span>
<ArrowUpRight className="h-3.5 w-3.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" aria-hidden />
Expand All @@ -219,6 +278,8 @@ export function AssistantDrawer() {
>
<div className="flex items-end gap-2 rounded-button border border-border bg-surface px-3 py-2 focus-within:border-gold focus-within:ring-1 focus-within:ring-gold transition-colors">
<textarea
ref={composerRef}
aria-label="Chat message"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
Expand All @@ -228,21 +289,22 @@ export function AssistantDrawer() {
}
}}
rows={1}
placeholder="Ask about spend, policies..."
placeholder="Ask for a financial briefing..."
className="max-h-32 flex-1 resize-none bg-transparent py-1.5 text-sm text-foreground outline-none placeholder:text-foreground-muted"
/>
<button
type="submit"
disabled={!draft.trim()}
className="grid h-8 w-8 shrink-0 place-items-center rounded-button bg-accent-gradient text-background-secondary font-semibold transition-opacity duration-fast disabled:opacity-40"
className="grid h-8 w-8 shrink-0 place-items-center rounded-button bg-accent-gradient text-background-secondary font-semibold transition-opacity duration-fast disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gold focus-visible:ring-offset-2"
aria-label="Send message"
>
<Send className="h-4 w-4" aria-hidden />
</button>
</div>
</form>
</div>
</div>
</>
</motion.div>
)}
</AnimatePresence>
);
}
69 changes: 41 additions & 28 deletions src/components/ui/button.tsx
Original file line number Diff line number Diff line change
@@ -1,56 +1,69 @@
import { cva, type VariantProps } from 'class-variance-authority';
import { Loader2 } from 'lucide-react';
import { forwardRef } from 'react';
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
import { cn } from '@/lib/cn';

const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-button font-sans font-medium transition-all duration-base ease-astroid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]',
'inline-flex items-center justify-center gap-2 whoitespace-nowrap rounded-button font-sans font-medium transition-all duration-base ease-astroid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]',
{
variants: {
variant: {
primary:
'bg-foreground text-background shadow-soft-1 hover:shadow-soft-2 hover:-translate-y-px',
gold: 'bg-gold text-accent-foreground shadow-soft-1 hover:shadow-gold hover:-translate-y-px',
secondary:
'bg-surface text-foreground border border-border shadow-soft-1 hover:bg-surface-secondary hover:-translate-y-px',
outline:
'border border-border-strong bg-transparent text-foreground hover:bg-surface-secondary',
ghost: 'text-foreground-secondary hover:bg-surface-secondary hover:text-foreground',
danger:
'bg-danger text-white shadow-soft-1 hover:brightness-105 hover:-translate-y-px',
link: 'text-foreground underline-offset-4 hover:underline hover:text-gold-strong',
},
size: {
sm: 'h-8 px-3 text-xs',
md: 'h-10 px-4 text-sm',
lg: 'h-12 px-6 text-sm',
icon: 'h-10 w-10',
'icon-sm': 'h-8 w-8',
},
primary:
'bg-foreground text-background shadow-soft-1 hover:shadow-soft-2 hover:-translate-y-px',
gold: 'bg-gold text-accent-foreground shadow-soft-1 hover:shadow-gold hover:-translate-y-px',
secondary:
'bg-surface text-foreground border border-border shadow-soft-1 hover:bg-surface-secondary hover:-translate-y-px',
outline: 'border border-border-strong bg-transparent text-foreground hover:bg-surface-secondary',
ghost: 'text-foreground-secondary hover:bg-surface-secondary hover:text-foreground',
danger:
'bg-danger text-white shadow-soft-1 hover:brightness-105 hover:-translate-y-px',
link: 'text-foreground underline-offset-4 hover:underline hover:text-gold-strong',
suggestion:
'rounded-full bg-surface text-foreground-secondary border border-border hover:bg-surface-secondary hover:text-foreground hover:border-border-strong',
},
size: {
sm: 'h-8 px-3 text-xs',
md: 'h-10 px-4 text-sm',
lg: 'h-12 px-6 text-sm',
icon: 'h-10 w-10',
'icon-sm': 'h-8 w-8',
chip: 'h-7 px-3 text-xs',
},
},
{
defaultVariants: { variant: 'primary', size: 'md' },
},
);

export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
loading?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
leftIcon?: ReactNode;
rightIcon?: ReactNode;
className?: string;
}

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{ className, variant, size, loading, leftIcon, rightIcon, children, disabled, ...props },
{
className,
variant,
size,
loading,
leftIcon,
rightIcon,
children,
disabled,
...props
},
ref,
) => {
return (
<button
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
disabled={disabled ?? loading}
{...props}
{.props}
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
Expand All @@ -65,4 +78,4 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
);
Button.displayName = 'Button';

export { buttonVariants };
export { buttonVariants };
Loading