Compare commits

..

No commits in common. "93baba3fa19ab2a35df9231c77cbf3887de66f6e" and "f4c67310cbbfff276f6ca47df38d3d7ff8038e85" have entirely different histories.

4 changed files with 21 additions and 161 deletions

View File

@ -420,71 +420,3 @@ export function blockedOn(lead) {
} }
return null 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

View File

@ -11,31 +11,18 @@ import './Conversation.css'
* right. * right.
* *
* WHAT IT CAN AND CANNOT SHOW, said plainly at the foot of the panel rather * WHAT IT CAN AND CANNOT SHOW, said plainly at the foot of the panel rather
* than left for someone to discover. The console can only show what the * than left for someone to discover. Every message the workflow RECORDS is
* workflow RECORDS, so anything sent from inside a trigger node and never * here: the customer's replies (customer_reply) and the replies written back
* written to a field is invisible here however certainly the customer received * (customer_answer). The three automatic sends are not. The quote goes out as
* it. The acceptance confirmation used to be exactly that the panel showed * a registered WhatsApp template, and the acknowledgement and the acceptance
* nothing after "I confirm", which read as though we had ignored somebody * confirmation are composed inside trigger nodes and never written to a field.
* agreeing to buy so 90 writes it into customer_answer and it appears. * A thread that quietly omitted them would be worse than one that says which
* * parts it holds.
* Two sends remain absent by choice: the quote, which is a registered WhatsApp
* template rather than text we compose, and the "I have your message" receipt,
* which would put a line between every question and its answer.
*
* A thread that quietly omitted any of this would be worse than one that says
* which parts it holds.
*/ */
function turnsFrom(rows) { function turnsFrom(rows) {
if (!Array.isArray(rows)) return [] if (!Array.isArray(rows)) return []
const out = [] const out = []
// Sorted on (timestamp, id). One submission writes three rows in the same const ordered = [...rows].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)))
// second the trigger commit, its repeat, and the settle so a timestamp
// alone leaves their order to the sort's stability and puts a reply before
// the message it answers whenever the two land in the same second.
const ordered = [...rows].sort((a, b) => {
const t = String(a.created_at).localeCompare(String(b.created_at))
return t !== 0 ? t : (Number(a.id) || 0) - (Number(b.id) || 0)
})
for (const r of ordered) { for (const r of ordered) {
const fields = Array.isArray(r.fields) && r.fields.length const fields = Array.isArray(r.fields) && r.fields.length
? r.fields ? r.fields
@ -48,17 +35,15 @@ function turnsFrom(rows) {
if (base === 'customer_answer') out.push({ side: 'us', text: v, at: r.created_at, key: r.id + '-out' }) if (base === 'customer_answer') out.push({ side: 'us', text: v, at: r.created_at, key: r.id + '-out' })
} }
} }
// One submission is recorded three times the trigger commit, its repeat, // One submission can be recorded more than once (the trigger commit and the
// and the settle so the same sentence would print three times. // settle both carry the payload), so the same sentence would print twice.
// // De-duped on side + text rather than on the row id for that reason.
// De-duped against the PREVIOUS turn only, not against the whole thread. A const seen = new Set()
// customer who asks the same thing twice because the first went unanswered return out.filter((t) => {
// has said it twice, and a thread that silently showed it once would hide const k = t.side + ' ' + t.text
// exactly the impatience an operator needs to see. Only an immediate repeat if (seen.has(k)) return false
// of the same side and the same words is the platform talking to itself. seen.add(k)
return out.filter((t, i) => { return true
const prev = out[i - 1]
return !(prev && prev.side === t.side && prev.text === t.text)
}) })
} }
@ -120,11 +105,9 @@ export default function Conversation({ rows, name, onClose }) {
</div> </div>
<p className="conv__note"> <p className="conv__note">
Shows the customer&apos;s messages and everything written back to them, Shows the customer&apos;s messages and the replies written back. The quote
including the acceptance confirmation. Two automatic sends are not here: itself, the read receipt and the acceptance confirmation are sent
the quote, which goes out as a registered WhatsApp template rather than automatically and are not recorded as text, so they do not appear here.
text we compose, and the &ldquo;I have your message&rdquo; receipt sent
before anything has read it.
</p> </p>
</div> </div>
</div> </div>

View File

@ -7,7 +7,7 @@ import AgentChip from '../components/AgentChip.jsx'
import ClampText from '../components/ClampText.jsx' import ClampText from '../components/ClampText.jsx'
import Conversation from '../components/Conversation.jsx' import Conversation from '../components/Conversation.jsx'
import Timeline from '../components/Timeline.jsx' import Timeline from '../components/Timeline.jsx'
import { APP_ID, DV_LEAD, PRODUCTS, STAGES, STALL_AFTER_MS, blockedOn, nudgeFor, phaseOf } from '../api/config.js' import { APP_ID, DV_LEAD, PRODUCTS, STAGES, blockedOn, phaseOf } from '../api/config.js'
import { AGENTS } from '../api/agents.js' import { AGENTS } from '../api/agents.js'
import { actionsFor, rolesOf } from '../api/permissions.js' import { actionsFor, rolesOf } from '../api/permissions.js'
import { describeError } from '../api/errors.js' import { describeError } from '../api/errors.js'
@ -152,11 +152,6 @@ export default function Lead() {
const [audit, setAudit] = useState(null) const [audit, setAudit] = useState(null)
const [showChat, setShowChat] = useState(false) const [showChat, setShowChat] = useState(false)
const [openAgent, setOpenAgent] = useState(null) 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) => { const load = useCallback((quiet = false) => {
if (!quiet) setErr(null) if (!quiet) setErr(null)
@ -211,11 +206,6 @@ export default function Lead() {
* moves while it is on screen. Poll quietly: no spinner, no skeleton, and not * moves while it is on screen. Poll quietly: no spinner, no skeleton, and not
* at all while the tab is in the background. * at all while the tab is in the background.
*/ */
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 20000)
return () => clearInterval(id)
}, [])
useEffect(() => { useEffect(() => {
// Not while a form is open. A successful poll rewrites `actions`, and if the // 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 // lead has moved on the open form unmounts taking whatever was typed into
@ -278,17 +268,6 @@ export default function Lead() {
// the "in progress" strip below, which would otherwise keep promising that // the "in progress" strip below, which would otherwise keep promising that
// something is happening for as long as the lead is left alone. // something is happening for as long as the lead is left alone.
const blocked = stage?.kind === 'auto' ? blockedOn(row) : null 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 ( return (
@ -419,29 +398,6 @@ export default function Lead() {
</button> </button>
</div> </div>
</div> </div>
) : stalled ? (
<div className="doing doing--stalled">
<span className="doing__stop" aria-hidden="true" />
<div>
<strong>{stage.doing} nothing for {stalled.mins} minutes</strong>
<span>
{stage.by} has not come back. That is usually the model provider being
slow or dropping a request, not a problem with this lead the work so far
is safe and nothing has been lost.
{stalled.nudge
? ` Running ${stalled.nudge.label.toLowerCase()} wakes ${stalled.nudge.by} to try again.`
: ' There is no step to re-run from here; the actions below are the way on.'}
</span>
{stalled.nudge ? (
<button
type="button" className="doing__fix"
onClick={() => setOpen(stalled.nudge.uid)}
>
{stalled.nudge.label}
</button>
) : null}
</div>
</div>
) : stage?.kind === 'customer' && stage.doing ? ( ) : stage?.kind === 'customer' && stage.doing ? (
/* Not an agent working, and not a task of yours either the lead /* Not an agent working, and not a task of yours either the lead
is with someone outside the business. Without this the screen is with someone outside the business. Without this the screen

View File

@ -1358,14 +1358,3 @@
color: var(--zk-muted); color: var(--zk-muted);
white-space: nowrap; 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); }