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
}
/**
* 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.
*
* 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
* workflow RECORDS, so anything sent from inside a trigger node and never
* written to a field is invisible here however certainly the customer received
* it. The acceptance confirmation used to be exactly that the panel showed
* nothing after "I confirm", which read as though we had ignored somebody
* agreeing to buy so 90 writes it into customer_answer and it appears.
*
* 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.
* than left for someone to discover. Every message the workflow RECORDS is
* here: the customer's replies (customer_reply) and the replies written back
* (customer_answer). The three automatic sends are not. The quote goes out as
* a registered WhatsApp template, and the acknowledgement and the acceptance
* confirmation are composed inside trigger nodes and never written to a field.
* A thread that quietly omitted them would be worse than one that says which
* parts it holds.
*/
function turnsFrom(rows) {
if (!Array.isArray(rows)) return []
const out = []
// Sorted on (timestamp, id). One submission writes three rows in the same
// 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)
})
const ordered = [...rows].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)))
for (const r of ordered) {
const fields = Array.isArray(r.fields) && r.fields.length
? 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' })
}
}
// One submission is recorded three times the trigger commit, its repeat,
// and the settle so the same sentence would print three times.
//
// De-duped against the PREVIOUS turn only, not against the whole thread. A
// customer who asks the same thing twice because the first went unanswered
// has said it twice, and a thread that silently showed it once would hide
// exactly the impatience an operator needs to see. Only an immediate repeat
// of the same side and the same words is the platform talking to itself.
return out.filter((t, i) => {
const prev = out[i - 1]
return !(prev && prev.side === t.side && prev.text === t.text)
// One submission can be recorded more than once (the trigger commit and the
// 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.
const seen = new Set()
return out.filter((t) => {
const k = t.side + ' ' + t.text
if (seen.has(k)) return false
seen.add(k)
return true
})
}
@ -120,11 +105,9 @@ export default function Conversation({ rows, name, onClose }) {
</div>
<p className="conv__note">
Shows the customer&apos;s messages and everything written back to them,
including the acceptance confirmation. Two automatic sends are not here:
the quote, which goes out as a registered WhatsApp template rather than
text we compose, and the &ldquo;I have your message&rdquo; receipt sent
before anything has read it.
Shows the customer&apos;s messages and the replies written back. The quote
itself, the read receipt and the acceptance confirmation are sent
automatically and are not recorded as text, so they do not appear here.
</p>
</div>
</div>

View File

@ -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, 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 { actionsFor, rolesOf } from '../api/permissions.js'
import { describeError } from '../api/errors.js'
@ -152,11 +152,6 @@ 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)
@ -211,11 +206,6 @@ 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
@ -278,17 +268,6 @@ 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 (
@ -419,29 +398,6 @@ export default function Lead() {
</button>
</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 ? (
/* Not an agent working, and not a task of yours either the lead
is with someone outside the business. Without this the screen

View File

@ -1358,14 +1358,3 @@
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); }