fix error line

This commit is contained in:
2026-08-17 17:33:56 +05:30
parent 69b38abced
commit 7fa21a4513
105 changed files with 1296 additions and 323 deletions

View File

@@ -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">

View File

@@ -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;

View File

@@ -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

View File

@@ -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>

View File

@@ -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);

View File

@@ -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];

View File

@@ -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;

View File

@@ -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. */

View File

@@ -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. */