The thirteen `.jsx` files under `src/components/ai-assistant/`, which
completes that directory: 24/24 files migrated and all 24 linted under
their new extensions.
39 errors, and converting 21 `/** @param {any} props */` hatches to
`: any` cleared 33 of them — the same pattern as batch 6, and the same
reason: without the JSDoc, TypeScript infers every destructured prop as
required, so `ResponseBlocks` and `AssistantMessage` alone produced 31
complaints about call sites that were always correct.
The remaining six were already there before this batch and are
unchanged.
13/13 erase byte-identically. `.jsx` -> `.tsx` is the move that can drop
unused imports through esbuild's loader difference; it did not here, and
the bundle hash is unchanged.
Measured against `3f835ee`:
typecheck 6 errors, unchanged; no new error anywhere
lint exit 0, 0 errors, 289 warnings, 24/24 linted by name
npm test 1684/1691, the same 7 failures verbatim
build exit 0, identical bundle hash 74d17e2d…
type erasure 96/96 byte-identical across Phase 11 so far
No baseline artifact touched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
1087 lines
46 KiB
TypeScript
1087 lines
46 KiB
TypeScript
import * as React from 'react';
|
|
import { useLocation, useNavigate } from 'react-router-dom';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import {
|
|
History, Home, Maximize2, Minimize2, PanelRightClose, Trash2, X,
|
|
} from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { Surface } from '@/components/ds/Surface';
|
|
import { IconButton } from '@/components/ds/IconButton';
|
|
import { Alert } from '@/components/ds/Alert';
|
|
import {
|
|
useAssignments, useAssignWorkers, useCreateWorkerWithRole, useCreateJobPosting,
|
|
useGenerateJobDescription,
|
|
fetchOwliverSuggestions, useMarkInterviewReady, useOwliverSuggestions, useShiftRecords,
|
|
useUpdateJobPosting,
|
|
usePreferences, useRoleCategories,
|
|
} from '@/lib/krowHooks';
|
|
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
|
|
import { runAction } from '@/lib/skills/actions';
|
|
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
|
|
import { allSkills, pageKeyForContext, skillsForContext } from '@/lib/skills/registry';
|
|
import { actionSuggestions } from '@/lib/skills/tools';
|
|
import { suggestionChips } from '@/lib/skills/serverSuggestions';
|
|
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 } from './dynamic';
|
|
import { agentScopedDisabled } from '@/lib/agents/runtime';
|
|
import { buildOwliverContext } from '@/lib/agents/context';
|
|
import { AgentBadge } from './AgentBadge';
|
|
import { useActiveAgent } from './AgentContext';
|
|
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
|
|
import { PromptInput } from './PromptInput';
|
|
import { PromptChips } from './PromptChips';
|
|
|
|
/**
|
|
* The chip row's "nothing to offer", as one shared array.
|
|
*
|
|
* Identity, not emptiness: `prompts` is recomputed on every keystroke, and a
|
|
* fresh `[]` each time would rerender the row — and anything memoized against
|
|
* it — on every turn that offers nothing.
|
|
*/
|
|
const EMPTY_PROMPTS = [];
|
|
|
|
/**
|
|
* The same, for a suggestion response that has not arrived.
|
|
*
|
|
* Separate from EMPTY_PROMPTS because they are different kinds of empty: one is
|
|
* "this page offers nothing", the other is "the server has not answered yet",
|
|
* and sharing an array between them would make a pending request and a
|
|
* considered refusal indistinguishable in a dependency list.
|
|
*/
|
|
const EMPTY_SUGGESTIONS = [];
|
|
|
|
/**
|
|
* 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>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Whether the composer is offering anything, as one rule in one place.
|
|
*
|
|
* Extracted because it is the whole of the interaction and every clause is a
|
|
* decision somebody could reasonably make differently:
|
|
*
|
|
* focused — an offer belongs to the thing you are about to type into. An
|
|
* unfocused composer showing suggestions is the panel talking
|
|
* first.
|
|
* !busy — nothing is offered while an answer is still arriving; the next
|
|
* question is not knowable until this one lands.
|
|
* chat — History is a different view with a different body.
|
|
* count — nothing to say, nothing shown, and this clause now carries what
|
|
* a `!typed` test used to. Typing does not hide the panel by
|
|
* rule; it changes what the panel HAS. An empty composer offers
|
|
* follow-ups, a typed action intent offers the matching actions,
|
|
* and arbitrary partial text matches no action and so offers
|
|
* nothing — which is the same outcome by a more honest route,
|
|
* and the reason "Create" can be answered while "he" cannot.
|
|
*/
|
|
export const shouldShowSuggestions = ({ focused, busy, view, count }) => Boolean(
|
|
focused && !busy && view === 'chat' && count > 0
|
|
);
|
|
|
|
/**
|
|
* The questions on offer, above the composer they belong to.
|
|
*
|
|
* A labelled panel rather than a bare row of chips: the label is what makes
|
|
* three sentences read as an offer rather than as something the assistant just
|
|
* said. It sits inside the composer's own region, above the input and below the
|
|
* conversation, so it reads as part of the thing you are about to type into.
|
|
*
|
|
* Always mounted, height animated. Mounting on open would move the input the
|
|
* instant the panel appeared and again when it left, so the caret would jump
|
|
* under the reader's hands every time they focused the box. Animating a
|
|
* collapsed height keeps the geometry continuous, and `pointer-events-none`
|
|
* plus `inert`-style tab removal means the closed panel cannot be clicked or
|
|
* tabbed into.
|
|
*
|
|
* `max-h` is generous enough for four wrapped questions and scrolls past that,
|
|
* so a narrow panel on a small screen cannot push the composer off the bottom.
|
|
*/
|
|
function SuggestedQuestions({ prompts, open, onSelect }) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'overflow-hidden transition-all duration-200 ease-out motion-reduce:transition-none',
|
|
open
|
|
? 'max-h-56 translate-y-0 opacity-100'
|
|
: 'pointer-events-none max-h-0 translate-y-1 opacity-0'
|
|
)}
|
|
aria-hidden={open ? undefined : 'true'}
|
|
>
|
|
<p className="px-1 pb-1.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
|
|
Suggested questions
|
|
</p>
|
|
{/* The existing chip component, and the existing submit path behind it —
|
|
`runPrompt` is the same handler a typed question goes through. */}
|
|
<PromptChips
|
|
prompts={prompts}
|
|
onSelect={onSelect}
|
|
align="start"
|
|
focusable={open}
|
|
className="pb-1"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Keep the thread pinned to its newest content.
|
|
*
|
|
* All that survives of a floating "Back to Home" row that used to sit between
|
|
* the header and the conversation. That row was `absolute`, so it did not take
|
|
* part in the layout — it OVERLAID the top of the scrolling region, and the
|
|
* first line or two of a long answer arrived underneath it. It hid on
|
|
* down-scroll to compensate, which meant the fix for a control covering the
|
|
* answer was to make the control disappear while you read.
|
|
*
|
|
* The navigation it carried now lives in the header, where the panel's other
|
|
* controls already are and where nothing can cover the response. What is left
|
|
* here is the scroll behaviour, which was always a separate concern that had
|
|
* been folded in because the two happened to share a listener.
|
|
*
|
|
* Direct `scrollTop` rather than smooth scrolling: at streaming frequency a
|
|
* smooth scroll never catches up and the thread visibly lags the text.
|
|
*/
|
|
function usePinToBottom(scrollRef) {
|
|
return React.useCallback(() => {
|
|
const el = scrollRef.current;
|
|
if (!el) return;
|
|
el.scrollTop = el.scrollHeight;
|
|
}, [scrollRef]);
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
const location = useLocation();
|
|
|
|
/* A question a page has handed over — see the effect below `runPrompt`. */
|
|
const {
|
|
request: panelRequest, consumeRequest,
|
|
notice: panelNotice, consumeNotice,
|
|
test: panelTest, reportTest, clearTest,
|
|
ask: askAssistant,
|
|
} = 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, question) => {
|
|
navigate(destination.route);
|
|
/* Hand the question to the panel that mounts on the other side. The
|
|
request lives in AssistantPanelContext, which sits ABOVE the router, so
|
|
it survives the navigation that discards this panel's thread. Without
|
|
this the reader has to retype what they just typed. */
|
|
if (question) askAssistant({ question });
|
|
},
|
|
[navigate, askAssistant]
|
|
);
|
|
|
|
/* 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();
|
|
/* `defaultAgent` rather than `native`: on a page with no agent of its own the
|
|
way out of a constrained state is the general agent, and naming nothing
|
|
there would leave the reader with a decline and no next step. */
|
|
const { agent, covers: agentCoversPage, defaultAgent } = useActiveAgent();
|
|
|
|
/**
|
|
* What this page will not offer.
|
|
*
|
|
* The account's own switched-off skills, plus everything the active agent
|
|
* does not carry. **This one memo is the entire agent scoping**: every
|
|
* consumer below — the skill list, the suggestions, the conversation, the
|
|
* tools — already takes `disabledSkills`, so none of them needed changing.
|
|
*
|
|
* It can only ever subtract. `getSkillsForPage` applies the page filter and
|
|
* this list in the same expression, so an agent cannot make a skill appear on
|
|
* a page that does not carry it, and with no agent the list is exactly the
|
|
* account's own.
|
|
*/
|
|
const accountDisabled = React.useMemo(
|
|
() => preferences.disabledSkills || [],
|
|
[preferences.disabledSkills]
|
|
);
|
|
const allRegisteredSkills = React.useMemo(
|
|
() => allSkills(preferences.customSkills || []),
|
|
[preferences.customSkills]
|
|
);
|
|
const disabledSkills = React.useMemo(
|
|
() => agentScopedDisabled(agent, allRegisteredSkills, accountDisabled),
|
|
[agent, allRegisteredSkills, accountDisabled]
|
|
);
|
|
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]
|
|
);
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
/**
|
|
* The page, as the API names it.
|
|
*
|
|
* `pageKeyForContext` is the existing translation from an assistant context
|
|
* id to a surface key — `admin.positions` → `positions` — and the surface
|
|
* keys are the same closed vocabulary the backend validates `page` against.
|
|
* So the panel asks about the page it is on without a second table mapping
|
|
* one to the other.
|
|
*/
|
|
const owliverPage = React.useMemo(() => pageKeyForContext(context.id), [context.id]);
|
|
|
|
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();
|
|
/* Shifts worked, missed and overrun — the records behind attendance and
|
|
overtime. Read from the same cache a page would, so a card and an answer
|
|
about the same fortnight cannot disagree. */
|
|
const { data: shifts = [] } = useShiftRecords();
|
|
/* Where the reader is, composed from the page's own channel — see
|
|
`lib/agents/context.js`. Read-only: nothing here writes back to the page. */
|
|
const owliverContext = React.useMemo(
|
|
() => buildOwliverContext({ context, pathname: location.pathname, pageContext }),
|
|
[context, location.pathname, pageContext]
|
|
);
|
|
|
|
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 || [],
|
|
shifts,
|
|
trainingPaths,
|
|
}), [pageContext, facts, assignments, shifts, 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]);
|
|
|
|
/**
|
|
* Every conversation's write, keyed by the flow's id.
|
|
*
|
|
* `useAssistant` looks the writer up by the flow the answering skill declares,
|
|
* so adding a conversation is adding an entry here rather than another prop
|
|
* threaded through the panel.
|
|
*/
|
|
const createRole = useCreateWorkerWithRole();
|
|
const createEmployeeRole = React.useCallback(async (draft, skill, status) => {
|
|
const result = runAction('create_employee_role', { draft, skill, status });
|
|
if (result?.type !== 'create_employee_role') return null;
|
|
return createRole.mutateAsync(result.data);
|
|
}, [createRole]);
|
|
|
|
/* The page's layout session, mounted by the layout above both this panel and
|
|
the page. Null on a surface that composes no tree, which is every page that
|
|
has not migrated — and every branch that reads it checks first. */
|
|
const uiEditing = useUiEditing();
|
|
|
|
const flowWriters = React.useMemo(() => ({
|
|
position: createPosition,
|
|
'employee-role': createEmployeeRole,
|
|
}), [createPosition, createEmployeeRole]);
|
|
|
|
/**
|
|
* The clients this organization already staffs for.
|
|
*
|
|
* Distinct company names off the postings the panel has already loaded for
|
|
* this caller — org-scoped by the API, and nothing here widens that. They are
|
|
* offered as chips on the conversation's company question so an existing
|
|
* client is a tap, while typing a name that is not on the list is still how a
|
|
* new one is named. There is no company record to create: see the `@companies`
|
|
* note in `lib/skills/flows/position.js`.
|
|
*
|
|
* Deliberately NOT sorted here. The order is the one the postings arrived in
|
|
* — the API's `-created_date` — so the clients staffed for most recently are
|
|
* the ones offered first, and the panel does no ranking of its own. That last
|
|
* part is a rule `npm test` enforces structurally, and it is the right rule:
|
|
* a second opinion formed in the panel outranking the server's is exactly the
|
|
* failure that decays quietly.
|
|
*/
|
|
/**
|
|
* The workers a role can be recorded against.
|
|
*
|
|
* The profiles the panel already holds for this caller — org-scoped by the
|
|
* API. The conversation offers the names as chips and resolves a pick back to
|
|
* the profile id and email, so the row names a real person rather than
|
|
* whatever was typed. The operator is never the subject: the question is
|
|
* required and there is no fallback to the session.
|
|
*/
|
|
const workers = React.useMemo(() => (facts.profiles || []).map((w) => ({
|
|
id: w.id,
|
|
name: w.full_name || w.name || '',
|
|
email: w.email || '',
|
|
})).filter((w) => w.email), [facts.profiles]);
|
|
|
|
const companies = React.useMemo(() => [...new Set(
|
|
(facts.postings || []).map((p) => String(p.company || '').trim()).filter(Boolean)
|
|
)], [facts.postings]);
|
|
|
|
/**
|
|
* What to ask next, from the server, after something has been written.
|
|
*
|
|
* The untyped form of the suggestions endpoint: nothing has been typed, so
|
|
* the API ranks against the organization's actual state — the rows the
|
|
* mutation just changed. Asking it here rather than deriving an answer in the
|
|
* panel is the whole point. The panel knows a position now exists; only the
|
|
* server knows whether that makes drafts, starved roles or an unscreened
|
|
* queue the thing worth raising, and only it knows what this caller's role
|
|
* permits.
|
|
*
|
|
* `fetchQuery` with `staleTime: 0` rather than a bare request: it resolves
|
|
* with the fresh answer *and* leaves it in the cache under the key the hook
|
|
* reads, so the chips this reply carries and the chips the composer would
|
|
* offer are one answer rather than two requests apart. Zero rather than the
|
|
* hook's thirty seconds because a cached answer is exactly what must not be
|
|
* used here — it was computed before the row existed.
|
|
*/
|
|
const queryClient = useQueryClient();
|
|
const refreshSuggestions = React.useCallback(async ({ query = '' } = {}) => {
|
|
if (!owliverPage) return [];
|
|
const fresh = await queryClient.fetchQuery({
|
|
/* An empty query is the untyped request — the same key
|
|
`useOwliverSuggestions` uses when the composer is empty, and what a
|
|
write wants: the page changed, so ask what matters on it now.
|
|
A question passed in ranks the same catalogue AGAINST that question,
|
|
which is what makes a follow-up follow from something. */
|
|
queryKey: ['owliverSuggestions', owliverPage, query],
|
|
queryFn: () => fetchOwliverSuggestions({ page: owliverPage, query }),
|
|
staleTime: 0,
|
|
});
|
|
return suggestionChips(fresh || [], context.id);
|
|
}, [queryClient, owliverPage, context.id]);
|
|
|
|
/**
|
|
* 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, announce, stop, reset,
|
|
history, conversationId, openConversation, forgetConversation,
|
|
submitFeedback, feedback, confirm,
|
|
} = useConversation({
|
|
contextId: context.id,
|
|
facts,
|
|
pageLabel: context.page,
|
|
onNavigate: goToPage,
|
|
onAction: performAction,
|
|
flowWriters,
|
|
uiEditing,
|
|
companies,
|
|
/* The postings this caller can already see — the evidence behind
|
|
role-aware certification suggestions. */
|
|
postings: facts.postings || [],
|
|
workers,
|
|
onRefreshSuggestions: refreshSuggestions,
|
|
onUpdatePosition,
|
|
onGenerateDescription,
|
|
onAssignWorkers,
|
|
onScheduleInterview,
|
|
workforce,
|
|
disabledSkills,
|
|
customSkills,
|
|
roles,
|
|
skillCategories,
|
|
courses: facts.forge?.library || [],
|
|
skillContext,
|
|
agent,
|
|
agentCoversPage,
|
|
agentSuggestion: defaultAgent,
|
|
owliverContext,
|
|
});
|
|
|
|
/* 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;
|
|
|
|
const pinToBottom = usePinToBottom(scrollRef);
|
|
|
|
/* Whether there is anything to leave. The header's own control is shown on
|
|
exactly the states that are not already home — the same test the removed
|
|
row used, now deciding a button rather than an overlay. */
|
|
const canGoHome = view === 'history' || messages.length > 0 || Boolean(pending);
|
|
|
|
/* 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]
|
|
);
|
|
|
|
/**
|
|
* What the composer offers, and when.
|
|
*
|
|
* Three rules, and they are about DIFFERENT questions — what to show, and
|
|
* whether to show anything at all.
|
|
*
|
|
* WHAT. Before the first question, the page's own suggested questions: the
|
|
* reader has asked nothing, so there is nothing to follow up and the useful
|
|
* offer is the range of what this page can answer. After an answer, the
|
|
* follow-ups that answer carried — questions this conversation has not
|
|
* already covered, worked out in `nextSteps`. Never both: a thread that has
|
|
* run out of new ground shows nothing rather than falling back to the
|
|
* catalogue it has already been through.
|
|
*
|
|
* WHEN. Only while the composer has focus and is empty. Suggestions used to
|
|
* appear from the second character typed, which is the wrong moment twice
|
|
* over: a reader who is typing has already decided what to ask, and two
|
|
* characters is not enough to know what they mean. So typing hides them and
|
|
* the reader's own text is never touched.
|
|
*
|
|
* The typed-query branch is gone with it. The endpoint still takes a query
|
|
* and `nextSteps` still uses it — that is what makes a follow-up follow from
|
|
* something — but nothing asks it on a keystroke any more.
|
|
*/
|
|
const followUp = messages[messages.length - 1]?.followUp;
|
|
const typed = input.trim();
|
|
const [composerFocused, setComposerFocused] = React.useState(false);
|
|
|
|
/* The page's own questions, for a thread that has not started. One untyped
|
|
request, cached by the hook, asked only while it could be shown. */
|
|
const { data: pageSuggestions = EMPTY_SUGGESTIONS } = useOwliverSuggestions({
|
|
page: owliverPage,
|
|
enabled: Boolean(owliverPage) && messages.length === 0,
|
|
});
|
|
|
|
/**
|
|
* What is on offer, which now depends on whether anything has been typed.
|
|
*
|
|
* TYPED — the actions this page can perform that the text is starting to
|
|
* name, and nothing else. "Create" reaches "Create a position" here because
|
|
* that is a skill on this page that declares an action; "he" reaches nothing,
|
|
* and neither does "abc". This is the narrow case the composer was missing:
|
|
* a reader typing an action intent had to finish the sentence unaided, while
|
|
* a reader typing anything at all used to get the whole page catalogue.
|
|
*
|
|
* EMPTY — the follow-ups the last answer left, or, before a thread starts,
|
|
* what this page can be asked. Unchanged.
|
|
*/
|
|
const reachableSkills = React.useMemo(
|
|
() => skillsForContext(context.id, disabledSkills, preferences.customSkills || []),
|
|
[context.id, disabledSkills, preferences.customSkills]
|
|
);
|
|
|
|
const prompts = React.useMemo(() => {
|
|
if (typed) return actionSuggestions(typed, reachableSkills);
|
|
if (messages.length) return followUp?.length ? followUp : EMPTY_PROMPTS;
|
|
return suggestionChips(pageSuggestions, context.id);
|
|
}, [typed, reachableSkills, messages.length, followUp, pageSuggestions, context.id]);
|
|
|
|
const showSuggestions = shouldShowSuggestions({
|
|
focused: composerFocused, busy, view, count: prompts.length,
|
|
});
|
|
|
|
/**
|
|
* Focus, read at the composer rather than at the input.
|
|
*
|
|
* A chip lives inside the same region, so moving to one keeps the region
|
|
* focused and the panel open long enough for the click to land — which a
|
|
* `blur` handler on the textarea alone would not do. `relatedTarget` is what
|
|
* makes "clicked outside" mean it: focus leaving for anywhere else in the
|
|
* document closes the panel.
|
|
*/
|
|
const onComposerBlur = React.useCallback((event) => {
|
|
if (!event.currentTarget.contains(event.relatedTarget)) setComposerFocused(false);
|
|
}, []);
|
|
|
|
/**
|
|
* Asking closes the panel, and only focus reopens it.
|
|
*
|
|
* A blur handler alone was not enough, which a live run showed: after a
|
|
* question was sent, focus ended up on `document.body` while `composerFocused`
|
|
* was still true, so the panel came back on its own under the finished answer
|
|
* with nobody's cursor in the box. The subtree re-renders while the answer
|
|
* streams, and a focus lost that way does not always arrive as a blur this
|
|
* handler sees.
|
|
*
|
|
* So submitting is treated as what it is — the reader has finished with the
|
|
* composer for now — rather than relying on a blur that may never come. The
|
|
* state table is unchanged: focus opens it, everything else leaves it shut.
|
|
*/
|
|
React.useEffect(() => {
|
|
if (busy) setComposerFocused(false);
|
|
}, [busy]);
|
|
|
|
/* The newest assistant turn, which is the one that carries the rating. */
|
|
const lastAnswerIndex = React.useMemo(
|
|
() => messages.map((m) => m.role).lastIndexOf('assistant'),
|
|
[messages]
|
|
);
|
|
|
|
/* 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, 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, scope });
|
|
}, [busy, send]);
|
|
|
|
|
|
/**
|
|
* 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, panelRequest.capability ?? null,
|
|
panelRequest.positionId, panelRequest.scope,
|
|
);
|
|
if (panelRequest.trace) reportTest(panelRequest.id, 'running');
|
|
consumeRequest(panelRequest.id);
|
|
}, [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
|
|
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">
|
|
{/* 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.
|
|
|
|
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">
|
|
{/* The way back to a clean panel, positioned in front of History */}
|
|
{canGoHome && (
|
|
<IconButton icon={Home} label="Back to home" variant="ghost" size="sm" onClick={goHome} />
|
|
)}
|
|
{/* 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'))}
|
|
/>
|
|
{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>
|
|
|
|
{/**
|
|
* 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}
|
|
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}
|
|
/* Approving a proposed write. Offered on every assistant turn
|
|
that carries one, not just the newest: a proposal scrolled
|
|
past is still a decision somebody has to make. */
|
|
onConfirm={confirm}
|
|
/* A rating belongs to the conversation, so it is offered on
|
|
the newest answer only — and never while one streams. */
|
|
feedback={message.role === 'assistant' && i === lastAnswerIndex ? feedback : null}
|
|
onFeedback={
|
|
message.role === 'assistant' && i === lastAnswerIndex && !pending
|
|
? submitFeedback
|
|
: null
|
|
}
|
|
/>
|
|
</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 ───────────────── */}
|
|
{/* Focus is tracked on the whole region rather than on the textarea, so
|
|
reaching for a suggestion does not close the panel out from under the
|
|
click. In normal flow, never floating: an overlay here would sit on
|
|
top of the answer, which is the mistake the removed Back to Home row
|
|
made. The body above is `flex-1`, so it yields the height and the
|
|
response stays whole and scrollable. */}
|
|
<div
|
|
className="shrink-0 space-y-2 border-t border-white/60 px-3 pb-2.5 pt-2.5"
|
|
onFocusCapture={() => setComposerFocused(true)}
|
|
onBlurCapture={onComposerBlur}
|
|
>
|
|
<SuggestedQuestions prompts={prompts} open={showSuggestions} onSelect={runPrompt} />
|
|
{composer}
|
|
</div>
|
|
</Surface>
|
|
);
|
|
}
|