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',
'\.(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',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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