update owliver agent

This commit is contained in:
2026-08-24 20:06:11 +05:30
parent 02eb48af99
commit 6d8b4dbf36
28 changed files with 3176 additions and 692 deletions

View File

@@ -0,0 +1,83 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { agentIconFor } from '@/components/agents/icons';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { useActiveAgent } from './AgentContext';
/**
* Who is answering, in Owliver's own header.
*
* This was an agent *switcher*: the header opened a popover with a search box,
* every registered agent, Browse all and Create agent. That made the panel a
* second place to decide which agent answers, competing with the one place that
* actually configures agents — the Agent Editor — and letting a reader put
* Owliver into a state nothing on the page explained.
*
* It is now a label. The agent is whatever `AgentContext` resolves for the page
* being read, which is the same resolution the runtime already uses to answer,
* so the header cannot disagree with the reply underneath it. Switching belongs
* to Agent Registry → Agents → Configure.
*
* Deliberately **not** a button, and deliberately no chevron: a disclosure
* affordance that discloses nothing is worse than none. Nothing here is
* clickable, so nothing here promises a menu.
*/
/** The avatar for the primary agent; a lucide glyph for the rest. */
function AgentGlyph({ agent, className = 'h-7 w-7' }) {
const Icon = agentIconFor(agent?.icon);
if (!Icon) return <OwliverAvatar className={className} rounded="rounded-lg" />;
return (
<span
className={cn(
'inline-flex shrink-0 items-center justify-center rounded-lg border border-border/80 bg-surface-subtle text-krow-blue shadow-2xs',
className
)}
aria-hidden="true"
>
<Icon className="h-4 w-4" />
</span>
);
}
/**
* The mark a constrained agent carries.
*
* Kept: it is a fact about the answer that follows — this agent does not cover
* this page and will say so — not a control, and losing it would make a
* constrained reply arrive with no warning.
*/
function ConstrainedTag() {
return (
<span className="inline-flex shrink-0 items-center rounded-md border border-amber-500/25 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-amber-700 dark:text-amber-300">
Constrained
</span>
);
}
/** @param {any} props */
export function AgentBadge({ page }) {
const { agent, covers } = useActiveAgent();
return (
<>
{agent
? <AgentGlyph agent={agent} />
: <OwliverAvatar className="h-7 w-7" rounded="rounded-lg" />}
<div className="min-w-0 flex-1">
<h2 className="truncate font-heading text-body-sm font-semibold leading-tight text-ink-1">
Owliver
</h2>
<div className="flex min-w-0 items-center gap-1.5">
<p className="truncate text-caption leading-tight text-ink-3">{page}</p>
{agent && !covers && <ConstrainedTag />}
</div>
</div>
</>
);
}
export default AgentBadge;

View File

