diff --git a/src/api/config.js b/src/api/config.js index 0be3b88..e97c960 100644 --- a/src/api/config.js +++ b/src/api/config.js @@ -420,3 +420,71 @@ export function blockedOn(lead) { } return null } + +/** + * WHEN AN AGENT STOPS, WHAT DOES A PERSON PRESS? + * + * Agents stall. Not often, but they do, and for reasons nothing in this app + * controls: the model provider slows to ninety seconds a call or returns a 502, + * a thinking budget runs out mid-sentence, a late webhook wakes the wrong + * employee. Watched from the console every one of those looks identical — + * a lead that simply stops — and the screen kept promising it would "complete + * within two minutes" indefinitely. + * + * There is no retry button in the platform, and none of these agents can be + * woken directly. But every one of them is woken BY AN ACTIVITY, so performing + * that activity again wakes it again. That is what this table holds: for a + * lead sitting in an automated step, the activity a person can perform to make + * the stalled agent run once more. + * + * `by` is who to expect to move afterwards, so the button says what will + * happen rather than just what it does. + * + * The choice within Document Pending depends on HOW FAR the chain got, because + * three agents work that state in sequence and the one to restart is the one + * that did not finish. + */ +export function nudgeFor(stage, lead) { + if (!stage || !lead) return null + + switch (stage.uid) { + case 'zk-state-new': + return { uid: 'zk-act-qualify', label: 'Run the check again', by: 'Intake' } + + case 'zk-state-qualified': + return { uid: 'zk-act-contact', label: 'Record the call outcome', by: 'Engage' } + + case 'zk-state-contacted': + return { uid: 'zk-act-request-docs', label: 'Ask for the documents again', by: 'Engage' } + + case 'zk-state-docs': { + // Engage captures, the Advisor recommends, the rating engine prices — + // in that order, each woken by the one before. + const line = lead.product_line === 'sme_package' ? 'zk-act-capture-sme' : 'zk-act-capture-motor' + if (!lead.motor_idv && !lead.sme_value_at_risk) { + return { uid: line, label: 'Capture the risk again', by: 'Engage' } + } + if (!lead.ai_recommended_cover) { + // The Advisor is the ONE step nobody may perform by hand — it is + // permitted to ai_advisor alone. Re-performing the activity that wakes + // it is the only way back, and it is the reason this table exists. + return { uid: line, label: 'Wake the Advisor', by: 'the Advisor' } + } + if (!lead.quoted_premium) { + return { uid: 'zk-act-quote', label: 'Build the quote again', by: 'the rating engine' } + } + return null + } + + case 'zk-state-issued': + return { uid: 'zk-act-onboard', label: 'Close the file', by: 'Engage' } + + default: + return null + } +} + +/** How long an automated step may sit before the console stops reassuring and + * starts offering the way out. Generous: an employee wake plus a slow model + * can legitimately take three or four minutes. */ +export const STALL_AFTER_MS = 5 * 60 * 1000 diff --git a/src/screens/Lead.jsx b/src/screens/Lead.jsx index 3c8d9f4..56cce39 100644 --- a/src/screens/Lead.jsx +++ b/src/screens/Lead.jsx @@ -7,7 +7,7 @@ import AgentChip from '../components/AgentChip.jsx' import ClampText from '../components/ClampText.jsx' import Conversation from '../components/Conversation.jsx' import Timeline from '../components/Timeline.jsx' -import { APP_ID, DV_LEAD, PRODUCTS, STAGES, blockedOn, phaseOf } from '../api/config.js' +import { APP_ID, DV_LEAD, PRODUCTS, STAGES, STALL_AFTER_MS, blockedOn, nudgeFor, phaseOf } from '../api/config.js' import { AGENTS } from '../api/agents.js' import { actionsFor, rolesOf } from '../api/permissions.js' import { describeError } from '../api/errors.js' @@ -152,6 +152,11 @@ export default function Lead() { const [audit, setAudit] = useState(null) const [showChat, setShowChat] = useState(false) const [openAgent, setOpenAgent] = useState(null) + // A clock in state rather than Date.now() in the render body. Reading the + // wall clock while rendering is impure — React may render twice and get two + // answers — and it also means the "nothing for 6 minutes" counter would only + // move when something else happened to re-render the page. This ticks it. + const [now, setNow] = useState(() => Date.now()) const load = useCallback((quiet = false) => { if (!quiet) setErr(null) @@ -206,6 +211,11 @@ export default function Lead() { * moves while it is on screen. Poll quietly: no spinner, no skeleton, and not * at all while the tab is in the background. */ + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 20000) + return () => clearInterval(id) + }, []) + useEffect(() => { // Not while a form is open. A successful poll rewrites `actions`, and if the // lead has moved on the open form unmounts — taking whatever was typed into @@ -268,6 +278,17 @@ export default function Lead() { // the "in progress" strip below, which would otherwise keep promising that // something is happening for as long as the lead is left alone. const blocked = stage?.kind === 'auto' ? blockedOn(row) : null + // An automated step that has sat too long. Distinct from `blocked`, which is + // the workflow refusing for a stated reason — this is the agent not having + // come back, and the cause is usually outside this app entirely: a slow or + // failing model provider, a thinking budget that ran out mid-sentence, a + // wake that never arrived. The console cannot see any of that, so it reports + // the only thing it can know — nothing has happened for a while — and offers + // the way out rather than going on promising two minutes. + const stillFor = row.updated_at ? now - Date.parse(row.updated_at) : 0 + const stalled = !blocked && stage?.kind === 'auto' && stillFor > STALL_AFTER_MS + ? { mins: Math.round(stillFor / 60000), nudge: nudgeFor(stage, row) } + : null return ( @@ -398,6 +419,29 @@ export default function Lead() { + ) : stalled ? ( +
+
) : stage?.kind === 'customer' && stage.doing ? ( /* Not an agent working, and not a task of yours either — the lead is with someone outside the business. Without this the screen diff --git a/src/screens/screens.css b/src/screens/screens.css index f1abc67..11a45ff 100644 --- a/src/screens/screens.css +++ b/src/screens/screens.css @@ -1358,3 +1358,14 @@ color: var(--zk-muted); white-space: nowrap; } + +/* ---- an agent that has not come back ---- + Amber like `blocked`, because both need a person — but worded and coloured + apart from it: blocked means the workflow refused for a stated reason and + the fix is known; stalled means nobody knows, and the honest offer is to try + again. No pulse either way. */ +.doing--stalled { + border-color: var(--zk-amber-line); + background: var(--zk-amber-tint); +} +.doing--stalled strong { color: var(--zk-amber-ink); }