- {canGoBack && (
-
- )}
+
+
+
+
+
+
+
+
+ New conversation
+
+
+ {isExpanded ? : }
+ {isExpanded ? "Collapse panel" : "Expand panel"}
+
+
+
- {hasThread && (
-
- )}
-
-
-
-
);
}
-
diff --git a/src/components/owliver/OwliverInsight.jsx b/src/components/owliver/OwliverInsight.jsx
deleted file mode 100644
index 23c3b2e..0000000
--- a/src/components/owliver/OwliverInsight.jsx
+++ /dev/null
@@ -1,58 +0,0 @@
-/**
- * The read on the current page, shown above the suggestions:
- *
- * 2 shifts are short of requested headcount this week.
- * Reviewing this week's schedule — coverage, labor cost and
- * upcoming staffing needs.
- * ─────────────────────────────────────────────────────
- *
- * The headline is the finding; the supporting line says what Owliver is reading
- * to get there, which is what makes the panel feel page-aware before you ask it
- * anything. Both come from the page's own data via buildInsight() — nothing here
- * is ever invented, and there is no greeting: the old "Good afternoon, Alex"
- * block cost ~120px before the user reached anything actionable, and its
- * placeholder copy was about hiring, on pages that have nothing to do with it.
- *
- * No insight is a valid state: the panel then opens straight onto its
- * suggestions.
- */
-export default function OwliverInsight({ insight, loading, failed }) {
- if (loading) {
- return (
-
-
-
-
Owliver is reading this page’s data
-
- );
- }
-
- const line = failed
- ? "I couldn't read this page's data just now — ask me anything and I'll pull what I need."
- : insight?.headline;
- const supporting = failed ? null : insight?.supporting;
-
- if (!line) return null;
-
- // No padding and no trailing rule: this sits inside an Astryx ChatMessage,
- // which owns the gutter and the separation from the next turn.
- return (
-
-
- {line}
-
-
- {supporting && (
-
- {supporting}
-
- )}
-
- );
-}
diff --git a/src/components/owliver/OwliverProvider.jsx b/src/components/owliver/OwliverProvider.jsx
index 893a6d9..05fa892 100644
--- a/src/components/owliver/OwliverProvider.jsx
+++ b/src/components/owliver/OwliverProvider.jsx
@@ -5,29 +5,34 @@ import { useLocation, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { krow } from "@/api/client";
import {
- resolveDomain, pageNameFromPath, detailEntityFor, rankSuggestions,
+ resolveDomain, pageNameFromPath, detailEntityFor,
OWLIVER_EXCLUDED, PAGE_CONTEXTS,
} from "@/lib/owliver/pageRegistry";
import { buildPageContext } from "@/lib/owliver/context";
+import getKnowledge from "@/lib/owliver/knowledge";
+import { getOwliverAnswer, lastTopicOf } from "@/lib/owliver/responseEngine";
/**
* OwliverProvider
* ---------------
- * Owns the Owliver conversation and the current page context. Mounted once at
- * the authenticated layout level, so the assistant survives navigation.
+ * Owns the conversation and the page context.
*
- * Performance contract: mounting this provider performs **no** entity reads.
- * Page signals load lazily the first time the user opens Owliver, and are then
- * cached per page by TanStack Query.
+ * Active page → domain → knowledge pack → read / suggestions / answers
*
- * There is exactly **one** submission path — `send()`. The prompt box and the
- * suggestion chips both call it; a suggestion is a pre-written prompt, never a
- * separate request shape and never a canned answer.
+ * The page is the source of context. The pack resolved for the active page
+ * decides what Owliver knows, what it offers to answer, and how it answers —
+ * so nothing on this surface is shared between pages, and a page that has no
+ * pack still falls back to its own registry entry rather than to a generic one.
*
- * Answers come from the on-device engine in lib/owliver/owliverEngine.js, which
- * reasons over `pageContext` — the same normalised page data the panel already
- * has. Nothing is sent anywhere: there is no conversation on a server, no socket
- * and no poll, so a turn is a local async call that always settles.
+ * There is exactly one submission path — `send()`. Suggestion chips, follow-up
+ * chips, in-answer action buttons and the composer all call it; a suggestion is
+ * a pre-written prompt, never a canned answer.
+ *
+ * A turn is staged rather than streamed: the answer is composed locally, then
+ * its thinking lines are played out and its blocks revealed one at a time. That
+ * is deliberate — when a real streaming transport replaces the local pack, the
+ * same three states (thinking → partial → complete) are already what the
+ * surface renders.
*/
const OwliverContext = createContext(null);
@@ -38,45 +43,44 @@ export const useOwliver = () => {
return ctx;
};
+/** Pacing for the staged turn. Long enough to read, short enough not to wait. */
+const STAGE_MS = 620;
+const BLOCK_MS = 260;
+
+const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+
export function OwliverProvider({ children }) {
const location = useLocation();
const [searchParams] = useSearchParams();
- // Owliver opens with the page. It is a non-modal card beside the work, not a
- // dialog the user has to go and find, so there is no closed default state to
- // discover — `close()` and the launcher pill handle dismiss/reopen.
- const [isOpen, setIsOpen] = useState(true);
+ // Docked beside the work on a desktop, a bottom sheet on a phone — where it
+ // would bury the page it is about, so it starts closed there.
+ const [isOpen, setIsOpen] = useState(
+ () => typeof window === "undefined" || window.matchMedia("(min-width: 768px)").matches
+ );
const [isExpanded, setIsExpanded] = useState(false);
- // Owliver is prompt-first: which surface is showing is explicit state, never
- // inferred from `messages.length`. "prompt" is greeting → insight →
- // suggestions → input; only a deliberate user action moves to
- // "conversation".
- const [viewMode, setViewMode] = useState("prompt");
- // The panel is open from the first paint, so its page signals are wanted from
- // the first paint too.
- const [hasOpened, setHasOpened] = useState(true);
const [messages, setMessages] = useState([]);
const [error, setError] = useState(null);
-
- // `isSending` means "a turn is in flight", from the moment the user submits
- // until the agent has finished answering. The response surface decides on its
- // own when to stop showing the working line (as soon as real text lands), so
- // this can stay true through streaming without pinning a spinner on screen.
const [isSending, setIsSendingState] = useState(false);
+ const [thinkingStage, setThinkingStage] = useState(null);
+ const [followUps, setFollowUps] = useState([]);
+
const isSendingRef = useRef(false);
const setSending = useCallback((next) => {
isSendingRef.current = next;
setIsSendingState(next);
}, []);
+ // Every turn takes a ticket. A turn whose ticket has been superseded — by a
+ // navigation, a reset, or a newer question — stops writing to state instead of
+ // finishing into a conversation that has moved on.
+ const turnRef = useRef(0);
const lastSentRef = useRef(null);
- // Mirrors `messages` so failure handling can read the current thread without
- // taking a dependency on it (and re-arming the timeout on every token).
const messagesRef = useRef(messages);
messagesRef.current = messages;
- // A page may publish its own live data (including tab/view state and the
- // selected record) via useOwliverPageContext.
+ /* ------------------------------------------------------- page context */
+
const [pageRegistration, setPageRegistration] = useState({ key: null, data: null });
const registerPage = useCallback((key, data) => {
setPageRegistration((prev) =>
@@ -88,13 +92,13 @@ export function OwliverProvider({ children }) {
const isExcluded = OWLIVER_EXCLUDED.has(pageName);
const detailEntity = detailEntityFor(pageName);
- // A registered page context wins over the route-derived domain — it knows
- // which in-page view is active.
+ // A page that registers its own context wins over the route-derived domain —
+ // it knows which in-page view is active.
const registered = pageRegistration.key ? PAGE_CONTEXTS[pageRegistration.key] : null;
- const domain = useMemo(
- () => registered || resolveDomain(pageName),
- [registered, pageName]
- );
+ const domain = useMemo(() => registered || resolveDomain(pageName), [registered, pageName]);
+
+ /** The knowledge pack for this page. Everything below reads from it. */
+ const knowledge = useMemo(() => getKnowledge(domain.key, domain), [domain]);
const { data: user } = useQuery({
queryKey: ["current-user-layout"], // shared with Layout — no extra request
@@ -106,9 +110,9 @@ export function OwliverProvider({ children }) {
(typeof localStorage !== "undefined" && localStorage.getItem("dev_role_override")) ||
user?.user_role || user?.role || "admin";
- /* ---------------------------------------------------------- page signals */
- // Owliver prefers data the page already loaded. It only fetches its own —
- // and only after the user has opened it once — when no page has registered.
+ /* ---------------------------------------------------------- page data */
+ // Owliver prefers data the page already loaded, and only fetches its own when
+ // no page has registered.
const usePageData = !!registered;
const {
@@ -118,13 +122,12 @@ export function OwliverProvider({ children }) {
} = useQuery({
queryKey: ["owliver-signals", domain.key],
queryFn: () => domain.loadSignals(),
- enabled: hasOpened && !isExcluded && !usePageData && !!domain.loadSignals,
+ enabled: !isExcluded && !usePageData && !!domain.loadSignals,
staleTime: 60 * 1000,
gcTime: 10 * 60 * 1000,
retry: 1,
});
- // `signals` is the normalised summary the registry reasons about.
const signals = useMemo(() => {
// `|| {}` matters: destructuring defaults cover `undefined` but throw on `null`.
if (usePageData) return domain.summarise(pageRegistration.data || {});
@@ -134,104 +137,103 @@ export function OwliverProvider({ children }) {
const signalsLoading = usePageData ? false : fetchLoading;
const signalsError = usePageData ? false : fetchError;
- const insight = useMemo(
- () => (signals ? domain.buildInsight(signals) : null),
- [signals, domain]
+ /** The dynamic current read — a different shape of finding on every page. */
+ const read = useMemo(
+ () => (signals ? knowledge.read(signals) : null),
+ [signals, knowledge]
);
+ /** The questions this page's data makes worth asking. */
const suggestions = useMemo(
- () => (signals ? rankSuggestions(domain.buildSuggestions(signals)) : []),
- [signals, domain]
+ () => (signals ? (knowledge.suggestions(signals) || []).slice(0, 5) : []),
+ [signals, knowledge]
);
const pageContext = useMemo(
() => buildPageContext({
- pageName, domain, role, searchParams, signals, insight, detailEntity,
+ pageName, domain, role, searchParams, signals,
+ insight: read ? { headline: read.headline, supporting: read.lines?.[0] } : null,
+ detailEntity,
}),
- [pageName, domain, role, searchParams, signals, insight, detailEntity]
+ [pageName, domain, role, searchParams, signals, read, detailEntity]
);
- // A request that never lands must not leave the surface looking broken.
- // Roll the optimistic turn back off the thread, return to the prompt-first
- // surface, and let the compact inline error carry the message — the insight
- // and suggestions stay available underneath it.
- const failSend = useCallback((err) => {
- if (err) console.error("Owliver: request failed", err);
- setSending(false);
+ /* ------------------------------------------------------- conversation */
- const prev = messagesRef.current;
- const last = prev[prev.length - 1];
- // Drop the optimistic turn we echoed; it was never persisted.
- const rolledBack = last && last.__optimistic ? prev.slice(0, -1) : prev;
- setMessages(rolledBack);
-
- // Only fall back to the prompt surface when nothing was actually answered.
- // A failed follow-up must not yank the user away from the reply they are
- // still reading — the compact error sits above the prompt either way.
- if (!rolledBack.some((m) => m.role !== "user")) setViewMode("prompt");
-
- // Carry the status through when the API gave one. A bare "couldn't complete
- // that request" is indistinguishable between a missing agent, an expired
- // session and a server fault, which is exactly what made this hard to place.
- const status = err?.status ? ` (${err.status})` : "";
- setError(`Owliver couldn't complete that request.${status}`);
- }, [setSending]);
-
- // A new page is a new context. Return to the prompt surface, drop the
- // visible thread and invalidate the context we last sent, so Owliver always
- // opens on what *this* page needs rather than on an answer about the last
- // one. (The route is the only thing that resets this — navigating is a
- // deliberate act, so it can't surprise the user mid-reply.)
- //
- // Keyed on the raw search *string*, not the URLSearchParams object: an
- // identity change there would tear down a healthy in-flight turn.
+ // A new page is a new context: drop the thread, cancel any turn in flight and
+ // open on what *this* page needs. Keyed on the raw search string, not the
+ // URLSearchParams object, whose identity changes on every render.
useEffect(() => {
+ turnRef.current += 1;
setSending(false);
- setViewMode("prompt");
+ setThinkingStage(null);
setMessages([]);
+ setFollowUps([]);
setError(null);
}, [pageName, location.search, setSending]);
/**
- * The one submission path. `rawText` is whatever the user asked — typed into
- * the prompt box or carried by a suggestion chip.
+ * The one submission path. `rawText` is whatever the user asked — typed, or
+ * carried by a suggestion, a follow-up or an in-answer action.
*/
const send = useCallback(async (rawText) => {
const text = (rawText || "").trim();
if (!text || isSendingRef.current) return;
+ const ticket = (turnRef.current += 1);
+ const live = () => turnRef.current === ticket;
+
lastSentRef.current = text;
setError(null);
+ setFollowUps([]);
setSending(true);
- // An actual request — and only an actual request — moves Owliver into the
- // response surface. Echo the user's turn first so a click on a suggestion
- // produces immediate visible feedback; `failSend` rolls it back if the
- // answer never arrives.
- setViewMode("conversation");
- // The thread as it stood *before* this turn — what a follow-up ("why?") is
- // answered against.
- const priorMessages = messagesRef.current;
- setMessages((prev) => [...prev, { role: "user", content: text, __optimistic: true }]);
+ // The user's turn lands immediately — a click on a suggestion has to produce
+ // visible feedback before anything is computed.
+ const prior = messagesRef.current;
+ setMessages([...prior, { id: `you-${Date.now()}`, role: "user", content: text }]);
+ setThinkingStage(`${knowledge.working}…`);
- try {
- // Loaded on demand so the engine only ships to sessions that open Owliver.
- const { getOwliverResponse } = await import("@/lib/owliver/owliverEngine");
- const reply = await getOwliverResponse({
- prompt: text,
- pageContext,
- conversation: priorMessages,
- });
- setMessages((prev) => [
- // Nothing will echo this turn back, so it stops being provisional.
- ...prev.map((m) => (m.__optimistic ? { ...m, __optimistic: false } : m)),
- { id: `owliver-${Date.now()}`, role: "assistant", content: reply },
- ]);
- setSending(false);
- } catch (err) {
- failSend(err);
+ const answer = await getOwliverAnswer({
+ prompt: text,
+ domainKey: domain.key,
+ domain,
+ signals,
+ pageContext,
+ conversation: prior,
+ });
+ if (!live()) return;
+
+ // Play the answer's own working lines ("Checking 27 opportunities…"), then
+ // hand over to the response surface.
+ const stages = answer.stages?.length ? answer.stages : [`${knowledge.working}…`];
+ for (const stage of stages) {
+ setThinkingStage(stage);
+ await wait(STAGE_MS);
+ if (!live()) return;
}
- }, [pageContext, failSend, setSending]);
+
+ setThinkingStage(null);
+ const id = `owliver-${Date.now()}`;
+ setMessages((prev) => [
+ ...prev,
+ { id, role: "assistant", blocks: answer.blocks, topic: answer.topic, revealed: 1 },
+ ]);
+
+ // Blocks land one at a time: the text of the answer, then the evidence it
+ // rests on, then the actions. The composer stays disabled until the answer
+ // is whole, so a follow-up is never asked against half an answer.
+ for (let i = 2; i <= answer.blocks.length; i += 1) {
+ await wait(BLOCK_MS);
+ if (!live()) return;
+ setMessages((prev) => prev.map((m) => (m.id === id ? { ...m, revealed: i } : m)));
+ }
+
+ if (!live()) return;
+ setFollowUps(answer.followUps || []);
+ if (answer.error) setError("Something went wrong while analyzing this workspace.");
+ setSending(false);
+ }, [domain, knowledge, signals, pageContext, setSending]);
/** Re-run the prompt that failed, not a blank one. */
const retry = useCallback(() => {
@@ -241,47 +243,59 @@ export function OwliverProvider({ children }) {
send(text);
}, [send]);
- // Opening always lands on the prompt-first surface, whatever happened in an
- // earlier turn.
- const open = useCallback(() => {
- setHasOpened(true);
- setIsOpen(true);
- setViewMode("prompt");
- setError(null);
- }, []);
+ const open = useCallback(() => { setIsOpen(true); setError(null); }, []);
const close = useCallback(() => setIsOpen(false), []);
const toggle = useCallback(() => (isOpen ? close() : open()), [isOpen, open, close]);
const toggleExpanded = useCallback(() => setIsExpanded((v) => !v), []);
const newConversation = useCallback(() => {
+ turnRef.current += 1;
setSending(false);
+ setThinkingStage(null);
setMessages([]);
+ setFollowUps([]);
setError(null);
- setViewMode("prompt");
}, [setSending]);
- // Return to the prompt surface without discarding the thread.
- const showInsight = useCallback(() => { setViewMode("prompt"); setError(null); }, []);
+ const hasThread = messages.length > 0;
+
+ // An answer normally names its own follow-ups. When it doesn't — the pages
+ // answered through the fallback adapter can't — the page's own remaining
+ // suggestions stand in, minus anything already asked in this thread. Offering
+ // back a question the user just asked is the fastest way to make a panel feel
+ // like it isn't listening.
+ const effectiveFollowUps = useMemo(() => {
+ if (followUps.length) return followUps;
+ const asked = new Set(
+ messages.filter((m) => m.role === "user").map((m) => (m.content || "").trim())
+ );
+ return suggestions
+ .filter((s) => !asked.has((s.prompt || s.label || "").trim()))
+ .slice(0, 3);
+ }, [followUps, messages, suggestions]);
const value = useMemo(() => ({
isOpen, open, close, toggle,
isExpanded, toggleExpanded,
isAvailable: !isExcluded,
- messages, send, retry, isSending, error,
- newConversation, showInsight, registerPage,
- atInsight: viewMode === "prompt",
- // Whether there is a thread to go back to — drives the header's back
- // control, so it only appears when it actually does something.
- hasThread: messages.length > 0,
- domain, pageName, pageContext, role,
- signals, signalsLoading, signalsError,
- insight, suggestions,
+
+ // conversation
+ messages, send, retry, isSending, error, thinkingStage,
+ followUps: effectiveFollowUps,
+ newConversation, hasThread,
+ lastTopic: lastTopicOf(messages),
+
+ // page context
+ registerPage, domain, knowledge, pageName, pageContext, role,
+ signals, signalsLoading, signalsError, read, suggestions,
+ placeholder: knowledge.placeholder,
+ contextLabel: knowledge.contextLabel,
+ workspaceTitle: knowledge.title,
}), [
isOpen, open, close, toggle, isExpanded, toggleExpanded, isExcluded,
- messages, send, retry, isSending, error, newConversation, showInsight,
- viewMode,
- registerPage, domain, pageName, pageContext, role, signals,
- signalsLoading, signalsError, insight, suggestions, user,
+ messages, send, retry, isSending, error, thinkingStage, effectiveFollowUps,
+ newConversation, hasThread, registerPage, domain, knowledge, pageName,
+ pageContext, role, signals, signalsLoading, signalsError, read, suggestions,
]);
return
{children};
diff --git a/src/components/owliver/OwliverShell.jsx b/src/components/owliver/OwliverShell.jsx
index 656ca9f..de776bf 100644
--- a/src/components/owliver/OwliverShell.jsx
+++ b/src/components/owliver/OwliverShell.jsx
@@ -1,98 +1,97 @@
-import { useRef } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
-import {
- ChatLayout,
- ChatMessageList,
- ChatMessage,
- ChatComposer,
-} from "@astryxdesign/core";
import { useOwliver } from "./OwliverProvider";
import OwliverHeader from "./OwliverHeader";
-import OwliverInsight from "./OwliverInsight";
+import OwliverContextBar from "./OwliverContextBar";
+import OwliverEmptyState from "./OwliverEmptyState";
+import OwliverResponse from "./blocks/OwliverResponse";
import OwliverSuggestions from "./OwliverSuggestions";
-import OwliverAnswer from "./OwliverAnswer";
-import OwliverAvatar from "./OwliverAvatar";
+import OwliverComposer from "./OwliverComposer";
+import OwliverThinking from "./OwliverThinking";
import OwliverLauncher from "./OwliverLauncher";
-/** Owliver's own mark on every turn he speaks — Astryx slots it, KROW supplies it. */
-const owliverMark =
;
-
/**
- * OwliverShell
- * ------------
- * Owliver's chat surface, built on Astryx's ChatLayout (@astryxdesign/core).
+ * OwliverShell — the AI workspace.
*
- * Astryx supplies the structure and the chat behaviour; KROW supplies the
- * branding and every piece of content. Nothing about the provider, the local
- * engine, the page registry or the demo data changes — this file only decides
- * how a turn is presented.
+ * ┌──────────────────────────────────────┐
+ * │ ✦ Owliver ⋯ × │ fixed — identity + page
+ * │ Saving Engine │
+ * │ ⛁ Using Saving Engine data ⌄ │ fixed — what is being read
+ * ├──────────────────────────────────────┤
+ * │ │
+ * │ conversation — text, metrics, cards, │ the only band that scrolls
+ * │ tables, charts, flows, actions │
+ * │ │
+ * ├──────────────────────────────────────┤
+ * │ Suggested follow-ups │ fixed — derived from the answer
+ * │ ( … ) ( … ) │
+ * │ ┌──────────────────────────────────┐ │
+ * │ │ Ask Owliver about Saving Engine… │ │ fixed
+ * │ │ ✦ Saving Engine [ ↑ ] │ │
+ * │ └──────────────────────────────────┘ │
+ * └──────────────────────────────────────┘
*
- * Why ChatLayout does the work here:
- * • With no `scrollRef`, its own root is the scroll container and the composer
- * rides a sticky dock at the bottom. That is exactly the required contract —
- * header pinned, conversation scrolling, composer always reachable — with no
- * scroll plumbing of our own.
- * • `ChatMessage` renders a flat sender-attributed row, not a bubble, which is
- * the message hierarchy this needs.
- * • `ChatComposer` already owns the input, the send button, the disabled state
- * and an error status line, so `OwliverPrompt` and `OwliverError` are gone
- * rather than reimplemented beside it.
+ * The conversation is the surface. Everything else is a thin band around it:
+ * the panel owns its own scrollport, so a long answer never lengthens the page
+ * behind it, and the composer is always one keystroke away from wherever the
+ * user has read to.
*
- * `density="compact"` because this is a ~360px rail rather than a full-page
- * chat; the default 'balanced' is tuned for a centred conversation column.
- *
- * The default state is not a special screen. It is the same ChatMessageList
- * holding one assistant turn — the page read — followed by the same suggestion
- * chips the reply state uses, so there is only ever one visual system.
- *
- * Placement (right-hand rail on desktop, bottom sheet on small screens) stays in
- * `.krow-owliver-panel`; Astryx owns everything inside it.
+ * Two scrolling rules, both about not fighting the reader. New turns are
+ * followed automatically, but only while the user is already at the bottom —
+ * scroll up to re-read an earlier answer and the panel leaves you there.
*/
export default function OwliverShell() {
const {
isOpen, close, isAvailable, isExpanded, toggleExpanded,
- domain, insight, suggestions, signalsLoading, signalsError,
- atInsight, showInsight, hasThread, newConversation,
- messages, send, isSending, error, retry,
+ knowledge, workspaceTitle, contextLabel, placeholder,
+ read, suggestions, signals, signalsLoading, signalsError,
+ hasThread, newConversation,
+ messages, send, isSending, error, retry, thinkingStage, followUps,
} = useOwliver();
- const launcherRef = useRef(null);
+ const scrollRef = useRef(null);
+ const endRef = useRef(null);
+ const [following, setFollowing] = useState(true);
+
+ // "Following" means the user is reading the newest turn. Anything more than a
+ // short distance from the bottom means they have gone back for something, and
+ // the panel stops pulling them forward until they return.
+ const onScroll = useCallback(() => {
+ const el = scrollRef.current;
+ if (!el) return;
+ const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
+ setFollowing(distance < 80);
+ }, []);
+
+ useEffect(() => {
+ if (!isOpen || !following) return;
+ endRef.current?.scrollIntoView({ block: "end", behavior: "smooth" });
+ }, [messages, thinkingStage, isSending, isOpen, following]);
+
+ // A new question is always followed, wherever the user had scrolled to.
+ const submit = useCallback((text) => {
+ setFollowing(true);
+ send(text);
+ }, [send]);
+
+ // The other floating controls step aside while the panel is open (the rule
+ // lives in index.css; this is what turns it on).
+ useEffect(() => {
+ const root = document.documentElement;
+ if (isOpen && isAvailable) root.setAttribute("data-owliver-open", "true");
+ else root.removeAttribute("data-owliver-open");
+ return () => root.removeAttribute("data-owliver-open");
+ }, [isOpen, isAvailable]);
if (!isAvailable) return null;
- const placeholder =
- domain.placeholder || `Ask Owliver about ${domain.title.toLowerCase()}...`;
-
- // Follow-ups are the same page-derived suggestions minus anything already
- // asked in this thread — offering back a question the user just asked is the
- // fastest way to make a panel feel like it isn't listening.
- const asked = new Set(
- messages.filter((m) => m.role === "user").map((m) => (m.content || "").trim())
- );
- const followUps = suggestions
- .filter((s) => !asked.has((s.prompt || s.label || "").trim()))
- .slice(0, 3);
-
- const composer = (
-
- );
+ const facts = signals ? knowledge.contextFacts(signals) || [] : [];
return (
<>
{/* Dismissing the panel leaves the pill behind, so the page never loses
- its way back to Owliver. Rendered unconditionally — the launcher owns
- its own `!isOpen` guard inside an AnimatePresence, and gating it here
- too would unmount it before it could animate out. */}
-
+ its way back to Owliver. */}
+
{isOpen && (
@@ -100,93 +99,99 @@ export default function OwliverShell() {
key="ow-panel"
data-owliver=""
role="complementary"
- aria-label={`Owliver — ${domain.title}`}
+ aria-label={`Owliver — ${workspaceTitle}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.16, ease: "easeOut" }}
className="krow-owliver-panel z-40 flex flex-col overflow-hidden"
data-expanded={isExpanded ? "true" : "false"}
- data-mode={atInsight ? "prompt" : "conversation"}
>
- {/* ChatLayout is the whole body: it scrolls internally and docks the
- composer. `scrollButton={null}` because a 360px rail with a
- pinned composer has nowhere to put a floating jump button. */}
-
-
-
- {atInsight ? (
-
-
-
-
- ) : (
- <>
- {messages.map((message, i) => (
-
-
-
- ))}
+
- {/* Where an answer leaves you: the next useful question,
- in the same chips as the default state. Held back
- while a turn is in flight. */}
- {!isSending && hasThread && followUps.length > 0 && (
-
-
-
- )}
-
- {error && (
-
-
- Try that again
-
-
- )}
- >
+ {/* The one scrolling band. */}
+
+ {!hasThread ? (
+
+ ) : (
+
+ {messages.map((message, i) =>
+ message.role === "user" ? (
+
+
+ You
+
+
+ {message.content}
+
+
+ ) : (
+
+ )
)}
-
-
+
+ {isSending && thinkingStage &&
}
+
+
+
+ )}
+
+ {/* Where an answer leaves you: the questions it made worth asking.
+ Only after a turn — before one, the suggestions live in the
+ conversation area where there is room to read them. */}
+ {hasThread && !isSending && followUps.length > 0 && (
+
+
+
+ )}
+
+
)}
diff --git a/src/components/owliver/OwliverSuggestions.jsx b/src/components/owliver/OwliverSuggestions.jsx
index 8dd2e9c..48e8401 100644
--- a/src/components/owliver/OwliverSuggestions.jsx
+++ b/src/components/owliver/OwliverSuggestions.jsx
@@ -1,107 +1,59 @@
-import {
- Sparkles,
- AlertTriangle,
- Calendar,
- DollarSign,
- Users,
- ChevronRight,
-} from "lucide-react";
-
/**
- * Returns a contextual icon matching the suggestion prompt text.
+ * Questions this page's data makes worth asking.
+ *
+ * They are prompts, not shortcuts: clicking one goes through the same `send()`
+ * path as typing it. The list is derived from the active page's knowledge pack,
+ * so it changes with the workspace — and after an answer it is replaced by that
+ * answer's own follow-ups, which is what stops the panel reading like a fixed
+ * command menu.
*/
-function getSuggestionIcon(label = "") {
- const text = String(label || "").toLowerCase();
- if (
- text.includes("short") ||
- text.includes("gap") ||
- text.includes("understaffed") ||
- text.includes("conflict") ||
- text.includes("risk") ||
- text.includes("emergency")
- ) {
- return AlertTriangle;
- }
- if (
- text.includes("cost") ||
- text.includes("budget") ||
- text.includes("spend") ||
- text.includes("$") ||
- text.includes("payroll") ||
- text.includes("labor")
- ) {
- return DollarSign;
- }
- if (
- text.includes("coverage") ||
- text.includes("upcoming") ||
- text.includes("week") ||
- text.includes("schedule") ||
- text.includes("shift")
- ) {
- return Calendar;
- }
- if (
- text.includes("staff") ||
- text.includes("worker") ||
- text.includes("candidate") ||
- text.includes("applicant") ||
- text.includes("roster")
- ) {
- return Users;
- }
- if (
- text.includes("summarize") ||
- text.includes("overview") ||
- text.includes("recap") ||
- text.includes("report") ||
- text.includes("health")
- ) {
- return Sparkles;
- }
- return ChevronRight;
-}
+export default function OwliverSuggestions({
+ suggestions,
+ onSelect,
+ disabled,
+ heading = "Suggested questions",
+ limit,
+ variant = "wrap",
+}) {
+ const items = (suggestions || [])
+ .filter(Boolean)
+ .map((item) => (typeof item === "string"
+ ? { label: item, prompt: item }
+ : { label: item.label || item.prompt, prompt: item.prompt || item.label }))
+ .filter((item) => item.label);
-export default function OwliverSuggestions({ suggestions, onSelect, disabled, heading }) {
- if (!suggestions?.length) return null;
-
- const sectionHeading = heading || "Suggested actions";
+ const shown = limit ? items.slice(0, limit) : items;
+ if (!shown.length) return null;
return (
-