update chatbox
This commit is contained in:
@@ -1,33 +1,48 @@
|
||||
import * as React from 'react';
|
||||
import { RotateCcw, Sparkles, X } from 'lucide-react';
|
||||
import { Maximize2, Minimize2, RotateCcw, Sparkles, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Surface } from '@/components/ds/Surface';
|
||||
import { IconButton } from '@/components/ds/IconButton';
|
||||
import { Alert } from '@/components/ds/Alert';
|
||||
import { KROW_LOGO_URL } from '@/assets/brand';
|
||||
import { useAssistantFacts, useConversation } from './useAssistant';
|
||||
import { Message, ThinkingIndicator } from './AssistantMessage';
|
||||
import { buildIntro, buildPrompts } from './dynamic';
|
||||
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
|
||||
import { PromptInput } from './PromptInput';
|
||||
import { PromptChips } from './PromptChips';
|
||||
|
||||
/**
|
||||
* Krow AI Assistant — the dashboard's contextual assistant.
|
||||
*
|
||||
* One component, reused on every supported page; only `context` differs.
|
||||
*
|
||||
* The layout is a fixed three-part frame — header, conversation, composer —
|
||||
* where only the conversation scrolls. That is what lets the panel stay pinned
|
||||
* beside a long dashboard without ever growing the page or losing its input.
|
||||
* The frame is fixed at three parts — header, body, composer — and only the body
|
||||
* scrolls. That is what lets the panel stay pinned beside a long dashboard
|
||||
* without ever growing the page or scrolling its own input out of reach.
|
||||
*
|
||||
* The empty state is deliberately a conversational entry point rather than a
|
||||
* menu: an icon, a question, and an input. Suggested chips fill the input rather
|
||||
* than firing an action, so the user always sees the question before it is asked
|
||||
* and can edit it — which is what makes this read as an assistant instead of a
|
||||
* second dashboard.
|
||||
* Two states share that frame:
|
||||
*
|
||||
* Independent of Owliver. It talks to a provider (see provider.js) which can
|
||||
* later be pointed at an external service without changing anything here.
|
||||
* - **Landing.** One centred block: mark, contextual label, greeting,
|
||||
* description, input, suggestions. Deliberately light — it is an invitation,
|
||||
* not a page, so nothing in it competes with the dashboard beside it.
|
||||
* - **Conversation.** The thread takes the body and the composer returns to the
|
||||
* bottom, because from then on the thread is what the user is reading.
|
||||
*
|
||||
* `expanded` widens the same panel into an analysis workspace. `data-wide` lets
|
||||
* individual response blocks use the extra room (a KPI row goes to three
|
||||
* columns) without any of them needing to know the panel's pixel width.
|
||||
*
|
||||
* Independent of Owliver. Answers come from a provider (see provider.js) that
|
||||
* can later be pointed at an external service without changing anything here.
|
||||
*/
|
||||
export default function KrowAssistant({ context, className, onClose }) {
|
||||
export default function KrowAssistant({
|
||||
context,
|
||||
className,
|
||||
expanded = false,
|
||||
onClose,
|
||||
onExpand,
|
||||
onRestore,
|
||||
}) {
|
||||
const facts = useAssistantFacts({ needsWorkerProfile: context.needsWorkerProfile });
|
||||
const { messages, pending, error, busy, send, stop, reset } = useConversation({
|
||||
contextId: context.id,
|
||||
@@ -38,23 +53,46 @@ export default function KrowAssistant({ context, className, onClose }) {
|
||||
const scrollRef = React.useRef(null);
|
||||
const isEmpty = messages.length === 0 && !pending;
|
||||
|
||||
/* Follow the newest content as it streams. */
|
||||
/* Greeting and suggestions come from live data, so they recompute only when
|
||||
the data or the page actually changes. */
|
||||
const intro = React.useMemo(
|
||||
() => buildIntro(context.id, facts, facts.profile?.full_name),
|
||||
[context.id, facts]
|
||||
);
|
||||
const prompts = React.useMemo(() => buildPrompts(context.id, facts), [context.id, facts]);
|
||||
|
||||
/* Follow the newest content. Direct `scrollTop` rather than smooth scrolling:
|
||||
at streaming frequency a smooth scroll never catches up and the thread
|
||||
visibly lags the text. */
|
||||
React.useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [messages, pending]);
|
||||
|
||||
/**
|
||||
* A chip's prompt resolves to its capability so the answer is precise, while
|
||||
* the same question typed by hand still resolves through keyword matching.
|
||||
*/
|
||||
const submit = (text) => {
|
||||
const question = text.trim();
|
||||
const submit = React.useCallback((text) => {
|
||||
const question = String(text).trim();
|
||||
if (!question || busy) return;
|
||||
const chip = context.chips.find((c) => c.prompt === question);
|
||||
send({ question, capability: chip?.capability ?? null });
|
||||
send({ question });
|
||||
setInput('');
|
||||
};
|
||||
}, [busy, send]);
|
||||
|
||||
/* A chip is already a complete question, so it runs on click. */
|
||||
const runPrompt = React.useCallback((prompt) => {
|
||||
if (busy) return;
|
||||
setInput('');
|
||||
send({ question: prompt.prompt, capability: prompt.capability });
|
||||
}, [busy, send]);
|
||||
|
||||
const composer = (
|
||||
<PromptInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSubmit={submit}
|
||||
onStop={stop}
|
||||
busy={busy}
|
||||
placeholders={intro.placeholders}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Surface
|
||||
@@ -62,13 +100,15 @@ export default function KrowAssistant({ context, className, onClose }) {
|
||||
radius="xl"
|
||||
padding="none"
|
||||
elevation="sm"
|
||||
data-wide={expanded || undefined}
|
||||
className={cn('flex flex-col overflow-hidden', className)}
|
||||
role="region"
|
||||
aria-label="Krow AI Assistant"
|
||||
>
|
||||
{/* Header — fixed */}
|
||||
<div className="flex shrink-0 items-center gap-2.5 border-b border-white/60 px-4 py-3">
|
||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-krow-blue text-white shadow-brand">
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
{/* Header */}
|
||||
<div className="flex shrink-0 items-center gap-2.5 border-b border-white/60 px-3.5 py-2.5">
|
||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-lg bg-krow-blue text-white shadow-brand">
|
||||
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -76,92 +116,101 @@ export default function KrowAssistant({ context, className, onClose }) {
|
||||
<p className="truncate text-caption leading-tight text-ink-3">{context.page}</p>
|
||||
</div>
|
||||
|
||||
{messages.length > 0 && (
|
||||
<IconButton icon={RotateCcw} label="New conversation" variant="ghost" size="sm" onClick={reset} />
|
||||
)}
|
||||
{onClose && (
|
||||
<IconButton icon={X} label="Close assistant" variant="ghost" size="sm" onClick={onClose} />
|
||||
)}
|
||||
{/* Window controls */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
{messages.length > 0 && (
|
||||
<IconButton icon={RotateCcw} label="New conversation" variant="ghost" size="sm" onClick={reset} />
|
||||
)}
|
||||
{expanded
|
||||
? onRestore && (
|
||||
<IconButton icon={Minimize2} label="Restore panel width" variant="ghost" size="sm" onClick={onRestore} />
|
||||
)
|
||||
: onExpand && (
|
||||
<IconButton icon={Maximize2} label="Expand to workspace" variant="ghost" size="sm" onClick={onExpand} />
|
||||
)}
|
||||
{onClose && (
|
||||
<IconButton icon={X} label="Close assistant" variant="ghost" size="sm" onClick={onClose} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conversation — the only scrolling region */}
|
||||
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto" aria-live="polite">
|
||||
{isEmpty ? (
|
||||
/* Landing: an invitation to ask, not a list of features. */
|
||||
<div className="flex h-full flex-col items-center justify-center px-6 py-8 text-center animate-fade-in">
|
||||
<span className="relative mb-5 grid h-14 w-14 place-items-center rounded-2xl bg-white shadow-brand ring-1 ring-krow-blue/10">
|
||||
<img src={KROW_LOGO_URL} alt="" aria-hidden="true" className="h-5 w-auto object-contain" />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -right-1 -top-1 grid h-5 w-5 place-items-center rounded-full bg-krow-blue text-white shadow-sm"
|
||||
>
|
||||
<Sparkles className="h-2.5 w-2.5" />
|
||||
{isEmpty ? (
|
||||
/* ── Landing: one compact centred block ───────────────────────── */
|
||||
<div className="flex min-h-0 flex-1 flex-col justify-center overflow-y-auto px-4 py-4">
|
||||
{/* Capped so the block stays a tight composition when expanded. */}
|
||||
<div className="mx-auto w-full max-w-sm animate-fade-in space-y-3.5">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<span className="relative mb-2.5 grid h-10 w-10 place-items-center rounded-xl bg-white shadow-brand ring-1 ring-krow-blue/10">
|
||||
<img src={KROW_LOGO_URL} alt="" aria-hidden="true" className="h-3.5 w-auto object-contain" />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -right-1 -top-1 grid h-4 w-4 place-items-center rounded-full bg-krow-blue text-white shadow-sm"
|
||||
>
|
||||
<Sparkles className="h-2 w-2" />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<h3 className="font-heading text-title text-ink-1">
|
||||
What would you like to analyze today?
|
||||
</h3>
|
||||
<p className="mt-2 max-w-[19rem] text-body-sm leading-relaxed text-ink-3">
|
||||
{context.tagline}
|
||||
</p>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-krow-blue">
|
||||
{intro.title}
|
||||
</p>
|
||||
<h3 className="mt-1 text-balance font-heading text-body-lg font-semibold leading-snug text-ink-1">
|
||||
{intro.greeting}
|
||||
</h3>
|
||||
<p className="mt-1 text-caption leading-relaxed text-ink-3">{intro.description}</p>
|
||||
</div>
|
||||
|
||||
{composer}
|
||||
|
||||
<PromptChips prompts={prompts} onSelect={runPrompt} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 px-4 py-4">
|
||||
</div>
|
||||
) : (
|
||||
/* ── Conversation ─────────────────────────────────────────────── */
|
||||
<>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-4"
|
||||
aria-live="polite"
|
||||
aria-relevant="additions text"
|
||||
>
|
||||
{messages.map((message, i) => (
|
||||
<Message key={i} role={message.role} text={message.text} stopped={message.stopped} />
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && message.role === 'user' && <TurnDivider />}
|
||||
<Message
|
||||
role={message.role}
|
||||
text={message.text}
|
||||
blocks={message.blocks}
|
||||
stopped={message.stopped}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{pending?.thinking && <ThinkingIndicator />}
|
||||
{pending && !pending.thinking && <Message role="assistant" text={pending.text} streaming />}
|
||||
{pending && !pending.thinking && (
|
||||
<Message role="assistant" blocks={pending.blocks} streaming />
|
||||
)}
|
||||
|
||||
{error && <Alert tone="destructive">{error}</Alert>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Suggested prompts + composer — fixed */}
|
||||
<div className="shrink-0 space-y-2.5 border-t border-white/60 px-3 pb-3 pt-2.5">
|
||||
{!busy && (
|
||||
/* On the landing there is room to show every suggestion, so they wrap.
|
||||
Once a conversation is underway the space belongs to the thread, so
|
||||
they collapse to a single scrollable row. */
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-1.5',
|
||||
isEmpty ? 'flex-wrap justify-center' : 'overflow-x-auto no-scrollbar'
|
||||
<div className="shrink-0 space-y-2 border-t border-white/60 px-3 pb-2.5 pt-2.5">
|
||||
{/* Capped in conversation: the vertical space belongs to the thread.
|
||||
Expanded fits more per line, so it can afford one more. */}
|
||||
{!busy && (
|
||||
<PromptChips
|
||||
prompts={prompts}
|
||||
onSelect={runPrompt}
|
||||
max={expanded ? 4 : 3}
|
||||
align="start"
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{context.chips.map((chip) => (
|
||||
<button
|
||||
key={chip.label}
|
||||
type="button"
|
||||
/* Fills the input — never sends, never navigates. */
|
||||
onClick={() => setInput(chip.prompt)}
|
||||
className="shrink-0 rounded-full border border-border bg-surface px-2.5 py-1 text-caption
|
||||
text-ink-3 transition-colors duration-base hover:border-krow-blue/40
|
||||
hover:bg-krow-blue-tint hover:text-krow-blue focus-ring"
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
))}
|
||||
{composer}
|
||||
<p className="px-1 text-[10px] leading-tight text-ink-4">
|
||||
Krow AI reads this page's data. Check anything you act on.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PromptInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSubmit={submit}
|
||||
onStop={stop}
|
||||
busy={busy}
|
||||
placeholders={context.placeholders}
|
||||
size={isEmpty ? 'large' : 'default'}
|
||||
/>
|
||||
|
||||
<p className="px-1 text-[10px] leading-tight text-ink-4">
|
||||
Krow AI reads this page's data. Check anything you act on.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user