updates on the design and the onboarding things
This commit is contained in:
@@ -179,6 +179,25 @@ export const onboardClient = async (data) => {
|
|||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit an onboarded client and its login (owner login only). `authid` is the
|
||||||
|
* login's id from GET /admin/clients/onboarded. Only fields sent change;
|
||||||
|
* `password` resets the login's password when non-empty.
|
||||||
|
*/
|
||||||
|
export const updateOnboardedClient = async (authid, data) => {
|
||||||
|
const response = await doormileAxios.put(`/admin/clients/${encodeURIComponent(authid)}`, data);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a client's console login (owner login only). The company and its
|
||||||
|
* order history are kept; the client is marked Inactive if no login remains.
|
||||||
|
*/
|
||||||
|
export const deleteOnboardedClient = async (authid) => {
|
||||||
|
const response = await doormileAxios.delete(`/admin/clients/${encodeURIComponent(authid)}`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
/** Operating cities a new client can be placed in (applocations; owner login only). */
|
/** Operating cities a new client can be placed in (applocations; owner login only). */
|
||||||
export const getOnboardingCities = async () => {
|
export const getOnboardingCities = async () => {
|
||||||
const response = await doormileAxios.get('/admin/clients/cities');
|
const response = await doormileAxios.get('/admin/clients/cities');
|
||||||
|
|||||||
@@ -193,6 +193,20 @@ export const useOnboardedClients = (options) =>
|
|||||||
export const useOnboardingCities = (options) =>
|
export const useOnboardingCities = (options) =>
|
||||||
useQuery({ queryKey: [...KEYS.tenants, 'onboarding-cities'], queryFn: api.getOnboardingCities, staleTime: 300_000, ...options });
|
useQuery({ queryKey: [...KEYS.tenants, 'onboarding-cities'], queryFn: api.getOnboardingCities, staleTime: 300_000, ...options });
|
||||||
|
|
||||||
|
export const useUpdateOnboardedClient = () =>
|
||||||
|
useDoormileMutation({
|
||||||
|
mutationFn: ({ authid, data }) => api.updateOnboardedClient(authid, data),
|
||||||
|
invalidates: [KEYS.tenants],
|
||||||
|
successMessage: 'Client updated',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useDeleteOnboardedClient = () =>
|
||||||
|
useDoormileMutation({
|
||||||
|
mutationFn: (authid) => api.deleteOnboardedClient(authid),
|
||||||
|
invalidates: [KEYS.tenants],
|
||||||
|
successMessage: 'Client login removed',
|
||||||
|
});
|
||||||
|
|
||||||
// No toasts: the page shows its own success panel and inline errors.
|
// No toasts: the page shows its own success panel and inline errors.
|
||||||
export const useOnboardClient = () => {
|
export const useOnboardClient = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|||||||
242
src/pages/doormile/clients/ClientLoginDialogs.jsx
Normal file
242
src/pages/doormile/clients/ClientLoginDialogs.jsx
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { KeyRound, Pencil, Trash2 } from 'lucide-react';
|
||||||
|
import { Alert, Button, Field, Input, Modal, Switch } from '@/components/ds';
|
||||||
|
import { inputVariants } from '@/components/ui/input';
|
||||||
|
import { useDeleteOnboardedClient, useUpdateOnboardedClient } from '@/lib/doormileHooks';
|
||||||
|
import { generateClientPassword } from '@/lib/clientOnboarding';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit and Remove for one onboarded client login (a row of "Clients with a
|
||||||
|
* console login"). Both call owner-only endpoints: PUT and DELETE
|
||||||
|
* /admin/clients/:authid.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STATUSES = ['Active', 'Pending', 'Inactive'];
|
||||||
|
|
||||||
|
/** Only the fields that actually changed, so an edit never rewrites the rest. */
|
||||||
|
export function changedFields(row, form) {
|
||||||
|
const out = {};
|
||||||
|
const trim = (v) => String(v ?? '').trim();
|
||||||
|
if (trim(form.companyname) !== trim(row.tenantname)) out.companyname = trim(form.companyname);
|
||||||
|
if (trim(form.contactname) !== trim(row.contactname)) out.contactname = trim(form.contactname);
|
||||||
|
if (trim(form.email).toLowerCase() !== trim(row.loginemail).toLowerCase()) out.email = trim(form.email).toLowerCase();
|
||||||
|
if (form.phone.replace(/\D/g, '') !== String(row.primarycontact || '').replace(/\D/g, '')) out.phone = form.phone.replace(/\D/g, '');
|
||||||
|
if (form.status !== row.status) out.status = form.status;
|
||||||
|
if (Boolean(form.requiredeliveryotp) !== Boolean(row.requiredeliveryotp)) out.requiredeliveryotp = Boolean(form.requiredeliveryotp);
|
||||||
|
if (form.password) out.password = form.password;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateEdit(form, changes) {
|
||||||
|
const errors = {};
|
||||||
|
if ('companyname' in changes && changes.companyname.length < 2) errors.companyname = 'Enter the company name';
|
||||||
|
if ('contactname' in changes && changes.contactname.length < 2) errors.contactname = "Enter the contact person's name";
|
||||||
|
if ('email' in changes && !/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(changes.email)) errors.email = 'Enter a valid email address';
|
||||||
|
if ('phone' in changes && !/^[6-9]\d{9}$/.test(changes.phone)) errors.phone = 'Enter a 10-digit mobile number';
|
||||||
|
if (form.password && form.password.length < 8) errors.password = 'At least 8 characters';
|
||||||
|
if (form.password && form.password.length > 72) errors.password = 'At most 72 characters';
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formFrom(row) {
|
||||||
|
return {
|
||||||
|
companyname: row?.tenantname || '',
|
||||||
|
contactname: row?.contactname || '',
|
||||||
|
email: row?.loginemail || '',
|
||||||
|
phone: String(row?.primarycontact || '').replace(/\D/g, ''),
|
||||||
|
status: STATUSES.includes(row?.status) ? row.status : 'Active',
|
||||||
|
requiredeliveryotp: Boolean(row?.requiredeliveryotp),
|
||||||
|
password: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditClientModal({ row, onClose }) {
|
||||||
|
const update = useUpdateOnboardedClient();
|
||||||
|
const [form, setForm] = useState(formFrom(row));
|
||||||
|
const [errors, setErrors] = useState({});
|
||||||
|
const [serverError, setServerError] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setForm(formFrom(row));
|
||||||
|
setErrors({});
|
||||||
|
setServerError('');
|
||||||
|
setShowPassword(false);
|
||||||
|
}, [row]);
|
||||||
|
|
||||||
|
const set = (key) => (value) => {
|
||||||
|
setForm((f) => ({ ...f, [key]: value }));
|
||||||
|
setErrors((e) => ({ ...e, [key]: undefined }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const changes = row ? changedFields(row, form) : {};
|
||||||
|
const nothingChanged = Object.keys(changes).length === 0;
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
const nextErrors = validateEdit(form, changes);
|
||||||
|
setErrors(nextErrors);
|
||||||
|
setServerError('');
|
||||||
|
if (Object.keys(nextErrors).length || nothingChanged) return;
|
||||||
|
update.mutate(
|
||||||
|
{ authid: row.authid, data: changes },
|
||||||
|
{
|
||||||
|
onSuccess: (res) => {
|
||||||
|
if (res?.success !== false) onClose();
|
||||||
|
},
|
||||||
|
onError: (err) => setServerError(err?.response?.data?.message || 'The change was not saved.'),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={Boolean(row)}
|
||||||
|
onOpenChange={(open) => !open && onClose()}
|
||||||
|
title={`Edit ${row?.tenantname || 'client'}`}
|
||||||
|
description="Changes the client and its console login together."
|
||||||
|
icon={Pencil}
|
||||||
|
busy={update.isPending}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={onClose} disabled={update.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={save} loading={update.isPending} disabled={nothingChanged}>
|
||||||
|
Save changes
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{serverError && (
|
||||||
|
<Alert tone="destructive" className="mb-4" role="alert">
|
||||||
|
{serverError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<Field label="Company name" error={errors.companyname} className="sm:col-span-2">
|
||||||
|
<Input value={form.companyname} maxLength={120} onChange={(e) => set('companyname')(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Contact person" error={errors.contactname}>
|
||||||
|
<Input value={form.contactname} maxLength={80} onChange={(e) => set('contactname')(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Mobile number" error={errors.phone}>
|
||||||
|
<Input
|
||||||
|
value={form.phone}
|
||||||
|
inputMode="numeric"
|
||||||
|
onChange={(e) => set('phone')(e.target.value.replace(/\D/g, '').slice(0, 10))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Login email" error={errors.email} hint="Changing it changes what they sign in with">
|
||||||
|
<Input type="email" value={form.email} autoComplete="off" onChange={(e) => set('email')(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Status">
|
||||||
|
<select value={form.status} onChange={(e) => set('status')(e.target.value)} className={inputVariants()}>
|
||||||
|
{STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="New password"
|
||||||
|
error={errors.password}
|
||||||
|
hint="Leave empty to keep the current password"
|
||||||
|
htmlFor="edit-client-password"
|
||||||
|
className="sm:col-span-2"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="edit-client-password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={form.password}
|
||||||
|
maxLength={72}
|
||||||
|
autoComplete="new-password"
|
||||||
|
onChange={(e) => set('password')(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="flex flex-wrap gap-2 sm:col-span-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
set('password')(generateClientPassword());
|
||||||
|
setShowPassword(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<KeyRound className="mr-1.5 h-4 w-4" /> Generate new password
|
||||||
|
</Button>
|
||||||
|
{form.password && (
|
||||||
|
<Button type="button" size="sm" variant="ghost" onClick={() => setShowPassword((s) => !s)}>
|
||||||
|
{showPassword ? 'Hide' : 'Show'} password
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Field label="Require delivery OTP" inline className="sm:col-span-2">
|
||||||
|
<Switch checked={form.requiredeliveryotp} onCheckedChange={set('requiredeliveryotp')} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RemoveClientModal({ row, onClose }) {
|
||||||
|
const remove = useDeleteOnboardedClient();
|
||||||
|
const [typed, setTyped] = useState('');
|
||||||
|
const [serverError, setServerError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTyped('');
|
||||||
|
setServerError('');
|
||||||
|
}, [row]);
|
||||||
|
|
||||||
|
const confirmed = row && typed.trim().toLowerCase() === String(row.tenantname).trim().toLowerCase();
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
if (!confirmed) return;
|
||||||
|
remove.mutate(row.authid, {
|
||||||
|
onSuccess: (res) => {
|
||||||
|
if (res?.success !== false) onClose();
|
||||||
|
},
|
||||||
|
onError: (err) => setServerError(err?.response?.data?.message || 'The login was not removed.'),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={Boolean(row)}
|
||||||
|
onOpenChange={(open) => !open && onClose()}
|
||||||
|
title={`Remove ${row?.tenantname || 'client'}'s login`}
|
||||||
|
icon={Trash2}
|
||||||
|
busy={remove.isPending}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={onClose} disabled={remove.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={submit} loading={remove.isPending} disabled={!confirmed}>
|
||||||
|
Remove login
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{serverError && (
|
||||||
|
<Alert tone="destructive" className="mb-4" role="alert">
|
||||||
|
{serverError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<div className="space-y-3 text-body-sm text-ink-2">
|
||||||
|
<p>
|
||||||
|
<span className="font-semibold">{row?.loginemail}</span> will no longer be able to sign in to the console.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The client's orders, customers and pricing are kept. If this is its last login, the client is marked{' '}
|
||||||
|
<span className="font-semibold">Inactive</span>. A session already signed in ends when its token expires.
|
||||||
|
</p>
|
||||||
|
<Field label={`Type "${row?.tenantname || ''}" to confirm`}>
|
||||||
|
<Input value={typed} onChange={(e) => setTyped(e.target.value)} autoComplete="off" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { ArrowLeft, Building2, Copy, Eye, EyeOff, KeyRound, ShieldCheck, UserPlus } from 'lucide-react';
|
import { ArrowLeft, Building2, Copy, Eye, EyeOff, KeyRound, Pencil, ShieldCheck, Trash2, UserPlus } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Alert, Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage,
|
Alert, Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage,
|
||||||
BreadcrumbSeparator, Button, DataTable, EmptyState, Field, Input, PageHeader, Stack,
|
BreadcrumbSeparator, Button, DataTable, EmptyState, Field, Input, PageHeader, Stack,
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { inputVariants } from '@/components/ui/input';
|
import { inputVariants } from '@/components/ui/input';
|
||||||
import { useAuth } from '@/lib/AuthContext';
|
import { useAuth } from '@/lib/AuthContext';
|
||||||
import { useOnboardClient, useOnboardedClients, useOnboardingCities } from '@/lib/doormileHooks';
|
import { useOnboardClient, useOnboardedClients, useOnboardingCities } from '@/lib/doormileHooks';
|
||||||
|
import { EditClientModal, RemoveClientModal } from './ClientLoginDialogs';
|
||||||
import {
|
import {
|
||||||
canOnboardClients, generateClientPassword, onboardingUnavailableMessage, validateOnboarding,
|
canOnboardClients, generateClientPassword, onboardingUnavailableMessage, validateOnboarding,
|
||||||
} from '@/lib/clientOnboarding';
|
} from '@/lib/clientOnboarding';
|
||||||
@@ -54,6 +55,12 @@ function serverFieldOf(message) {
|
|||||||
|
|
||||||
function OnboardedList() {
|
function OnboardedList() {
|
||||||
const { data = [], isLoading } = useOnboardedClients();
|
const { data = [], isLoading } = useOnboardedClients();
|
||||||
|
const [editing, setEditing] = useState(null);
|
||||||
|
const [removing, setRemoving] = useState(null);
|
||||||
|
// Rows without an authid come from a backend older than Edit/Delete (it has
|
||||||
|
// neither the field nor the PUT/DELETE routes). Never send /clients/undefined:
|
||||||
|
// disable the actions and say what to do instead.
|
||||||
|
const backendTooOld = data.length > 0 && data.some((r) => r.authid == null);
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'tenantname', header: 'Client', accessor: (r) => r.tenantname },
|
{ key: 'tenantname', header: 'Client', accessor: (r) => r.tenantname },
|
||||||
{ key: 'loginemail', header: 'Console login', accessor: (r) => r.loginemail },
|
{ key: 'loginemail', header: 'Console login', accessor: (r) => r.loginemail },
|
||||||
@@ -64,17 +71,53 @@ function OnboardedList() {
|
|||||||
header: 'Login created',
|
header: 'Login created',
|
||||||
accessor: (r) => (r.logincreatedat ? new Date(r.logincreatedat).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }) : '—'),
|
accessor: (r) => (r.logincreatedat ? new Date(r.logincreatedat).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }) : '—'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: 'Actions',
|
||||||
|
align: 'right',
|
||||||
|
cell: (r) => (
|
||||||
|
<div className="flex justify-end gap-1.5">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={r.authid == null}
|
||||||
|
onClick={() => setEditing(r)}
|
||||||
|
aria-label={`Edit ${r.tenantname}`}
|
||||||
|
>
|
||||||
|
<Pencil className="mr-1 h-3.5 w-3.5" /> Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
disabled={r.authid == null}
|
||||||
|
onClick={() => setRemoving(r)}
|
||||||
|
aria-label={`Delete ${r.tenantname}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1 h-3.5 w-3.5" /> Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<Surface radius="xl" padding="lg">
|
<Surface radius="xl" padding="lg">
|
||||||
<h2 className="text-body font-semibold text-ink-1 mb-3">Clients with a console login</h2>
|
<h2 className="text-body font-semibold text-ink-1 mb-3">Clients with a console login</h2>
|
||||||
|
{backendTooOld && (
|
||||||
|
<Alert tone="warning" className="mb-3" role="alert" title="Edit and Delete are not available on this server yet">
|
||||||
|
The API server this console talks to is running an older backend build without the edit and delete
|
||||||
|
endpoints. Rebuild and redeploy doormile_backend from the current code, then reload this page.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rows={data}
|
rows={data}
|
||||||
getRowId={(row, i) => `${row.tenantid}-${row.loginemail}-${i}`}
|
getRowId={(row, i) => row.authid ?? `${row.tenantid}-${row.loginemail}-${i}`}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
emptyState={<EmptyState icon={Building2} size="sm" title="No client logins yet" />}
|
emptyState={<EmptyState icon={Building2} size="sm" title="No client logins yet" />}
|
||||||
/>
|
/>
|
||||||
|
<EditClientModal row={editing} onClose={() => setEditing(null)} />
|
||||||
|
<RemoveClientModal row={removing} onClose={() => setRemoving(null)} />
|
||||||
</Surface>
|
</Surface>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { useRunAiPlayground } from '../../../../lib/doormileHooks';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent Studio's Test tab. Each prompt is one real run on the backend
|
* Agent Studio's Test tab. Each prompt is one real run on the backend
|
||||||
* (POST /admin/ai/playground/run): Claude with the chosen skill's tools, where
|
* (POST /admin/ai/playground/run): the configured model with the chosen skill's tools, where
|
||||||
* read tools execute with personal data redacted and write/notify tools come
|
* read tools execute with personal data redacted and write/notify tools come
|
||||||
* back as proposals that are never executed. The trace shown is the server's,
|
* back as proposals that are never executed. The trace shown is the server's,
|
||||||
* step for step — nothing here is simulated.
|
* step for step — nothing here is simulated.
|
||||||
@@ -34,7 +34,7 @@ export function playgroundErrorMessage(err) {
|
|||||||
const status = err?.response?.status;
|
const status = err?.response?.status;
|
||||||
const data = err?.response?.data || {};
|
const data = err?.response?.data || {};
|
||||||
if (data.code === 'PLAYGROUND_NOT_CONFIGURED') {
|
if (data.code === 'PLAYGROUND_NOT_CONFIGURED') {
|
||||||
return 'The Test playground is not switched on for this server yet (no Claude client is configured). Nothing was run.';
|
return 'The Test playground is not switched on for this server yet (no model API key is configured). Nothing was run.';
|
||||||
}
|
}
|
||||||
if (status === 429) return data.message || 'Playground limit reached. Try again in a few minutes.';
|
if (status === 429) return data.message || 'Playground limit reached. Try again in a few minutes.';
|
||||||
if (status === 403) return 'Only an admin can run the Test playground.';
|
if (status === 403) return 'Only an admin can run the Test playground.';
|
||||||
@@ -128,7 +128,7 @@ export default function AgentPlayground({
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-bold text-slate-900 font-heading">Agent Test Playground</h3>
|
<h3 className="text-base font-bold text-slate-900 font-heading">Agent Test Playground</h3>
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-xs text-slate-500">
|
||||||
Run a prompt through Claude as {activeAgent?.name || 'this agent'} and see every tool call it makes.
|
Run a prompt through the AI model as {activeAgent?.name || 'this agent'} and see every tool call it makes.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,8 +153,8 @@ export default function AgentPlayground({
|
|||||||
>
|
>
|
||||||
<ShieldCheck className="w-4 h-4 mt-0.5 shrink-0 text-emerald-600" />
|
<ShieldCheck className="w-4 h-4 mt-0.5 shrink-0 text-emerald-600" />
|
||||||
<p>
|
<p>
|
||||||
Runs are real Claude calls on live data. Read tools execute with names, phones, addresses and notes removed
|
Runs are real AI model calls on live data. Read tools execute with names, phones, addresses and notes removed
|
||||||
before Claude sees them. Write and notify tools are <span className="font-bold">never executed</span> — they
|
before the model sees them. Write and notify tools are <span className="font-bold">never executed</span> — they
|
||||||
come back as proposals. Admin only, 10 runs per 10 minutes.
|
come back as proposals. Admin only, 10 runs per 10 minutes.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -205,7 +205,7 @@ export default function AgentPlayground({
|
|||||||
<div className="font-semibold text-sm">Playground session ready</div>
|
<div className="font-semibold text-sm">Playground session ready</div>
|
||||||
<p className="text-xs text-slate-400 max-w-sm">
|
<p className="text-xs text-slate-400 max-w-sm">
|
||||||
{canRun
|
{canRun
|
||||||
? `Type a prompt${selectedSkill ? ` for “${selectedSkill.title}”` : ''} to see which tools Claude calls and what they return.`
|
? `Type a prompt${selectedSkill ? ` for “${selectedSkill.title}”` : ''} to see which tools the model calls and what they return.`
|
||||||
: 'Only an admin can run the Test playground.'}
|
: 'Only an admin can run the Test playground.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -249,7 +249,7 @@ export default function AgentPlayground({
|
|||||||
{run.isPending && (
|
{run.isPending && (
|
||||||
<div className="flex items-center gap-2 text-red-400 animate-pulse font-sans text-xs py-2">
|
<div className="flex items-center gap-2 text-red-400 animate-pulse font-sans text-xs py-2">
|
||||||
<div className="w-3 h-3 border-2 border-red-400 border-t-transparent rounded-full animate-spin" />
|
<div className="w-3 h-3 border-2 border-red-400 border-t-transparent rounded-full animate-spin" />
|
||||||
<span>Running on Claude…</span>
|
<span>Running on the model…</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -125,17 +125,17 @@ export default function AgentStudio() {
|
|||||||
{/* Said on screen: what this page changes today, and what it does not. */}
|
{/* Said on screen: what this page changes today, and what it does not. */}
|
||||||
<div
|
<div
|
||||||
role="note"
|
role="note"
|
||||||
className="flex items-start gap-2.5 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-900"
|
className="flex items-start gap-3 rounded-2xl border border-amber-200/80 bg-gradient-to-r from-amber-50 to-orange-50 px-4 py-3.5 text-xs text-amber-900 shadow-sm"
|
||||||
>
|
>
|
||||||
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0 text-amber-600" />
|
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0 text-amber-500" />
|
||||||
<p>
|
<p className="leading-relaxed">
|
||||||
<span className="font-bold">Half wired.</span> Changes here are saved to the Doormile agent registry and
|
<span className="font-bold text-amber-800">Half wired.</span>{' '}
|
||||||
every change is audited. The Console Ops Agent's skills — the Exceptions early warnings and the
|
Changes here are saved to the Doormile agent registry and every change is audited. The Console Ops Agent's skills — the Exceptions early warnings and the
|
||||||
chat's “what needs attention” — run on these settings now. AI_engine's Dispatch,
|
chat's “what needs attention” — run on these settings now. AI_engine's Dispatch,
|
||||||
Exception, Express Dispatch and Customer agents pick up changes within about 30 seconds once its
|
Exception, Express Dispatch and Customer agents pick up changes within about 30 seconds once its
|
||||||
registry-reading build is deployed; until then, and whenever the registry cannot be reached, they run on
|
registry-reading build is deployed; until then, and whenever the registry cannot be reached, they run on
|
||||||
their environment defaults. Hub, Fleet and Route Optimizer are simulations. The Test tab runs real Claude
|
their environment defaults. Hub, Fleet and Route Optimizer are simulations. The Test tab runs real AI model
|
||||||
calls (reads redacted, writes only proposed) once the server has a Claude client; until then it says so.
|
calls (reads redacted, writes only proposed) once the server has a model API key; until then it says so.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -174,9 +174,10 @@ export default function AgentStudio() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* 1. Top Level Mode Tabs */}
|
{/* 1. Top Level Mode Tabs */}
|
||||||
<div className="flex items-center gap-8 border-b border-slate-200/80 text-xs sm:text-sm font-semibold">
|
<div className="flex items-center gap-1 p-1 bg-slate-100/80 rounded-2xl border border-slate-200/80">
|
||||||
{TOP_NAV_TABS.map((tab) => {
|
{TOP_NAV_TABS.map((tab) => {
|
||||||
const isActive = activeTab === tab.id;
|
const isActive = activeTab === tab.id;
|
||||||
|
const Icon = tab.icon;
|
||||||
const badgeCount =
|
const badgeCount =
|
||||||
tab.id === 'skills' ? agentSkills.length : tab.id === 'tools' ? model.tools.length : null;
|
tab.id === 'skills' ? agentSkills.length : tab.id === 'tools' ? model.tools.length : null;
|
||||||
|
|
||||||
@@ -186,18 +187,19 @@ export default function AgentStudio() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'pb-3.5 -mb-px transition-all cursor-pointer flex items-center gap-2 select-none border-b-2',
|
'flex items-center gap-1.5 px-3.5 py-2 rounded-xl text-xs font-semibold transition-all cursor-pointer select-none whitespace-nowrap',
|
||||||
isActive
|
isActive
|
||||||
? 'border-brand text-brand font-bold'
|
? 'bg-white text-slate-900 shadow-sm border border-slate-200/80'
|
||||||
: 'border-transparent text-slate-500 hover:text-slate-800 hover:border-slate-300'
|
: 'text-slate-500 hover:text-slate-800 hover:bg-slate-200/50'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<Icon className={cn('w-3.5 h-3.5', isActive ? 'text-brand' : 'text-slate-400')} />
|
||||||
<span>{tab.label}</span>
|
<span>{tab.label}</span>
|
||||||
{badgeCount !== null && (
|
{badgeCount !== null && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-2 py-0.5 rounded-full text-[11px] font-bold transition-colors',
|
'px-1.5 py-0.5 rounded-md text-[10px] font-bold transition-colors',
|
||||||
isActive ? 'bg-brand text-white' : 'bg-slate-100 text-slate-500'
|
isActive ? 'bg-brand/10 text-brand' : 'bg-slate-200 text-slate-500'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{badgeCount}
|
{badgeCount}
|
||||||
|
|||||||
@@ -9,67 +9,78 @@ import {
|
|||||||
Route,
|
Route,
|
||||||
Check,
|
Check,
|
||||||
Layers,
|
Layers,
|
||||||
|
CircleDot,
|
||||||
|
ChevronRight,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const CATEGORY_META = {
|
||||||
|
AUTHORING: {
|
||||||
|
icon: Pencil,
|
||||||
|
gradient: 'from-violet-500 to-purple-600',
|
||||||
|
bgGlow: 'bg-violet-50',
|
||||||
|
badgeBg: 'bg-violet-100 text-violet-700 border-violet-200',
|
||||||
|
accent: 'text-violet-600',
|
||||||
|
glow: 'hover:shadow-violet-100',
|
||||||
|
},
|
||||||
|
WORKFORCE: {
|
||||||
|
icon: Users,
|
||||||
|
gradient: 'from-blue-500 to-indigo-600',
|
||||||
|
bgGlow: 'bg-blue-50',
|
||||||
|
badgeBg: 'bg-blue-100 text-blue-700 border-blue-200',
|
||||||
|
accent: 'text-blue-600',
|
||||||
|
glow: 'hover:shadow-blue-100',
|
||||||
|
},
|
||||||
|
LOGISTICS: {
|
||||||
|
icon: Package,
|
||||||
|
gradient: 'from-emerald-500 to-teal-600',
|
||||||
|
bgGlow: 'bg-emerald-50',
|
||||||
|
badgeBg: 'bg-emerald-100 text-emerald-700 border-emerald-200',
|
||||||
|
accent: 'text-emerald-600',
|
||||||
|
glow: 'hover:shadow-emerald-100',
|
||||||
|
},
|
||||||
|
DISPATCH: {
|
||||||
|
icon: Route,
|
||||||
|
gradient: 'from-orange-500 to-rose-500',
|
||||||
|
bgGlow: 'bg-orange-50',
|
||||||
|
badgeBg: 'bg-orange-100 text-orange-700 border-orange-200',
|
||||||
|
accent: 'text-orange-600',
|
||||||
|
glow: 'hover:shadow-orange-100',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_META = {
|
||||||
|
icon: Layers,
|
||||||
|
gradient: 'from-slate-500 to-slate-600',
|
||||||
|
bgGlow: 'bg-slate-50',
|
||||||
|
badgeBg: 'bg-slate-100 text-slate-600 border-slate-200',
|
||||||
|
accent: 'text-slate-600',
|
||||||
|
glow: 'hover:shadow-slate-100',
|
||||||
|
};
|
||||||
|
|
||||||
export default function SkillCard({ skill, onTest, onDetails }) {
|
export default function SkillCard({ skill, onTest, onDetails }) {
|
||||||
const isEnabled = skill.enabled !== false;
|
const isEnabled = skill.enabled !== false;
|
||||||
|
const meta = CATEGORY_META[(skill.category || '').toUpperCase()] || DEFAULT_META;
|
||||||
// Icon and color mappings based on category
|
|
||||||
const getCategoryMeta = (cat) => {
|
|
||||||
switch ((cat || '').toUpperCase()) {
|
|
||||||
case 'AUTHORING':
|
|
||||||
return {
|
|
||||||
icon: Pencil,
|
|
||||||
textColor: 'text-brand',
|
|
||||||
bgColor: 'bg-brand-tint',
|
|
||||||
borderColor: 'border-brand/20',
|
|
||||||
};
|
|
||||||
case 'WORKFORCE':
|
|
||||||
return {
|
|
||||||
icon: Users,
|
|
||||||
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',
|
|
||||||
borderColor: 'border-emerald-200',
|
|
||||||
};
|
|
||||||
case 'DISPATCH':
|
|
||||||
return {
|
|
||||||
icon: Route,
|
|
||||||
textColor: 'text-brand',
|
|
||||||
bgColor: 'bg-brand-tint',
|
|
||||||
borderColor: 'border-brand/20',
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
icon: Layers,
|
|
||||||
textColor: 'text-slate-600',
|
|
||||||
bgColor: 'bg-slate-50',
|
|
||||||
borderColor: 'border-slate-200',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const meta = getCategoryMeta(skill.category);
|
|
||||||
const CategoryIcon = meta.icon;
|
const CategoryIcon = meta.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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>
|
|
||||||
{/* 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-2.5 py-0.5 rounded-full text-[10px] font-bold tracking-wider border',
|
'relative bg-white rounded-2xl border border-slate-200/80 shadow-sm transition-all duration-300 overflow-hidden cursor-pointer group',
|
||||||
meta.bgColor,
|
'hover:shadow-xl hover:shadow-slate-200/60 hover:-translate-y-0.5 hover:border-slate-300',
|
||||||
meta.textColor,
|
meta.glow
|
||||||
meta.borderColor
|
)}
|
||||||
|
>
|
||||||
|
{/* Top gradient accent bar */}
|
||||||
|
<div className={cn('h-1 w-full bg-gradient-to-r', meta.gradient)} />
|
||||||
|
|
||||||
|
<div className="p-5">
|
||||||
|
{/* Header row: category badge + status */}
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-4">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[10px] font-bold tracking-wider border',
|
||||||
|
meta.badgeBg
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CategoryIcon className="w-3 h-3" />
|
<CategoryIcon className="w-3 h-3" />
|
||||||
@@ -78,72 +89,93 @@ export default function SkillCard({ skill, onTest, onDetails }) {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10.5px] font-semibold border',
|
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[10px] font-bold border transition-colors',
|
||||||
isEnabled
|
isEnabled
|
||||||
? 'bg-brand-tint/70 text-brand border-brand/20'
|
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
||||||
: 'bg-slate-100 text-slate-500 border-slate-200'
|
: 'bg-slate-100 text-slate-500 border-slate-200'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isEnabled ? (
|
{isEnabled ? (
|
||||||
<>
|
<>
|
||||||
<Check className="w-3 h-3 stroke-[2.5]" />
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
<span>Added</span>
|
<span>Active</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
<CircleDot className="w-3 h-3" />
|
||||||
<span>Disabled</span>
|
<span>Disabled</span>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Skill Title */}
|
{/* Icon + Title */}
|
||||||
<h3 className="text-base font-bold text-slate-900 font-heading mt-3 tracking-tight">
|
<div className="flex items-start gap-3 mb-3">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-10 h-10 rounded-xl flex items-center justify-center shrink-0 bg-gradient-to-br shadow-sm',
|
||||||
|
meta.gradient
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CategoryIcon className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="text-sm font-bold text-slate-900 font-heading leading-tight tracking-tight">
|
||||||
{skill.title}
|
{skill.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
<p className="text-[11px] text-slate-500 mt-0.5 leading-relaxed line-clamp-2">
|
||||||
{/* Skill Description */}
|
|
||||||
<p className="text-xs text-slate-500 mt-1.5 leading-relaxed line-clamp-2">
|
|
||||||
{skill.description}
|
{skill.description}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Prompt Trigger Pill (if present) */}
|
{/* Prompt pill */}
|
||||||
{skill.prompt && (
|
{skill.prompt && (
|
||||||
<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">
|
<div className="mb-3 px-3 py-2 rounded-xl bg-slate-50 border border-slate-200/80 flex items-start gap-2">
|
||||||
<Sparkles className="w-3.5 h-3.5 text-brand shrink-0" />
|
<Sparkles className={cn('w-3 h-3 shrink-0 mt-0.5', meta.accent)} />
|
||||||
<span className="italic truncate font-medium">"{skill.prompt}"</span>
|
<span className={cn('text-[10.5px] italic truncate font-medium', meta.accent)}>
|
||||||
|
“{skill.prompt}”
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Scope / Integration Tag */}
|
{/* Integration tag */}
|
||||||
{skill.integration && (
|
{skill.integration && (
|
||||||
<div className="mt-3">
|
<div className="mb-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">
|
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md bg-slate-50 border border-slate-200 text-[10px] font-semibold text-slate-500">
|
||||||
<Layers className="w-3 h-3 text-slate-400" />
|
<Layers className="w-3 h-3" />
|
||||||
<span>{skill.integration}</span>
|
{skill.integration}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer Action Bar */}
|
{/* Footer */}
|
||||||
<div className="flex items-center justify-between pt-3.5 mt-3.5 border-t border-slate-100 text-xs">
|
<div className="flex items-center justify-between pt-3.5 mt-1 border-t border-slate-100">
|
||||||
<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 font-bold transition-colors cursor-pointer"
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 text-[11px] font-bold transition-all cursor-pointer',
|
||||||
|
'px-3 py-1.5 rounded-lg bg-slate-50 hover:bg-slate-100 text-slate-600 hover:text-slate-900 border border-slate-200'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Play className="w-3 h-3 fill-current" />
|
<Play className="w-3 h-3 fill-current" />
|
||||||
<span>Test in Playground</span>
|
<span>Test</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 font-medium transition-colors cursor-pointer"
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 text-[11px] font-semibold transition-all cursor-pointer group/btn',
|
||||||
|
meta.accent, 'hover:opacity-70'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<span>Details</span>
|
<span>Details</span>
|
||||||
<ArrowRight className="w-3 h-3" />
|
<ChevronRight className="w-3.5 h-3.5 group-hover/btn:translate-x-0.5 transition-transform" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,21 +117,21 @@ export default function SkillCardsGrid({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 2. Global Search Input (Sleek pill style matching reference) */}
|
{/* 2. Global Search Input */}
|
||||||
<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
|
||||||
type="text"
|
type="text"
|
||||||
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 integrations..."
|
||||||
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"
|
className="w-full h-11 bg-white border border-slate-200 rounded-2xl pl-11 pr-10 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:border-brand focus:ring-2 focus:ring-brand/10 shadow-sm hover:border-slate-300 transition-all"
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSearchQuery('')}
|
onClick={() => setSearchQuery('')}
|
||||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600 rounded-full cursor-pointer"
|
className="absolute right-3.5 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-700 rounded-full hover:bg-slate-100 transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
<X className="w-3.5 h-3.5" />
|
<X className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -150,17 +150,17 @@ export default function SkillCardsGrid({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCategoryFilter(cat)}
|
onClick={() => setCategoryFilter(cat)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-3.5 py-1 rounded-full text-xs font-medium transition-all whitespace-nowrap cursor-pointer flex items-center gap-1.5',
|
'px-3.5 py-1.5 rounded-xl text-xs font-semibold transition-all duration-200 whitespace-nowrap cursor-pointer flex items-center gap-1.5 border',
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-brand text-white shadow-2xs font-semibold'
|
? 'bg-slate-900 text-white border-slate-900 shadow-sm'
|
||||||
: 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-50 hover:text-slate-900'
|
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50 hover:border-slate-300 hover:text-slate-900'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span>{displayLabel}</span>
|
<span>{displayLabel}</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-[10.5px]',
|
'text-[10px] font-bold px-1.5 py-0.5 rounded-md',
|
||||||
isSelected ? 'text-white/90 font-bold' : 'text-slate-400'
|
isSelected ? 'bg-white/20 text-white' : 'bg-slate-100 text-slate-500'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{count}
|
{count}
|
||||||
@@ -170,14 +170,17 @@ export default function SkillCardsGrid({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 4. Capabilities Count & "+ New skill" Action Row */}
|
{/* 4. Stats + New Skill Row */}
|
||||||
<div className="flex items-center justify-between pt-1 text-xs text-slate-500">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div className="flex items-center gap-3">
|
||||||
<span className="font-semibold text-slate-800">
|
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-50 border border-slate-200 text-xs">
|
||||||
{filteredSkills.length} of {scopedSkills.length} capabilities
|
<span className="text-slate-500">Total</span>
|
||||||
</span>{' '}
|
<span className="font-bold text-slate-900">{filteredSkills.length}</span>
|
||||||
for this scope ·{' '}
|
</div>
|
||||||
<span className="font-semibold text-brand">{enabledCount} enabled</span>
|
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-emerald-50 border border-emerald-200 text-xs">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||||
|
<span className="text-emerald-700 font-semibold">{enabledCount} active</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -185,10 +188,10 @@ export default function SkillCardsGrid({
|
|||||||
onClick={onNewSkill}
|
onClick={onNewSkill}
|
||||||
disabled={!canCreate}
|
disabled={!canCreate}
|
||||||
title={canCreate ? undefined : createDisabledReason}
|
title={canCreate ? undefined : createDisabledReason}
|
||||||
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 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100 disabled:hover:bg-white"
|
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-slate-900 hover:bg-slate-800 text-white font-semibold text-xs shadow-sm transition-all active:scale-95 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<Plus className="w-3.5 h-3.5 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>
|
||||||
|
|
||||||
@@ -220,7 +223,7 @@ export default function SkillCardsGrid({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
{filteredSkills.map((skill) => (
|
{filteredSkills.map((skill) => (
|
||||||
<SkillCard
|
<SkillCard
|
||||||
key={skill.id}
|
key={skill.id}
|
||||||
|
|||||||
@@ -1,213 +1,351 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Wrench,
|
Wrench,
|
||||||
Search,
|
Search,
|
||||||
Code,
|
Code2,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
Eye,
|
||||||
|
Edit3,
|
||||||
|
Bell,
|
||||||
|
Database,
|
||||||
|
X,
|
||||||
|
Hash,
|
||||||
|
MapPin,
|
||||||
|
Layers,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export default function ToolRegistryView({ tools = [] }) {
|
/* ─── Kind config ─────────────────────────────────────────────────────────── */
|
||||||
const [search, setSearch] = useState('');
|
const KIND = {
|
||||||
const [expandedTool, setExpandedTool] = useState(null);
|
read: {
|
||||||
|
icon: Eye,
|
||||||
|
label: 'Read',
|
||||||
|
card: 'border-sky-200 bg-sky-50/40',
|
||||||
|
iconWrap: 'bg-sky-100 text-sky-600',
|
||||||
|
badge: 'bg-sky-100 text-sky-700 border-sky-200',
|
||||||
|
dot: 'bg-sky-500',
|
||||||
|
},
|
||||||
|
write: {
|
||||||
|
icon: Edit3,
|
||||||
|
label: 'Write',
|
||||||
|
card: 'border-violet-200 bg-violet-50/30',
|
||||||
|
iconWrap: 'bg-violet-100 text-violet-600',
|
||||||
|
badge: 'bg-violet-100 text-violet-700 border-violet-200',
|
||||||
|
dot: 'bg-violet-500',
|
||||||
|
},
|
||||||
|
notify: {
|
||||||
|
icon: Bell,
|
||||||
|
label: 'Notify',
|
||||||
|
card: 'border-amber-200 bg-amber-50/30',
|
||||||
|
iconWrap: 'bg-amber-100 text-amber-600',
|
||||||
|
badge: 'bg-amber-100 text-amber-700 border-amber-200',
|
||||||
|
dot: 'bg-amber-500',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const KIND_DEFAULT = {
|
||||||
|
icon: Database,
|
||||||
|
label: 'General',
|
||||||
|
card: 'border-slate-200 bg-white',
|
||||||
|
iconWrap: 'bg-slate-100 text-slate-500',
|
||||||
|
badge: 'bg-slate-100 text-slate-600 border-slate-200',
|
||||||
|
dot: 'bg-slate-400',
|
||||||
|
};
|
||||||
|
|
||||||
const filtered = tools.filter((t) => {
|
function kindOf(tool) {
|
||||||
const q = search.toLowerCase().trim();
|
return KIND[(tool.kind || '').toLowerCase()] || KIND_DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Stat chip ───────────────────────────────────────────────────────────── */
|
||||||
|
function StatChip({ label, value, tone = 'slate' }) {
|
||||||
|
const tones = {
|
||||||
|
slate: 'bg-slate-100 text-slate-700 border-slate-200',
|
||||||
|
amber: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||||
|
emerald: 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
||||||
|
sky: 'bg-sky-50 text-sky-700 border-sky-200',
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
!q ||
|
<div className={cn('flex flex-col items-center px-5 py-3 rounded-2xl border', tones[tone])}>
|
||||||
t.name.toLowerCase().includes(q) ||
|
<span className="text-2xl font-black leading-none">{value}</span>
|
||||||
t.description.toLowerCase().includes(q) ||
|
<span className="text-[10px] font-semibold uppercase tracking-widest mt-1 opacity-70">{label}</span>
|
||||||
(t.parentSkill || '').toLowerCase().includes(q) ||
|
</div>
|
||||||
(t.category || '').toLowerCase().includes(q)
|
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
/* ─── Parameter pill ──────────────────────────────────────────────────────── */
|
||||||
|
function ParamPill({ param }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="flex flex-col gap-1 p-3 rounded-xl bg-white border border-slate-200 shadow-sm">
|
||||||
{/* Header & Stats Bar */}
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-white p-5 rounded-3xl border border-slate-200/90 shadow-2xs">
|
<span className="font-mono font-bold text-[11px] text-brand">{param.name}</span>
|
||||||
<div>
|
<div className="flex items-center gap-1">
|
||||||
<h3 className="text-base font-bold text-slate-900 font-heading flex items-center gap-2">
|
<span className="font-mono text-[9px] px-1.5 py-0.5 rounded-md bg-slate-100 text-slate-600 border border-slate-200">
|
||||||
<Wrench className="w-4 h-4 text-brand" />
|
|
||||||
<span>Agent Tool Registry (Schema Engine)</span>
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-slate-500 mt-1">
|
|
||||||
Every capability a skill may call, from the Doormile agent registry. Write and messaging tools need an
|
|
||||||
operator's confirmation unless their agent is autonomous.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4 text-xs font-semibold">
|
|
||||||
<div className="px-3 py-1.5 rounded-xl bg-slate-50 border border-slate-200 text-slate-700">
|
|
||||||
<span className="text-slate-400 font-normal">Total Tools: </span>
|
|
||||||
<span className="font-bold text-slate-900">{tools.length}</span>
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1.5 rounded-xl bg-amber-50 border border-amber-200 text-amber-800">
|
|
||||||
<span className="text-amber-600/80 font-normal">Gated Actions: </span>
|
|
||||||
<span className="font-bold">
|
|
||||||
{tools.filter((t) => t.requiresConfirmation).length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search Input */}
|
|
||||||
<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
|
|
||||||
type="text"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
placeholder="Filter tools by function name, category or description..."
|
|
||||||
className="w-full bg-white border border-slate-200/90 rounded-2xl pl-11 pr-4 py-2.5 text-xs sm:text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:border-brand focus:ring-2 focus:ring-brand/15 shadow-2xs transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tools Table Container */}
|
|
||||||
<div className="bg-white rounded-3xl border border-slate-200/90 shadow-2xs overflow-hidden">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-left text-xs">
|
|
||||||
<thead className="bg-slate-50/80 border-b border-slate-200/80 text-[11px] font-bold text-slate-600 uppercase tracking-wider">
|
|
||||||
<tr>
|
|
||||||
<th className="py-3 px-4">Tool Function</th>
|
|
||||||
<th className="py-3 px-4">Kind</th>
|
|
||||||
<th className="py-3 px-4">Used by</th>
|
|
||||||
<th className="py-3 px-4">Safety Gate</th>
|
|
||||||
<th className="py-3 px-4 text-right">Arguments</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-100">
|
|
||||||
{filtered.map((tool) => {
|
|
||||||
const isExpanded = expandedTool === tool.name;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment key={tool.name}>
|
|
||||||
<tr
|
|
||||||
onClick={() =>
|
|
||||||
setExpandedTool(isExpanded ? null : tool.name)
|
|
||||||
}
|
|
||||||
className={cn(
|
|
||||||
'hover:bg-slate-50/80 transition-colors cursor-pointer select-none',
|
|
||||||
isExpanded && 'bg-brand-tint/30'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<td className="py-3 px-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{isExpanded ? (
|
|
||||||
<ChevronDown className="w-3.5 h-3.5 text-brand" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="w-3.5 h-3.5 text-slate-400" />
|
|
||||||
)}
|
|
||||||
<span className="font-mono font-bold text-slate-900 text-xs">
|
|
||||||
{tool.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-[11px] text-slate-500 mt-0.5 pl-5 line-clamp-1">
|
|
||||||
{tool.description}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="py-3 px-4">
|
|
||||||
<span className="inline-flex px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-slate-100 text-slate-700">
|
|
||||||
{tool.category || 'General'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="py-3 px-4 font-medium text-slate-700">
|
|
||||||
{tool.parentSkill || '—'}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="py-3 px-4">
|
|
||||||
{/* It read "Autonomous" for every ungated tool, which
|
|
||||||
described a read-only lookup as an agent acting on
|
|
||||||
its own. Ungated tools are reads or internal events. */}
|
|
||||||
{tool.requiresConfirmation ? (
|
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-50 text-amber-800 border border-amber-200">
|
|
||||||
<ShieldAlert className="w-3 h-3" />
|
|
||||||
Confirmation Required
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-50 text-emerald-700 border border-emerald-200">
|
|
||||||
<ShieldCheck className="w-3 h-3" />
|
|
||||||
No confirmation needed
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="py-3 px-4 text-right">
|
|
||||||
<span className="px-2.5 py-1 rounded-xl bg-slate-100 text-slate-700 font-bold text-[11px]">
|
|
||||||
{tool.parameters?.length || 0} params
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
{/* Expandable JSONSchema Parameters View */}
|
|
||||||
{isExpanded && (
|
|
||||||
<tr className="bg-slate-50/90">
|
|
||||||
<td colSpan={5} className="p-4 pl-9 space-y-3">
|
|
||||||
{(tool.target || tool.implementedAt) && (
|
|
||||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[11px]">
|
|
||||||
<dt className="font-semibold text-slate-500">Touches</dt>
|
|
||||||
<dd className="font-mono text-slate-800 break-all">{tool.target || '—'}</dd>
|
|
||||||
<dt className="font-semibold text-slate-500">Implemented at</dt>
|
|
||||||
<dd className="font-mono text-slate-800 break-all">{tool.implementedAt || '—'}</dd>
|
|
||||||
</dl>
|
|
||||||
)}
|
|
||||||
{(!tool.parameters || tool.parameters.length === 0) && (
|
|
||||||
<p className="text-[11px] text-slate-500">
|
|
||||||
No parameters are pinned down for this tool yet — its input is open.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="text-xs font-bold text-slate-800 flex items-center gap-2">
|
|
||||||
<Code className="w-3.5 h-3.5 text-brand" />
|
|
||||||
<span>Tool Parameters Schema (OpenAI / Claude JSONSchema Specification)</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
{tool.parameters?.map((param) => (
|
|
||||||
<div
|
|
||||||
key={param.name}
|
|
||||||
className="p-3 bg-white rounded-xl border border-slate-200 text-xs space-y-1"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="font-mono font-bold text-brand">
|
|
||||||
{param.name}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded-md bg-slate-100 text-slate-600">
|
|
||||||
{param.type}
|
{param.type}
|
||||||
</span>
|
</span>
|
||||||
{param.required && (
|
{param.required && (
|
||||||
<span className="text-[9px] font-bold text-rose-600 bg-rose-50 px-1 rounded">
|
<span className="text-[9px] font-bold text-rose-600 bg-rose-50 px-1.5 py-0.5 rounded-md border border-rose-200">
|
||||||
required
|
req
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-slate-600">
|
{param.description && (
|
||||||
{param.description}
|
<p className="text-[10.5px] text-slate-500 leading-relaxed">{param.description}</p>
|
||||||
</p>
|
)}
|
||||||
{param.default !== undefined && (
|
{param.default !== undefined && (
|
||||||
<div className="text-[10px] text-slate-400 font-mono">
|
<div className="text-[9.5px] font-mono text-slate-400 bg-slate-50 px-2 py-0.5 rounded-md mt-0.5">
|
||||||
default: {String(param.default)}
|
default: {String(param.default)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
);
|
||||||
})}
|
}
|
||||||
</tbody>
|
|
||||||
</table>
|
/* ─── Tool Card ───────────────────────────────────────────────────────────── */
|
||||||
|
function ToolCard({ tool }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const k = kindOf(tool);
|
||||||
|
const KindIcon = k.icon;
|
||||||
|
const hasParams = tool.parameters && tool.parameters.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-2xl border transition-all duration-200 overflow-hidden group',
|
||||||
|
k.card,
|
||||||
|
open ? 'shadow-md' : 'shadow-sm hover:shadow-md hover:-translate-y-px'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Card header — always visible */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="w-full text-left p-4 flex items-start gap-3 cursor-pointer"
|
||||||
|
>
|
||||||
|
{/* Kind icon */}
|
||||||
|
<div className={cn('w-9 h-9 rounded-xl flex items-center justify-center shrink-0 shadow-sm', k.iconWrap)}>
|
||||||
|
<KindIcon className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name + description */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||||
|
<span className="font-mono font-black text-xs text-slate-900 tracking-tight">{tool.name}</span>
|
||||||
|
|
||||||
|
{/* Kind badge */}
|
||||||
|
<span className={cn('px-2 py-0.5 rounded-lg text-[9.5px] font-bold border capitalize', k.badge)}>
|
||||||
|
{k.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Safety */}
|
||||||
|
{tool.requiresConfirmation ? (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-lg text-[9.5px] font-bold bg-rose-50 text-rose-600 border border-rose-200">
|
||||||
|
<ShieldAlert className="w-2.5 h-2.5" />
|
||||||
|
Gated
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-lg text-[9.5px] font-bold bg-emerald-50 text-emerald-700 border border-emerald-200">
|
||||||
|
<ShieldCheck className="w-2.5 h-2.5" />
|
||||||
|
Open
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Params count */}
|
||||||
|
{hasParams && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-lg text-[9.5px] font-bold bg-slate-100 text-slate-600 border border-slate-200">
|
||||||
|
<Hash className="w-2.5 h-2.5" />
|
||||||
|
{tool.parameters.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[11px] text-slate-500 leading-relaxed line-clamp-1">{tool.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expand chevron */}
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
'w-4 h-4 text-slate-400 shrink-0 mt-1 transition-transform duration-200',
|
||||||
|
open && 'rotate-180 text-brand'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Expandable detail panel */}
|
||||||
|
{open && (
|
||||||
|
<div className="px-4 pb-4 pt-1 border-t border-black/5 space-y-4">
|
||||||
|
{/* Meta grid */}
|
||||||
|
{(tool.target || tool.implementedAt || tool.parentSkill) && (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
{tool.target && (
|
||||||
|
<div className="flex items-start gap-2 p-2.5 rounded-xl bg-white/70 border border-slate-200">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-slate-400 mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] font-bold text-slate-400 uppercase tracking-wider">Touches</div>
|
||||||
|
<div className="font-mono text-[11px] text-slate-800 mt-0.5">{tool.target}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{tool.implementedAt && (
|
||||||
|
<div className="flex items-start gap-2 p-2.5 rounded-xl bg-white/70 border border-slate-200">
|
||||||
|
<Layers className="w-3.5 h-3.5 text-slate-400 mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] font-bold text-slate-400 uppercase tracking-wider">Implemented at</div>
|
||||||
|
<div className="font-mono text-[10px] text-slate-700 mt-0.5 break-all">{tool.implementedAt}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Parameters */}
|
||||||
|
{hasParams ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-1.5 text-[10px] font-bold text-slate-600 uppercase tracking-wider">
|
||||||
|
<Code2 className="w-3.5 h-3.5 text-brand" />
|
||||||
|
Parameters
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
{tool.parameters.map((p) => <ParamPill key={p.name} param={p} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-[11px] text-slate-400 italic">No parameters — input is open-ended.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Main view ───────────────────────────────────────────────────────────── */
|
||||||
|
export default function ToolRegistryView({ tools = [] }) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [kindFilter, setKindFilter] = useState('all');
|
||||||
|
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const c = { all: tools.length };
|
||||||
|
tools.forEach((t) => {
|
||||||
|
const k = (t.kind || 'read').toLowerCase();
|
||||||
|
c[k] = (c[k] || 0) + 1;
|
||||||
|
});
|
||||||
|
return c;
|
||||||
|
}, [tools]);
|
||||||
|
|
||||||
|
const kindTabs = ['all', ...Object.keys(KIND).filter((k) => counts[k])];
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = search.toLowerCase().trim();
|
||||||
|
return tools.filter((t) => {
|
||||||
|
const matchesKind = kindFilter === 'all' || (t.kind || 'read').toLowerCase() === kindFilter;
|
||||||
|
const matchesSearch =
|
||||||
|
!q ||
|
||||||
|
t.name.toLowerCase().includes(q) ||
|
||||||
|
t.description.toLowerCase().includes(q) ||
|
||||||
|
(t.parentSkill || '').toLowerCase().includes(q) ||
|
||||||
|
(t.target || '').toLowerCase().includes(q);
|
||||||
|
return matchesKind && matchesSearch;
|
||||||
|
});
|
||||||
|
}, [tools, kindFilter, search]);
|
||||||
|
|
||||||
|
const gatedCount = tools.filter((t) => t.requiresConfirmation).length;
|
||||||
|
const openCount = tools.length - gatedCount;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* ── Stats row ─────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex flex-wrap items-stretch gap-3">
|
||||||
|
<StatChip label="Total Tools" value={tools.length} tone="slate" />
|
||||||
|
<StatChip label="Read" value={counts.read || 0} tone="sky" />
|
||||||
|
<StatChip label="Write / Notify" value={(counts.write || 0) + (counts.notify || 0)} tone="amber" />
|
||||||
|
<StatChip label="Gated" value={gatedCount} tone="amber" />
|
||||||
|
<StatChip label="Open" value={openCount} tone="emerald" />
|
||||||
|
|
||||||
|
{/* Right-side header text */}
|
||||||
|
<div className="flex-1 min-w-[200px] flex flex-col justify-center pl-2">
|
||||||
|
<h3 className="text-sm font-bold text-slate-900 font-heading flex items-center gap-2">
|
||||||
|
<Wrench className="w-4 h-4 text-brand" />
|
||||||
|
Agent Tool Registry
|
||||||
|
</h3>
|
||||||
|
<p className="text-[11px] text-slate-500 mt-0.5 leading-relaxed">
|
||||||
|
Every capability a skill may call. Write & notify tools need operator confirmation unless the agent is autonomous.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Search + Kind filter ───────────────────────────────────────── */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search by name, target system, or description…"
|
||||||
|
className="w-full h-10 bg-white border border-slate-200 rounded-xl pl-10 pr-9 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:border-brand focus:ring-2 focus:ring-brand/10 shadow-sm transition-all"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearch('')}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 rounded-full text-slate-400 hover:text-slate-700 hover:bg-slate-100 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Kind filter tabs */}
|
||||||
|
<div className="flex items-center gap-1 bg-slate-100 rounded-xl p-1 shrink-0">
|
||||||
|
{kindTabs.map((k) => {
|
||||||
|
const meta = KIND[k];
|
||||||
|
const isActive = kindFilter === k;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setKindFilter(k)}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 rounded-lg text-xs font-semibold transition-all cursor-pointer capitalize flex items-center gap-1.5',
|
||||||
|
isActive ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-800'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{meta && <span className={cn('w-1.5 h-1.5 rounded-full', meta.dot)} />}
|
||||||
|
{k === 'all' ? 'All' : meta?.label || k}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-[10px] font-bold',
|
||||||
|
isActive ? 'text-slate-600' : 'text-slate-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{counts[k] || 0}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Tool Cards ────────────────────────────────────────────────── */}
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="p-16 rounded-2xl border border-dashed border-slate-200 bg-slate-50 text-center">
|
||||||
|
<Wrench className="w-8 h-8 text-slate-300 mx-auto mb-3" />
|
||||||
|
<p className="text-sm font-semibold text-slate-500">No tools match your filter</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setSearch(''); setKindFilter('all'); }}
|
||||||
|
className="mt-3 text-xs text-brand font-semibold hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
|
{filtered.map((tool) => <ToolCard key={tool.name} tool={tool} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ describe('Agent Studio page', () => {
|
|||||||
// The engine reads it only once its Phase 5 build is deployed; say so, not "live".
|
// The engine reads it only once its Phase 5 build is deployed; say so, not "live".
|
||||||
expect(note).toHaveTextContent(/once its registry-reading build is deployed/);
|
expect(note).toHaveTextContent(/once its registry-reading build is deployed/);
|
||||||
expect(note).toHaveTextContent(/environment defaults/);
|
expect(note).toHaveTextContent(/environment defaults/);
|
||||||
expect(note).toHaveTextContent(/The Test tab runs real Claude calls/);
|
expect(note).toHaveTextContent(/The Test tab runs real AI model calls/);
|
||||||
expect(note).not.toHaveTextContent(/The Test tab is a simulation/);
|
expect(note).not.toHaveTextContent(/The Test tab is a simulation/);
|
||||||
// Since Phase 3 the console skills DO run on these settings; the note must not say otherwise.
|
// Since Phase 3 the console skills DO run on these settings; the note must not say otherwise.
|
||||||
expect(note).toHaveTextContent(/run on these settings now/);
|
expect(note).toHaveTextContent(/run on these settings now/);
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ jest.mock('@/api/doormile', () => ({
|
|||||||
onboardClient: jest.fn(),
|
onboardClient: jest.fn(),
|
||||||
getOnboardedClients: jest.fn(),
|
getOnboardedClients: jest.fn(),
|
||||||
getOnboardingCities: jest.fn(),
|
getOnboardingCities: jest.fn(),
|
||||||
|
updateOnboardedClient: jest.fn(),
|
||||||
|
deleteOnboardedClient: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@/api/doormile/notify', () => ({
|
||||||
|
OpenToast: jest.fn(),
|
||||||
|
messageOf: (err, fallback = 'Something went wrong') => err?.response?.data?.message || err?.message || fallback,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let mockAuth = { user: { email: 'admin@doormile.com', role: 'admin', tenantid: null }, isClient: false };
|
let mockAuth = { user: { email: 'admin@doormile.com', role: 'admin', tenantid: null }, isClient: false };
|
||||||
@@ -67,8 +74,79 @@ beforeEach(() => {
|
|||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
api.getOnboardingCities.mockResolvedValue(CITIES);
|
api.getOnboardingCities.mockResolvedValue(CITIES);
|
||||||
api.getOnboardedClients.mockResolvedValue([
|
api.getOnboardedClients.mockResolvedValue([
|
||||||
{ tenantid: 12, tenantname: 'Older Client', primaryemail: 'a@old.example', primarycontact: '9000000000', status: 'Active', loginemail: 'a@old.example', loginrole: 'manager', logincreatedat: '2026-09-28T10:00:00Z' },
|
{ authid: 31, tenantid: 12, tenantname: 'Older Client', primaryemail: 'a@old.example', primarycontact: '9000000000', status: 'Active', requiredeliveryotp: false, contactname: 'Old Contact', loginemail: 'a@old.example', loginrole: 'manager', logincreatedat: '2026-09-28T10:00:00Z' },
|
||||||
]);
|
]);
|
||||||
|
api.updateOnboardedClient.mockResolvedValue({ success: true, data: {} });
|
||||||
|
api.deleteOnboardedClient.mockResolvedValue({ success: true, data: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edit and Delete on a client login', () => {
|
||||||
|
it('shows Edit and Delete on each row', async () => {
|
||||||
|
renderPage();
|
||||||
|
expect(await screen.findByRole('button', { name: 'Edit Older Client' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Delete Older Client' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends only the fields that changed', async () => {
|
||||||
|
renderPage();
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'Edit Older Client' }));
|
||||||
|
const dialog = await screen.findByRole('dialog');
|
||||||
|
const save = screen.getByRole('button', { name: /Save changes/ });
|
||||||
|
expect(save).toBeDisabled(); // nothing changed yet
|
||||||
|
fireEvent.change(dialog.querySelector('select'), { target: { value: 'Inactive' } });
|
||||||
|
fireEvent.change(screen.getAllByLabelText(/Mobile number/)[1], { target: { value: '9123456780' } });
|
||||||
|
fireEvent.click(save);
|
||||||
|
await waitFor(() => expect(api.updateOnboardedClient).toHaveBeenCalledWith(31, { phone: '9123456780', status: 'Inactive' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets the password only when one is entered, and checks its length', async () => {
|
||||||
|
renderPage();
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'Edit Older Client' }));
|
||||||
|
await screen.findByRole('dialog');
|
||||||
|
fireEvent.change(document.getElementById('edit-client-password'), { target: { value: 'short' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Save changes/ }));
|
||||||
|
await waitFor(() => expect(document.getElementById('edit-client-password-error')).toHaveTextContent('At least 8 characters'));
|
||||||
|
expect(api.updateOnboardedClient).not.toHaveBeenCalled();
|
||||||
|
fireEvent.change(document.getElementById('edit-client-password'), { target: { value: 'new-pass-123' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Save changes/ }));
|
||||||
|
await waitFor(() => expect(api.updateOnboardedClient).toHaveBeenCalledWith(31, { password: 'new-pass-123' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a server conflict in the dialog', async () => {
|
||||||
|
api.updateOnboardedClient.mockRejectedValue({ response: { status: 409, data: { message: 'this email already has a console login' } } });
|
||||||
|
renderPage();
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'Edit Older Client' }));
|
||||||
|
await screen.findByRole('dialog');
|
||||||
|
fireEvent.change(screen.getAllByLabelText(/Login email/)[1], { target: { value: 'taken@x.example' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Save changes/ }));
|
||||||
|
expect(await screen.findByText('this email already has a console login')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never calls /clients/undefined when the backend is too old to send ids', async () => {
|
||||||
|
api.getOnboardedClients.mockResolvedValue([
|
||||||
|
{ tenantid: 12, tenantname: 'Older Client', primarycontact: '9000000000', status: 'Active', loginemail: 'a@old.example', loginrole: 'manager' },
|
||||||
|
]);
|
||||||
|
renderPage();
|
||||||
|
const edit = await screen.findByRole('button', { name: 'Edit Older Client' });
|
||||||
|
expect(edit).toBeDisabled();
|
||||||
|
expect(screen.getByRole('button', { name: 'Delete Older Client' })).toBeDisabled();
|
||||||
|
expect(screen.getByText('Edit and Delete are not available on this server yet')).toBeInTheDocument();
|
||||||
|
fireEvent.click(edit);
|
||||||
|
expect(api.updateOnboardedClient).not.toHaveBeenCalled();
|
||||||
|
expect(api.deleteOnboardedClient).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes only after the company name is typed', async () => {
|
||||||
|
renderPage();
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'Delete Older Client' }));
|
||||||
|
await screen.findByRole('dialog');
|
||||||
|
const remove = screen.getByRole('button', { name: /Remove login/ });
|
||||||
|
expect(remove).toBeDisabled();
|
||||||
|
fireEvent.change(screen.getByLabelText(/to confirm/), { target: { value: 'older client' } });
|
||||||
|
expect(remove).not.toBeDisabled();
|
||||||
|
fireEvent.click(remove);
|
||||||
|
await waitFor(() => expect(api.deleteOnboardedClient).toHaveBeenCalledWith(31));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Client onboarding page', () => {
|
describe('Client onboarding page', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user