702 lines
28 KiB
JavaScript
702 lines
28 KiB
JavaScript
import * as React from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import {
|
|
ArrowDownRight, ArrowRight, ArrowUpRight, Check, ChevronRight, TriangleAlert,
|
|
} from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { ProgressBar, toneForScore } from '@/components/ds/Progress';
|
|
import { SECTION_COMPONENTS } from '@/components/skills/SkillSections';
|
|
import { useSkillDataContext } from '@/components/skills/SkillSurface';
|
|
import { resolveSkillData } from '@/lib/skills/dataResolver';
|
|
import { usePageAction } from './PageContext';
|
|
|
|
/**
|
|
* Renderers for response blocks.
|
|
*
|
|
* Every block is drawn with the same tokens as the dashboard — the ink scale,
|
|
* the shared radius, the score-band colours — so a generated report looks like
|
|
* it belongs to Krow rather than to a chat library.
|
|
*
|
|
* Blocks are memoized individually: a streaming response re-renders on every
|
|
* snapshot, and settled blocks must not re-render with it.
|
|
*/
|
|
|
|
const INLINE = /(\*\*[^*]+\*\*|_[^_]+_|\[[^\]\n]+\]\([^)\s]*\))/g;
|
|
const LINK = /^\[([^\]\n]+)\]\(([^)\s]*)\)$/;
|
|
|
|
/**
|
|
* Where a link in an answer is allowed to point.
|
|
*
|
|
* An allow-list, and it is a security boundary rather than a tidiness rule: the
|
|
* text being parsed here was written by a model, and a model reading a document
|
|
* that says "link to javascript:…" is exactly the injection §I7 of CLAUDE.md
|
|
* calls untrusted input. Anything not on this list renders as the plain text it
|
|
* came from — visible, inert, and obvious.
|
|
*
|
|
* `#` alone is deliberately absent. A bare empty anchor is the shape the
|
|
* citation sanitiser removes; one that reaches here is a link to nowhere, and
|
|
* showing its label as text is better than an anchor that does nothing.
|
|
*/
|
|
const isSafeHref = (href) => (
|
|
/^\/(?!\/)/.test(href) // in-app route
|
|
|| /^#[^\s]+$/.test(href) // an anchor on this page, but not a bare '#'
|
|
|| /^https?:\/\//i.test(href) // the open web
|
|
|| /^mailto:[^\s]+$/i.test(href)
|
|
);
|
|
|
|
/**
|
|
* Inline `**bold**`, `_italic_` and `[label](href)`.
|
|
*
|
|
* Links were the gap: `markdownToBlocks` never touched them, and this renderer
|
|
* had no case for them, so `[staffing policy](#staffing)` reached the reader as
|
|
* its own source. Structure is still carried by blocks rather than by markup —
|
|
* this stays three constructs, not a markdown library.
|
|
*
|
|
* A citation-shaped link never arrives here at all. `[337042b3](#)` is removed
|
|
* upstream in `provider.js`, by the wrapper's shape, before a block is built —
|
|
* so the two concerns stay apart: the sanitiser decides what is an internal
|
|
* reference, and this decides how a real link looks.
|
|
*/
|
|
function Inline({ value }) {
|
|
const parts = React.useMemo(() => String(value).split(INLINE).filter(Boolean), [value]);
|
|
|
|
return parts.map((part, i) => {
|
|
if (part.startsWith('**') && part.endsWith('**')) {
|
|
return <strong key={i} className="font-semibold text-ink-1">{part.slice(2, -2)}</strong>;
|
|
}
|
|
if (part.startsWith('_') && part.endsWith('_')) {
|
|
return <em key={i} className="text-ink-3">{part.slice(1, -1)}</em>;
|
|
}
|
|
|
|
const link = LINK.exec(part);
|
|
if (link) {
|
|
const [, label, href] = link;
|
|
if (!isSafeHref(href)) return part;
|
|
/* An in-app route goes through the router, like every other internal link
|
|
in this file — a full page load would throw away the conversation the
|
|
reader is being pointed away from. Everything else is an anchor, and
|
|
anything leaving the app opens away from it. */
|
|
const external = /^https?:\/\//i.test(href);
|
|
const className = 'font-medium text-krow-blue underline decoration-krow-blue/30 underline-offset-2'
|
|
+ ' transition-colors hover:decoration-krow-blue focus-visible:outline-none'
|
|
+ ' focus-visible:ring-2 focus-visible:ring-krow-blue/50 rounded-sm';
|
|
|
|
if (href.startsWith('/')) return <Link key={i} to={href} className={className}>{label}</Link>;
|
|
return (
|
|
<a
|
|
key={i}
|
|
href={href}
|
|
className={className}
|
|
{...(external ? { target: '_blank', rel: 'noreferrer noopener' } : null)}
|
|
>
|
|
{label}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
return part;
|
|
});
|
|
}
|
|
|
|
const TONE_TEXT = {
|
|
info: 'text-krow-blue',
|
|
success: 'text-success',
|
|
warning: 'text-warning',
|
|
risk: 'text-destructive',
|
|
neutral: 'text-ink-3',
|
|
};
|
|
|
|
const TONE_SURFACE = {
|
|
info: 'bg-krow-blue-tint border-krow-blue/15',
|
|
success: 'bg-success-muted border-success/15',
|
|
warning: 'bg-warning-muted border-warning/20',
|
|
risk: 'bg-destructive-muted border-destructive/15',
|
|
neutral: 'bg-surface-subtle border-border',
|
|
};
|
|
|
|
const TONE_BADGE = {
|
|
info: 'info', success: 'success', warning: 'warning', risk: 'destructive', neutral: 'neutral',
|
|
};
|
|
|
|
/* ── Individual blocks ──────────────────────────────────────────────────── */
|
|
|
|
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(/** @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>}
|
|
</div>
|
|
));
|
|
HeadingBlock.displayName = 'HeadingBlock';
|
|
|
|
/** Headline figures. Two columns keeps the numbers large in a 380px panel. */
|
|
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
|
|
key={item.label}
|
|
className="rounded-xl border border-border bg-surface px-3 py-2.5 shadow-xs"
|
|
>
|
|
<p className="truncate text-[10px] uppercase tracking-wide text-ink-4">{item.label}</p>
|
|
<div className="mt-0.5 flex items-baseline gap-1.5">
|
|
<span className={cn(
|
|
'font-heading text-title font-bold tabular-nums',
|
|
item.tone ? TONE_TEXT[item.tone] : 'text-ink-1'
|
|
)}>
|
|
{item.value}
|
|
</span>
|
|
{typeof item.delta === 'number' && item.delta !== 0 && (
|
|
<span className={cn(
|
|
'inline-flex items-center text-[10px] font-semibold',
|
|
item.delta > 0 ? 'text-success' : 'text-destructive'
|
|
)}>
|
|
{item.delta > 0
|
|
? <ArrowUpRight className="h-2.5 w-2.5" aria-hidden="true" />
|
|
: <ArrowDownRight className="h-2.5 w-2.5" aria-hidden="true" />}
|
|
{Math.abs(item.delta)}%
|
|
</span>
|
|
)}
|
|
</div>
|
|
{item.sub && <p className="mt-0.5 truncate text-[10px] text-ink-4">{item.sub}</p>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
));
|
|
KpisBlock.displayName = 'KpisBlock';
|
|
|
|
/** Pass/warn checks. The icon carries the state so colour is not the only cue. */
|
|
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">
|
|
<span
|
|
className={cn(
|
|
'mt-0.5 grid h-4 w-4 shrink-0 place-items-center rounded-full',
|
|
item.ok ? 'bg-success-muted text-success' : 'bg-warning-muted text-warning'
|
|
)}
|
|
>
|
|
{item.ok
|
|
? <Check className="h-2.5 w-2.5" aria-hidden="true" />
|
|
: <TriangleAlert className="h-2.5 w-2.5" aria-hidden="true" />}
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-baseline justify-between gap-2">
|
|
<span className="text-body-sm font-medium text-ink-1">{item.label}</span>
|
|
{item.value && (
|
|
<span className="shrink-0 text-caption font-semibold tabular-nums text-ink-2">
|
|
{item.value}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{item.note && <p className="mt-0.5 text-caption leading-relaxed text-ink-3">{item.note}</p>}
|
|
</div>
|
|
<span className="sr-only">{item.ok ? 'Passing' : 'Needs attention'}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
));
|
|
StatusBlock.displayName = 'StatusBlock';
|
|
|
|
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">
|
|
<div className="flex items-baseline justify-between gap-2">
|
|
<span className="truncate text-caption text-ink-2">{item.label}</span>
|
|
<span className="flex shrink-0 items-baseline gap-1.5">
|
|
{item.weight != null && <span className="text-[10px] text-ink-4">{item.weight}%</span>}
|
|
<span className="text-caption font-semibold tabular-nums text-ink-1">{item.value}</span>
|
|
</span>
|
|
</div>
|
|
<ProgressBar value={item.value} max={item.max ?? 100} tone={item.tone ?? 'score'} size="sm" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
));
|
|
MetersBlock.displayName = 'MetersBlock';
|
|
|
|
/**
|
|
* A table. Scrolls horizontally inside its own container so a wide comparison
|
|
* never widens the panel.
|
|
*/
|
|
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') {
|
|
if (value.badge) {
|
|
return <Badge variant={TONE_BADGE[value.tone] || 'neutral'} size="sm">{value.badge}</Badge>;
|
|
}
|
|
return (
|
|
<span className={cn('tabular-nums', value.tone ? TONE_TEXT[value.tone] : undefined)}>
|
|
{value.value}
|
|
</span>
|
|
);
|
|
}
|
|
return <span className="tabular-nums">{value}</span>;
|
|
};
|
|
|
|
return (
|
|
<div className="overflow-x-auto rounded-xl border border-border bg-surface">
|
|
<table className="w-full border-collapse text-caption">
|
|
{block.caption && <caption className="sr-only">{block.caption}</caption>}
|
|
<thead>
|
|
<tr className="border-b border-border bg-surface-subtle">
|
|
{block.columns.map((column) => (
|
|
<th
|
|
key={column.key}
|
|
scope="col"
|
|
className={cn(
|
|
'whitespace-nowrap px-2.5 py-2 text-[10px] font-semibold uppercase tracking-wide text-ink-4',
|
|
column.align === 'right' ? 'text-right' : 'text-left'
|
|
)}
|
|
>
|
|
{column.label}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{block.rows.map((row, i) => (
|
|
<tr key={i} className="border-b border-border last:border-0">
|
|
{block.columns.map((column, ci) => (
|
|
<td
|
|
key={column.key}
|
|
className={cn(
|
|
'px-2.5 py-2 align-middle',
|
|
column.align === 'right' ? 'text-right' : 'text-left',
|
|
ci === 0 ? 'font-medium text-ink-1' : 'text-ink-2'
|
|
)}
|
|
>
|
|
{cell(row[column.key])}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
});
|
|
TableBlock.displayName = 'TableBlock';
|
|
|
|
/** Stage progression, widths relative to the largest stage. */
|
|
const FunnelBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
|
const max = Math.max(...block.steps.map((s) => s.count), 1);
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
{block.steps.map((step) => (
|
|
<div key={step.label}>
|
|
<div className="mb-1 flex items-baseline justify-between gap-2">
|
|
<span className="text-caption text-ink-2">{step.label}</span>
|
|
<span className="flex shrink-0 items-baseline gap-1.5">
|
|
{step.rate != null && <span className="text-[10px] text-ink-4">{step.rate}%</span>}
|
|
<span className="text-caption font-semibold tabular-nums text-ink-1">{step.count}</span>
|
|
</span>
|
|
</div>
|
|
<div className="h-2 overflow-hidden rounded-full bg-surface-sunken">
|
|
<div
|
|
className="h-full rounded-full bg-krow-blue transition-all duration-slow ease-out"
|
|
style={{ width: `${Math.max((step.count / max) * 100, step.count > 0 ? 4 : 0)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
});
|
|
FunnelBlock.displayName = 'FunnelBlock';
|
|
|
|
const ListBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
|
const Tag = block.ordered ? 'ol' : 'ul';
|
|
return (
|
|
<Tag className="space-y-1.5">
|
|
{block.items.map((item, i) => (
|
|
<li key={i} className="flex gap-2 text-body-sm leading-relaxed text-ink-2">
|
|
{block.ordered ? (
|
|
<span className="mt-0.5 grid h-4 w-4 shrink-0 place-items-center rounded-full bg-krow-blue-tint text-[10px] font-bold text-krow-blue">
|
|
{i + 1}
|
|
</span>
|
|
) : (
|
|
<span className="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-krow-blue" aria-hidden="true" />
|
|
)}
|
|
<span className="min-w-0"><Inline value={item} /></span>
|
|
</li>
|
|
))}
|
|
</Tag>
|
|
);
|
|
});
|
|
ListBlock.displayName = 'ListBlock';
|
|
|
|
/**
|
|
* Findings, and — where the item carries one — a way to act on it.
|
|
*
|
|
* Two kinds of action, and the distinction is the point:
|
|
*
|
|
* `prompt` asks Owliver the next question, in place. The panel stays open,
|
|
* the thread keeps its history, and the reader never loses the
|
|
* position they were working on. This is the default for a
|
|
* recommended person: inspecting a candidate is part of the
|
|
* conversation, not a trip to another page.
|
|
* `to` leaves for a route the app already has, with the whole card as the
|
|
* target. Reserved for items whose only meaning is "open this".
|
|
* `action` a named link inside the card — `{ label, to }`. For an item that
|
|
* has *both* a conversation and a record: the card keeps asking
|
|
* Owliver, and the link is the one explicit way to leave. Reading a
|
|
* card can then never navigate by accident, which matters most where
|
|
* the body is an explanation worth reading.
|
|
*
|
|
* `hint` is for an item that can do neither — it says why, instead of looking
|
|
* 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(/** @param {any} props */ ({ block, onPrompt }) => (
|
|
<div className="space-y-2">
|
|
{block.items.map((item, i) => {
|
|
const surface = cn(
|
|
'block w-full text-left rounded-xl border px-3 py-2.5',
|
|
TONE_SURFACE[item.tone] || TONE_SURFACE.neutral
|
|
);
|
|
const interactive = cn(
|
|
surface,
|
|
'transition-colors hover:border-krow-blue/50',
|
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50'
|
|
);
|
|
const asksOwliver = Boolean(item.prompt && onPrompt);
|
|
const actionable = asksOwliver || Boolean(item.to);
|
|
|
|
const body = (
|
|
<>
|
|
<p className="flex items-center gap-1 text-body-sm font-semibold text-ink-1">
|
|
{item.title}
|
|
{actionable && <ChevronRight className="h-3.5 w-3.5 shrink-0 opacity-60" aria-hidden="true" />}
|
|
</p>
|
|
{item.body && (
|
|
<p className="mt-0.5 text-caption leading-relaxed text-ink-2"><Inline value={item.body} /></p>
|
|
)}
|
|
{item.hint && (
|
|
<p className="mt-1 text-[11px] italic leading-relaxed text-ink-4">{item.hint}</p>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
/* A named link cannot live inside a button, so when an item has both, the
|
|
card is a plain container: the readable area is its own button and the
|
|
action sits beside it. One click target each, never nested. */
|
|
if (item.action?.to) {
|
|
return (
|
|
<div key={i} className={surface}>
|
|
{asksOwliver ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => onPrompt(item.prompt)}
|
|
className="block w-full rounded-lg text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
|
>
|
|
{body}
|
|
</button>
|
|
) : body}
|
|
|
|
<div className="mt-2 flex justify-end">
|
|
<Link
|
|
to={item.action.to}
|
|
className="inline-flex items-center gap-0.5 rounded 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"
|
|
>
|
|
{item.action.label}
|
|
<ArrowRight className="h-3 w-3" aria-hidden="true" />
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (asksOwliver) {
|
|
return (
|
|
<button key={i} type="button" onClick={() => onPrompt(item.prompt)} className={interactive}>
|
|
{body}
|
|
</button>
|
|
);
|
|
}
|
|
if (item.to) {
|
|
return <Link key={i} to={item.to} className={interactive}>{body}</Link>;
|
|
}
|
|
return <div key={i} className={surface}>{body}</div>;
|
|
})}
|
|
</div>
|
|
));
|
|
InsightsBlock.displayName = 'InsightsBlock';
|
|
|
|
/** Recommended steps — numbered, because order is the recommendation. */
|
|
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">
|
|
<span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-krow-blue text-[10px] font-bold text-white">
|
|
{i + 1}
|
|
</span>
|
|
<div className="min-w-0">
|
|
<p className="text-body-sm font-semibold text-ink-1">{item.title}</p>
|
|
{item.body && (
|
|
<p className="mt-0.5 text-caption leading-relaxed text-ink-2"><Inline value={item.body} /></p>
|
|
)}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
));
|
|
ActionsBlock.displayName = 'ActionsBlock';
|
|
|
|
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">
|
|
{item.label}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
));
|
|
BadgesBlock.displayName = 'BadgesBlock';
|
|
|
|
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">
|
|
<div className="flex flex-col items-center">
|
|
<span
|
|
className={cn(
|
|
'mt-1 h-2 w-2 shrink-0 rounded-full',
|
|
item.tone === 'success' ? 'bg-success'
|
|
: item.tone === 'warning' ? 'bg-warning'
|
|
: item.current ? 'bg-krow-blue' : 'bg-ink-4'
|
|
)}
|
|
aria-hidden="true"
|
|
/>
|
|
{i < block.items.length - 1 && <span className="mt-1 w-px flex-1 bg-border" aria-hidden="true" />}
|
|
</div>
|
|
<div className="min-w-0 flex-1 pb-1">
|
|
<div className="flex items-baseline justify-between gap-2">
|
|
<span className="text-body-sm font-medium text-ink-1">{item.title}</span>
|
|
{item.timestamp && (
|
|
<span className="shrink-0 text-[10px] text-ink-4">{item.timestamp}</span>
|
|
)}
|
|
</div>
|
|
{item.description && (
|
|
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{item.description}</p>
|
|
)}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
));
|
|
TimelineBlock.displayName = 'TimelineBlock';
|
|
|
|
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>
|
|
));
|
|
NoteBlock.displayName = 'NoteBlock';
|
|
|
|
/**
|
|
* A section a skill declared, drawn by the component the page uses for it.
|
|
*
|
|
* There is no chat-specific renderer for a skill's shapes, and there must not
|
|
* be: `SECTION_COMPONENTS` is the single table from a declared type to a
|
|
* component, and this block goes through it exactly as `SkillSurface` does. A
|
|
* definition that gains a new shape gains it in both places at once, and a
|
|
* shape the table does not carry renders nothing rather than something
|
|
* improvised.
|
|
*/
|
|
const SkillSectionBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
|
const section = block.section;
|
|
const Component = SECTION_COMPONENTS[section?.shape || section?.type];
|
|
|
|
/**
|
|
* A settable answer is re-read, never replayed.
|
|
*
|
|
* An ordinary answer is a record of what was true when it was given, and the
|
|
* stored blocks are exactly right for that. A section offering *controls* is
|
|
* different: it is a live view of a value the reader can still change, from
|
|
* here or on the page behind the panel. Replaying the stored copy would show
|
|
* a weight of 25% next to a form that now says 30%, and the control would
|
|
* write the stale figure back.
|
|
*
|
|
* So an editable section resolves from the current context on every render,
|
|
* through the same resolver the page's own card uses. There is one value and
|
|
* one place it lives; this is a second window onto it, not a second copy.
|
|
*/
|
|
const live = Boolean(section?.editable);
|
|
const context = useSkillDataContext(null);
|
|
const data = React.useMemo(
|
|
() => (live && section ? resolveSkillData(section, context) : block.data),
|
|
[live, section, context, block.data]
|
|
);
|
|
const apply = usePageAction(live ? section?.source : null);
|
|
|
|
if (!Component) return null;
|
|
|
|
return (
|
|
<div className="rounded-xl border border-border bg-surface-subtle/60 p-3">
|
|
<Component data={data} section={section} onApply={apply} compact />
|
|
</div>
|
|
);
|
|
});
|
|
SkillSectionBlock.displayName = 'SkillSectionBlock';
|
|
|
|
/**
|
|
* A write the agent has proposed. The only block a person can act on.
|
|
*
|
|
* Three things it must do, and they are all about not being clicked past:
|
|
*
|
|
* - **Say what will happen, in the server's words.** The title, summary and
|
|
* details are rendered as they arrived. A browser paraphrasing them would
|
|
* be describing a different act than the one the token authorises.
|
|
* - **Put warnings above the button.** A clash or an over-headcount is
|
|
* exactly what somebody is about to approve without noticing, and a warning
|
|
* underneath the decision is a warning read afterwards.
|
|
* - **Not pretend to be finished.** Once approved, the block stays visible
|
|
* and says so. Replacing it with a tick would lose what was agreed to.
|
|
*/
|
|
const ConfirmationBlock = React.memo(/** @param {any} props */ ({ block, onConfirm }) => {
|
|
const [state, setState] = React.useState('pending');
|
|
|
|
const approve = React.useCallback(() => {
|
|
/* Guarded rather than debounced. The token is single-use server-side, so a
|
|
double click costs a confusing refusal rather than a duplicate write —
|
|
but a button that visibly does nothing the second time is kinder than
|
|
one that reports an error somebody caused by being quick. */
|
|
if (state !== 'pending') return;
|
|
setState('approved');
|
|
onConfirm?.(block);
|
|
}, [state, block, onConfirm]);
|
|
|
|
return (
|
|
<div className="rounded-xl border border-krow-blue/30 bg-krow-blue/5 p-3.5">
|
|
<div className="flex items-start gap-2.5">
|
|
<span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-krow-blue text-white">
|
|
<TriangleAlert className="h-3 w-3" aria-hidden="true" />
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-body-sm font-semibold text-ink-1">{block.title}</p>
|
|
{block.summary && (
|
|
<p className="mt-1 text-caption leading-relaxed text-ink-2">{block.summary}</p>
|
|
)}
|
|
|
|
{block.details?.length > 0 && (
|
|
<dl className="mt-2.5 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
|
|
{block.details.map((d, i) => (
|
|
<React.Fragment key={i}>
|
|
<dt className="text-caption text-ink-3">{d.label}</dt>
|
|
<dd className="text-caption font-medium text-ink-1">{d.value}</dd>
|
|
</React.Fragment>
|
|
))}
|
|
</dl>
|
|
)}
|
|
|
|
{/* Above the button, deliberately. */}
|
|
{block.warnings?.length > 0 && (
|
|
<ul className="mt-2.5 space-y-1">
|
|
{block.warnings.map((w, i) => (
|
|
<li key={i} className="flex gap-1.5 text-caption text-amber-700 dark:text-amber-400">
|
|
<TriangleAlert className="mt-0.5 h-3 w-3 shrink-0" aria-hidden="true" />
|
|
<span>{w}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
<div className="mt-3 flex items-center gap-2">
|
|
{state === 'pending' ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={approve}
|
|
className="rounded-lg bg-krow-blue px-3 py-1.5 text-caption font-semibold text-white
|
|
transition hover:opacity-90 focus-visible:outline focus-visible:outline-2
|
|
focus-visible:outline-offset-2 focus-visible:outline-krow-blue"
|
|
>
|
|
Approve
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setState('dismissed')}
|
|
className="rounded-lg px-3 py-1.5 text-caption font-medium text-ink-3
|
|
transition hover:text-ink-1 focus-visible:outline focus-visible:outline-2
|
|
focus-visible:outline-offset-2 focus-visible:outline-krow-blue"
|
|
>
|
|
Not now
|
|
</button>
|
|
</>
|
|
) : (
|
|
<p className="flex items-center gap-1.5 text-caption font-medium text-ink-3">
|
|
<Check className="h-3.5 w-3.5" aria-hidden="true" />
|
|
{state === 'approved' ? 'Approved — carrying on.' : 'Left for now. Nothing was changed.'}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
ConfirmationBlock.displayName = 'ConfirmationBlock';
|
|
|
|
const RENDERERS = {
|
|
text: TextBlock,
|
|
confirmation: ConfirmationBlock,
|
|
skillSection: SkillSectionBlock,
|
|
heading: HeadingBlock,
|
|
kpis: KpisBlock,
|
|
status: StatusBlock,
|
|
meters: MetersBlock,
|
|
table: TableBlock,
|
|
funnel: FunnelBlock,
|
|
list: ListBlock,
|
|
insights: InsightsBlock,
|
|
actions: ActionsBlock,
|
|
badges: BadgesBlock,
|
|
timeline: TimelineBlock,
|
|
note: NoteBlock,
|
|
};
|
|
|
|
/**
|
|
* Renders a response document.
|
|
*
|
|
* Spacing is decided here rather than by the blocks, so any combination of
|
|
* blocks has consistent rhythm — a heading hugs what follows it, everything
|
|
* else breathes.
|
|
*/
|
|
export const ResponseDocument = React.memo(/** @param {any} props */ ({ blocks = [], streaming = false, onPrompt, onConfirm }) => (
|
|
<div className="space-y-3">
|
|
{blocks.map((block, i) => {
|
|
const Renderer = RENDERERS[block.type];
|
|
if (!Renderer) return null;
|
|
const hugsNext = block.type === 'heading' && i < blocks.length - 1;
|
|
const isLast = i === blocks.length - 1;
|
|
|
|
return (
|
|
<div key={i} className={cn(hugsNext && '-mb-1.5', 'animate-fade-in')}>
|
|
<Renderer block={block} onPrompt={onPrompt} onConfirm={onConfirm} />
|
|
{/* The cursor trails the final block only while text is still arriving. */}
|
|
{streaming && isLast && (block.type === 'text' || block.type === 'heading') && (
|
|
<span
|
|
className="ml-0.5 inline-block h-3.5 w-1.5 -mb-0.5 animate-caret bg-krow-blue align-middle"
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
));
|
|
ResponseDocument.displayName = 'ResponseDocument';
|
|
|
|
export { toneForScore };
|