776 lines
32 KiB
JavaScript
776 lines
32 KiB
JavaScript
import * as React from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import {
|
|
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Trash2,
|
|
} 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 {
|
|
useAssignments, useAssignWorkers, useCreateJobPosting, useGenerateJobDescription,
|
|
useMarkInterviewReady, useUpdateJobPosting,
|
|
usePreferences, useRoleCategories,
|
|
} from '@/lib/krowHooks';
|
|
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
|
|
import { runAction } from '@/lib/skills/actions';
|
|
import { skillsForContext } from '@/lib/skills/registry';
|
|
import { owliverSuggestions } from '@/lib/skills/owliverResolver';
|
|
import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
|
|
import { profileForEmail } from '@/lib/skillGraph';
|
|
import { groupByRecency } from './history';
|
|
import { useAssistantPanel } from './AssistantPanelContext';
|
|
import { usePageContext } from './PageContext';
|
|
import { useAssistantFacts, useConversation, useCurrentUserName } from './useAssistant';
|
|
import { buildIntro, buildPrompts } from './dynamic';
|
|
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
|
|
import { PromptInput } from './PromptInput';
|
|
import { PromptChips } from './PromptChips';
|
|
|
|
/**
|
|
* Owliver History — the conversations that came before.
|
|
*
|
|
* A list rather than a second panel: it replaces the thread in the body while
|
|
* the header and composer stay exactly where they are, so History is a state of
|
|
* the panel rather than a place you navigate to and have to find your way out
|
|
* of. Grouped by when, because that is how people look for a conversation they
|
|
* half-remember.
|
|
*/
|
|
function HistoryView({ groups, currentId, onOpen, onForget }) {
|
|
if (!groups.length) {
|
|
return (
|
|
<div className="animate-fade-in">
|
|
<p className="font-heading text-body font-semibold text-ink-1">No conversations yet</p>
|
|
<p className="mt-1 text-body-sm leading-relaxed text-ink-3">
|
|
Ask Owliver something and it will be here afterwards. History is kept on this device.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="animate-fade-in space-y-4">
|
|
{groups.map((group) => (
|
|
<section key={group.id} aria-label={group.label}>
|
|
<h3 className="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
|
|
{group.label}
|
|
</h3>
|
|
<ul className="space-y-1">
|
|
{group.items.map((record) => (
|
|
<li key={record.id} className="group/item relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => onOpen(record)}
|
|
aria-current={record.id === currentId ? 'true' : undefined}
|
|
className={cn(
|
|
`flex w-full items-start gap-2 rounded-lg border px-2.5 py-2 pr-8 text-left transition-colors
|
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50`,
|
|
record.id === currentId
|
|
? 'border-krow-blue/40 bg-krow-blue-tint/60'
|
|
: 'border-transparent hover:border-border hover:bg-surface-subtle'
|
|
)}
|
|
>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-body-sm font-medium text-ink-1">
|
|
{record.title}
|
|
</span>
|
|
<span className="mt-0.5 block truncate text-[10px] text-ink-4">
|
|
{[
|
|
record.page,
|
|
`${record.turns} question${record.turns === 1 ? '' : 's'}`,
|
|
new Date(record.updatedAt).toLocaleTimeString(undefined, {
|
|
hour: 'numeric', minute: '2-digit',
|
|
}),
|
|
].filter(Boolean).join(' · ')}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
|
|
{/* Destructive, so it stays out of the way until wanted rather
|
|
than sitting beside every row waiting to be mis-clicked. */}
|
|
<button
|
|
type="button"
|
|
onClick={() => onForget(record.id)}
|
|
aria-label={`Delete conversation: ${record.title}`}
|
|
className="absolute right-1.5 top-1/2 hidden -translate-y-1/2 rounded p-1.5 text-ink-4 transition-colors
|
|
hover:text-destructive focus-visible:outline-none focus-visible:ring-2
|
|
focus-visible:ring-krow-blue/50 group-hover/item:block group-focus-within/item:block"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Show the Back to Home row on scroll direction, not scroll position.
|
|
*
|
|
* Hidden while reading downwards, back the instant the user scrolls up — the
|
|
* way out of a long answer should not be something you have to scroll all the
|
|
* way to the top to reach. Direction is read off the panel's own scrolling
|
|
* element (the body region below), never the window: the page behind the panel
|
|
* does not move when the thread does.
|
|
*
|
|
* The listener is passive and rAF-throttled, and `setVisible` is only ever
|
|
* called with a value that can change — React bails out on an identical one, so
|
|
* a fast scroll costs at most one render per direction change.
|
|
*
|
|
* `pinToBottom` is the same follow-the-stream scroll the panel already did,
|
|
* routed through here so a programmatic jump is not mistaken for the user
|
|
* scrolling down and does not hide the control under them.
|
|
*/
|
|
function useDirectionalNav(scrollRef, { active, resetKey }) {
|
|
const [visible, setVisible] = React.useState(true);
|
|
const lastY = React.useRef(0);
|
|
|
|
/* Ignore sub-pixel and trackpad jitter, but nothing a deliberate scroll would
|
|
produce: a real direction change clears this within one frame. */
|
|
const NOISE = 4;
|
|
|
|
const pinToBottom = React.useCallback(() => {
|
|
const el = scrollRef.current;
|
|
if (!el) return;
|
|
el.scrollTop = el.scrollHeight;
|
|
/* Adopt the new position before the scroll event lands, so the next read
|
|
sees no delta and the row keeps whatever state the user left it in. */
|
|
lastY.current = el.scrollTop;
|
|
}, [scrollRef]);
|
|
|
|
React.useEffect(() => {
|
|
const el = scrollRef.current;
|
|
/* Nothing to hide when the row is not rendered — and a fresh thread or a
|
|
newly opened panel always starts with it showing. */
|
|
setVisible(true);
|
|
if (!el || !active) return undefined;
|
|
|
|
lastY.current = el.scrollTop;
|
|
let frame = 0;
|
|
|
|
const read = () => {
|
|
frame = 0;
|
|
const y = el.scrollTop;
|
|
if (y <= 0) {
|
|
lastY.current = y;
|
|
setVisible(true);
|
|
return;
|
|
}
|
|
const delta = y - lastY.current;
|
|
/* Leave `lastY` alone below the threshold so slow scrolls accumulate
|
|
rather than being swallowed frame by frame. */
|
|
if (Math.abs(delta) < NOISE) return;
|
|
lastY.current = y;
|
|
setVisible(delta < 0);
|
|
};
|
|
|
|
const onScroll = () => {
|
|
if (!frame) frame = requestAnimationFrame(read);
|
|
};
|
|
|
|
el.addEventListener('scroll', onScroll, { passive: true });
|
|
return () => {
|
|
el.removeEventListener('scroll', onScroll);
|
|
if (frame) cancelAnimationFrame(frame);
|
|
};
|
|
}, [scrollRef, active, resetKey]);
|
|
|
|
return { visible, pinToBottom };
|
|
}
|
|
|
|
/**
|
|
* Owliver — the dashboard's contextual panel.
|
|
*
|
|
* One component, reused on every supported page; only `context` differs.
|
|
*
|
|
* 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 frame does not change between states, only the body's content does: an
|
|
* empty thread shows the greeting and this page's most relevant fact, and once a
|
|
* question is asked the same region holds the conversation. Keeping the composer
|
|
* anchored either way means the primary action never moves under the user, and
|
|
* the panel never reads as two different components.
|
|
*
|
|
* `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,
|
|
expanded = false,
|
|
onClose,
|
|
onExpand,
|
|
onRestore,
|
|
}) {
|
|
const facts = useAssistantFacts();
|
|
const userName = useCurrentUserName();
|
|
const navigate = useNavigate();
|
|
|
|
/* A question a page has handed over — see the effect below `runPrompt`. */
|
|
const { request: panelRequest, consumeRequest } = useAssistantPanel();
|
|
|
|
/* The app's own router, not a location assignment: a full page load would
|
|
discard the thread and the panel state along with it. */
|
|
const goToPage = React.useCallback(
|
|
(destination) => navigate(destination.route),
|
|
[navigate]
|
|
);
|
|
|
|
/* Skills available here, and the roles they can act on — both read from what
|
|
the app already has rather than from anything written into this component.
|
|
A page whose skills change needs no edit here. */
|
|
const preferences = usePreferences();
|
|
const disabledSkills = React.useMemo(
|
|
() => preferences.disabledSkills || [],
|
|
[preferences.disabledSkills]
|
|
);
|
|
const { data: customCategories = [] } = useRoleCategories();
|
|
const roles = React.useMemo(
|
|
() => [...new Set([...ROLE_CATEGORIES, ...customCategories.map((c) => c.name)])],
|
|
[customCategories]
|
|
);
|
|
/* The categories Forge skills are actually filed under, so "create a kitchen
|
|
safety training" fills in the category this deployment uses rather than one
|
|
written into a skill file. */
|
|
const skillCategories = React.useMemo(
|
|
() => [...new Set((facts.forge?.library || []).map((c) => c.category).filter(Boolean))],
|
|
[facts.forge]
|
|
);
|
|
const customSkills = React.useMemo(
|
|
() => preferences.customSkills || [],
|
|
[preferences.customSkills]
|
|
);
|
|
const skills = React.useMemo(
|
|
() => skillsForContext(context.id, disabledSkills, customSkills),
|
|
[context.id, disabledSkills, customSkills]
|
|
);
|
|
|
|
/**
|
|
* What a declared skill reads from.
|
|
*
|
|
* The same collections `SkillSurface` hands the resolver on the page, plus
|
|
* whatever record the page has published as open. One context, one resolver:
|
|
* the card on the page and the answer in the panel are two readings of the
|
|
* same data rather than two queries that happen to agree.
|
|
*/
|
|
const pageContext = usePageContext();
|
|
const { statesFor } = useWorkforcePaths();
|
|
const trainingPaths = React.useMemo(
|
|
() => statesFor(facts.forge?.library || [], profileForEmail(facts.profiles || [], facts.user?.email)),
|
|
[statesFor, facts.forge, facts.profiles, facts.user]
|
|
);
|
|
const { data: assignments = [] } = useAssignments();
|
|
const skillContext = React.useMemo(() => ({
|
|
...pageContext,
|
|
applications: facts.applications || [],
|
|
positions: facts.postings || [],
|
|
interviews: facts.interviews || [],
|
|
courses: facts.forge?.library || [],
|
|
workerProfiles: facts.profiles || [],
|
|
/* The same records under the workforce engine's name, and the commitments
|
|
it reads availability from — so a source that scores candidates against a
|
|
position resolves here exactly as it does on the page. */
|
|
profiles: facts.profiles || [],
|
|
assignments,
|
|
staff: facts.staff || [],
|
|
activity: facts.activity || [],
|
|
trainingPaths,
|
|
}), [pageContext, facts, assignments, trainingPaths]);
|
|
|
|
/**
|
|
* Which face of the panel the body is showing.
|
|
*
|
|
* A state rather than a route: the header, the composer and the panel's width
|
|
* are unchanged between the two, and only the scrolling region swaps. That is
|
|
* also why there is always a way back — leaving History is a state change this
|
|
* component owns, not a browser-history entry it has to hope exists.
|
|
*/
|
|
const [view, setView] = React.useState('chat');
|
|
|
|
/**
|
|
* A skill asked for something to happen. The names come from the skill file;
|
|
* `runAction` decides what they mean and refuses anything the file did not
|
|
* declare.
|
|
*/
|
|
const performAction = React.useCallback((action, skill) => {
|
|
const result = runAction(action.name, { ...action.payload, skill });
|
|
if (result?.type === 'navigate' && result.route) {
|
|
navigate(result.route, result.state ? { state: result.state } : undefined);
|
|
}
|
|
}, [navigate]);
|
|
|
|
/**
|
|
* Write the position the conversation collected.
|
|
*
|
|
* The same mutation the Create Position form calls, so there is one create
|
|
* path and the list behind the panel refreshes the way it always did. The
|
|
* skill still has to declare `create_position` — `runAction` returns nothing
|
|
* for a skill whose file does not, and then nothing is written.
|
|
*/
|
|
const createJob = useCreateJobPosting();
|
|
const createPosition = React.useCallback(async (draft, skill, status) => {
|
|
const result = runAction('create_position', { draft, skill, status });
|
|
if (result?.type !== 'create_position') return null;
|
|
return createJob.mutateAsync(result.data);
|
|
}, [createJob]);
|
|
|
|
/**
|
|
* Finishing a draft, through the mutations the Create Position form calls.
|
|
*
|
|
* `useUpdateJobPosting` is the same update the form and the Positions page
|
|
* use, and `useGenerateJobDescription` is the same generator behind the
|
|
* form's "Generate Job Description with AI" button. Reusing both is what
|
|
* keeps one position record, one description and one set of vetting weights
|
|
* — the panel changes the record, it does not keep a copy of it.
|
|
*/
|
|
const updateJob = useUpdateJobPosting();
|
|
const onUpdatePosition = React.useCallback(
|
|
(id, data) => updateJob.mutateAsync({ id, data }),
|
|
[updateJob]
|
|
);
|
|
|
|
const generateDescription = useGenerateJobDescription();
|
|
const onGenerateDescription = React.useCallback(
|
|
async (position) => {
|
|
const result = await generateDescription.mutateAsync({ data: position, draftId: position.id });
|
|
return result || null;
|
|
},
|
|
[generateDescription]
|
|
);
|
|
|
|
/**
|
|
* The workforce picture the panel reasons over.
|
|
*
|
|
* The same collections the Positions page reads, so a recommendation and the
|
|
* card behind it are looking at one moment. Assembled here rather than in the
|
|
* routing layer because this is where the app's data already is; routing stays
|
|
* pure and testable.
|
|
*/
|
|
const workforce = React.useMemo(() => ({
|
|
positions: facts.postings || [],
|
|
/* The position the page has open, so "who matches this position?" resolves
|
|
against the record in front of the reader rather than asking which role
|
|
they meant. A page with nothing open publishes nothing, and the question
|
|
is answered by name or asked for, exactly as before. */
|
|
currentPositionId: pageContext.position?.id || null,
|
|
context: {
|
|
profiles: facts.profiles || [],
|
|
applications: facts.applications || [],
|
|
assignments,
|
|
courses: facts.forge?.library || [],
|
|
staff: facts.staff || [],
|
|
},
|
|
}), [facts.postings, facts.profiles, facts.applications, facts.forge, facts.staff, assignments,
|
|
pageContext.position?.id]);
|
|
|
|
/**
|
|
* The workforce write, through the mutation the app already has.
|
|
*
|
|
* `useAssignWorkers` creates the assignment, moves the application and logs
|
|
* the activity — one path, reused rather than reimplemented. It only ever runs
|
|
* on a turn the admin explicitly confirmed.
|
|
*/
|
|
const assignWorkers = useAssignWorkers();
|
|
const onAssignWorkers = React.useCallback(async (plan) => {
|
|
const created = await assignWorkers.mutateAsync({
|
|
position: plan.position,
|
|
workers: plan.workers,
|
|
applications: facts.applications || [],
|
|
});
|
|
return created?.length ? created : null;
|
|
}, [assignWorkers, facts.applications]);
|
|
|
|
/**
|
|
* Move an assigned candidate to interview stage.
|
|
*
|
|
* The status transition the Position and Candidates pages already perform —
|
|
* not a new interview record. `AIInterviewModal` writes the AIInterview when
|
|
* the interview is actually conducted, and creating an empty one here would be
|
|
* a second interview store claiming something that has not happened.
|
|
*/
|
|
const markInterviewReady = useMarkInterviewReady();
|
|
const onScheduleInterview = React.useCallback(
|
|
(plan) => markInterviewReady.mutateAsync({ application: plan.application, position: plan.position }),
|
|
[markInterviewReady]
|
|
);
|
|
|
|
const {
|
|
messages, pending, error, busy, send, stop, reset,
|
|
history, conversationId, openConversation, forgetConversation,
|
|
} = useConversation({
|
|
contextId: context.id,
|
|
facts,
|
|
pageLabel: context.page,
|
|
onNavigate: goToPage,
|
|
onAction: performAction,
|
|
onCreatePosition: createPosition,
|
|
onUpdatePosition,
|
|
onGenerateDescription,
|
|
onAssignWorkers,
|
|
onScheduleInterview,
|
|
workforce,
|
|
disabledSkills,
|
|
customSkills,
|
|
roles,
|
|
skillCategories,
|
|
courses: facts.forge?.library || [],
|
|
skillContext,
|
|
});
|
|
|
|
/* A block inside an answer asking the next question, in place. Same entry
|
|
point as the composer and the chips, so an in-panel action is an ordinary
|
|
turn rather than a second way for the panel to change. */
|
|
const askOwliver = React.useCallback((question) => {
|
|
if (!busy) send({ question });
|
|
}, [busy, send]);
|
|
|
|
const historyGroups = React.useMemo(() => groupByRecency(history), [history]);
|
|
|
|
/**
|
|
* Home is the empty panel: greeting, page fact, suggestions.
|
|
*
|
|
* One handler for every way back — the History header button, the Back to
|
|
* Home control, and reopening after reading an old thread — so "home" cannot
|
|
* mean two slightly different states depending on how you got there. Nothing
|
|
* is lost: the thread being left is already archived.
|
|
*/
|
|
const goHome = React.useCallback(() => {
|
|
setView('chat');
|
|
reset();
|
|
}, [reset]);
|
|
|
|
const openHistoryItem = React.useCallback((record) => {
|
|
openConversation(record);
|
|
setView('chat');
|
|
}, [openConversation]);
|
|
|
|
const [input, setInput] = React.useState('');
|
|
const scrollRef = React.useRef(null);
|
|
const isEmpty = messages.length === 0 && !pending;
|
|
|
|
/* The Back to Home row only exists in the states that are not already home. */
|
|
const showBackRow = view === 'history' || messages.length > 0 || Boolean(pending);
|
|
const { visible: backVisible, pinToBottom } = useDirectionalNav(scrollRef, {
|
|
active: showBackRow,
|
|
resetKey: `${view}:${conversationId || ''}`,
|
|
});
|
|
|
|
/* 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, userName),
|
|
[context.id, facts, userName]
|
|
);
|
|
/* Suggestions are the page's own, with any skill that offers one on the front.
|
|
The last answer can also propose follow-ups — the role list after "create a
|
|
position" — which replace the standing set until the next turn. */
|
|
const followUp = messages[messages.length - 1]?.followUp;
|
|
const prompts = React.useMemo(() => {
|
|
if (followUp?.length) return followUp;
|
|
/* A definition's own suggestions come first: they are the only ones written
|
|
for this workspace rather than derived from the page, and they are capped
|
|
by the resolver so a workspace with several skills attached cannot bury
|
|
the page's own. */
|
|
const declared = owliverSuggestions(context.id, disabledSkills, customSkills, pageContext);
|
|
const skillPrompts = skills
|
|
.filter((s) => s.prompt)
|
|
.map((s) => ({ label: s.prompt, prompt: s.prompt }));
|
|
|
|
/* Three sources can propose the same question — a skill's declared
|
|
suggestion and the page's own derived one often word it identically —
|
|
and two chips reading "Summarize hiring activity" is both a duplicate
|
|
React key and a duplicate offer. First wins, so the definition's own
|
|
wording survives and the derived copy drops out. */
|
|
/* A declared suggestion that cannot answer yet — it reads one record and
|
|
none is selected — ranks behind the page's own offers rather than
|
|
leading with a question. The lifecycle reads correctly either way:
|
|
before a position exists the page's actions lead; once one is open or
|
|
has just been created, the readings about it come first. */
|
|
const ready = declared.filter((c) => !c.deferred);
|
|
const asking = declared.filter((c) => c.deferred);
|
|
|
|
/**
|
|
* One chip per *intent*, not per wording.
|
|
*
|
|
* De-duplicating on the label alone let two chips through whenever the same
|
|
* answer was worded twice — a skill's "Show hiring activity" and its
|
|
* "Summarize hiring activity" both resolved to that skill's `summary`
|
|
* capability, so the reader was offered the same reading under two names and
|
|
* had no way to tell them apart. What a chip *resolves to* is the thing that
|
|
* must be unique: a skill capability, the page capability, or, for a chip
|
|
* that is neither, the question it sends. The label is compared too, so two
|
|
* differently-routed chips still cannot arrive reading identically.
|
|
*/
|
|
const seen = new Set();
|
|
const intentOf = (chip) => {
|
|
if (chip?.skillId && chip?.skillCapability) return `skill:${chip.skillId}:${chip.skillCapability}`;
|
|
if (chip?.capability) return `page:${chip.capability}`;
|
|
return `ask:${String(chip?.prompt ?? '').trim().toLowerCase()}`;
|
|
};
|
|
|
|
return [...ready, ...skillPrompts, ...buildPrompts(context.id, facts, workforce), ...asking]
|
|
.filter((chip) => {
|
|
const label = String(chip?.label ?? '').trim().toLowerCase();
|
|
if (!label) return false;
|
|
const intent = intentOf(chip);
|
|
if (seen.has(intent) || seen.has(`label:${label}`)) return false;
|
|
seen.add(intent);
|
|
seen.add(`label:${label}`);
|
|
return true;
|
|
});
|
|
/* `pageContext` decides which suggestions can answer without asking, so the
|
|
chips re-rank when a position is opened or closed. */
|
|
}, [followUp, skills, context.id, disabledSkills, customSkills, facts, workforce, pageContext]);
|
|
|
|
/* 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(() => {
|
|
pinToBottom();
|
|
}, [messages, pending, pinToBottom]);
|
|
|
|
/**
|
|
* The one entry point for every message, whatever raised it.
|
|
*
|
|
* A chip carries a capability — it *is* one of this page's answers, so it
|
|
* bypasses intent routing. A typed question carries none and is routed. Both
|
|
* land in the same `send`, so there is a single pipeline to reason about.
|
|
*/
|
|
const handleMessage = React.useCallback((question, capability = null, positionId = null) => {
|
|
const text = String(question).trim();
|
|
if (!text || busy) return;
|
|
setInput('');
|
|
/* Asking something while reading History returns to the conversation — the
|
|
answer is about to arrive there, and leaving the list up would hide it. */
|
|
setView('chat');
|
|
send({ question: text, capability, positionId });
|
|
}, [busy, send]);
|
|
|
|
const submit = React.useCallback((text) => handleMessage(text), [handleMessage]);
|
|
|
|
/**
|
|
* A chip is already a complete question, so it runs on click.
|
|
*
|
|
* A chip may instead carry a destination — "View position", offered after
|
|
* Owliver creates one. That is the user choosing to go somewhere after the
|
|
* work is finished, which is the only navigation any of this does.
|
|
*/
|
|
const runPrompt = React.useCallback((prompt) => {
|
|
if (prompt.route) {
|
|
navigate(prompt.route);
|
|
return;
|
|
}
|
|
/* A chip built from a position says which one, so a follow-up acts on the
|
|
record the previous answer was about rather than re-deriving it from the
|
|
words. */
|
|
handleMessage(prompt.prompt, prompt.capability, prompt.positionId);
|
|
}, [handleMessage, navigate]);
|
|
|
|
/**
|
|
* A question the page asked on the reader's behalf.
|
|
*
|
|
* Continue on a draft card is the only source today: the card knows which
|
|
* record it is, and the work of finishing it lives here. Deferred while a
|
|
* response is streaming — the effect re-runs when the panel is free, so the
|
|
* request waits its turn rather than being dropped for arriving mid-answer.
|
|
*/
|
|
React.useEffect(() => {
|
|
if (!panelRequest || busy) return;
|
|
handleMessage(panelRequest.question, null, panelRequest.positionId);
|
|
consumeRequest(panelRequest.id);
|
|
}, [panelRequest, busy, handleMessage, consumeRequest]);
|
|
|
|
const composer = (
|
|
<PromptInput
|
|
value={input}
|
|
onChange={setInput}
|
|
onSubmit={submit}
|
|
onStop={stop}
|
|
busy={busy}
|
|
placeholders={intro.placeholders}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<Surface
|
|
variant="glass"
|
|
radius="xl"
|
|
padding="none"
|
|
elevation="sm"
|
|
data-wide={expanded || undefined}
|
|
className={cn('relative flex flex-col overflow-hidden', className)}
|
|
role="region"
|
|
aria-label="Owliver assistant"
|
|
>
|
|
{/* 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-white overflow-hidden shadow-brand border border-border">
|
|
<img src="/krow logo.jpeg" alt="KROW Logo" className="h-full w-full object-cover" />
|
|
</span>
|
|
|
|
<div className="min-w-0 flex-1">
|
|
<h2 className="font-heading text-body-sm font-semibold leading-tight text-ink-1">Owliver</h2>
|
|
<p className="truncate text-caption leading-tight text-ink-3">{context.page}</p>
|
|
</div>
|
|
|
|
{/* Window controls.
|
|
|
|
Authoring a skill is deliberately not among them. Skills are a
|
|
registry with a lifecycle — authored, enabled, edited, removed — and
|
|
Workspace → Skills is where that lifecycle lives. A second entry
|
|
point here meant the panel you *use* Owliver from was also a place
|
|
you *configured* it from, and the two lists could be reached from
|
|
different places with different affordances. The panel does the
|
|
first job only; the registry behind it is unchanged. */}
|
|
<div className="flex items-center gap-0.5">
|
|
{/* History lives with the other window controls rather than in the
|
|
body, so the layout of the panel is unchanged whether or not
|
|
there is anything to show. It toggles: pressing it again returns
|
|
to the conversation you were reading. */}
|
|
<IconButton
|
|
icon={History}
|
|
label={view === 'history' ? 'Back to the conversation' : 'Owliver history'}
|
|
variant="ghost"
|
|
size="sm"
|
|
aria-pressed={view === 'history'}
|
|
onClick={() => setView((v) => (v === 'history' ? 'chat' : 'history'))}
|
|
/>
|
|
{messages.length > 0 && view === 'chat' && (
|
|
<IconButton icon={RotateCcw} label="New conversation" variant="ghost" size="sm" onClick={reset} />
|
|
)}
|
|
{expanded
|
|
? onRestore && (
|
|
<IconButton icon={Minimize2} label="Restore the default workspace width" variant="ghost" size="sm" onClick={onRestore} />
|
|
)
|
|
: onExpand && (
|
|
<IconButton icon={Maximize2} label="Expand the Owliver workspace" variant="ghost" size="sm" onClick={onExpand} />
|
|
)}
|
|
{onClose && (
|
|
<IconButton icon={PanelRightClose} label="Collapse the Owliver workspace" variant="ghost" size="sm" onClick={onClose} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Floating directional Back to Home row — reveals on UP-scroll, hides on DOWN-scroll */}
|
|
{showBackRow && (
|
|
<div
|
|
className={cn(
|
|
`absolute top-[3.25rem] left-0 right-0 z-20 flex items-center justify-between gap-2
|
|
border-b border-border bg-white/95 dark:bg-slate-900/95 px-4 py-2 shadow-sm backdrop-blur-md
|
|
transition-all duration-200 ease-out motion-reduce:transition-none`,
|
|
backVisible
|
|
? 'translate-y-0 opacity-100 pointer-events-auto'
|
|
: '-translate-y-full opacity-0 pointer-events-none'
|
|
)}
|
|
aria-hidden={backVisible ? undefined : 'true'}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={goHome}
|
|
tabIndex={backVisible ? undefined : -1}
|
|
className="inline-flex items-center gap-1.5 rounded text-caption font-semibold text-ink-1 dark:text-white transition-colors
|
|
hover:text-krow-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50 cursor-pointer"
|
|
>
|
|
<ArrowLeft className="h-3.5 w-3.5 text-krow-blue" aria-hidden="true" />
|
|
<span>Back to Home</span>
|
|
</button>
|
|
<span className="truncate text-[11px] font-medium text-ink-3">
|
|
{view === 'history'
|
|
? `${history.length} conversation${history.length === 1 ? '' : 's'}`
|
|
: context.page}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Body: the only region that scrolls ─────────────────────────── */}
|
|
<div
|
|
ref={scrollRef}
|
|
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-4 py-3.5"
|
|
aria-live="polite"
|
|
aria-relevant="additions text"
|
|
>
|
|
|
|
{view === 'history' ? (
|
|
<HistoryView
|
|
groups={historyGroups}
|
|
currentId={conversationId}
|
|
onOpen={openHistoryItem}
|
|
onForget={forgetConversation}
|
|
/>
|
|
) : isEmpty ? (
|
|
/* Greeting and the single most relevant fact about this page. Top
|
|
aligned rather than centred: it is the first thing in a
|
|
conversation, not a splash screen, so it belongs where the first
|
|
message would be. */
|
|
<div className="animate-fade-in">
|
|
<p className="font-heading text-body-lg font-semibold leading-snug text-ink-1">
|
|
{intro.title}
|
|
</p>
|
|
<p className="mt-1 text-balance text-body-sm leading-relaxed text-ink-2">
|
|
{intro.greeting}
|
|
</p>
|
|
<p className="mt-2.5 border-t border-border/70 pt-2.5 text-caption leading-relaxed text-ink-3">
|
|
{intro.description}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{messages.map((message, i) => (
|
|
<React.Fragment key={i}>
|
|
{i > 0 && message.role === 'user' && <TurnDivider />}
|
|
<Message
|
|
role={message.role}
|
|
text={message.text}
|
|
blocks={message.blocks}
|
|
stopped={message.stopped}
|
|
onPrompt={askOwliver}
|
|
/>
|
|
</React.Fragment>
|
|
))}
|
|
|
|
{pending?.thinking && <ThinkingIndicator />}
|
|
{pending && !pending.thinking && (
|
|
<Message role="assistant" blocks={pending.blocks} streaming />
|
|
)}
|
|
|
|
{error && <Alert tone="destructive">{error}</Alert>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Composer: fixed to the bottom in both states ───────────────── */}
|
|
<div className="shrink-0 space-y-2 border-t border-white/60 px-3 pb-2.5 pt-2.5">
|
|
{/* All suggestions on the landing screen, where they teach what can be
|
|
asked. Capped once a thread exists, because from then on the vertical
|
|
space belongs to the conversation. Expanded fits more per line, so it
|
|
can afford one more.
|
|
|
|
Follow-ups are never capped: when Owliver has asked a question, its
|
|
chips *are* the answers, and hiding three of the six roles would make
|
|
the flow look broken. */}
|
|
{!busy && view === 'chat' && (
|
|
<PromptChips
|
|
prompts={prompts}
|
|
onSelect={runPrompt}
|
|
max={isEmpty || followUp?.length ? undefined : expanded ? 4 : 3}
|
|
align="start"
|
|
/>
|
|
)}
|
|
{composer}
|
|
<p className="px-1 text-[10px] leading-tight text-ink-4">
|
|
Owliver reads this page's data. Check anything you act on.
|
|
</p>
|
|
</div>
|
|
</Surface>
|
|
);
|
|
}
|