fix error line
This commit is contained in:
@@ -37,7 +37,9 @@ export function ThinkingIndicator() {
|
||||
* with tables and KPI tiles, and a chat bubble would waste a third of a 380px
|
||||
* column on padding around them.
|
||||
*/
|
||||
export const Message = React.memo(({ role, text, blocks, streaming, stopped, onPrompt }) => {
|
||||
export const Message = React.memo(
|
||||
/** @param {any} props */
|
||||
({ role, text, blocks, streaming, stopped, onPrompt }) => {
|
||||
if (role === 'user') {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
|
||||
@@ -15,7 +15,8 @@ import { useAssistantPanel } from './AssistantPanelContext';
|
||||
* It self-hides on pages with no assistant, so a page can render it
|
||||
* unconditionally and never needs to know the placement rules.
|
||||
*/
|
||||
export function AssistantTrigger({ className, size = 'default' }) {
|
||||
/** @param {any} props */
|
||||
export function AssistantTrigger({ className = '', size = 'default' }) {
|
||||
const { supported, isOpen, toggle, context } = useAssistantPanel();
|
||||
|
||||
if (!supported) return null;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Sparkles, Trash2,
|
||||
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Trash2,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Surface } from '@/components/ds/Surface';
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
usePreferences, useRoleCategories,
|
||||
} from '@/lib/krowHooks';
|
||||
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';
|
||||
@@ -286,7 +285,6 @@ export default function KrowAssistant({
|
||||
activity: facts.activity || [],
|
||||
trainingPaths,
|
||||
}), [pageContext, facts, assignments, trainingPaths]);
|
||||
const [addSkillOpen, setAddSkillOpen] = React.useState(false);
|
||||
|
||||
/**
|
||||
* Which face of the panel the body is showing.
|
||||
@@ -500,12 +498,33 @@ export default function KrowAssistant({
|
||||
const ready = declared.filter((c) => !c.deferred);
|
||||
const asking = declared.filter((c) => c.deferred);
|
||||
|
||||
/**
|
||||
* One chip per *intent*, not per wording.
|
||||
*
|
||||
* De-duplicating on the label alone let two chips through whenever the same
|
||||
* answer was worded twice — a skill's "Show hiring activity" and its
|
||||
* "Summarize hiring activity" both resolved to that skill's `summary`
|
||||
* capability, so the reader was offered the same reading under two names and
|
||||
* had no way to tell them apart. What a chip *resolves to* is the thing that
|
||||
* must be unique: a skill capability, the page capability, or, for a chip
|
||||
* that is neither, the question it sends. The label is compared too, so two
|
||||
* differently-routed chips still cannot arrive reading identically.
|
||||
*/
|
||||
const seen = new Set();
|
||||
const intentOf = (chip) => {
|
||||
if (chip?.skillId && chip?.skillCapability) return `skill:${chip.skillId}:${chip.skillCapability}`;
|
||||
if (chip?.capability) return `page:${chip.capability}`;
|
||||
return `ask:${String(chip?.prompt ?? '').trim().toLowerCase()}`;
|
||||
};
|
||||
|
||||
return [...ready, ...skillPrompts, ...buildPrompts(context.id, facts, workforce), ...asking]
|
||||
.filter((chip) => {
|
||||
const key = String(chip?.label ?? '').trim().toLowerCase();
|
||||
if (!key || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
const label = String(chip?.label ?? '').trim().toLowerCase();
|
||||
if (!label) return false;
|
||||
const intent = intentOf(chip);
|
||||
if (seen.has(intent) || seen.has(`label:${label}`)) return false;
|
||||
seen.add(intent);
|
||||
seen.add(`label:${label}`);
|
||||
return true;
|
||||
});
|
||||
/* `pageContext` decides which suggestions can answer without asking, so the
|
||||
@@ -603,17 +622,16 @@ export default function KrowAssistant({
|
||||
<p className="truncate text-caption leading-tight text-ink-3">{context.page}</p>
|
||||
</div>
|
||||
|
||||
{/* Window controls */}
|
||||
{/* 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">
|
||||
{/* Opens the same dialog Settings does; there is one registry behind
|
||||
both, so a skill added here is the skill added there. */}
|
||||
<IconButton
|
||||
icon={Sparkles}
|
||||
label="Add an Owliver skill"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setAddSkillOpen(true)}
|
||||
/>
|
||||
{/* 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
|
||||
@@ -729,8 +747,6 @@ export default function KrowAssistant({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AddSkillDialog open={addSkillOpen} onOpenChange={setAddSkillOpen} />
|
||||
|
||||
{/* ── Composer: fixed to the bottom in both states ───────────────── */}
|
||||
<div className="shrink-0 space-y-2 border-t border-white/60 px-3 pb-2.5 pt-2.5">
|
||||
{/* All suggestions on the landing screen, where they teach what can be
|
||||
|
||||
@@ -15,9 +15,16 @@ import { cn } from '@/lib/utils';
|
||||
* `max` caps how many are offered in a conversation, where vertical space belongs
|
||||
* to the thread rather than to suggestions.
|
||||
*
|
||||
* A chip is shrink-to-fit up to the panel's width, so a long suggestion spends the
|
||||
* whole row before it is allowed to take a second line. When it does take one, the
|
||||
* radius stays at the single-line pill's 14px rather than following the height:
|
||||
* `rounded-full` on a two-line chip resolves to half of 46px, and that corner is
|
||||
* what turns the same component into something that reads as a card.
|
||||
*
|
||||
* Arrow keys move between chips, so the whole set is one tab stop.
|
||||
*/
|
||||
export function PromptChips({ prompts = [], onSelect, max, align = 'center', className }) {
|
||||
/** @param {any} props */
|
||||
export function PromptChips({ prompts = [], onSelect, max = 0, align = 'center', className = '' }) {
|
||||
const chipRefs = React.useRef([]);
|
||||
const visible = max ? prompts.slice(0, max) : prompts;
|
||||
|
||||
@@ -54,15 +61,21 @@ export function PromptChips({ prompts = [], onSelect, max, align = 'center', cla
|
||||
onKeyDown={(e) => onKeyDown(e, i)}
|
||||
title={prompt.prompt}
|
||||
className={cn(
|
||||
'rounded-full border border-border bg-surface px-2.5 py-1',
|
||||
'text-caption font-medium text-ink-2 shadow-xs',
|
||||
/* 14px is the radius `rounded-full` already produces on a one-line
|
||||
chip, pinned so a wrapped chip keeps the same corner. */
|
||||
'flex max-w-full items-center rounded-[14px] border border-border bg-surface px-2.5 py-1',
|
||||
'text-caption font-medium text-ink-2 shadow-xs text-left',
|
||||
'transition-all duration-base ease-out',
|
||||
'hover:-translate-y-px hover:border-krow-blue/40 hover:bg-krow-blue-tint hover:text-krow-blue hover:shadow-sm',
|
||||
'active:translate-y-0 active:shadow-xs',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/60 focus-visible:ring-offset-1'
|
||||
)}
|
||||
>
|
||||
{prompt.label}
|
||||
{/* `pretty` rather than `balance`: the row should be spent before a
|
||||
second line starts, and balancing would leave the pill wide with
|
||||
two short lines inside it. Clamped so no suggestion can grow the
|
||||
chip into a paragraph — the full text stays in `title`. */}
|
||||
<span className="line-clamp-3 text-pretty">{prompt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { cn } from '@/lib/utils';
|
||||
* gain — the suggestion chips already teach the range of what can be asked, and
|
||||
* they do it without a timer or a rerender every four seconds.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function PromptInput({
|
||||
value,
|
||||
onChange,
|
||||
@@ -26,7 +27,7 @@ export function PromptInput({
|
||||
placeholders = ['Ask anything…'],
|
||||
autoFocus = false,
|
||||
align = 'left',
|
||||
className,
|
||||
className = '',
|
||||
}) {
|
||||
const textareaRef = React.useRef(null);
|
||||
const [focused, setFocused] = React.useState(false);
|
||||
|
||||
@@ -62,14 +62,14 @@ const TONE_BADGE = {
|
||||
|
||||
/* ── Individual blocks ──────────────────────────────────────────────────── */
|
||||
|
||||
const TextBlock = React.memo(({ block }) => (
|
||||
const TextBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
<p className="text-body-sm leading-relaxed text-ink-2">
|
||||
<Inline value={block.text} />
|
||||
</p>
|
||||
));
|
||||
TextBlock.displayName = 'TextBlock';
|
||||
|
||||
const HeadingBlock = React.memo(({ block }) => (
|
||||
const HeadingBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
<div className="space-y-0.5">
|
||||
<h4 className="font-heading text-body-sm font-semibold text-ink-1">{block.text}</h4>
|
||||
{block.sub && <p className="text-caption text-ink-3">{block.sub}</p>}
|
||||
@@ -78,7 +78,7 @@ const HeadingBlock = React.memo(({ block }) => (
|
||||
HeadingBlock.displayName = 'HeadingBlock';
|
||||
|
||||
/** Headline figures. Two columns keeps the numbers large in a 380px panel. */
|
||||
const KpisBlock = React.memo(({ block }) => (
|
||||
const KpisBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
<div className="grid grid-cols-2 gap-2 [[data-wide]_&]:grid-cols-3">
|
||||
{block.items.map((item) => (
|
||||
<div
|
||||
@@ -113,7 +113,7 @@ const KpisBlock = React.memo(({ block }) => (
|
||||
KpisBlock.displayName = 'KpisBlock';
|
||||
|
||||
/** Pass/warn checks. The icon carries the state so colour is not the only cue. */
|
||||
const StatusBlock = React.memo(({ block }) => (
|
||||
const StatusBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
<ul className="space-y-1.5">
|
||||
{block.items.map((item) => (
|
||||
<li key={item.label} className="flex items-start gap-2">
|
||||
@@ -145,7 +145,7 @@ const StatusBlock = React.memo(({ block }) => (
|
||||
));
|
||||
StatusBlock.displayName = 'StatusBlock';
|
||||
|
||||
const MetersBlock = React.memo(({ block }) => (
|
||||
const MetersBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
<div className="space-y-2.5">
|
||||
{block.items.map((item) => (
|
||||
<div key={item.label} className="space-y-1">
|
||||
@@ -167,7 +167,7 @@ MetersBlock.displayName = 'MetersBlock';
|
||||
* A table. Scrolls horizontally inside its own container so a wide comparison
|
||||
* never widens the panel.
|
||||
*/
|
||||
const TableBlock = React.memo(({ block }) => {
|
||||
const TableBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
||||
const cell = (value) => {
|
||||
if (value == null || value === '') return <span className="text-ink-4">—</span>;
|
||||
if (typeof value === 'object') {
|
||||
@@ -228,7 +228,7 @@ const TableBlock = React.memo(({ block }) => {
|
||||
TableBlock.displayName = 'TableBlock';
|
||||
|
||||
/** Stage progression, widths relative to the largest stage. */
|
||||
const FunnelBlock = React.memo(({ block }) => {
|
||||
const FunnelBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
||||
const max = Math.max(...block.steps.map((s) => s.count), 1);
|
||||
|
||||
return (
|
||||
@@ -255,7 +255,7 @@ const FunnelBlock = React.memo(({ block }) => {
|
||||
});
|
||||
FunnelBlock.displayName = 'FunnelBlock';
|
||||
|
||||
const ListBlock = React.memo(({ block }) => {
|
||||
const ListBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
||||
const Tag = block.ordered ? 'ol' : 'ul';
|
||||
return (
|
||||
<Tag className="space-y-1.5">
|
||||
@@ -298,7 +298,7 @@ ListBlock.displayName = 'ListBlock';
|
||||
* clickable and doing nothing. An item may carry a hint *and* an action: the
|
||||
* hint explains the record, the action opens it.
|
||||
*/
|
||||
const InsightsBlock = React.memo(({ block, onPrompt }) => (
|
||||
const InsightsBlock = React.memo(/** @param {any} props */ ({ block, onPrompt }) => (
|
||||
<div className="space-y-2">
|
||||
{block.items.map((item, i) => {
|
||||
const surface = cn(
|
||||
@@ -375,7 +375,7 @@ const InsightsBlock = React.memo(({ block, onPrompt }) => (
|
||||
InsightsBlock.displayName = 'InsightsBlock';
|
||||
|
||||
/** Recommended steps — numbered, because order is the recommendation. */
|
||||
const ActionsBlock = React.memo(({ block }) => (
|
||||
const ActionsBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
<ol className="space-y-2">
|
||||
{block.items.map((item, i) => (
|
||||
<li key={i} className="flex gap-2.5 rounded-xl border border-border bg-surface px-3 py-2.5 shadow-xs">
|
||||
@@ -394,7 +394,7 @@ const ActionsBlock = React.memo(({ block }) => (
|
||||
));
|
||||
ActionsBlock.displayName = 'ActionsBlock';
|
||||
|
||||
const BadgesBlock = React.memo(({ block }) => (
|
||||
const BadgesBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{block.items.map((item) => (
|
||||
<Badge key={item.label} variant={TONE_BADGE[item.tone] || 'soft'} size="lg">
|
||||
@@ -405,7 +405,7 @@ const BadgesBlock = React.memo(({ block }) => (
|
||||
));
|
||||
BadgesBlock.displayName = 'BadgesBlock';
|
||||
|
||||
const TimelineBlock = React.memo(({ block }) => (
|
||||
const TimelineBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
<ol className="relative space-y-3">
|
||||
{block.items.map((item, i) => (
|
||||
<li key={i} className="flex gap-3">
|
||||
@@ -438,7 +438,7 @@ const TimelineBlock = React.memo(({ block }) => (
|
||||
));
|
||||
TimelineBlock.displayName = 'TimelineBlock';
|
||||
|
||||
const NoteBlock = React.memo(({ block }) => (
|
||||
const NoteBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
||||
<p className="border-l-2 border-border pl-2.5 text-caption italic leading-relaxed text-ink-3">
|
||||
<Inline value={block.text} />
|
||||
</p>
|
||||
@@ -455,7 +455,7 @@ NoteBlock.displayName = 'NoteBlock';
|
||||
* shape the table does not carry renders nothing rather than something
|
||||
* improvised.
|
||||
*/
|
||||
const SkillSectionBlock = React.memo(({ block }) => {
|
||||
const SkillSectionBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
||||
const section = block.section;
|
||||
const Component = SECTION_COMPONENTS[section?.shape || section?.type];
|
||||
|
||||
@@ -515,7 +515,7 @@ const RENDERERS = {
|
||||
* blocks has consistent rhythm — a heading hugs what follows it, everything
|
||||
* else breathes.
|
||||
*/
|
||||
export const ResponseDocument = React.memo(({ blocks = [], streaming = false, onPrompt }) => (
|
||||
export const ResponseDocument = React.memo(/** @param {any} props */ ({ blocks = [], streaming = false, onPrompt }) => (
|
||||
<div className="space-y-3">
|
||||
{blocks.map((block, i) => {
|
||||
const Renderer = RENDERERS[block.type];
|
||||
|
||||
@@ -459,7 +459,7 @@ const PROMPTS = {
|
||||
prompt: `Continue the ${drafts[0].title} draft`,
|
||||
}
|
||||
: {
|
||||
label: `${drafts.length} drafts to finish`,
|
||||
label: `Which ${plural(drafts.length, 'position')} ${verb(drafts.length, 'is', 'are')} still in draft?`,
|
||||
prompt: 'Which positions are still in draft?',
|
||||
capability: 'position-drafts',
|
||||
});
|
||||
@@ -496,8 +496,9 @@ const PROMPTS = {
|
||||
|
||||
if (withStrong.length) {
|
||||
const best = withStrong.sort((a, b) => b.status.strong.length - a.status.strong.length)[0];
|
||||
const n = best.status.strong.length;
|
||||
out.push({
|
||||
label: `${best.status.strong.length} strong for ${best.position.title}`,
|
||||
label: `Show the ${plural(n, 'strongest candidate')} for ${best.position.title}`,
|
||||
prompt: `Who matches ${best.position.title}?`,
|
||||
});
|
||||
}
|
||||
@@ -505,10 +506,16 @@ const PROMPTS = {
|
||||
out.push({ label: 'Which position needs people first?', prompt: 'Which position needs people first?' });
|
||||
}
|
||||
if (newToday) {
|
||||
out.push({ label: `${newToday} applied today`, prompt: 'Who applied today?' });
|
||||
out.push({
|
||||
label: `Which ${plural(newToday, 'candidate')} applied today?`,
|
||||
prompt: 'Who applied today?',
|
||||
});
|
||||
}
|
||||
if (readyCount) {
|
||||
out.push({ label: `${readyCount} ready for interview`, prompt: 'Who is ready for interview?' });
|
||||
out.push({
|
||||
label: `Which ${plural(readyCount, 'candidate')} ${verb(readyCount, 'is', 'are')} ready for interview?`,
|
||||
prompt: 'Who is ready for interview?',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,13 +523,25 @@ const PROMPTS = {
|
||||
ones — they are still the right answer to "how is hiring going". */
|
||||
out.push(
|
||||
f.starvedPositions.length
|
||||
? { label: `${f.starvedPositions.length} roles need attention`, prompt: 'What needs attention across my positions?', capability: 'positions-attention' }
|
||||
? {
|
||||
label: `Which ${plural(f.starvedPositions.length, 'role')} ${verb(f.starvedPositions.length, 'needs', 'need')} attention?`,
|
||||
prompt: 'What needs attention across my positions?',
|
||||
capability: 'positions-attention',
|
||||
}
|
||||
: { label: 'What needs attention?', prompt: 'What needs attention across my positions?', capability: 'positions-attention' },
|
||||
{ label: 'Which position has the strongest pipeline?', prompt: 'Which position has the strongest pipeline?', capability: 'position-strength' },
|
||||
f.unscreened.length
|
||||
? { label: `${f.unscreened.length} waiting for review`, prompt: 'Show candidates waiting for review', capability: 'candidates-waiting' }
|
||||
? {
|
||||
label: `Show the ${plural(f.unscreened.length, 'application')} waiting for review`,
|
||||
prompt: 'Show candidates waiting for review',
|
||||
capability: 'candidates-waiting',
|
||||
}
|
||||
: { label: 'Show candidates waiting for review', prompt: 'Show candidates waiting for review', capability: 'candidates-waiting' },
|
||||
{ label: 'Summarize hiring activity', prompt: 'Summarize hiring activity across my positions', capability: 'hiring-operations' }
|
||||
/* Named for the whole board. The Hiring Activity skill answers the same
|
||||
question *for one position*, and the two used to arrive worded
|
||||
identically — see the intent de-duplication in `KrowAssistant`, which
|
||||
now also compares what a chip resolves to rather than only its words. */
|
||||
{ label: 'Summarize hiring activity across all positions', prompt: 'Summarize hiring activity across my positions', capability: 'hiring-operations' }
|
||||
);
|
||||
|
||||
return out;
|
||||
|
||||
@@ -42,7 +42,7 @@ export function readHistory() {
|
||||
return list
|
||||
.filter((r) => r && r.id && Array.isArray(r.messages) && r.messages.length)
|
||||
.filter((r) => new Date(r.updatedAt || 0).getTime() >= cutoff)
|
||||
.sort((a, b) => new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0));
|
||||
.sort((a, b) => new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime());
|
||||
} catch {
|
||||
/* Corrupt or unavailable storage is an empty history, not an error the
|
||||
reader has to deal with. */
|
||||
|
||||
@@ -60,7 +60,7 @@ export function buildFacts({
|
||||
/* Time-to-hire — the created→updated span ImpactMetrics uses. */
|
||||
const timeToHire = hired.length
|
||||
? Math.round(avg(hired.map((a) =>
|
||||
Math.max(1, (new Date(a.updated_date) - new Date(a.created_date)) / DAY_MS))))
|
||||
Math.max(1, (new Date(a.updated_date).getTime() - new Date(a.created_date).getTime()) / DAY_MS))))
|
||||
: 0;
|
||||
|
||||
/* Open roles attracting nobody. */
|
||||
|
||||
Reference in New Issue
Block a user