updates on the design and chatbot fix
This commit is contained in:
@@ -30,6 +30,10 @@ module.exports = {
|
||||
'\.(css|less|scss|sass)$': '<rootDir>/tests/setup/styleMock.cjs',
|
||||
'\.(png|jpe?g|gif|svg|webp|woff2?|ttf|eot)$': '<rootDir>/tests/setup/fileMock.cjs',
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
// Vite resolves these bare specifiers from src/ (jsconfig baseUrl).
|
||||
// Jest needs telling, or any test importing a file that uses them fails
|
||||
// to resolve rather than failing an assertion.
|
||||
'^(components|pages|utils|themes|assets|lib|api)/(.*)$': '<rootDir>/src/$1/$2',
|
||||
},
|
||||
collectCoverageFrom: [
|
||||
'src/api/doormile/**/*.js',
|
||||
|
||||
@@ -1428,6 +1428,7 @@ const AIPanel = ({ isOpen, onClose }) => {
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
side="bottom"
|
||||
label={view === 'history' ? 'Back to conversation' : 'Conversation history'}
|
||||
tooltip={view === 'history' ? 'Back to conversation' : 'History'}
|
||||
aria-pressed={view === 'history'}
|
||||
@@ -1437,6 +1438,7 @@ const AIPanel = ({ isOpen, onClose }) => {
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
side="bottom"
|
||||
label="New conversation"
|
||||
tooltip="New conversation"
|
||||
icon={<LuRotateCcw />}
|
||||
@@ -1445,6 +1447,7 @@ const AIPanel = ({ isOpen, onClose }) => {
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
side="bottom"
|
||||
label={isWide ? 'Standard width (20%)' : 'Expand width (30%)'}
|
||||
tooltip={isWide ? 'Standard width (20%)' : 'Expand width (30%)'}
|
||||
aria-pressed={isWide}
|
||||
@@ -1454,6 +1457,7 @@ const AIPanel = ({ isOpen, onClose }) => {
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
side="bottom"
|
||||
label="Close assistant"
|
||||
tooltip="Close assistant"
|
||||
icon={<LuX />}
|
||||
|
||||
@@ -69,7 +69,12 @@ export const CHIP_LABELS = {
|
||||
'How many tripsheets are dispatched?': 'Tripsheets',
|
||||
'How many open exceptions?': 'Open exceptions',
|
||||
'How many pricing rules are configured?': 'Pricing rules',
|
||||
"Repeat yesterday's orders": 'Repeat yesterday'
|
||||
"Repeat yesterday's orders": 'Repeat yesterday',
|
||||
'How many consignments do we have?': 'Consignments',
|
||||
'How many app users do we have?': 'App users',
|
||||
'How many partners do we have?': 'Partners',
|
||||
'How many competitor branches are tracked?': 'Competitor branches',
|
||||
'How many carriers do we have?': 'Carriers'
|
||||
};
|
||||
|
||||
// The one follow-up phrasing that is not already a page suggestion. Verified
|
||||
@@ -121,39 +126,27 @@ const ORDERS = {
|
||||
};
|
||||
|
||||
const RIDERS = {
|
||||
label: 'Riders',
|
||||
label: 'Milers',
|
||||
suggestions: [
|
||||
{ icon: LuBike, text: 'How many riders are active?' },
|
||||
{ icon: LuClock3, text: "Give me today's operations summary" },
|
||||
{ icon: LuPackage, text: 'How many orders today?' },
|
||||
{ icon: LuTruck, text: 'How many vehicles are available?' }
|
||||
{ icon: LuUserPlus, text: 'Assign a rider' }
|
||||
],
|
||||
more: [
|
||||
{ icon: LuBuilding2, text: 'Current hub status' },
|
||||
{ icon: LuBanknote, text: 'Total revenue today' }
|
||||
]
|
||||
more: []
|
||||
};
|
||||
|
||||
const VEHICLES = {
|
||||
label: 'Vehicles',
|
||||
suggestions: [
|
||||
{ icon: LuTruck, text: 'How many vehicles are available?' },
|
||||
{ icon: LuBike, text: 'How many riders are active?' },
|
||||
{ icon: LuBuilding2, text: 'Current hub status' },
|
||||
{ icon: LuClock3, text: "Give me today's operations summary" }
|
||||
],
|
||||
more: [{ icon: LuPackage, text: 'How many orders today?' }]
|
||||
suggestions: [{ icon: LuTruck, text: 'How many vehicles are available?' }],
|
||||
more: []
|
||||
};
|
||||
|
||||
const HUBS = {
|
||||
label: 'Hubs',
|
||||
suggestions: [
|
||||
{ icon: LuBuilding2, text: 'Current hub status' },
|
||||
{ icon: LuTimerOff, text: 'Which hubs are experiencing delays?' },
|
||||
{ icon: LuTruck, text: 'How many vehicles are available?' },
|
||||
{ icon: LuBike, text: 'How many riders are active?' }
|
||||
{ icon: LuTimerOff, text: 'Which hubs are experiencing delays?' }
|
||||
],
|
||||
more: [{ icon: LuPackage, text: 'How many orders today?' }]
|
||||
more: []
|
||||
};
|
||||
|
||||
const DISPATCH = {
|
||||
@@ -162,24 +155,16 @@ const DISPATCH = {
|
||||
{ icon: LuLayers, text: 'Morning Batch orders today' },
|
||||
{ icon: LuTimerOff, text: 'Which orders are delayed?' },
|
||||
{ icon: LuBike, text: 'How many riders are active?' },
|
||||
{ icon: LuClock3, text: "Give me today's operations summary" }
|
||||
{ icon: LuUserPlus, text: 'Assign a rider' }
|
||||
],
|
||||
more: [
|
||||
{ icon: LuPackage, text: 'How many orders today?' },
|
||||
{ icon: LuCircleDot, text: 'How many pending orders today?' }
|
||||
]
|
||||
more: []
|
||||
};
|
||||
|
||||
// Linehaul and middle-mile runs.
|
||||
const TRIPSHEETS = {
|
||||
label: 'Tripsheets',
|
||||
suggestions: [
|
||||
{ icon: LuLayers, text: 'How many tripsheets are dispatched?' },
|
||||
{ icon: LuTruck, text: 'How many vehicles are available?' },
|
||||
{ icon: LuBike, text: 'How many riders are active?' },
|
||||
{ icon: LuClock3, text: "Give me today's operations summary" }
|
||||
],
|
||||
more: [{ icon: LuBuilding2, text: 'Current hub status' }]
|
||||
suggestions: [{ icon: LuLayers, text: 'How many tripsheets are dispatched?' }],
|
||||
more: []
|
||||
};
|
||||
|
||||
// The register of things that went wrong.
|
||||
@@ -187,9 +172,7 @@ const EXCEPTIONS = {
|
||||
label: 'Exceptions',
|
||||
suggestions: [
|
||||
{ icon: LuCircleDot, text: 'How many open exceptions?' },
|
||||
{ icon: LuTimerOff, text: 'Which orders are delayed?' },
|
||||
{ icon: LuTimerOff, text: 'Which hubs are experiencing delays?' },
|
||||
{ icon: LuClock3, text: "Give me today's operations summary" }
|
||||
{ icon: LuTimerOff, text: 'Which orders are delayed?' }
|
||||
],
|
||||
more: []
|
||||
};
|
||||
@@ -198,13 +181,8 @@ const EXCEPTIONS = {
|
||||
// real page and deserves its own questions like any other.
|
||||
const PRICING = {
|
||||
label: 'Pricing',
|
||||
suggestions: [
|
||||
{ icon: LuBanknote, text: 'How many pricing rules are configured?' },
|
||||
{ icon: LuBanknote, text: 'Total revenue today' },
|
||||
{ icon: LuUsers, text: 'How many tenants do we have?' },
|
||||
{ icon: LuClock3, text: "Give me today's operations summary" }
|
||||
],
|
||||
more: [{ icon: LuBanknote, text: 'Total revenue this week' }]
|
||||
suggestions: [{ icon: LuBanknote, text: 'How many pricing rules are configured?' }],
|
||||
more: []
|
||||
};
|
||||
|
||||
// Bookings — what customers raised in the app, before it becomes an order.
|
||||
@@ -227,23 +205,62 @@ const BOOKINGS = {
|
||||
suggestions: [
|
||||
{ icon: LuPackage, text: 'How many orders today?' },
|
||||
{ icon: LuCircleDot, text: 'How many pending orders today?' },
|
||||
{ icon: LuTimerOff, text: 'Which orders are delayed?' },
|
||||
{ icon: LuClock3, text: "Give me today's operations summary" }
|
||||
],
|
||||
more: [
|
||||
{ icon: LuPackage, text: 'How many delivered orders today?' },
|
||||
{ icon: LuPackage, text: 'How many cancelled orders today?' },
|
||||
{ icon: LuClock3, text: 'Orders today vs yesterday' }
|
||||
]
|
||||
{ icon: LuTimerOff, text: 'Which orders are delayed?' }
|
||||
],
|
||||
more: []
|
||||
};
|
||||
|
||||
// Deliveries — an order after it has been picked up and become a consignment.
|
||||
//
|
||||
// This page used to be `{ ...ORDERS, label: 'Deliveries' }`: the Orders
|
||||
// question set under a different name, so a table of consignments offered
|
||||
// "Create an order" and "Repeat yesterday's orders". The label made it look
|
||||
// deliberate, which is the same trap the Tripsheets/Exceptions/Pricing note
|
||||
// below describes.
|
||||
//
|
||||
// Every phrasing here is checked against its intent's own `match`:
|
||||
// consignmentStatus is /\bconsignments?\b/, parcelTrack wants a tracking
|
||||
// number, and the delivered/delayed counts are the order intents narrowed to
|
||||
// the statuses this page actually shows.
|
||||
const DELIVERIES = {
|
||||
label: 'Deliveries',
|
||||
suggestions: [
|
||||
{ icon: LuTruck, text: 'How many consignments do we have?' },
|
||||
{ icon: LuPackage, text: 'How many delivered orders today?' },
|
||||
{ icon: LuTimerOff, text: 'Which orders are delayed?' },
|
||||
{ icon: LuBike, text: 'How many riders are active?' }
|
||||
],
|
||||
more: []
|
||||
};
|
||||
|
||||
// Reference data behind the console — not an operational page, so it gets the
|
||||
// one question that is actually about it rather than the operations summary.
|
||||
const APP_USERS = {
|
||||
label: 'App users',
|
||||
suggestions: [{ icon: LuUsers, text: 'How many app users do we have?' }],
|
||||
more: []
|
||||
};
|
||||
|
||||
const COMPETITIVE_INTEL = {
|
||||
label: 'Competitive intel',
|
||||
suggestions: [
|
||||
{ icon: LuBuilding2, text: 'How many competitor branches are tracked?' },
|
||||
// NOT "how many carrier pricing rules", which is the phrasing
|
||||
// carrierPricingCount documents for itself and which never reaches it:
|
||||
// pricingCount matches /\bpricing\b/ and runs 57 lines earlier, so that
|
||||
// wording is answered with the general pricing-rule count. Its actual
|
||||
// predicate is /\bcarriers?\b/.
|
||||
{ icon: LuBanknote, text: 'How many carriers do we have?' }
|
||||
],
|
||||
more: []
|
||||
};
|
||||
|
||||
const TENANTS = {
|
||||
label: 'Tenants',
|
||||
suggestions: [
|
||||
{ icon: LuUsers, text: 'How many tenants do we have?' },
|
||||
{ icon: LuPackage, text: 'How many orders today?' },
|
||||
{ icon: LuBanknote, text: 'Total revenue today' },
|
||||
{ icon: LuClock3, text: "Give me today's operations summary" }
|
||||
{ icon: LuUsers, text: 'How many partners do we have?' }
|
||||
],
|
||||
more: []
|
||||
};
|
||||
@@ -352,7 +369,7 @@ const FOLLOW_UPS = {
|
||||
// with the wrong label.
|
||||
const ROUTES = [
|
||||
['/doormile/control-x', DISPATCH],
|
||||
['/doormile/deliveries', { ...ORDERS, label: 'Deliveries' }],
|
||||
['/doormile/deliveries', DELIVERIES],
|
||||
['/doormile/bookings', BOOKINGS],
|
||||
['/doormile/orders', ORDERS],
|
||||
['/doormile/milers', RIDERS],
|
||||
@@ -386,7 +403,11 @@ const ROUTES = [
|
||||
// so none is shadowed by the order intents that run earlier in the catalog.
|
||||
['/doormile/tripsheets', TRIPSHEETS],
|
||||
['/doormile/exceptions', EXCEPTIONS],
|
||||
['/doormile/pricing', PRICING]
|
||||
['/doormile/pricing', PRICING],
|
||||
// Missing from this list entirely until now, so both fell through to
|
||||
// DEFAULT_CONTEXT and offered rider and hub questions over their own data.
|
||||
['/doormile/app-users', APP_USERS],
|
||||
['/doormile/competitive-intel', COMPETITIVE_INTEL]
|
||||
];
|
||||
|
||||
// ---- every question, from every page ---------------------------------------
|
||||
@@ -399,7 +420,20 @@ const ROUTES = [
|
||||
//
|
||||
// `more` is retired: AIWelcome renders one flat list now, so a second array
|
||||
// would only be a second place for a question to hide.
|
||||
const ALL_CONTEXTS = [ORDERS, RIDERS, VEHICLES, HUBS, DISPATCH, TENANTS, REPORTS, DEFAULT_CONTEXT, ...ROUTES.map(([, c]) => c)];
|
||||
const ALL_CONTEXTS = [
|
||||
ORDERS,
|
||||
RIDERS,
|
||||
VEHICLES,
|
||||
HUBS,
|
||||
DISPATCH,
|
||||
DELIVERIES,
|
||||
APP_USERS,
|
||||
COMPETITIVE_INTEL,
|
||||
TENANTS,
|
||||
REPORTS,
|
||||
DEFAULT_CONTEXT,
|
||||
...ROUTES.map(([, c]) => c)
|
||||
];
|
||||
|
||||
// Every question declared anywhere. Two jobs: it is the icon index behind
|
||||
// QUESTION_BY_TEXT (so a follow-up naming a question that is no longer global
|
||||
|
||||
@@ -2090,6 +2090,30 @@ const INTENTS = [
|
||||
|
||||
const INTENTS_BY_ID = Object.fromEntries(INTENTS.map((i) => [i.id, i]));
|
||||
|
||||
// Which intent the ordered catalog reaches for a question, WITHOUT running it.
|
||||
//
|
||||
// Exported for the page-suggestion test. The catalog is ordered and the first
|
||||
// match wins, so "something matches this chip" and "the right thing matches
|
||||
// this chip" are different questions — ROADMAP.md B1/B2 are two shipped cases
|
||||
// where they had different answers.
|
||||
//
|
||||
// Covers the deterministic pass only. answerQuestion tries semantic routing
|
||||
// and the multi-part splitter ahead of this, both of which are additive: they
|
||||
// can route a question the catalog would miss, never away from one it hits.
|
||||
export const resolveIntentId = (text) => {
|
||||
const normalized = correctTypos(text);
|
||||
for (const intent of INTENTS) {
|
||||
let params = null;
|
||||
try {
|
||||
params = intent.match(normalized);
|
||||
} catch {
|
||||
params = null;
|
||||
}
|
||||
if (params) return intent.id;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const SUPPORTED_QUESTIONS = INTENTS.map((i) => i.label);
|
||||
|
||||
// Clean, directly-askable example phrasings — for "recommended question"
|
||||
|
||||
@@ -18,7 +18,7 @@ const TooltipContent = React.forwardRef(({ className, sideOffset = 4, ...props }
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"z-[9999] overflow-hidden rounded-lg bg-slate-900 px-2.5 py-1 text-xs font-medium text-white shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
|
||||
@@ -198,7 +198,7 @@ export function AgentInsightsView() {
|
||||
<span>420 calls (28%)</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-slate-100 overflow-hidden">
|
||||
<div className="h-full bg-indigo-600 rounded-full w-[28%]" />
|
||||
<div className="h-full bg-slate-700 rounded-full w-[28%]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
GraduationCap,
|
||||
Sparkles,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
@@ -9,6 +8,8 @@ import {
|
||||
Wrench,
|
||||
Pencil,
|
||||
Users,
|
||||
Package,
|
||||
Route,
|
||||
Layers,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
@@ -36,32 +37,28 @@ export default function AgentScopeNavigator({
|
||||
const boardSkills = skills.filter((s) => s.scope === 'board');
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-3xl border border-slate-200/90 shadow-2xs p-4 flex flex-col space-y-4">
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-xs p-4 flex flex-col space-y-4">
|
||||
{/* 1. Agent Profile Header + Dropdown Switcher */}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAgentMenuOpen((v) => !v)}
|
||||
className="w-full flex items-center justify-between p-2 rounded-2xl hover:bg-slate-50 transition-colors text-left cursor-pointer group"
|
||||
className="w-full flex items-center justify-between p-2 rounded-xl hover:bg-slate-50 transition-colors text-left cursor-pointer group"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-10 h-10 rounded-2xl bg-brand-tint border border-brand/20 text-brand flex items-center justify-center shrink-0 shadow-3xs">
|
||||
{currentAgent.iconType === 'grad' ? (
|
||||
<GraduationCap className="w-5 h-5" />
|
||||
) : (
|
||||
<Sparkles className="w-5 h-5" />
|
||||
)}
|
||||
<div className="w-9 h-9 rounded-full bg-brand-tint border border-brand/20 text-brand flex items-center justify-center shrink-0 shadow-2xs">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs sm:text-sm font-bold text-slate-900 truncate font-heading group-hover:text-brand transition-colors">
|
||||
{currentAgent.name}
|
||||
</div>
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mt-0.5">
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
|
||||
{currentAgent.badge || 'AGENT'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown className="w-4 h-4 text-slate-400 shrink-0 ml-1" />
|
||||
<ChevronDown className="w-4 h-4 text-slate-400 shrink-0 ml-1 group-hover:text-slate-600 transition-colors" />
|
||||
</button>
|
||||
|
||||
{/* Agent Switcher Menu */}
|
||||
@@ -71,7 +68,7 @@ export default function AgentScopeNavigator({
|
||||
className="fixed inset-0 z-40 bg-transparent"
|
||||
onClick={() => setAgentMenuOpen(false)}
|
||||
/>
|
||||
<div className="absolute left-0 right-0 top-full mt-1.5 bg-white border border-slate-200 rounded-2xl shadow-xl p-1.5 z-50 animate-in fade-in zoom-in-95">
|
||||
<div className="absolute left-0 right-0 top-full mt-1.5 bg-white border border-slate-200 rounded-xl shadow-lg p-1.5 z-50 animate-in fade-in zoom-in-95">
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider px-3 py-1.5">
|
||||
Switch Agent Scope
|
||||
</div>
|
||||
@@ -84,7 +81,7 @@ export default function AgentScopeNavigator({
|
||||
setAgentMenuOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between px-3 py-2 rounded-xl text-xs font-semibold text-left transition-colors cursor-pointer',
|
||||
'w-full flex items-center justify-between px-3 py-2 rounded-lg text-xs font-semibold text-left transition-colors cursor-pointer',
|
||||
agent.id === activeAgentId
|
||||
? 'bg-brand-tint text-brand'
|
||||
: 'text-slate-700 hover:bg-slate-50'
|
||||
@@ -101,14 +98,14 @@ export default function AgentScopeNavigator({
|
||||
|
||||
<div className="h-px bg-slate-100" />
|
||||
|
||||
{/* 2. Hierarchical Collapsible Tree */}
|
||||
<div className="space-y-3 text-xs">
|
||||
{/* 2. Hierarchical Tree (Clean, un-cluttered style) */}
|
||||
<div className="space-y-2 text-xs">
|
||||
{/* SURFACES TREE NODE */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSurfacesOpen((v) => !v)}
|
||||
className="w-full flex items-center justify-between py-1 px-1.5 text-slate-700 hover:text-slate-900 rounded-lg font-semibold cursor-pointer group"
|
||||
className="w-full flex items-center justify-between py-1.5 px-2 text-slate-700 hover:text-slate-900 hover:bg-slate-50 rounded-lg font-medium cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{surfacesOpen ? (
|
||||
@@ -116,20 +113,20 @@ export default function AgentScopeNavigator({
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-slate-400" />
|
||||
)}
|
||||
<LayoutGrid className="w-3.5 h-3.5 text-slate-500" />
|
||||
<span className="font-bold">Surfaces</span>
|
||||
<LayoutGrid className="w-3.5 h-3.5 text-slate-400" />
|
||||
<span className="font-semibold text-slate-800">Surfaces</span>
|
||||
</div>
|
||||
<span className="text-[11px] font-bold text-slate-400">
|
||||
<span className="text-[11px] font-semibold text-slate-400">
|
||||
{surfaces.length || 1}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{surfacesOpen && (
|
||||
<div className="ml-6 pl-2.5 border-l border-slate-200/80 mt-1 space-y-1">
|
||||
<div className="pl-7 pr-1 mt-1 space-y-0.5">
|
||||
{surfaces.map((surf) => (
|
||||
<div
|
||||
key={surf.id}
|
||||
className="py-1 px-2 rounded-lg text-slate-600 hover:text-slate-900 hover:bg-slate-50 text-[11px] font-medium truncate cursor-pointer"
|
||||
className="py-1 px-2 rounded-md text-slate-600 hover:text-slate-900 hover:bg-slate-50 text-[11px] truncate cursor-pointer"
|
||||
title={surf.description}
|
||||
>
|
||||
{surf.name}
|
||||
@@ -144,7 +141,7 @@ export default function AgentScopeNavigator({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSkillsOpen((v) => !v)}
|
||||
className="w-full flex items-center justify-between py-1 px-1.5 text-slate-700 hover:text-slate-900 rounded-lg font-semibold cursor-pointer group"
|
||||
className="w-full flex items-center justify-between py-1.5 px-2 text-slate-700 hover:text-slate-900 hover:bg-slate-50 rounded-lg font-medium cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{skillsOpen ? (
|
||||
@@ -152,22 +149,22 @@ export default function AgentScopeNavigator({
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-slate-400" />
|
||||
)}
|
||||
<Zap className="w-3.5 h-3.5 text-slate-500" />
|
||||
<span className="font-bold">Skills</span>
|
||||
<Zap className="w-3.5 h-3.5 text-slate-400" />
|
||||
<span className="font-semibold text-slate-800">Skills</span>
|
||||
</div>
|
||||
<span className="text-[11px] font-bold text-slate-400">
|
||||
<span className="text-[11px] font-semibold text-slate-400">
|
||||
{skills.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{skillsOpen && (
|
||||
<div className="ml-4 pl-2 border-l border-slate-200/80 mt-1 space-y-2">
|
||||
<div className="pl-4 mt-1 space-y-1">
|
||||
{/* Agent Skills Sub-node */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAgentSkillsOpen((v) => !v)}
|
||||
className="w-full flex items-center justify-between py-1 text-[11px] font-semibold text-slate-600 hover:text-slate-900 cursor-pointer"
|
||||
className="w-full flex items-center justify-between py-1 px-2 text-[11px] font-medium text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-md cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{agentSkillsOpen ? (
|
||||
@@ -175,31 +172,45 @@ export default function AgentScopeNavigator({
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3 text-slate-400" />
|
||||
)}
|
||||
<Zap className="w-3 h-3 text-slate-400" />
|
||||
<span>Agent Skills</span>
|
||||
<Zap className="w-3 h-3 text-brand" />
|
||||
<span className="font-medium text-slate-700">Agent Skills</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400 font-bold">
|
||||
<span className="text-[10px] text-slate-400 font-semibold">
|
||||
{agentSkills.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{agentSkillsOpen && (
|
||||
<div className="ml-4 pl-2 border-l border-slate-200/80 mt-1 space-y-1">
|
||||
{agentSkills.map((skill) => (
|
||||
<button
|
||||
key={skill.id}
|
||||
type="button"
|
||||
onClick={() => onSelectSkill(skill)}
|
||||
className="w-full text-left py-1 px-2 rounded-lg text-slate-600 hover:text-brand hover:bg-brand-tint/60 text-[11px] font-medium flex items-center gap-1.5 truncate cursor-pointer"
|
||||
>
|
||||
{skill.category === 'AUTHORING' ? (
|
||||
<Pencil className="w-3 h-3 shrink-0 text-brand" />
|
||||
) : (
|
||||
<Users className="w-3 h-3 shrink-0 text-indigo-500" />
|
||||
)}
|
||||
<span className="truncate">{skill.title}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="pl-6 pr-1 mt-0.5 space-y-0.5">
|
||||
{agentSkills.map((skill) => {
|
||||
const getSkillIcon = (cat) => {
|
||||
switch ((cat || '').toUpperCase()) {
|
||||
case 'AUTHORING':
|
||||
return Pencil;
|
||||
case 'WORKFORCE':
|
||||
return Users;
|
||||
case 'LOGISTICS':
|
||||
return Package;
|
||||
case 'DISPATCH':
|
||||
return Route;
|
||||
default:
|
||||
return Zap;
|
||||
}
|
||||
};
|
||||
const SkillIcon = getSkillIcon(skill.category);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={skill.id}
|
||||
type="button"
|
||||
onClick={() => onSelectSkill(skill)}
|
||||
className="w-full text-left py-1.5 px-2 rounded-md text-slate-600 hover:text-brand hover:bg-brand-tint/60 text-[11px] flex items-center gap-2 truncate cursor-pointer transition-colors group"
|
||||
>
|
||||
<SkillIcon className="w-3 h-3 shrink-0 text-slate-400 group-hover:text-brand transition-colors" />
|
||||
<span className="truncate">{skill.title}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -209,7 +220,7 @@ export default function AgentScopeNavigator({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBoardOpen((v) => !v)}
|
||||
className="w-full flex items-center justify-between py-1 text-[11px] font-semibold text-slate-600 hover:text-slate-900 cursor-pointer"
|
||||
className="w-full flex items-center justify-between py-1 px-2 text-[11px] font-medium text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-md cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{boardOpen ? (
|
||||
@@ -218,15 +229,15 @@ export default function AgentScopeNavigator({
|
||||
<ChevronRight className="w-3 h-3 text-slate-400" />
|
||||
)}
|
||||
<Layers className="w-3 h-3 text-slate-400" />
|
||||
<span>Board Skills</span>
|
||||
<span className="font-medium text-slate-700">Board Skills</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400 font-bold">
|
||||
<span className="text-[10px] text-slate-400 font-semibold">
|
||||
{boardSkills.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{boardOpen && (
|
||||
<div className="ml-4 pl-2 mt-1">
|
||||
<div className="pl-7 pr-2 mt-0.5">
|
||||
<p className="text-[10px] text-slate-400 leading-snug py-1">
|
||||
None drawing on this agent's surfaces.
|
||||
</p>
|
||||
@@ -242,7 +253,7 @@ export default function AgentScopeNavigator({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setToolsOpen((v) => !v)}
|
||||
className="w-full flex items-center justify-between py-1 px-1.5 text-slate-700 hover:text-slate-900 rounded-lg font-semibold cursor-pointer group"
|
||||
className="w-full flex items-center justify-between py-1.5 px-2 text-slate-700 hover:text-slate-900 hover:bg-slate-50 rounded-lg font-medium cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{toolsOpen ? (
|
||||
@@ -250,31 +261,29 @@ export default function AgentScopeNavigator({
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-slate-400" />
|
||||
)}
|
||||
<Wrench className="w-3.5 h-3.5 text-slate-500" />
|
||||
<span className="font-bold">Tools</span>
|
||||
<Wrench className="w-3.5 h-3.5 text-slate-400" />
|
||||
<span className="font-semibold text-slate-800">Tools</span>
|
||||
</div>
|
||||
<span className="text-[11px] font-bold text-slate-400">
|
||||
<span className="text-[11px] font-semibold text-slate-400">
|
||||
{tools.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{toolsOpen && (
|
||||
<div className="ml-4 pl-2 border-l border-slate-200/80 mt-1 space-y-1.5">
|
||||
{tools.slice(0, 4).map((tool) => (
|
||||
<div className="pl-6 pr-1 mt-1 space-y-0.5">
|
||||
{tools.map((tool) => (
|
||||
<button
|
||||
key={tool.name}
|
||||
type="button"
|
||||
onClick={() => onSelectTool(tool)}
|
||||
className="w-full text-left py-1.5 px-2 rounded-xl text-slate-700 hover:text-brand hover:bg-brand-tint/60 transition-colors cursor-pointer group"
|
||||
onClick={onSelectTool}
|
||||
className="w-full text-left py-1 px-2 rounded-md hover:bg-slate-50 text-[11px] group cursor-pointer transition-colors block"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Wrench className="w-3 h-3 text-slate-400 group-hover:text-brand shrink-0" />
|
||||
<span className="text-[11px] font-semibold truncate capitalize">
|
||||
{tool.name.replace(/_/g, ' ')}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 text-slate-700 group-hover:text-brand truncate font-mono text-[10.5px]">
|
||||
<Wrench className="w-3 h-3 shrink-0 text-slate-400 group-hover:text-brand" />
|
||||
<span className="truncate">{tool.name}</span>
|
||||
</div>
|
||||
{tool.parentSkill && (
|
||||
<div className="text-[9px] text-slate-400 font-medium pl-4 truncate">
|
||||
<div className="text-[9.5px] text-slate-400 pl-4 truncate font-sans">
|
||||
{tool.parentSkill}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -118,28 +118,25 @@ export default function AgentStudio() {
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
'pb-3.5 relative transition-all cursor-pointer flex items-center gap-2 select-none',
|
||||
'pb-3.5 -mb-px transition-all cursor-pointer flex items-center gap-2 select-none border-b-2',
|
||||
isActive
|
||||
? 'text-brand font-bold'
|
||||
: 'text-slate-500 hover:text-slate-800'
|
||||
? 'border-brand text-brand font-bold'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-800 hover:border-slate-300'
|
||||
)}
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
{badgeCount !== null && (
|
||||
<span
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded-full text-[11px] font-bold transition-colors',
|
||||
'px-2 py-0.5 rounded-full text-[11px] font-bold transition-colors',
|
||||
isActive
|
||||
? 'bg-brand-tint text-brand'
|
||||
? 'bg-brand text-white'
|
||||
: 'bg-slate-100 text-slate-500'
|
||||
)}
|
||||
>
|
||||
{badgeCount}
|
||||
</span>
|
||||
)}
|
||||
{isActive && (
|
||||
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-brand rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
Route,
|
||||
Check,
|
||||
Layers,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -24,34 +23,34 @@ export default function SkillCard({ skill, onTest, onDetails }) {
|
||||
icon: Pencil,
|
||||
textColor: 'text-brand',
|
||||
bgColor: 'bg-brand-tint',
|
||||
borderColor: 'border-brand/30',
|
||||
borderColor: 'border-brand/20',
|
||||
};
|
||||
case 'WORKFORCE':
|
||||
return {
|
||||
icon: Users,
|
||||
textColor: 'text-indigo-700',
|
||||
bgColor: 'bg-indigo-50/80',
|
||||
borderColor: 'border-indigo-200/80',
|
||||
textColor: 'text-slate-700',
|
||||
bgColor: 'bg-slate-100',
|
||||
borderColor: 'border-slate-200',
|
||||
};
|
||||
case 'LOGISTICS':
|
||||
return {
|
||||
icon: Package,
|
||||
textColor: 'text-emerald-700',
|
||||
bgColor: 'bg-emerald-50/80',
|
||||
borderColor: 'border-emerald-200/80',
|
||||
bgColor: 'bg-emerald-50',
|
||||
borderColor: 'border-emerald-200',
|
||||
};
|
||||
case 'DISPATCH':
|
||||
return {
|
||||
icon: Route,
|
||||
textColor: 'text-rose-700',
|
||||
bgColor: 'bg-rose-50/80',
|
||||
borderColor: 'border-rose-200/80',
|
||||
textColor: 'text-brand',
|
||||
bgColor: 'bg-brand-tint',
|
||||
borderColor: 'border-brand/20',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: Layers,
|
||||
textColor: 'text-slate-700',
|
||||
bgColor: 'bg-slate-100',
|
||||
textColor: 'text-slate-600',
|
||||
bgColor: 'bg-slate-50',
|
||||
borderColor: 'border-slate-200',
|
||||
};
|
||||
}
|
||||
@@ -61,27 +60,27 @@ export default function SkillCard({ skill, onTest, onDetails }) {
|
||||
const CategoryIcon = meta.icon;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-3xl border border-slate-200/90 shadow-2xs hover:shadow-sm hover:border-slate-300 transition-all p-5 flex flex-col justify-between relative group">
|
||||
{/* Top Header: Category Tag (Left) & Status Badge (Right) */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-2xs hover:shadow-md hover:border-slate-300 transition-all p-5 flex flex-col justify-between group">
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-2 mb-3.5">
|
||||
{/* Top Header: Category Tag (Left) & Status Badge (Right) */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[11px] font-bold tracking-wider border',
|
||||
'inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[10px] font-bold tracking-wider border',
|
||||
meta.bgColor,
|
||||
meta.textColor,
|
||||
meta.borderColor
|
||||
)}
|
||||
>
|
||||
<CategoryIcon className="w-3.5 h-3.5" />
|
||||
<CategoryIcon className="w-3 h-3" />
|
||||
<span>{(skill.category || 'SKILL').toUpperCase()}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold border',
|
||||
'inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10.5px] font-semibold border',
|
||||
isEnabled
|
||||
? 'bg-brand-tint text-brand border-brand/30'
|
||||
? 'bg-brand-tint/70 text-brand border-brand/20'
|
||||
: 'bg-slate-100 text-slate-500 border-slate-200'
|
||||
)}
|
||||
>
|
||||
@@ -97,27 +96,27 @@ export default function SkillCard({ skill, onTest, onDetails }) {
|
||||
</div>
|
||||
|
||||
{/* Skill Title */}
|
||||
<h3 className="text-base sm:text-[17px] font-bold text-slate-900 font-heading leading-snug">
|
||||
<h3 className="text-base font-bold text-slate-900 font-heading mt-3 tracking-tight">
|
||||
{skill.title}
|
||||
</h3>
|
||||
|
||||
{/* Skill Description */}
|
||||
<p className="text-xs sm:text-[13px] text-slate-600 mt-2 leading-relaxed line-clamp-3">
|
||||
<p className="text-xs text-slate-500 mt-1.5 leading-relaxed line-clamp-2">
|
||||
{skill.description}
|
||||
</p>
|
||||
|
||||
{/* Prompt Trigger Pill (if present) */}
|
||||
{skill.prompt && (
|
||||
<div className="mt-3.5 p-2.5 rounded-2xl bg-brand-tint/40 border border-brand/20 text-xs text-brand font-medium flex items-center gap-2">
|
||||
<div className="mt-3 p-2 rounded-xl bg-brand-tint/30 border border-brand/15 text-xs text-brand flex items-center gap-2">
|
||||
<Sparkles className="w-3.5 h-3.5 text-brand shrink-0" />
|
||||
<span className="font-medium italic truncate">"{skill.prompt}"</span>
|
||||
<span className="italic truncate font-medium">"{skill.prompt}"</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scope / Integration Tag */}
|
||||
{skill.integration && (
|
||||
<div className="mt-3.5">
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-xl bg-slate-50 border border-slate-200/80 text-[11px] font-semibold text-slate-600">
|
||||
<div className="mt-3">
|
||||
<div className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-slate-50 border border-slate-200/80 text-[10.5px] font-medium text-slate-600">
|
||||
<Layers className="w-3 h-3 text-slate-400" />
|
||||
<span>{skill.integration}</span>
|
||||
</div>
|
||||
@@ -126,23 +125,23 @@ export default function SkillCard({ skill, onTest, onDetails }) {
|
||||
</div>
|
||||
|
||||
{/* Footer Action Bar */}
|
||||
<div className="flex items-center justify-between pt-4 mt-4 border-t border-slate-100 text-xs font-semibold">
|
||||
<div className="flex items-center justify-between pt-3.5 mt-3.5 border-t border-slate-100 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTest(skill)}
|
||||
className="inline-flex items-center gap-1.5 text-brand hover:text-brand-dark transition-colors cursor-pointer group-hover:translate-x-0.5 transition-transform"
|
||||
className="inline-flex items-center gap-1.5 text-brand hover:text-brand-dark font-bold transition-colors cursor-pointer"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5 fill-current" />
|
||||
<Play className="w-3 h-3 fill-current" />
|
||||
<span>Test in Playground</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDetails(skill)}
|
||||
className="inline-flex items-center gap-1 text-slate-500 hover:text-slate-900 transition-colors cursor-pointer"
|
||||
className="inline-flex items-center gap-1 text-slate-500 hover:text-slate-900 font-medium transition-colors cursor-pointer"
|
||||
>
|
||||
<span>Details</span>
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -59,9 +59,9 @@ export default function SkillCardsGrid({
|
||||
const boardCount = skills.filter((s) => s.scope === 'board').length;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-4">
|
||||
{/* 1. Scope Sub-tabs (Agent Skills / Board Skills) */}
|
||||
<div className="flex items-center gap-6 border-b border-slate-200/80 pb-px text-xs sm:text-sm font-semibold">
|
||||
<div className="flex items-center gap-8 border-b border-slate-200/80 text-xs sm:text-sm font-medium">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -69,26 +69,23 @@ export default function SkillCardsGrid({
|
||||
setCategoryFilter('ALL');
|
||||
}}
|
||||
className={cn(
|
||||
'pb-3 relative transition-colors cursor-pointer flex items-center gap-2',
|
||||
'pb-3 -mb-px transition-all cursor-pointer flex items-center gap-2 border-b-2 select-none',
|
||||
scopeTab === 'agent'
|
||||
? 'text-brand font-bold'
|
||||
: 'text-slate-500 hover:text-slate-800'
|
||||
? 'border-brand text-brand font-bold'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-900 hover:border-slate-300'
|
||||
)}
|
||||
>
|
||||
<span>Agent Skills</span>
|
||||
<span
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded-full text-[11px] font-bold',
|
||||
'px-2 py-0.5 rounded-full text-[11px] font-bold transition-colors',
|
||||
scopeTab === 'agent'
|
||||
? 'bg-brand-tint text-brand'
|
||||
? 'bg-brand text-white'
|
||||
: 'bg-slate-100 text-slate-500'
|
||||
)}
|
||||
>
|
||||
{agentCount}
|
||||
</span>
|
||||
{scopeTab === 'agent' && (
|
||||
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-brand rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -98,30 +95,27 @@ export default function SkillCardsGrid({
|
||||
setCategoryFilter('ALL');
|
||||
}}
|
||||
className={cn(
|
||||
'pb-3 relative transition-colors cursor-pointer flex items-center gap-2',
|
||||
'pb-3 -mb-px transition-all cursor-pointer flex items-center gap-2 border-b-2 select-none',
|
||||
scopeTab === 'board'
|
||||
? 'text-brand font-bold'
|
||||
: 'text-slate-500 hover:text-slate-800'
|
||||
? 'border-brand text-brand font-bold'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-900 hover:border-slate-300'
|
||||
)}
|
||||
>
|
||||
<span>Board Skills</span>
|
||||
<span
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded-full text-[11px] font-bold',
|
||||
'px-2 py-0.5 rounded-full text-[11px] font-bold transition-colors',
|
||||
scopeTab === 'board'
|
||||
? 'bg-brand-tint text-brand'
|
||||
? 'bg-brand text-white'
|
||||
: 'bg-slate-100 text-slate-500'
|
||||
)}
|
||||
>
|
||||
{boardCount}
|
||||
</span>
|
||||
{scopeTab === 'board' && (
|
||||
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-brand rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 2. Global Search Bar */}
|
||||
{/* 2. Global Search Input (Sleek pill style matching reference) */}
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 text-slate-400 absolute left-4 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||
<input
|
||||
@@ -129,7 +123,7 @@ export default function SkillCardsGrid({
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search skills, capabilities or pages..."
|
||||
className="w-full bg-white border border-slate-200/90 rounded-2xl pl-11 pr-10 py-3 text-xs sm:text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:border-brand focus:ring-2 focus:ring-brand/20 shadow-2xs transition-all"
|
||||
className="w-full h-10 bg-white border border-slate-200 rounded-full pl-10 pr-9 text-xs text-slate-800 placeholder:text-slate-400 focus:outline-none focus:border-brand focus:ring-1 focus:ring-brand/20 shadow-2xs transition-all"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
@@ -143,7 +137,7 @@ export default function SkillCardsGrid({
|
||||
</div>
|
||||
|
||||
{/* 3. Category Filter Pills */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1 no-scrollbar">
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-0.5 no-scrollbar">
|
||||
{Object.entries(categories).map(([cat, count]) => {
|
||||
const isSelected = categoryFilter === cat;
|
||||
const displayLabel = cat === 'ALL' ? 'All' : cat.charAt(0) + cat.slice(1).toLowerCase();
|
||||
@@ -154,17 +148,17 @@ export default function SkillCardsGrid({
|
||||
type="button"
|
||||
onClick={() => setCategoryFilter(cat)}
|
||||
className={cn(
|
||||
'px-4 py-1.5 rounded-full text-xs font-semibold transition-all whitespace-nowrap cursor-pointer shadow-2xs flex items-center gap-1.5',
|
||||
'px-3.5 py-1 rounded-full text-xs font-medium transition-all whitespace-nowrap cursor-pointer flex items-center gap-1.5',
|
||||
isSelected
|
||||
? 'bg-brand text-white shadow-xs'
|
||||
: 'bg-white border border-slate-200/80 text-slate-600 hover:bg-slate-50 hover:text-slate-900'
|
||||
? 'bg-brand text-white shadow-2xs font-semibold'
|
||||
: 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-50 hover:text-slate-900'
|
||||
)}
|
||||
>
|
||||
<span>{displayLabel}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[11px] font-bold opacity-80',
|
||||
isSelected ? 'text-white' : 'text-slate-400'
|
||||
'text-[10.5px]',
|
||||
isSelected ? 'text-white/90 font-bold' : 'text-slate-400'
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
@@ -174,39 +168,39 @@ export default function SkillCardsGrid({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 4. Capabilities Count & "+ New skill" Button */}
|
||||
{/* 4. Capabilities Count & "+ New skill" Action Row */}
|
||||
<div className="flex items-center justify-between pt-1 text-xs text-slate-500">
|
||||
<div>
|
||||
<span className="font-bold text-slate-800">
|
||||
<span className="font-semibold text-slate-800">
|
||||
{filteredSkills.length} of {scopedSkills.length} capabilities
|
||||
</span>{' '}
|
||||
for this scope ·{' '}
|
||||
<span className="font-bold text-brand">{enabledCount} enabled</span>
|
||||
<span className="font-semibold text-brand">{enabledCount} enabled</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewSkill}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-white border border-slate-200/90 text-brand hover:bg-brand-tint hover:border-brand/30 font-bold text-xs shadow-2xs transition-all active:scale-95 cursor-pointer"
|
||||
className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-full bg-white border border-brand text-brand hover:bg-brand-tint font-semibold text-xs shadow-2xs transition-all active:scale-95 cursor-pointer"
|
||||
>
|
||||
<Plus className="w-4 h-4 stroke-[2.5]" />
|
||||
<Plus className="w-3.5 h-3.5 stroke-[2.5]" />
|
||||
<span>New skill</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 5. Responsive Skill Cards Grid */}
|
||||
{scopeTab === 'board' && filteredSkills.length === 0 ? (
|
||||
<div className="p-10 rounded-3xl border border-dashed border-slate-200 bg-slate-50/50 text-center space-y-2">
|
||||
<Layers className="w-8 h-8 text-slate-400 mx-auto" />
|
||||
<div className="text-sm font-bold text-slate-700">No Board Skills Connected</div>
|
||||
<p className="text-xs text-slate-500 max-w-sm mx-auto">
|
||||
<div className="p-10 rounded-2xl border border-dashed border-slate-200 bg-white/60 text-center space-y-2">
|
||||
<Layers className="w-7 h-7 text-slate-400 mx-auto" />
|
||||
<div className="text-sm font-semibold text-slate-700">No Board Skills Connected</div>
|
||||
<p className="text-xs text-slate-400 max-w-sm mx-auto">
|
||||
None drawing on this agent's surfaces. Connect board skills to link live operational boards with this agent.
|
||||
</p>
|
||||
</div>
|
||||
) : filteredSkills.length === 0 ? (
|
||||
<div className="p-10 rounded-3xl border border-slate-200/80 bg-white text-center space-y-3 shadow-2xs">
|
||||
<div className="p-10 rounded-2xl border border-slate-200 bg-white text-center space-y-3 shadow-2xs">
|
||||
<AlertCircle className="w-7 h-7 text-slate-400 mx-auto" />
|
||||
<div className="text-sm font-bold text-slate-800">No matching capabilities found</div>
|
||||
<div className="text-sm font-semibold text-slate-800">No matching capabilities found</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
Try adjusting your search query or selecting a different category filter.
|
||||
</p>
|
||||
@@ -216,13 +210,13 @@ export default function SkillCardsGrid({
|
||||
setSearchQuery('');
|
||||
setCategoryFilter('ALL');
|
||||
}}
|
||||
className="px-4 py-2 rounded-xl bg-slate-100 hover:bg-slate-200 text-xs font-semibold text-slate-700 transition-colors cursor-pointer"
|
||||
className="px-3.5 py-1.5 rounded-lg bg-slate-100 hover:bg-slate-200 text-xs font-medium text-slate-700 transition-colors cursor-pointer"
|
||||
>
|
||||
Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{filteredSkills.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill.id}
|
||||
|
||||
@@ -135,7 +135,7 @@ export const DEFAULT_SKILLS = [
|
||||
id: 'learning-analysis',
|
||||
title: 'Learning Analysis',
|
||||
category: 'WORKFORCE',
|
||||
categoryTheme: 'indigo',
|
||||
categoryTheme: 'slate',
|
||||
scope: 'agent',
|
||||
integration: 'MileTruth AI',
|
||||
enabled: true,
|
||||
|
||||
119
tests/assistant/pageSuggestions.test.js
Normal file
119
tests/assistant/pageSuggestions.test.js
Normal file
@@ -0,0 +1,119 @@
|
||||
import { getPageContext, CHIP_LABELS } from '@/components/assistant/DoormileAI/pageContext';
|
||||
import { resolveIntentId } from '@/components/assistant/intents';
|
||||
|
||||
/**
|
||||
* Every suggestion a page offers must resolve — and must resolve to the
|
||||
* intent that page means.
|
||||
*
|
||||
* `pageContext.jsx` states the rule at the top of the file: "Every suggestion
|
||||
* here MUST be a phrasing the deterministic matcher in intents.js actually
|
||||
* resolves — a suggestion chip that returns 'I can't answer that yet' is worse
|
||||
* than no chip." It was enforced by reading. It is now enforced here.
|
||||
*
|
||||
* Resolving is not enough on its own, because INTENTS is an ordered catalog
|
||||
* and the FIRST match wins. A question can match the intent it was written for
|
||||
* and still be answered by a different one that happens to run earlier —
|
||||
* `ROADMAP.md` B1/B2 are two shipped instances of exactly that. So these tests
|
||||
* assert on which intent actually wins, not merely that something does.
|
||||
*/
|
||||
|
||||
// The route of every page that carries the assistant. Sourced from App.jsx.
|
||||
const PAGES = [
|
||||
'/doormile/orders',
|
||||
'/doormile/bookings',
|
||||
'/doormile/deliveries',
|
||||
'/doormile/control-x',
|
||||
'/doormile/milers',
|
||||
'/doormile/vehicles',
|
||||
'/doormile/hubs',
|
||||
'/doormile/tenants',
|
||||
'/doormile/customers',
|
||||
'/doormile/tripsheets',
|
||||
'/doormile/exceptions',
|
||||
'/doormile/pricing',
|
||||
'/doormile/app-users',
|
||||
'/doormile/competitive-intel',
|
||||
'/doormile/reports'
|
||||
];
|
||||
|
||||
/** The intent the ordered catalog actually reaches for a question. */
|
||||
const winningIntent = (text) => resolveIntentId(text);
|
||||
|
||||
const chipsFor = (route) => getPageContext(route).suggestions;
|
||||
|
||||
describe('every page suggestion resolves to an intent', () => {
|
||||
PAGES.forEach((route) => {
|
||||
it(`${route}`, () => {
|
||||
const chips = chipsFor(route);
|
||||
expect(chips.length).toBeGreaterThan(0);
|
||||
chips.forEach((chip) => {
|
||||
expect(winningIntent(chip.text)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('a page offers its own subject, not another page\'s', () => {
|
||||
// The intent id a page's questions are allowed to reach. This is the actual
|
||||
// separation being asked for: on Orders, order intents; on Deliveries,
|
||||
// delivery intents. Not "an intent exists" — the RIGHT one.
|
||||
const ALLOWED = {
|
||||
'/doormile/orders': /order|repeat|create|assign|summary|revenue|delay/i,
|
||||
'/doormile/bookings': /order|delay/i,
|
||||
'/doormile/deliveries': /consignment|order|delay|rider/i,
|
||||
'/doormile/control-x': /order|batch|rider|assign|delay/i,
|
||||
'/doormile/milers': /rider|assign/i,
|
||||
'/doormile/vehicles': /vehicle/i,
|
||||
'/doormile/hubs': /hub/i,
|
||||
'/doormile/tenants': /tenant|partner/i,
|
||||
'/doormile/customers': /customer/i,
|
||||
'/doormile/tripsheets': /tripsheet/i,
|
||||
'/doormile/exceptions': /exception|delay/i,
|
||||
'/doormile/pricing': /pricing/i,
|
||||
'/doormile/app-users': /appuser/i,
|
||||
'/doormile/competitive-intel': /competitor|carrier/i
|
||||
};
|
||||
|
||||
Object.entries(ALLOWED).forEach(([route, allowed]) => {
|
||||
it(`${route} stays on topic`, () => {
|
||||
chipsFor(route).forEach((chip) => {
|
||||
const id = winningIntent(chip.text);
|
||||
expect(`${route} · "${chip.text}" → ${id}`).toMatch(allowed);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('the two bugs this change fixes', () => {
|
||||
it('Deliveries is no longer the Orders list wearing a different name', () => {
|
||||
const deliveries = chipsFor('/doormile/deliveries').map((c) => c.text);
|
||||
const orders = chipsFor('/doormile/orders').map((c) => c.text);
|
||||
expect(deliveries).not.toEqual(orders);
|
||||
// It used to be `{ ...ORDERS, label: 'Deliveries' }`, so a page of
|
||||
// consignments offered "Create an order" and "Repeat yesterday's orders".
|
||||
expect(deliveries).not.toContain('Create an order');
|
||||
expect(deliveries).not.toContain("Repeat yesterday's orders");
|
||||
expect(deliveries).toContain('How many consignments do we have?');
|
||||
});
|
||||
|
||||
it('"carrier pricing rules" would be answered by the WRONG intent', () => {
|
||||
// Kept as a live assertion rather than a comment. carrierPricingCount
|
||||
// documents this exact phrasing for itself, and pricingCount matches
|
||||
// /\bpricing\b/ 57 lines earlier, so the catalog never reaches it.
|
||||
expect(winningIntent('How many carrier pricing rules?')).toBe('pricingCount');
|
||||
// Which is why Competitive Intel asks it the other way.
|
||||
expect(winningIntent('How many carriers do we have?')).toBe('carrierPricingCount');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chips are readable in a 393px panel', () => {
|
||||
it('every suggestion has a short label', () => {
|
||||
const missing = [];
|
||||
PAGES.forEach((route) => {
|
||||
chipsFor(route).forEach((chip) => {
|
||||
if (!CHIP_LABELS[chip.text]) missing.push(`${route} · ${chip.text}`);
|
||||
});
|
||||
});
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user