updates on the design and chatbot fix

This commit is contained in:
2026-09-22 17:32:07 +05:30
parent 6585a8da00
commit b9b0380927
12 changed files with 383 additions and 199 deletions

View File

@@ -30,6 +30,10 @@ module.exports = {
'\.(css|less|scss|sass)$': '<rootDir>/tests/setup/styleMock.cjs', '\.(css|less|scss|sass)$': '<rootDir>/tests/setup/styleMock.cjs',
'\.(png|jpe?g|gif|svg|webp|woff2?|ttf|eot)$': '<rootDir>/tests/setup/fileMock.cjs', '\.(png|jpe?g|gif|svg|webp|woff2?|ttf|eot)$': '<rootDir>/tests/setup/fileMock.cjs',
'^@/(.*)$': '<rootDir>/src/$1', '^@/(.*)$': '<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: [ collectCoverageFrom: [
'src/api/doormile/**/*.js', 'src/api/doormile/**/*.js',

View File

@@ -1428,6 +1428,7 @@ const AIPanel = ({ isOpen, onClose }) => {
<IconButton <IconButton
size="sm" size="sm"
variant="ghost" variant="ghost"
side="bottom"
label={view === 'history' ? 'Back to conversation' : 'Conversation history'} label={view === 'history' ? 'Back to conversation' : 'Conversation history'}
tooltip={view === 'history' ? 'Back to conversation' : 'History'} tooltip={view === 'history' ? 'Back to conversation' : 'History'}
aria-pressed={view === 'history'} aria-pressed={view === 'history'}
@@ -1437,6 +1438,7 @@ const AIPanel = ({ isOpen, onClose }) => {
<IconButton <IconButton
size="sm" size="sm"
variant="ghost" variant="ghost"
side="bottom"
label="New conversation" label="New conversation"
tooltip="New conversation" tooltip="New conversation"
icon={<LuRotateCcw />} icon={<LuRotateCcw />}
@@ -1445,6 +1447,7 @@ const AIPanel = ({ isOpen, onClose }) => {
<IconButton <IconButton
size="sm" size="sm"
variant="ghost" variant="ghost"
side="bottom"
label={isWide ? 'Standard width (20%)' : 'Expand width (30%)'} label={isWide ? 'Standard width (20%)' : 'Expand width (30%)'}
tooltip={isWide ? 'Standard width (20%)' : 'Expand width (30%)'} tooltip={isWide ? 'Standard width (20%)' : 'Expand width (30%)'}
aria-pressed={isWide} aria-pressed={isWide}
@@ -1454,6 +1457,7 @@ const AIPanel = ({ isOpen, onClose }) => {
<IconButton <IconButton
size="sm" size="sm"
variant="ghost" variant="ghost"
side="bottom"
label="Close assistant" label="Close assistant"
tooltip="Close assistant" tooltip="Close assistant"
icon={<LuX />} icon={<LuX />}

View File

@@ -69,7 +69,12 @@ export const CHIP_LABELS = {
'How many tripsheets are dispatched?': 'Tripsheets', 'How many tripsheets are dispatched?': 'Tripsheets',
'How many open exceptions?': 'Open exceptions', 'How many open exceptions?': 'Open exceptions',
'How many pricing rules are configured?': 'Pricing rules', '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 // The one follow-up phrasing that is not already a page suggestion. Verified
@@ -121,39 +126,27 @@ const ORDERS = {
}; };
const RIDERS = { const RIDERS = {
label: 'Riders', label: 'Milers',
suggestions: [ suggestions: [
{ icon: LuBike, text: 'How many riders are active?' }, { icon: LuBike, text: 'How many riders are active?' },
{ icon: LuClock3, text: "Give me today's operations summary" }, { icon: LuUserPlus, text: 'Assign a rider' }
{ icon: LuPackage, text: 'How many orders today?' },
{ icon: LuTruck, text: 'How many vehicles are available?' }
], ],
more: [ more: []
{ icon: LuBuilding2, text: 'Current hub status' },
{ icon: LuBanknote, text: 'Total revenue today' }
]
}; };
const VEHICLES = { const VEHICLES = {
label: 'Vehicles', label: 'Vehicles',
suggestions: [ suggestions: [{ icon: LuTruck, text: 'How many vehicles are available?' }],
{ icon: LuTruck, text: 'How many vehicles are available?' }, more: []
{ 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?' }]
}; };
const HUBS = { const HUBS = {
label: 'Hubs', label: 'Hubs',
suggestions: [ suggestions: [
{ icon: LuBuilding2, text: 'Current hub status' }, { icon: LuBuilding2, text: 'Current hub status' },
{ icon: LuTimerOff, text: 'Which hubs are experiencing delays?' }, { icon: LuTimerOff, text: 'Which hubs are experiencing delays?' }
{ icon: LuTruck, text: 'How many vehicles are available?' },
{ icon: LuBike, text: 'How many riders are active?' }
], ],
more: [{ icon: LuPackage, text: 'How many orders today?' }] more: []
}; };
const DISPATCH = { const DISPATCH = {
@@ -162,24 +155,16 @@ const DISPATCH = {
{ icon: LuLayers, text: 'Morning Batch orders today' }, { icon: LuLayers, text: 'Morning Batch orders today' },
{ icon: LuTimerOff, text: 'Which orders are delayed?' }, { icon: LuTimerOff, text: 'Which orders are delayed?' },
{ icon: LuBike, text: 'How many riders are active?' }, { icon: LuBike, text: 'How many riders are active?' },
{ icon: LuClock3, text: "Give me today's operations summary" } { icon: LuUserPlus, text: 'Assign a rider' }
], ],
more: [ more: []
{ icon: LuPackage, text: 'How many orders today?' },
{ icon: LuCircleDot, text: 'How many pending orders today?' }
]
}; };
// Linehaul and middle-mile runs. // Linehaul and middle-mile runs.
const TRIPSHEETS = { const TRIPSHEETS = {
label: 'Tripsheets', label: 'Tripsheets',
suggestions: [ suggestions: [{ icon: LuLayers, text: 'How many tripsheets are dispatched?' }],
{ icon: LuLayers, text: 'How many tripsheets are dispatched?' }, more: []
{ 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' }]
}; };
// The register of things that went wrong. // The register of things that went wrong.
@@ -187,9 +172,7 @@ const EXCEPTIONS = {
label: 'Exceptions', label: 'Exceptions',
suggestions: [ suggestions: [
{ icon: LuCircleDot, text: 'How many open exceptions?' }, { icon: LuCircleDot, text: 'How many open exceptions?' },
{ icon: LuTimerOff, text: 'Which orders are delayed?' }, { icon: LuTimerOff, text: 'Which orders are delayed?' }
{ icon: LuTimerOff, text: 'Which hubs are experiencing delays?' },
{ icon: LuClock3, text: "Give me today's operations summary" }
], ],
more: [] more: []
}; };
@@ -198,13 +181,8 @@ const EXCEPTIONS = {
// real page and deserves its own questions like any other. // real page and deserves its own questions like any other.
const PRICING = { const PRICING = {
label: 'Pricing', label: 'Pricing',
suggestions: [ suggestions: [{ icon: LuBanknote, text: 'How many pricing rules are configured?' }],
{ icon: LuBanknote, text: 'How many pricing rules are configured?' }, more: []
{ 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' }]
}; };
// Bookings — what customers raised in the app, before it becomes an order. // Bookings — what customers raised in the app, before it becomes an order.
@@ -227,23 +205,62 @@ const BOOKINGS = {
suggestions: [ suggestions: [
{ icon: LuPackage, text: 'How many orders today?' }, { icon: LuPackage, text: 'How many orders today?' },
{ icon: LuCircleDot, text: 'How many pending 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: 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 = { const TENANTS = {
label: 'Tenants', label: 'Tenants',
suggestions: [ suggestions: [
{ icon: LuUsers, text: 'How many tenants do we have?' }, { icon: LuUsers, text: 'How many tenants do we have?' },
{ icon: LuPackage, text: 'How many orders today?' }, { icon: LuUsers, text: 'How many partners do we have?' }
{ icon: LuBanknote, text: 'Total revenue today' },
{ icon: LuClock3, text: "Give me today's operations summary" }
], ],
more: [] more: []
}; };
@@ -352,7 +369,7 @@ const FOLLOW_UPS = {
// with the wrong label. // with the wrong label.
const ROUTES = [ const ROUTES = [
['/doormile/control-x', DISPATCH], ['/doormile/control-x', DISPATCH],
['/doormile/deliveries', { ...ORDERS, label: 'Deliveries' }], ['/doormile/deliveries', DELIVERIES],
['/doormile/bookings', BOOKINGS], ['/doormile/bookings', BOOKINGS],
['/doormile/orders', ORDERS], ['/doormile/orders', ORDERS],
['/doormile/milers', RIDERS], ['/doormile/milers', RIDERS],
@@ -386,7 +403,11 @@ const ROUTES = [
// so none is shadowed by the order intents that run earlier in the catalog. // so none is shadowed by the order intents that run earlier in the catalog.
['/doormile/tripsheets', TRIPSHEETS], ['/doormile/tripsheets', TRIPSHEETS],
['/doormile/exceptions', EXCEPTIONS], ['/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 --------------------------------------- // ---- every question, from every page ---------------------------------------
@@ -399,7 +420,20 @@ const ROUTES = [
// //
// `more` is retired: AIWelcome renders one flat list now, so a second array // `more` is retired: AIWelcome renders one flat list now, so a second array
// would only be a second place for a question to hide. // 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 // 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 // QUESTION_BY_TEXT (so a follow-up naming a question that is no longer global

View File

@@ -2090,6 +2090,30 @@ const INTENTS = [
const INTENTS_BY_ID = Object.fromEntries(INTENTS.map((i) => [i.id, i])); 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); export const SUPPORTED_QUESTIONS = INTENTS.map((i) => i.label);
// Clean, directly-askable example phrasings — for "recommended question" // Clean, directly-askable example phrasings — for "recommended question"

View File

@@ -18,7 +18,7 @@ const TooltipContent = React.forwardRef(({ className, sideOffset = 4, ...props }
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( 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 className
)} )}
{...props} /> {...props} />

View File

@@ -198,7 +198,7 @@ export function AgentInsightsView() {
<span>420 calls (28%)</span> <span>420 calls (28%)</span>
</div> </div>
<div className="h-2 rounded-full bg-slate-100 overflow-hidden"> <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>
</div> </div>

View File

@@ -1,6 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { import {
GraduationCap,
Sparkles, Sparkles,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
@@ -9,6 +8,8 @@ import {
Wrench, Wrench,
Pencil, Pencil,
Users, Users,
Package,
Route,
Layers, Layers,
Check, Check,
} from 'lucide-react'; } from 'lucide-react';
@@ -36,32 +37,28 @@ export default function AgentScopeNavigator({
const boardSkills = skills.filter((s) => s.scope === 'board'); const boardSkills = skills.filter((s) => s.scope === 'board');
return ( 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 */} {/* 1. Agent Profile Header + Dropdown Switcher */}
<div className="relative"> <div className="relative">
<button <button
type="button" type="button"
onClick={() => setAgentMenuOpen((v) => !v)} 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="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"> <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">
{currentAgent.iconType === 'grad' ? ( <Sparkles className="w-4 h-4" />
<GraduationCap className="w-5 h-5" />
) : (
<Sparkles className="w-5 h-5" />
)}
</div> </div>
<div className="min-w-0"> <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"> <div className="text-xs sm:text-sm font-bold text-slate-900 truncate font-heading group-hover:text-brand transition-colors">
{currentAgent.name} {currentAgent.name}
</div> </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'} {currentAgent.badge || 'AGENT'}
</div> </div>
</div> </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> </button>
{/* Agent Switcher Menu */} {/* Agent Switcher Menu */}
@@ -71,7 +68,7 @@ export default function AgentScopeNavigator({
className="fixed inset-0 z-40 bg-transparent" className="fixed inset-0 z-40 bg-transparent"
onClick={() => setAgentMenuOpen(false)} 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"> <div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider px-3 py-1.5">
Switch Agent Scope Switch Agent Scope
</div> </div>
@@ -84,7 +81,7 @@ export default function AgentScopeNavigator({
setAgentMenuOpen(false); setAgentMenuOpen(false);
}} }}
className={cn( 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 agent.id === activeAgentId
? 'bg-brand-tint text-brand' ? 'bg-brand-tint text-brand'
: 'text-slate-700 hover:bg-slate-50' : 'text-slate-700 hover:bg-slate-50'
@@ -101,14 +98,14 @@ export default function AgentScopeNavigator({
<div className="h-px bg-slate-100" /> <div className="h-px bg-slate-100" />
{/* 2. Hierarchical Collapsible Tree */} {/* 2. Hierarchical Tree (Clean, un-cluttered style) */}
<div className="space-y-3 text-xs"> <div className="space-y-2 text-xs">
{/* SURFACES TREE NODE */} {/* SURFACES TREE NODE */}
<div> <div>
<button <button
type="button" type="button"
onClick={() => setSurfacesOpen((v) => !v)} 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"> <div className="flex items-center gap-2">
{surfacesOpen ? ( {surfacesOpen ? (
@@ -116,20 +113,20 @@ export default function AgentScopeNavigator({
) : ( ) : (
<ChevronRight className="w-3.5 h-3.5 text-slate-400" /> <ChevronRight className="w-3.5 h-3.5 text-slate-400" />
)} )}
<LayoutGrid className="w-3.5 h-3.5 text-slate-500" /> <LayoutGrid className="w-3.5 h-3.5 text-slate-400" />
<span className="font-bold">Surfaces</span> <span className="font-semibold text-slate-800">Surfaces</span>
</div> </div>
<span className="text-[11px] font-bold text-slate-400"> <span className="text-[11px] font-semibold text-slate-400">
{surfaces.length || 1} {surfaces.length || 1}
</span> </span>
</button> </button>
{surfacesOpen && ( {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) => ( {surfaces.map((surf) => (
<div <div
key={surf.id} 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} title={surf.description}
> >
{surf.name} {surf.name}
@@ -144,7 +141,7 @@ export default function AgentScopeNavigator({
<button <button
type="button" type="button"
onClick={() => setSkillsOpen((v) => !v)} 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"> <div className="flex items-center gap-2">
{skillsOpen ? ( {skillsOpen ? (
@@ -152,22 +149,22 @@ export default function AgentScopeNavigator({
) : ( ) : (
<ChevronRight className="w-3.5 h-3.5 text-slate-400" /> <ChevronRight className="w-3.5 h-3.5 text-slate-400" />
)} )}
<Zap className="w-3.5 h-3.5 text-slate-500" /> <Zap className="w-3.5 h-3.5 text-slate-400" />
<span className="font-bold">Skills</span> <span className="font-semibold text-slate-800">Skills</span>
</div> </div>
<span className="text-[11px] font-bold text-slate-400"> <span className="text-[11px] font-semibold text-slate-400">
{skills.length} {skills.length}
</span> </span>
</button> </button>
{skillsOpen && ( {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 */} {/* Agent Skills Sub-node */}
<div> <div>
<button <button
type="button" type="button"
onClick={() => setAgentSkillsOpen((v) => !v)} 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"> <div className="flex items-center gap-1.5">
{agentSkillsOpen ? ( {agentSkillsOpen ? (
@@ -175,31 +172,45 @@ export default function AgentScopeNavigator({
) : ( ) : (
<ChevronRight className="w-3 h-3 text-slate-400" /> <ChevronRight className="w-3 h-3 text-slate-400" />
)} )}
<Zap className="w-3 h-3 text-slate-400" /> <Zap className="w-3 h-3 text-brand" />
<span>Agent Skills</span> <span className="font-medium text-slate-700">Agent Skills</span>
</div> </div>
<span className="text-[10px] text-slate-400 font-bold"> <span className="text-[10px] text-slate-400 font-semibold">
{agentSkills.length} {agentSkills.length}
</span> </span>
</button> </button>
{agentSkillsOpen && ( {agentSkillsOpen && (
<div className="ml-4 pl-2 border-l border-slate-200/80 mt-1 space-y-1"> <div className="pl-6 pr-1 mt-0.5 space-y-0.5">
{agentSkills.map((skill) => ( {agentSkills.map((skill) => {
<button const getSkillIcon = (cat) => {
key={skill.id} switch ((cat || '').toUpperCase()) {
type="button" case 'AUTHORING':
onClick={() => onSelectSkill(skill)} return Pencil;
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" case 'WORKFORCE':
> return Users;
{skill.category === 'AUTHORING' ? ( case 'LOGISTICS':
<Pencil className="w-3 h-3 shrink-0 text-brand" /> return Package;
) : ( case 'DISPATCH':
<Users className="w-3 h-3 shrink-0 text-indigo-500" /> return Route;
)} default:
<span className="truncate">{skill.title}</span> return Zap;
</button> }
))} };
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>
)} )}
</div> </div>
@@ -209,7 +220,7 @@ export default function AgentScopeNavigator({
<button <button
type="button" type="button"
onClick={() => setBoardOpen((v) => !v)} 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"> <div className="flex items-center gap-1.5">
{boardOpen ? ( {boardOpen ? (
@@ -218,15 +229,15 @@ export default function AgentScopeNavigator({
<ChevronRight className="w-3 h-3 text-slate-400" /> <ChevronRight className="w-3 h-3 text-slate-400" />
)} )}
<Layers 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> </div>
<span className="text-[10px] text-slate-400 font-bold"> <span className="text-[10px] text-slate-400 font-semibold">
{boardSkills.length} {boardSkills.length}
</span> </span>
</button> </button>
{boardOpen && ( {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"> <p className="text-[10px] text-slate-400 leading-snug py-1">
None drawing on this agent's surfaces. None drawing on this agent's surfaces.
</p> </p>
@@ -242,7 +253,7 @@ export default function AgentScopeNavigator({
<button <button
type="button" type="button"
onClick={() => setToolsOpen((v) => !v)} 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"> <div className="flex items-center gap-2">
{toolsOpen ? ( {toolsOpen ? (
@@ -250,31 +261,29 @@ export default function AgentScopeNavigator({
) : ( ) : (
<ChevronRight className="w-3.5 h-3.5 text-slate-400" /> <ChevronRight className="w-3.5 h-3.5 text-slate-400" />
)} )}
<Wrench className="w-3.5 h-3.5 text-slate-500" /> <Wrench className="w-3.5 h-3.5 text-slate-400" />
<span className="font-bold">Tools</span> <span className="font-semibold text-slate-800">Tools</span>
</div> </div>
<span className="text-[11px] font-bold text-slate-400"> <span className="text-[11px] font-semibold text-slate-400">
{tools.length} {tools.length}
</span> </span>
</button> </button>
{toolsOpen && ( {toolsOpen && (
<div className="ml-4 pl-2 border-l border-slate-200/80 mt-1 space-y-1.5"> <div className="pl-6 pr-1 mt-1 space-y-0.5">
{tools.slice(0, 4).map((tool) => ( {tools.map((tool) => (
<button <button
key={tool.name} key={tool.name}
type="button" type="button"
onClick={() => onSelectTool(tool)} onClick={onSelectTool}
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" 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"> <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 text-slate-400 group-hover:text-brand shrink-0" /> <Wrench className="w-3 h-3 shrink-0 text-slate-400 group-hover:text-brand" />
<span className="text-[11px] font-semibold truncate capitalize"> <span className="truncate">{tool.name}</span>
{tool.name.replace(/_/g, ' ')}
</span>
</div> </div>
{tool.parentSkill && ( {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} {tool.parentSkill}
</div> </div>
)} )}

View File

@@ -118,28 +118,25 @@ export default function AgentStudio() {
type="button" type="button"
onClick={() => setActiveTab(tab.id)} onClick={() => setActiveTab(tab.id)}
className={cn( 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 isActive
? 'text-brand font-bold' ? 'border-brand text-brand font-bold'
: 'text-slate-500 hover:text-slate-800' : 'border-transparent text-slate-500 hover:text-slate-800 hover:border-slate-300'
)} )}
> >
<span>{tab.label}</span> <span>{tab.label}</span>
{badgeCount !== null && ( {badgeCount !== null && (
<span <span
className={cn( 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 isActive
? 'bg-brand-tint text-brand' ? 'bg-brand text-white'
: 'bg-slate-100 text-slate-500' : 'bg-slate-100 text-slate-500'
)} )}
> >
{badgeCount} {badgeCount}
</span> </span>
)} )}
{isActive && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-brand rounded-full" />
)}
</button> </button>
); );
})} })}

View File

@@ -9,7 +9,6 @@ import {
Route, Route,
Check, Check,
Layers, Layers,
CheckCircle2,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -24,34 +23,34 @@ export default function SkillCard({ skill, onTest, onDetails }) {
icon: Pencil, icon: Pencil,
textColor: 'text-brand', textColor: 'text-brand',
bgColor: 'bg-brand-tint', bgColor: 'bg-brand-tint',
borderColor: 'border-brand/30', borderColor: 'border-brand/20',
}; };
case 'WORKFORCE': case 'WORKFORCE':
return { return {
icon: Users, icon: Users,
textColor: 'text-indigo-700', textColor: 'text-slate-700',
bgColor: 'bg-indigo-50/80', bgColor: 'bg-slate-100',
borderColor: 'border-indigo-200/80', borderColor: 'border-slate-200',
}; };
case 'LOGISTICS': case 'LOGISTICS':
return { return {
icon: Package, icon: Package,
textColor: 'text-emerald-700', textColor: 'text-emerald-700',
bgColor: 'bg-emerald-50/80', bgColor: 'bg-emerald-50',
borderColor: 'border-emerald-200/80', borderColor: 'border-emerald-200',
}; };
case 'DISPATCH': case 'DISPATCH':
return { return {
icon: Route, icon: Route,
textColor: 'text-rose-700', textColor: 'text-brand',
bgColor: 'bg-rose-50/80', bgColor: 'bg-brand-tint',
borderColor: 'border-rose-200/80', borderColor: 'border-brand/20',
}; };
default: default:
return { return {
icon: Layers, icon: Layers,
textColor: 'text-slate-700', textColor: 'text-slate-600',
bgColor: 'bg-slate-100', bgColor: 'bg-slate-50',
borderColor: 'border-slate-200', borderColor: 'border-slate-200',
}; };
} }
@@ -61,27 +60,27 @@ export default function SkillCard({ skill, onTest, onDetails }) {
const CategoryIcon = meta.icon; const CategoryIcon = meta.icon;
return ( 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"> <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">
{/* Top Header: Category Tag (Left) & Status Badge (Right) */}
<div> <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 <div
className={cn( 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.bgColor,
meta.textColor, meta.textColor,
meta.borderColor meta.borderColor
)} )}
> >
<CategoryIcon className="w-3.5 h-3.5" /> <CategoryIcon className="w-3 h-3" />
<span>{(skill.category || 'SKILL').toUpperCase()}</span> <span>{(skill.category || 'SKILL').toUpperCase()}</span>
</div> </div>
<div <div
className={cn( 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 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' : 'bg-slate-100 text-slate-500 border-slate-200'
)} )}
> >
@@ -97,27 +96,27 @@ export default function SkillCard({ skill, onTest, onDetails }) {
</div> </div>
{/* Skill Title */} {/* 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} {skill.title}
</h3> </h3>
{/* Skill Description */} {/* 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} {skill.description}
</p> </p>
{/* Prompt Trigger Pill (if present) */} {/* Prompt Trigger Pill (if present) */}
{skill.prompt && ( {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" /> <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> </div>
)} )}
{/* Scope / Integration Tag */} {/* Scope / Integration Tag */}
{skill.integration && ( {skill.integration && (
<div className="mt-3.5"> <div className="mt-3">
<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="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" /> <Layers className="w-3 h-3 text-slate-400" />
<span>{skill.integration}</span> <span>{skill.integration}</span>
</div> </div>
@@ -126,23 +125,23 @@ export default function SkillCard({ skill, onTest, onDetails }) {
</div> </div>
{/* Footer Action Bar */} {/* 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 <button
type="button" type="button"
onClick={() => onTest(skill)} 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> <span>Test in Playground</span>
</button> </button>
<button <button
type="button" type="button"
onClick={() => onDetails(skill)} 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> <span>Details</span>
<ArrowRight className="w-3.5 h-3.5" /> <ArrowRight className="w-3 h-3" />
</button> </button>
</div> </div>
</div> </div>

View File

@@ -59,9 +59,9 @@ export default function SkillCardsGrid({
const boardCount = skills.filter((s) => s.scope === 'board').length; const boardCount = skills.filter((s) => s.scope === 'board').length;
return ( return (
<div className="space-y-5"> <div className="space-y-4">
{/* 1. Scope Sub-tabs (Agent Skills / Board Skills) */} {/* 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 <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -69,26 +69,23 @@ export default function SkillCardsGrid({
setCategoryFilter('ALL'); setCategoryFilter('ALL');
}} }}
className={cn( 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' scopeTab === 'agent'
? 'text-brand font-bold' ? 'border-brand text-brand font-bold'
: 'text-slate-500 hover:text-slate-800' : 'border-transparent text-slate-500 hover:text-slate-900 hover:border-slate-300'
)} )}
> >
<span>Agent Skills</span> <span>Agent Skills</span>
<span <span
className={cn( 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' scopeTab === 'agent'
? 'bg-brand-tint text-brand' ? 'bg-brand text-white'
: 'bg-slate-100 text-slate-500' : 'bg-slate-100 text-slate-500'
)} )}
> >
{agentCount} {agentCount}
</span> </span>
{scopeTab === 'agent' && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-brand rounded-full" />
)}
</button> </button>
<button <button
@@ -98,30 +95,27 @@ export default function SkillCardsGrid({
setCategoryFilter('ALL'); setCategoryFilter('ALL');
}} }}
className={cn( 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' scopeTab === 'board'
? 'text-brand font-bold' ? 'border-brand text-brand font-bold'
: 'text-slate-500 hover:text-slate-800' : 'border-transparent text-slate-500 hover:text-slate-900 hover:border-slate-300'
)} )}
> >
<span>Board Skills</span> <span>Board Skills</span>
<span <span
className={cn( 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' scopeTab === 'board'
? 'bg-brand-tint text-brand' ? 'bg-brand text-white'
: 'bg-slate-100 text-slate-500' : 'bg-slate-100 text-slate-500'
)} )}
> >
{boardCount} {boardCount}
</span> </span>
{scopeTab === 'board' && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-brand rounded-full" />
)}
</button> </button>
</div> </div>
{/* 2. Global Search Bar */} {/* 2. Global Search Input (Sleek pill style matching reference) */}
<div className="relative"> <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" /> <Search className="w-4 h-4 text-slate-400 absolute left-4 top-1/2 -translate-y-1/2 pointer-events-none" />
<input <input
@@ -129,7 +123,7 @@ export default function SkillCardsGrid({
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search skills, capabilities or pages..." 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 && ( {searchQuery && (
<button <button
@@ -143,7 +137,7 @@ export default function SkillCardsGrid({
</div> </div>
{/* 3. Category Filter Pills */} {/* 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]) => { {Object.entries(categories).map(([cat, count]) => {
const isSelected = categoryFilter === cat; const isSelected = categoryFilter === cat;
const displayLabel = cat === 'ALL' ? 'All' : cat.charAt(0) + cat.slice(1).toLowerCase(); const displayLabel = cat === 'ALL' ? 'All' : cat.charAt(0) + cat.slice(1).toLowerCase();
@@ -154,17 +148,17 @@ export default function SkillCardsGrid({
type="button" type="button"
onClick={() => setCategoryFilter(cat)} onClick={() => setCategoryFilter(cat)}
className={cn( 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 isSelected
? 'bg-brand text-white shadow-xs' ? 'bg-brand text-white shadow-2xs font-semibold'
: 'bg-white border border-slate-200/80 text-slate-600 hover:bg-slate-50 hover:text-slate-900' : 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-50 hover:text-slate-900'
)} )}
> >
<span>{displayLabel}</span> <span>{displayLabel}</span>
<span <span
className={cn( className={cn(
'text-[11px] font-bold opacity-80', 'text-[10.5px]',
isSelected ? 'text-white' : 'text-slate-400' isSelected ? 'text-white/90 font-bold' : 'text-slate-400'
)} )}
> >
{count} {count}
@@ -174,39 +168,39 @@ export default function SkillCardsGrid({
})} })}
</div> </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 className="flex items-center justify-between pt-1 text-xs text-slate-500">
<div> <div>
<span className="font-bold text-slate-800"> <span className="font-semibold text-slate-800">
{filteredSkills.length} of {scopedSkills.length} capabilities {filteredSkills.length} of {scopedSkills.length} capabilities
</span>{' '} </span>{' '}
for this scope ·{' '} for this scope ·{' '}
<span className="font-bold text-brand">{enabledCount} enabled</span> <span className="font-semibold text-brand">{enabledCount} enabled</span>
</div> </div>
<button <button
type="button" type="button"
onClick={onNewSkill} 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> <span>New skill</span>
</button> </button>
</div> </div>
{/* 5. Responsive Skill Cards Grid */} {/* 5. Responsive Skill Cards Grid */}
{scopeTab === 'board' && filteredSkills.length === 0 ? ( {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"> <div className="p-10 rounded-2xl border border-dashed border-slate-200 bg-white/60 text-center space-y-2">
<Layers className="w-8 h-8 text-slate-400 mx-auto" /> <Layers className="w-7 h-7 text-slate-400 mx-auto" />
<div className="text-sm font-bold text-slate-700">No Board Skills Connected</div> <div className="text-sm font-semibold text-slate-700">No Board Skills Connected</div>
<p className="text-xs text-slate-500 max-w-sm mx-auto"> <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. None drawing on this agent's surfaces. Connect board skills to link live operational boards with this agent.
</p> </p>
</div> </div>
) : filteredSkills.length === 0 ? ( ) : 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" /> <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"> <p className="text-xs text-slate-500">
Try adjusting your search query or selecting a different category filter. Try adjusting your search query or selecting a different category filter.
</p> </p>
@@ -216,13 +210,13 @@ export default function SkillCardsGrid({
setSearchQuery(''); setSearchQuery('');
setCategoryFilter('ALL'); 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 Clear Filters
</button> </button>
</div> </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) => ( {filteredSkills.map((skill) => (
<SkillCard <SkillCard
key={skill.id} key={skill.id}

View File

@@ -135,7 +135,7 @@ export const DEFAULT_SKILLS = [
id: 'learning-analysis', id: 'learning-analysis',
title: 'Learning Analysis', title: 'Learning Analysis',
category: 'WORKFORCE', category: 'WORKFORCE',
categoryTheme: 'indigo', categoryTheme: 'slate',
scope: 'agent', scope: 'agent',
integration: 'MileTruth AI', integration: 'MileTruth AI',
enabled: true, enabled: true,

View 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([]);
});
});