@@ -1,234 +0,0 @@
import * as React from 'react';
import { useNavigate } from 'react-router-dom';
import { Check, ChevronDown, Plus, Search, SquareArrowOutUpRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { searchAgents } from '@/lib/agents/registry';
import { agentIconFor } from '@/components/agents/icons';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { useActiveAgent } from './AgentContext';
/**
* The agent switcher, inside Owliver's own header.
*
* Designed with high-density Krow Control Tower aesthetics: clean popover
* geometry, smooth row hover/active states, and legible status indicators.
*/
/** The avatar for the primary agent; a lucide glyph for the rest. */
function AgentGlyph({ agent, className = 'h-7 w-7' }) {
const Icon = agentIconFor(agent?.icon);
if (!Icon) return <OwliverAvatar className={className} rounded="rounded-lg" />;
return (
<span
className={cn(
'inline-flex shrink-0 items-center justify-center rounded-lg border border-border/80 bg-surface-subtle text-krow-blue shadow-2xs',
className
)}
aria-hidden="true"
>
<Icon className="h-4 w-4" />
</span>
);
}
/** The label a constrained agent carries, in the switcher and in the header. */
function ConstrainedTag({ className = '' }) {
return (
<span
className={cn(
'inline-flex shrink-0 items-center rounded-md border border-amber-500/25 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-amber-700 dark:text-amber-300',
className
)}
>
Constrained
</span>
);
}
/** One row in the list. */
function AgentRow({ agent, active, covers, onSelect }) {
return (
<button
type="button"
role="option"
aria-selected={active}
onClick={() => onSelect(agent.id)}
className={cn(
'flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left transition-all',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50',
active
? 'bg-krow-blue-tint/70 text-ink-1'
: 'hover:bg-surface-subtle text-ink-2'
)}
>
<AgentGlyph agent={agent} className="h-7 w-7 shrink-0" />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
<span className="min-w-0 truncate text-body-sm font-semibold text-ink-1">
{agent.name}
</span>
{!covers && <ConstrainedTag />}
</span>
{agent.description && (
<span className="mt-0.5 block truncate text-[11px] leading-tight text-ink-4">
{agent.description}
</span>
)}
</span>
{active && (
<Check className="h-4 w-4 text-krow-blue shrink-0 ml-1" aria-hidden="true" />
)}
</button>
);
}
export function AgentSwitcher({ page }) {
const navigate = useNavigate();
const { agents, agent, covers, select, coversPage } = useActiveAgent();
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState('');
const listRef = React.useRef(null);
React.useEffect(() => {
if (!open) setQuery('');
}, [open]);
const matches = React.useMemo(() => {
const found = searchAgents(agents, query);
return [...found].sort((a, b) => Number(coversPage(b)) - Number(coversPage(a)));
}, [agents, query, coversPage]);
const choose = (id) => {
select(id);
setOpen(false);
};
const onListKeyDown = (event) => {
const step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
if (!step) return;
const rows = [...(listRef.current?.querySelectorAll('[role="option"]') ?? [])];
if (!rows.length) return;
event.preventDefault();
const at = rows.indexOf(document.activeElement);
rows[Math.max(0, Math.min(rows.length - 1, (at === -1 ? 0 : at + step)))]?.focus();
};
if (!agent) {
return (
<>
<OwliverAvatar className="h-7 w-7" rounded="rounded-lg" />
<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">{page}</p>
</div>
</>
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
aria-label={`Owliver agent: ${agent.name}. Switch to another agent`}
aria-haspopup="listbox"
aria-expanded={open}
className="group flex min-w-0 flex-1 items-center gap-2.5 rounded-lg px-1.5 py-1 text-left transition-colors hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
<AgentGlyph agent={agent} />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1">
<span className="min-w-0 truncate font-heading text-body-sm font-semibold leading-tight text-ink-1">
Owliver
</span>
<ChevronDown
className="h-3 w-3 shrink-0 text-ink-4 transition-colors group-hover:text-krow-blue"
aria-hidden="true"
/>
</span>
<span className="flex items-center gap-1.5">
<span className="truncate text-caption leading-tight text-ink-3">{page}</span>
{!covers && <ConstrainedTag />}
</span>
</span>
</button>
</PopoverTrigger>
<PopoverContent align="start" sideOffset={6} className="w-[21.5rem] p-0 shadow-lg rounded-xl border border-border/80 overflow-hidden">
<div className="border-b border-border/60 bg-surface-subtle/40 px-3.5 py-2.5">
<p className="font-heading text-body-sm font-semibold text-ink-1">Switch agent</p>
<p className="mt-0.5 text-[11px] text-ink-4">
Choose an agent to focus Owliver's scope on this page.
</p>
</div>
<div className="px-3 pt-2.5 pb-1">
<label className="relative block">
<span className="sr-only">Search agents</span>
<Search
className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-4"
aria-hidden="true"
/>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search agents..."
className="w-full h-8 rounded-lg border border-border/70 bg-surface-subtle/40 py-1 pl-8 pr-2.5 text-body-sm text-ink-1 placeholder:text-ink-4 transition-all focus:bg-surface focus:border-krow-blue/50 focus:outline-none focus:ring-1 focus:ring-krow-blue/30"
/>
</label>
</div>
<div
ref={listRef}
role="listbox"
aria-label="Agents"
onKeyDown={onListKeyDown}
className="max-h-[16rem] space-y-0.5 overflow-y-auto px-2 py-1.5"
>
{matches.map((candidate) => (
<AgentRow
key={candidate.id}
agent={candidate}
active={candidate.id === agent.id}
covers={coversPage(candidate)}
onSelect={choose}
/>
))}
{!matches.length && (
<p className="px-3 py-4 text-center text-body-sm text-ink-3">No agents match that.</p>
)}
</div>
<div className="flex items-center justify-between border-t border-border/80 bg-surface-subtle/30 px-3 py-2">
<button
type="button"
onClick={() => { setOpen(false); navigate('/admin/workspace/agents'); }}
className="inline-flex items-center gap-1.5 rounded px-1.5 py-0.5 text-caption font-medium text-ink-3 transition-colors hover:text-krow-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
<SquareArrowOutUpRight className="h-3 w-3" aria-hidden="true" />
Browse all
</button>
<button
type="button"
onClick={() => { setOpen(false); navigate('/admin/workspace/agents/new'); }}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-caption font-semibold text-krow-blue transition-colors hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
Create agent
</button>
</div>
</PopoverContent>
</Popover>
);
}
export default AgentSwitcher;

View File

@@ -125,6 +125,36 @@ export function AssistantPanelProvider({ role, pathname, children }) {
const [request, setRequest] = React.useState(null);
const requestSeq = React.useRef(0);
/**
* Something the workspace has *told* Owliver, waiting to be said.
*
* `ask` above is the page speaking as the reader — it puts a question in the
* composer's place and a turn runs. This is the other half, and it is a
* different act: attaching a skill to an agent is not a question, and staging
* it as one would put words in the reader's mouth and then answer them.
*
* So a notice appends a single assistant turn stating what changed and what
* can now be asked. It is the same conversation, the same panel and the same
* thread — deliberately not a second chat, a toast queue or a system-message
* lane, because the point of Owliver knowing about a skill is that the next
* thing the reader types can use it.
*
* Held as state for the same reason a request is: the panel is unmounted
* while collapsed, and the notice must still be here when it mounts.
*/
const [notice, setNotice] = React.useState(null);
const noticeSeq = React.useRef(0);
/**
* A capability test in flight: `{ id, trace, state, error }`.
*
* `trace` is what the agent editor was testing — the skill, the agent and the
* surface — so the panel can say so and the drawer can show the outcome. It
* is presentation only: nothing in the runtime reads it, and it is never
* persisted with the thread.
*/
const [test, setTest] = React.useState(null);
const context = React.useMemo(
() => resolveAssistantContext(role, pathname),
[role, pathname]
@@ -174,19 +204,68 @@ export function AssistantPanelProvider({ role, pathname, children }) {
* match. Each call carries its own id, so asking the same question twice is
* two turns rather than one ignored repeat.
*/
const ask = React.useCallback(({ question, positionId = null }) => {
const ask = React.useCallback(({
question, positionId = null, scope = null, capability = null, trace = null,
}) => {
const text = String(question || '').trim();
if (!text) return;
requestSeq.current += 1;
setRequest({ id: requestSeq.current, question: text, positionId });
const id = requestSeq.current;
/* `scope` runs the turn against another page's skills — the agent editor
testing a capability. See `useConversation.send`. Ordinary callers omit
it and the turn is about the page the reader is on. */
setRequest({ id, question: text, positionId, scope, capability, trace });
/**
* A capability test is a turn somebody is *waiting on the outcome of*, and
* the panel is the only thing that knows when a turn has finished. So a
* traced ask opens a small status the caller can watch — queued while the
* panel is still mounting or busy, running once the turn starts, then done
* or error. Untraced asks set nothing and behave exactly as before.
*/
if (trace) setTest({ id, trace, state: 'queued', error: null });
setOpenState(true);
}, [setOpenState]);
/** The panel reporting back on a traced turn. */
const reportTest = React.useCallback((id, state, error = null) => {
setTest((current) => (current?.id === id ? { ...current, state, error } : current));
}, []);
/** Drops the test banner — on a new ordinary question, or when it is dismissed. */
const clearTest = React.useCallback(() => setTest(null), []);
/** Taken by the panel once it has actually asked it. */
const consumeRequest = React.useCallback((id) => {
setRequest((current) => (current?.id === id ? null : current));
}, []);
/**
* States a fact in the conversation, without asking anything.
*
* `followUp` are the chips offered under it — a skill's own suggestions, so
* the next step after attaching one is a question that skill was written to
* answer rather than a blank composer.
*
* Never opens the panel on its own. A reader who collapsed Owliver to get the
* full width does not want it reopened by a checkbox, and the notice is still
* waiting in the thread when they open it again.
*/
const announce = React.useCallback(({ text, followUp = null }) => {
const body = String(text || '').trim();
if (!body) return;
noticeSeq.current += 1;
setNotice({
id: noticeSeq.current,
text: body,
followUp: Array.isArray(followUp) && followUp.length ? followUp : null,
});
}, []);
/** Taken by the panel once it has actually said it. */
const consumeNotice = React.useCallback((id) => {
setNotice((current) => (current?.id === id ? null : current));
}, []);
/* Width is written on every drag frame, so persistence is debounced rather
than hitting sessionStorage sixty times a second. */
const setWidthState = React.useCallback((next) => {
@@ -220,8 +299,14 @@ export function AssistantPanelProvider({ role, pathname, children }) {
ask,
request,
consumeRequest,
announce,
notice,
consumeNotice,
test,
reportTest,
clearTest,
}), [context, supported, isOpen, isExpanded, width, setWidthState, setOpenState, setExpandedState,
ask, request, consumeRequest]);
ask, request, consumeRequest, announce, notice, consumeNotice, test, reportTest, clearTest]);
/* The page's own selection travels beside the window state, mounted here so
both the page and the panel are inside it — a skill's data source resolves
@@ -265,5 +350,11 @@ export function useAssistantPanel() {
ask: () => {},
request: null,
consumeRequest: () => {},
announce: () => {},
notice: null,
consumeNotice: () => {},
test: null,
reportTest: () => {},
clearTest: () => {},
};
}

View File

@@ -1,7 +1,7 @@
import * as React from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Trash2,
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Trash2, X,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Surface } from '@/components/ds/Surface';
@@ -25,7 +25,7 @@ import { useAssistantFacts, useConversation, useCurrentUserName } from './useAss
import { buildIntro, buildPrompts } from './dynamic';
import { agentScopedDisabled, agentStarters } from '@/lib/agents/runtime';
import { buildOwliverContext } from '@/lib/agents/context';
import { AgentSwitcher } from './AgentSwitcher';
import { AgentBadge } from './AgentBadge';
import { useActiveAgent } from './AgentContext';
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
import { PromptInput } from './PromptInput';
@@ -221,7 +221,11 @@ export default function KrowAssistant({
const location = useLocation();
/* A question a page has handed over — see the effect below `runPrompt`. */
const { request: panelRequest, consumeRequest } = useAssistantPanel();
const {
request: panelRequest, consumeRequest,
notice: panelNotice, consumeNotice,
test: panelTest, reportTest, clearTest,
} = 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. */
@@ -447,7 +451,7 @@ export default function KrowAssistant({
);
const {
messages, pending, error, busy, send, stop, reset,
messages, pending, error, busy, send, announce, stop, reset,
history, conversationId, openConversation, forgetConversation,
submitFeedback, feedback,
} = useConversation({
@@ -604,17 +608,18 @@ export default function KrowAssistant({
* 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 handleMessage = React.useCallback((
question, capability = null, positionId = null, scope = 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 });
send({ question: text, capability, positionId, scope });
}, [busy, send]);
const submit = React.useCallback((text) => handleMessage(text), [handleMessage]);
/**
* A chip is already a complete question, so it runs on click.
@@ -644,9 +649,54 @@ export default function KrowAssistant({
*/
React.useEffect(() => {
if (!panelRequest || busy) return;
handleMessage(panelRequest.question, null, panelRequest.positionId);
handleMessage(
panelRequest.question, panelRequest.capability ?? null,
panelRequest.positionId, panelRequest.scope,
);
if (panelRequest.trace) reportTest(panelRequest.id, 'running');
consumeRequest(panelRequest.id);
}, [panelRequest, busy, handleMessage, consumeRequest]);
}, [panelRequest, busy, handleMessage, consumeRequest, reportTest]);
/**
* Reporting the end of a traced turn.
*
* The panel is the only place that knows a turn finished, and a reader who
* pressed Test is waiting on exactly that. `busy` falling is the signal;
* `error` decides which outcome it was. Nothing is inferred about the
* *content* of the answer — a capability that answered is a pass, and judging
* whether the answer was any good is the reader's job, which is the whole
* reason the answer is shown to them rather than summarised.
*/
React.useEffect(() => {
if (panelTest?.state !== 'running' || busy) return;
reportTest(panelTest.id, error ? 'error' : 'done', error || null);
}, [panelTest, busy, error, reportTest]);
/* A question the reader types themselves ends the test banner: they have
moved on, and leaving "Testing…" above an unrelated answer would label
something that is no longer happening. */
const submit = React.useCallback((text) => {
if (panelTest) clearTest();
handleMessage(text);
}, [handleMessage, panelTest, clearTest]);
/**
* Something the workspace told Owliver, said in the thread.
*
* Attaching a skill to an agent is the only source today. It is stated rather
* than asked, so it appends a turn instead of running one — see
* `AssistantPanelContext.announce` for why those are different acts.
*
* Deferred while a response streams, for the same reason a request is: a
* notice arriving mid-answer waits for the panel rather than landing between
* a question and its reply. Reading History is left alone — the notice is in
* the live thread when the reader comes back to it.
*/
React.useEffect(() => {
if (!panelNotice || busy) return;
announce({ text: panelNotice.text, followUp: panelNotice.followUp });
consumeNotice(panelNotice.id);
}, [panelNotice, busy, announce, consumeNotice]);
const composer = (
<PromptInput
@@ -672,11 +722,12 @@ export default function KrowAssistant({
>
{/* Header */}
<div className="flex shrink-0 items-center gap-2.5 border-b border-white/60 px-3.5 py-2.5">
{/* The avatar and the two lines beside it, as they always were —
"Owliver" over the page, with the pair doubling as the agent
switcher. The header element, its geometry and the window controls
to the right are unchanged. */}
<AgentSwitcher page={context.page} />
{/* The avatar and the two lines beside it — "Owliver" over the page,
and who is answering. It is a label, not a control: choosing an
agent belongs to Agent Registry → Configure, not to this header.
The header element, its geometry and the window controls to the
right are unchanged. */}
<AgentBadge page={context.page} />
{/* Window controls.
@@ -747,6 +798,46 @@ export default function KrowAssistant({
</div>
)}
{/**
* A capability test running through this panel.
*
* One line, in the panel's own vocabulary, so the reader knows the next
* answer is the thing they asked for rather than the page talking to
* itself. Deliberately outside the scrolling body: it describes the turn,
* not a message in it, and nothing about it is persisted with the thread.
*/}
{panelTest && (
<div className="flex shrink-0 items-center gap-2 border-b border-white/60 bg-krow-blue-tint/50 px-3.5 py-1.5">
<span
className={cn(
'h-1.5 w-1.5 shrink-0 rounded-full',
panelTest.state === 'error' ? 'bg-destructive'
: panelTest.state === 'done' ? 'bg-success'
: 'animate-pulse bg-krow-blue'
)}
aria-hidden="true"
/>
<p className="min-w-0 flex-1 truncate text-caption text-ink-2">
<span className="font-medium">
{panelTest.state === 'done' ? 'Tested'
: panelTest.state === 'error' ? 'Test failed' : 'Testing'}
</span>
{': '}
{panelTest.trace.skillName}
{panelTest.trace.agentName ? ` on ${panelTest.trace.agentName}` : ''}
</p>
<button
type="button"
onClick={clearTest}
aria-label="Dismiss test indicator"
className={`shrink-0 rounded p-0.5 text-ink-4 transition-colors hover:text-ink-2
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40`}
>
<X className="h-3 w-3" aria-hidden="true" />
</button>
</div>
)}
{/* ── Body: the only region that scrolls ─────────────────────────── */}
<div
ref={scrollRef}

View File

@@ -26,7 +26,7 @@ import { buildFacts } from './insights';
import { agentRequest } from '@/lib/agents/runtime';
import { createAssistantProvider } from './provider';
import { resolveIntent } from './routing';
import { toSnapshots } from './blocks';
import { doc, text as textBlock, toSnapshots } from './blocks';
/** One provider instance for the app's lifetime. */
const provider = createAssistantProvider();
@@ -397,10 +397,36 @@ export function useConversation({
const messagesRef = React.useRef(messages);
messagesRef.current = messages;
const send = React.useCallback(async ({ question, capability = null, positionId = null }) => {
/**
* Asks something.
*
* `scope` runs this one turn against a *different* page and a different set
* of reachable skills, and exists for exactly one caller: testing a
* capability from the agent editor. The reader is standing on the
* configuration screen, so the panel's own context is the workspace — asking
* "what happened today?" there would resolve against the workspace's skills
* and prove nothing about the Activity capability being configured.
*
* It is an override of *which page this question is about*, never of what may
* be read: `resolveIntent` and the provider apply the same page rules to the
* substituted context that they apply to a real one, so a test cannot reach a
* record the real page would not have offered. The turn lands in the ordinary
* thread, is persisted and archived like any other, and nothing about it is
* simulated — it is the live pipeline pointed at another surface.
*/
const send = React.useCallback(async ({
question, capability = null, positionId = null, scope = null,
}) => {
const text = String(question).trim();
if (!text) return;
/* The page this turn is about, and what is reachable there. Defaults are
the panel's own, so every existing caller is unchanged. */
const turnContext = scope?.contextId || contextId;
const turnDisabled = scope?.disabledSkills || disabledSkills;
const turnAgent = scope?.agent || agent;
const turnCovers = scope ? true : agentCoversPage;
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
@@ -419,7 +445,7 @@ export function useConversation({
*/
let intent = null;
if (flowRef.current) {
const skill = skillsForContext(contextId, disabledSkills, customSkills)
const skill = skillsForContext(turnContext, turnDisabled, customSkills)
.find((s) => s.id === flowRef.current.skillId);
if (!skill) setFlow(null);
@@ -438,9 +464,14 @@ export function useConversation({
intent = capability
? { kind: 'answer' }
: resolveIntent({
question: text, contextId, disabledSkills, customSkills, roles, skillCategories,
question: text,
contextId: turnContext,
disabledSkills: turnDisabled,
customSkills, roles, skillCategories,
courses, workforce, skillContext, positionId,
agent, agentCoversPage, agentSuggestion, owliverContext,
agent: turnAgent,
agentCoversPage: turnCovers,
agentSuggestion, owliverContext,
});
}
@@ -476,7 +507,7 @@ export function useConversation({
followUp: [
...createdFollowUp(created),
...(ready
? suggestionsForPosition(contextId, disabledSkills, customSkills, created)
? suggestionsForPosition(turnContext, turnDisabled, customSkills, created)
: []),
],
};
@@ -664,10 +695,10 @@ export function useConversation({
try {
let latest = [];
for await (const snapshot of provider.stream({
contextId, capability, question: text, facts, signal: controller.signal,
contextId: turnContext, capability, question: text, facts, signal: controller.signal,
/* What the agent *is*, never what it may read. The page settled that
before this call, and `agentRequest` carries no records. */
agent: agent ? agentRequest(agent, contextId) : null,
agent: turnAgent ? agentRequest(turnAgent, turnContext) : null,
owliverContext,
})) {
if (controller.signal.aborted) break;
@@ -703,10 +734,39 @@ export function useConversation({
onGenerateDescription, onAssignWorkers,
onScheduleInterview,
workforce, setFlow, disabledSkills,
customSkills, roles, skillCategories, courses, skillContext]);
customSkills, roles, skillCategories, courses, skillContext,
agent, agentCoversPage, agentSuggestion, owliverContext]);
const stop = React.useCallback(() => abortRef.current?.abort(), []);
/**
* States something in the thread without a question having been asked.
*
* The workspace's own voice: "Attendance Analysis is now available to this
* agent." It is a real turn — persisted and archived by the same `persist`
* every answer goes through, so it survives a reload and appears in History
* exactly where it happened, rather than being a banner that evaporates.
*
* Nothing is generated. The caller supplies the sentence and the chips, which
* is what keeps this from being a second answering path: no skill runs, no
* provider is called, and nothing here can claim a figure.
*/
const announce = React.useCallback(({ text: body, followUp = null }) => {
const sentence = String(body || '').trim();
if (!sentence) return;
const message = {
role: 'assistant',
text: sentence,
blocks: doc(textBlock(sentence)).blocks,
...(followUp?.length ? { followUp } : null),
};
const next = [...messagesRef.current, message];
messagesRef.current = next;
persist(next);
}, [persist]);
/**
* Back to an empty panel.
*
@@ -797,6 +857,7 @@ export function useConversation({
error,
busy: Boolean(pending),
send,
announce,
stop,
reset,
submitFeedback,