diff --git a/src/components/Timeline.css b/src/components/Timeline.css index f4b94f4..2af431a 100644 --- a/src/components/Timeline.css +++ b/src/components/Timeline.css @@ -289,3 +289,62 @@ /* A chat entry's dot takes the channel's colour, so a scan down the rail shows where the conversation happened without reading a word. */ .tl__item--chat .tl__dot { background: #0f7b45; border-color: #0f7b45; } + +/* ── what this step wrote ──────────────────────────────────────────────── + Folded by default: most steps write a dozen fields and the trail has to + stay readable as a story. Opened, the entry becomes an itemised account of + what the agent decided — which is the closest an app JWT can get to its + reasoning, since ai_reasoning is on the row but not in the API. */ +.tl__wrote { margin-top: 8px; } + +.tl__wrote > summary { + cursor: pointer; + list-style: none; + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.72rem; + font-weight: 500; + color: var(--zk-blue); +} +.tl__wrote > summary::-webkit-details-marker { display: none; } +.tl__wrote > summary::after { + content: ''; + width: 5px; height: 5px; + border-right: 1.4px solid currentColor; + border-bottom: 1.4px solid currentColor; + transform: rotate(45deg) translate(-1px, -1px); + transition: transform var(--t-fast); +} +.tl__wrote[open] > summary::after { transform: rotate(-135deg) translate(-2px, -2px); } +.tl__wrote > summary:hover { color: var(--zk-blue-dark); } + +.tl__wrote dl { + margin: 8px 0 0; + padding: 10px 12px; + border: 1px solid var(--zk-line-soft); + border-radius: var(--r-sm, 6px); + background: var(--zk-tint); + display: flex; + flex-direction: column; + gap: 6px; +} + +.tl__wrote dl > div { display: flex; gap: 10px; align-items: baseline; } + +.tl__wrote dt { + flex: none; + width: 42%; + font-size: 0.72rem; + color: var(--zk-grey); + overflow-wrap: anywhere; +} + +.tl__wrote dd { + margin: 0; + flex: 1; + min-width: 0; + font-size: 0.75rem; + color: var(--zk-ink); + overflow-wrap: anywhere; +} diff --git a/src/components/Timeline.jsx b/src/components/Timeline.jsx index 6a040cf..f2ce670 100644 --- a/src/components/Timeline.jsx +++ b/src/components/Timeline.jsx @@ -42,6 +42,33 @@ const NARRATIVE = [ const MONEY = new Set(['quoted_premium', 'commission_amount', 'sme_value_at_risk', 'motor_idv']) +/** + * Fields already shown elsewhere in the entry, so the "what it wrote" list + * does not repeat them: the narrative prose above it, the money figures below + * it, the document chips, and the platform's own bookkeeping. + */ +const SHOWN_ELSEWHERE = new Set([...NARRATIVE.map(([k]) => k), ...MONEY, '_system']) + +/** A value as one short line. Long prose is already in the narrative block, so + * anything here is a field value, not a paragraph. */ +function short(f) { + const v = f.value + if (v === null || v === undefined || v === '') return null + if (Array.isArray(v)) { + const named = v + .map((x) => (x && typeof x === 'object' ? (x.original_name || x.uuid || '') : x)) + .filter(Boolean) + return named.length ? named.join(', ') : null + } + if (typeof v === 'object') return null + const t = String(v).trim() + if (!t) return null + // A value long enough to be prose belongs in the narrative block, not in a + // list of what changed — truncating it here would show half a sentence and + // teach the reader that this list is unreliable. + return t.length > 90 ? t.slice(0, 88) + '…' : t +} + /** Document slots, in the order they are worth reading. Labels are shorter than * the workflow's own — "Registration Certificate (RC)" is a chip, not a form * label — and a slot missing from here still renders under its server label. */ @@ -236,6 +263,26 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat }) { figures: [...byBase] .filter(([base, f]) => MONEY.has(base) && f.value) .map(([base, f]) => [base.replace(/_/g, ' '), '₹' + Number(f.value).toLocaleString('en-IN')]), + + /** + * EVERYTHING THIS STEP WROTE. + * + * Twelve fields were rendered as prose and four as money; the other + * hundred-odd were invisible. So Capture Motor Risk — which writes engine + * capacity, fuel, year, NCB, previous insurer, policy number, IDV and the + * add-ons — showed as a heading and a timestamp, and the only way to see + * what an agent had actually decided was to open the Lead file and guess + * which values came from that step. + * + * This is not the model's internal reasoning; that is recorded on the row + * (ai_reasoning, ai_tool_calls) and the audit endpoint does not return it. + * It is the next best and arguably more useful thing: the decision it + * committed, field by field, against the step that made it. + */ + wrote: [...byBase] + .filter(([base]) => !SHOWN_ELSEWHERE.has(base) && !base.startsWith('doc_')) + .map(([base, f]) => [f.label || base.replace(/_/g, ' '), short(f)]) + .filter(([, v]) => v !== null), } }) @@ -365,6 +412,20 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat }) { ))} ) : null} + + {/* Folded, because most steps write a lot and the trail has to + stay readable as a story. Open it and the step becomes an + itemised account of what it decided. */} + {it.wrote.length ? ( +
+ {it.wrote.length} field{it.wrote.length === 1 ? '' : 's'} written +
+ {it.wrote.map(([k, v]) => ( +
{k}
{v}
+ ))} +
+
+ ) : null} ))}