update owliver skill
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Maximize2, Minimize2, PanelRightClose, RotateCcw, Sparkles } from 'lucide-react';
|
||||
import {
|
||||
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Sparkles, Trash2,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Surface } from '@/components/ds/Surface';
|
||||
import { IconButton } from '@/components/ds/IconButton';
|
||||
@@ -13,12 +15,171 @@ import { ROLE_CATEGORIES } from '@/lib/roleCategories';
|
||||
import { AddSkillDialog } from '@/components/skills/AddSkillDialog';
|
||||
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 { 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.
|
||||
*
|
||||
@@ -88,8 +249,50 @@ export default function KrowAssistant({
|
||||
() => 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]);
|
||||
const [addSkillOpen, setAddSkillOpen] = React.useState(false);
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -111,8 +314,8 @@ export default function KrowAssistant({
|
||||
* for a skill whose file does not, and then nothing is written.
|
||||
*/
|
||||
const createJob = useCreateJobPosting();
|
||||
const createPosition = React.useCallback(async (draft, skill) => {
|
||||
const result = runAction('create_position', { draft, skill });
|
||||
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]);
|
||||
@@ -125,10 +328,13 @@ export default function KrowAssistant({
|
||||
* routing layer because this is where the app's data already is; routing stays
|
||||
* pure and testable.
|
||||
*/
|
||||
const { data: assignments = [] } = useAssignments();
|
||||
const workforce = React.useMemo(() => ({
|
||||
positions: facts.postings || [],
|
||||
currentPositionId: null,
|
||||
/* 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 || [],
|
||||
@@ -136,7 +342,8 @@ export default function KrowAssistant({
|
||||
courses: facts.forge?.library || [],
|
||||
staff: facts.staff || [],
|
||||
},
|
||||
}), [facts.postings, facts.profiles, facts.applications, facts.forge, facts.staff, assignments]);
|
||||
}), [facts.postings, facts.profiles, facts.applications, facts.forge, facts.staff, assignments,
|
||||
pageContext.position?.id]);
|
||||
|
||||
/**
|
||||
* The workforce write, through the mutation the app already has.
|
||||
@@ -169,9 +376,13 @@ export default function KrowAssistant({
|
||||
[markInterviewReady]
|
||||
);
|
||||
|
||||
const { messages, pending, error, busy, send, stop, reset } = useConversation({
|
||||
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,
|
||||
@@ -183,6 +394,7 @@ export default function KrowAssistant({
|
||||
roles,
|
||||
skillCategories,
|
||||
courses: facts.forge?.library || [],
|
||||
skillContext,
|
||||
});
|
||||
|
||||
/* A block inside an answer asking the next question, in place. Same entry
|
||||
@@ -192,10 +404,37 @@ export default function KrowAssistant({
|
||||
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(
|
||||
@@ -208,19 +447,46 @@ export default function KrowAssistant({
|
||||
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 }));
|
||||
return [...skillPrompts, ...buildPrompts(context.id, facts, workforce)];
|
||||
}, [followUp, skills, context.id, facts, workforce]);
|
||||
|
||||
/* 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);
|
||||
|
||||
const seen = new Set();
|
||||
return [...ready, ...skillPrompts, ...buildPrompts(context.id, facts, workforce), ...asking]
|
||||
.filter((chip) => {
|
||||
const key = String(chip?.label ?? '').trim().toLowerCase();
|
||||
if (!key || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
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(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [messages, pending]);
|
||||
pinToBottom();
|
||||
}, [messages, pending, pinToBottom]);
|
||||
|
||||
/**
|
||||
* The one entry point for every message, whatever raised it.
|
||||
@@ -233,6 +499,9 @@ export default function KrowAssistant({
|
||||
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 });
|
||||
}, [busy, send]);
|
||||
|
||||
@@ -271,7 +540,7 @@ export default function KrowAssistant({
|
||||
padding="none"
|
||||
elevation="sm"
|
||||
data-wide={expanded || undefined}
|
||||
className={cn('flex flex-col overflow-hidden', className)}
|
||||
className={cn('relative flex flex-col overflow-hidden', className)}
|
||||
role="region"
|
||||
aria-label="Owliver assistant"
|
||||
>
|
||||
@@ -297,7 +566,19 @@ export default function KrowAssistant({
|
||||
size="sm"
|
||||
onClick={() => setAddSkillOpen(true)}
|
||||
/>
|
||||
{messages.length > 0 && (
|
||||
{/* 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
|
||||
@@ -313,14 +594,53 @@ export default function KrowAssistant({
|
||||
</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 px-4 py-3.5"
|
||||
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-4 py-3.5"
|
||||
aria-live="polite"
|
||||
aria-relevant="additions text"
|
||||
>
|
||||
{isEmpty ? (
|
||||
|
||||
{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
|
||||
@@ -373,7 +693,7 @@ export default function KrowAssistant({
|
||||
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 && (
|
||||
{!busy && view === 'chat' && (
|
||||
<PromptChips
|
||||
prompts={prompts}
|
||||
onSelect={runPrompt}
|
||||
|
||||
Reference in New Issue
Block a user