diff --git a/src/components/ai-assistant/AssistantMessage.jsx b/src/components/ai-assistant/AssistantMessage.jsx index 379f7a5..9a51df9 100644 --- a/src/components/ai-assistant/AssistantMessage.jsx +++ b/src/components/ai-assistant/AssistantMessage.jsx @@ -1,132 +1,47 @@ import * as React from 'react'; +import { Sparkles } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { ResponseDocument } from './ResponseBlocks'; /** - * Renders the light markup the capabilities emit — `**bold**`, bullets, - * numbered items, `_asides_`, and ✓/⚠ check lines. + * The thinking state, shown between sending and the first block arriving. * - * A full markdown renderer is available in the project, but this content is - * produced locally in a known shape, and parsing five constructs keeps the - * assistant's typography inside the design system rather than inheriting a - * library's defaults. + * Labelled as well as animated: three moving dots mean nothing to a screen + * reader, and the visually-hidden text is what tells one that a reply is coming. */ - -const BOLD = /\*\*(.+?)\*\*/g; - -function inline(line, keyPrefix) { - const parts = []; - let last = 0; - let match; - BOLD.lastIndex = 0; - - while ((match = BOLD.exec(line)) !== null) { - if (match.index > last) parts.push(line.slice(last, match.index)); - parts.push( - - {match[1]} - - ); - last = match.index + match[0].length; - } - if (last < line.length) parts.push(line.slice(last)); - return parts.length ? parts : line; -} - -export function RichText({ text, className }) { - const lines = React.useMemo(() => String(text).split('\n'), [text]); - - return ( -
- {lines.map((line, i) => { - if (!line.trim()) return - ); -} - -/** The three-dot indicator shown before the first delta arrives. */ export function ThinkingIndicator() { return ( -
- {[0, 1, 2].map((i) => ( - - ))} - Assistant is thinking +
+ + + + {[0, 1, 2].map((i) => ( + + ))} + + Krow AI is preparing a response
); } /** - * One turn in the conversation. The user's own words sit in a filled bubble; the - * assistant's answer runs full width, because these answers are structured - * multi-paragraph reports and a chat bubble would waste a third of a 380px - * column on padding. + * One turn. + * + * The user's words sit in a filled bubble; the assistant's reply runs the full + * width of the panel. That asymmetry is deliberate — these replies are reports + * with tables and KPI tiles, and a chat bubble would waste a third of a 380px + * column on padding around them. */ -export function Message({ role, text, streaming, stopped }) { +export const Message = React.memo(({ role, text, blocks, streaming, stopped }) => { if (role === 'user') { return ( -
-
+
+
{text}
@@ -135,16 +50,24 @@ export function Message({ role, text, streaming, stopped }) { return (
- - {streaming && ( -
); +}); +Message.displayName = 'Message'; + +/** A subtle divider between turns, so a long thread stays readable. */ +export function TurnDivider() { + return
; } + +export { cn }; diff --git a/src/components/ai-assistant/AssistantPanel.jsx b/src/components/ai-assistant/AssistantPanel.jsx index 7c9258a..8ea8584 100644 --- a/src/components/ai-assistant/AssistantPanel.jsx +++ b/src/components/ai-assistant/AssistantPanel.jsx @@ -4,9 +4,27 @@ import { cn } from '@/lib/utils'; import KrowAssistant from './KrowAssistant'; import { useAssistantPanel } from './AssistantPanelContext'; -/** Panel width, plus the gutter separating it from the dashboard. */ -const PANEL_WIDTH = 380; +const DEFAULT_WIDTH = 380; +const EXPANDED_WIDTH = 620; +/** Gutter separating the panel from the dashboard. */ const GUTTER = 24; +/** Expanded must never dominate: the dashboard stays the primary experience. */ +const MAX_VIEWPORT_SHARE = 0.42; + +/** Tracks viewport width so the expanded panel can be clamped on smaller screens. */ +function useViewportWidth() { + const [width, setWidth] = React.useState(() => + typeof window === 'undefined' ? 1440 : window.innerWidth + ); + + React.useEffect(() => { + const onResize = () => setWidth(window.innerWidth); + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, []); + + return width; +} /** * AssistantPanel — the sliding column the layout renders. @@ -15,23 +33,30 @@ const GUTTER = 24; * dashboard reflow smoothly instead of being covered, and what lets closing * restore the original layout exactly. * - * Two things about the structure matter, and both were bugs before: + * Two structural details matter, and both were bugs at one point: * - * 1. The clip lives *inside* the sticky element, not around it. An ancestor with - * `overflow: hidden` becomes the nearest scroll container, which silently - * disables `position: sticky` on everything beneath it — the panel computed - * as sticky but never actually stuck. + * 1. The clip lives *inside* the sticky element. An ancestor with + * `overflow: hidden` becomes the nearest scroll container and silently + * disables `position: sticky` beneath it. + * 2. The aside stretches to the shell's full height (`self-stretch`), so its + * sticky context runs the whole page rather than ending partway down and + * leaving empty space beside a long dashboard. * - * 2. The aside must stretch to the shell's full height. As a flex item under - * `items-start` it collapsed to its own height, so its sticky context ended - * partway down the page and the panel scrolled away, leaving empty space - * beside a long dashboard. - * - * With both fixed the panel stays pinned for the whole scroll, and because the - * assistant is a fixed-height frame, only its conversation scrolls. + * Expanding widens the same panel in place — it is an analysis workspace inside + * the application layout, not a fullscreen takeover, and never covers the + * dashboard. */ export function AssistantPanel({ stickyClassName, panelHeightClassName }) { - const { context, isOpen, close } = useAssistantPanel(); + const { context, isOpen, isExpanded, close, expand, restore } = useAssistantPanel(); + const viewportWidth = useViewportWidth(); + + // Clamped so a wide panel cannot squeeze the dashboard on a small screen. + const panelWidth = isExpanded + ? Math.max(DEFAULT_WIDTH, Math.min(EXPANDED_WIDTH, Math.round(viewportWidth * MAX_VIEWPORT_SHARE))) + : DEFAULT_WIDTH; + + const trackWidth = panelWidth + GUTTER; + const easing = [0.65, 0, 0.35, 1]; return ( @@ -40,34 +65,40 @@ export function AssistantPanel({ stickyClassName, panelHeightClassName }) { key="assistant" aria-label="Krow AI Assistant" initial={{ width: 0, opacity: 0 }} - animate={{ width: PANEL_WIDTH + GUTTER, opacity: 1 }} + animate={{ width: trackWidth, opacity: 1 }} exit={{ width: 0, opacity: 0 }} - transition={{ duration: 0.3, ease: [0.65, 0, 0.35, 1] }} - /* No `overflow-hidden` here — see (1) above. `self-stretch` keeps the - sticky context running the full height of the shell — see (2). */ + transition={{ duration: 0.3, ease: easing }} + /* No `overflow-hidden` here — see (1). `self-stretch` — see (2). */ className="relative shrink-0 self-stretch" >
- {/* The clip: contains the slide-in and the fixed panel width while - the track animates open, without breaking the sticky above it. */} -
+ {/* The clip: contains the slide-in and the panel width while the + track animates, without breaking the sticky above it. */} + {/* Keyed on the context so switching pages gives a clean panel and aborts any response still streaming for the last one. */} -
+
)} diff --git a/src/components/ai-assistant/AssistantPanelContext.jsx b/src/components/ai-assistant/AssistantPanelContext.jsx index 0c25f59..144fa7c 100644 --- a/src/components/ai-assistant/AssistantPanelContext.jsx +++ b/src/components/ai-assistant/AssistantPanelContext.jsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { resolveAssistantContext } from './placement'; /** - * Open/closed state for the Krow AI Assistant. + * Window state for the Krow AI Assistant: open/closed and default/expanded. * * Lives in context rather than in the layout so the trigger can sit anywhere in * the page — beside that page's own actions — without the layout having to know @@ -14,23 +14,33 @@ import { resolveAssistantContext } from './placement'; const AssistantPanelContext = React.createContext(null); -const STORAGE_KEY = 'krow_assistant_open'; +const OPEN_KEY = 'krow_assistant_open'; +const EXPANDED_KEY = 'krow_assistant_expanded'; /** - * Whether the panel was open before. Kept for the session so moving between - * supported pages does not close a panel the user deliberately opened, while a - * new session still starts clean on the dashboard. + * Window state is kept for the session so moving between supported pages does + * not collapse a panel the user deliberately opened or expanded, while a new + * session still starts clean on the dashboard. */ -function readStoredOpen() { +function readFlag(key) { try { - return sessionStorage.getItem(STORAGE_KEY) === '1'; + return sessionStorage.getItem(key) === '1'; } catch { return false; } } +function writeFlag(key, value) { + try { + sessionStorage.setItem(key, value ? '1' : '0'); + } catch { + // Private mode — state simply resets next session. + } +} + export function AssistantPanelProvider({ role, pathname, children }) { - const [open, setOpen] = React.useState(readStoredOpen); + const [open, setOpen] = React.useState(() => readFlag(OPEN_KEY)); + const [expanded, setExpanded] = React.useState(() => readFlag(EXPANDED_KEY)); const context = React.useMemo( () => resolveAssistantContext(role, pathname), @@ -38,36 +48,47 @@ export function AssistantPanelProvider({ role, pathname, children }) { ); const supported = Boolean(context); - // A stored "open" must not force the panel onto a page that has no assistant. + // A stored "open" must not force the panel onto a page with no assistant. const isOpen = supported && open; + const isExpanded = isOpen && expanded; - const persist = React.useCallback((next) => { + const setOpenState = React.useCallback((next) => { setOpen(next); - try { - sessionStorage.setItem(STORAGE_KEY, next ? '1' : '0'); - } catch { - // Private mode — the panel simply reverts to closed next session. - } + writeFlag(OPEN_KEY, next); }, []); - /* Escape closes the panel, as it does for every other dismissible surface. */ + const setExpandedState = React.useCallback((next) => { + setExpanded(next); + writeFlag(EXPANDED_KEY, next); + }, []); + + /** + * Escape steps back one level rather than closing outright: expanded → default + * → closed. Collapsing a workspace and dismissing it are different intentions, + * and one key press should not do both. + */ React.useEffect(() => { if (!isOpen) return undefined; const onKey = (e) => { - if (e.key === 'Escape') persist(false); + if (e.key !== 'Escape') return; + if (isExpanded) setExpandedState(false); + else setOpenState(false); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [isOpen, persist]); + }, [isOpen, isExpanded, setExpandedState, setOpenState]); const value = React.useMemo(() => ({ context, supported, isOpen, - open: () => persist(true), - close: () => persist(false), - toggle: () => persist(!isOpen), - }), [context, supported, isOpen, persist]); + isExpanded, + open: () => setOpenState(true), + close: () => setOpenState(false), + toggle: () => setOpenState(!isOpen), + expand: () => setExpandedState(true), + restore: () => setExpandedState(false), + }), [context, supported, isOpen, isExpanded, setOpenState, setExpandedState]); return ( @@ -79,7 +100,7 @@ export function AssistantPanelProvider({ role, pathname, children }) { /** * Panel state for triggers and the layout. * - * Returns a safe inert value outside a provider, so a page rendering an + * Returns an inert value outside a provider, so a page rendering an * `AssistantTrigger` in isolation (a test, a storybook) does not crash. */ export function useAssistantPanel() { @@ -87,8 +108,11 @@ export function useAssistantPanel() { context: null, supported: false, isOpen: false, + isExpanded: false, open: () => {}, close: () => {}, toggle: () => {}, + expand: () => {}, + restore: () => {}, }; } diff --git a/src/components/ai-assistant/KrowAssistant.jsx b/src/components/ai-assistant/KrowAssistant.jsx index 52d5861..ccb7c28 100644 --- a/src/components/ai-assistant/KrowAssistant.jsx +++ b/src/components/ai-assistant/KrowAssistant.jsx @@ -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 = ( + + ); return ( - {/* Header — fixed */} -
- -