From 39671ed62c9d76d9359665ee9e4bd2aa87f54258 Mon Sep 17 00:00:00 2001 From: iLoveChicken Date: Wed, 29 Apr 2026 19:21:21 +0100 Subject: [PATCH 1/8] fix(web-client): resolve all Biome lint errors in web client source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix 22 pre-existing Biome TypeScript lint violations across 6 files: - noExplicitAny: replace (import.meta as any) with typed cast in App.tsx - noGlobalIsNan: isNaN → Number.isNaN in productPreviewUnavailable.ts - noNonNullAssertion: current! → current?.toFixed(2) in MandateApproval.tsx - noSvgWithoutTitle: add elements to all 5 inline SVGs (MandateApproval.tsx ×4, InventoryOptionsCard.tsx ×2) - useButtonType: add type="button" to all 7 button elements missing it (App.tsx ×3, MandateApproval.tsx ×2, InventoryOptionsCard.tsx ×1, MonitoringCard.tsx ×1) - useExhaustiveDependencies: destructure messages from chatState so useEffect dep array references a stable value - useOptionalChain: proseText && proseText.trim() → proseText?.trim() in MessageRenderer.tsx (×2) - useSemanticElements: div[role=button] → <button> in InventoryOptionsCard.tsx (onKeyDown handler removed as buttons natively handle Enter/Space) --- code/web-client/src/App.tsx | 8 ++++++-- .../src/components/InventoryOptionsCard.tsx | 12 +++++++----- code/web-client/src/components/MandateApproval.tsx | 12 ++++++++---- code/web-client/src/components/MessageRenderer.tsx | 4 ++-- code/web-client/src/components/MonitoringCard.tsx | 2 +- .../src/utils/productPreviewUnavailable.ts | 2 +- 6 files changed, 25 insertions(+), 15 deletions(-) diff --git a/code/web-client/src/App.tsx b/code/web-client/src/App.tsx index a6ddb758..6b1cb5b5 100644 --- a/code/web-client/src/App.tsx +++ b/code/web-client/src/App.tsx @@ -25,7 +25,7 @@ const AppHeader = ({usedServers}: {usedServers: Set<string>}) => { }, ]; - const flow = (import.meta as any).env?.VITE_FLOW; + const flow = (import.meta as {env?: {VITE_FLOW?: string}}).env?.VITE_FLOW; return ( <div className="app-header"> @@ -69,11 +69,13 @@ const TabBar = ({ }) => ( <div className="tab-bar"> <button + type="button" className={`tab ${activeTab === 'chat' ? 'active' : ''}`} onClick={() => onChange('chat')}> Chat </button> <button + type="button" className={`tab ${activeTab === 'mandates' ? 'active' : ''}`} onClick={() => onChange('mandates')}> Mandates @@ -125,6 +127,7 @@ const ChatInput = ({input, setInput, handleSend, loading}: ChatInputProps) => ( className="chat-input" /> <button + type="button" onClick={() => handleSend({fallbackIfEmpty: DEFAULT_CHAT_STARTER_MESSAGE}) } @@ -141,6 +144,7 @@ const ChatInput = ({input, setInput, handleSend, loading}: ChatInputProps) => ( export default function App() { const chatState: ChatState = useChat(); + const {messages} = chatState; const [activeTab, setActiveTab] = useState<TabKey>('chat'); const bottomRef = useRef<HTMLDivElement>(null); @@ -148,7 +152,7 @@ export default function App() { if (activeTab === 'chat') { bottomRef.current?.scrollIntoView({behavior: 'smooth'}); } - }, [chatState.messages, activeTab]); + }, [messages, activeTab]); return ( <div className="app-container"> diff --git a/code/web-client/src/components/InventoryOptionsCard.tsx b/code/web-client/src/components/InventoryOptionsCard.tsx index 5238213a..8bbcc1ed 100644 --- a/code/web-client/src/components/InventoryOptionsCard.tsx +++ b/code/web-client/src/components/InventoryOptionsCard.tsx @@ -17,16 +17,16 @@ function ItemRow({ onClick?: () => void; }) { return ( - <div + <button className={`item-card ${onClick ? 'clickable' : ''} ${selected ? 'selected' : ''}`} - role="button" - tabIndex={0} + type="button" onClick={onClick} - onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && onClick?.()}> +> <div className="row-content"> {selected && ( <div className="selected-icon"> <svg width="8" height="8" viewBox="0 0 8 8"> + <title>Selected {item.stock} in stock )} - + ); } @@ -66,6 +66,7 @@ export function InventoryOptionsCard({inventory, onSelect}: Props) {
+ Inventory available {canConfirm && (
+ Mandate - Reference price: ${current!.toFixed(2)} (list) + Reference price: ${current?.toFixed(2) ?? "0.00"} (list)
)} @@ -148,7 +149,7 @@ export function MandateApproval({ }, { label: 'Current', - value: hasCurrentPrice ? `$${current!.toFixed(2)}` : '—', + value: hasCurrentPrice ? `$${current?.toFixed(2) ?? "0.00"}` : '—', accent: '#f87171', }, {label: 'Qty', value: String(qty), accent: '#94a3b8'}, @@ -187,6 +188,7 @@ export function MandateApproval({ {/* Payment method row */}
+ Payment card -
@@ -272,6 +275,7 @@ export function MandateApproval({
+ Signed - {proseText && proseText.trim() && } + {proseText?.trim() && }
); @@ -183,7 +183,7 @@ export const MessageRenderer = ({ return (
- {proseText && proseText.trim() && } + {proseText?.trim() && } )} {onCheckNow && ( - )} diff --git a/code/web-client/src/utils/productPreviewUnavailable.ts b/code/web-client/src/utils/productPreviewUnavailable.ts index d043a20f..69011923 100644 --- a/code/web-client/src/utils/productPreviewUnavailable.ts +++ b/code/web-client/src/utils/productPreviewUnavailable.ts @@ -19,7 +19,7 @@ export function normalizeProductPreviewUnavailable( if (typeof v === 'string') { const cleaned = v.replace(/[$,]/g, '').trim(); const n = Number(cleaned); - return isNaN(n) ? undefined : n; + return Number.isNaN(n) ? undefined : n; } return undefined; }; From b2806638d79894542a5a97df3bbcedc3c2c81409 Mon Sep 17 00:00:00 2001 From: iLoveChicken Date: Wed, 29 Apr 2026 19:30:23 +0100 Subject: [PATCH 2/8] chore(cspell): add sublabel to custom words list The word 'sublabel' (used as a JSX prop name in MessageRenderer.tsx) is not in any standard dictionary. Add both cases to suppress the cspell CI false positive. --- .cspell/custom-words.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.cspell/custom-words.txt b/.cspell/custom-words.txt index ce73c361..e0d6c617 100644 --- a/.cspell/custom-words.txt +++ b/.cspell/custom-words.txt @@ -163,6 +163,8 @@ spyderproject spyproject stablecoins stdr +sublabel +Sublabel stretchr superfences Truelayer From 3ddb0bbdd1c4c6cf62124261f7c89854b68647c3 Mon Sep 17 00:00:00 2001 From: iLoveChicken Date: Wed, 29 Apr 2026 19:40:40 +0100 Subject: [PATCH 3/8] fix(web-client): resolve remaining Biome lint and Prettier issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix all remaining lint violations across the full web-client source so that BIOME_LINT passes on all TypeScript files in the repo: Biome fixes: - useTemplate: string concat → template literals in useChat.ts (×2) - useExhaustiveDependencies: add fetchMandate to sendToAgent deps; add monitoringData?.qty to auto-poll useEffect deps; reference messages.length in App.tsx scroll effect callback - noNonNullAssertion: guard getElementById('root') with null check - noUnusedImports: remove MonitoringStatus from mandateEntries.ts - noUnusedFunctionParameters: msg → _msg, tc → _tc in toolCallEntries - noUnusedVariables: remove unused args variable in toolCallEntries - useButtonType: add type="button" to CopyButton and card-header button in MandateCard.tsx - noArrayIndexKey: replace key={i} with key={d.salt??d.key??String(i)} - noSvgWithoutTitle: add to SVGs in ReceiptCard.tsx and UserActionCard.tsx Prettier fixes: - mandateEntries.ts: break long import (>80 chars) to multi-line, remove triple-blank-line block --- code/web-client/src/App.tsx | 2 +- code/web-client/src/components/MandateCard.tsx | 4 +++- code/web-client/src/components/ReceiptCard.tsx | 1 + code/web-client/src/components/UserActionCard.tsx | 1 + code/web-client/src/hooks/useChat.ts | 7 ++++--- code/web-client/src/main.tsx | 4 +++- code/web-client/src/utils/mandateEntries.ts | 14 +++++++++----- 7 files changed, 22 insertions(+), 11 deletions(-) diff --git a/code/web-client/src/App.tsx b/code/web-client/src/App.tsx index 6b1cb5b5..bda39dfc 100644 --- a/code/web-client/src/App.tsx +++ b/code/web-client/src/App.tsx @@ -149,7 +149,7 @@ export default function App() { const bottomRef = useRef<HTMLDivElement>(null); useEffect(() => { - if (activeTab === 'chat') { + if (activeTab === 'chat' && messages.length > 0) { bottomRef.current?.scrollIntoView({behavior: 'smooth'}); } }, [messages, activeTab]); diff --git a/code/web-client/src/components/MandateCard.tsx b/code/web-client/src/components/MandateCard.tsx index e315a9aa..c8b461ef 100644 --- a/code/web-client/src/components/MandateCard.tsx +++ b/code/web-client/src/components/MandateCard.tsx @@ -203,6 +203,7 @@ function CopyButton({ text }: { text: string }) { const [copied, setCopied] = useState(false); return ( <button + type="button" className="copy-button" onClick={() => { navigator.clipboard.writeText(text); @@ -268,6 +269,7 @@ export function MandateCard({ entry }: Props) { return ( <div className="mandate-viewer-card"> <button + type="button" className="card-header" onClick={() => setExpanded((v) => !v)} aria-expanded={expanded}> @@ -358,7 +360,7 @@ export function MandateCard({ entry }: Props) { <span>Value</span> </div> {sd.disclosures.map((d, i) => ( - <div key={i} className="disclosure-row"> + <div key={d.salt ?? d.key ?? String(i)} className="disclosure-row"> <span className="mono small"> {truncate(d.salt, 18)} </span> diff --git a/code/web-client/src/components/ReceiptCard.tsx b/code/web-client/src/components/ReceiptCard.tsx index 5a61a6f6..8615c749 100644 --- a/code/web-client/src/components/ReceiptCard.tsx +++ b/code/web-client/src/components/ReceiptCard.tsx @@ -43,6 +43,7 @@ export function ReceiptCard({ purchase, itemName }: Props) { <div className="success-header"> <div className="success-badge"> <svg width="18" height="18" viewBox="0 0 18 18" fill="none"> + <title>Purchase confirmed
+ Action confirmed (''); @@ -494,6 +494,7 @@ export function useChat() { monitoringData?.price_cap, monitoringData?.open_checkout_mandate, monitoringData?.open_payment_mandate, + monitoringData?.qty, sendToAgent, ]); diff --git a/code/web-client/src/main.tsx b/code/web-client/src/main.tsx index 94418abe..c3759bb6 100644 --- a/code/web-client/src/main.tsx +++ b/code/web-client/src/main.tsx @@ -3,7 +3,9 @@ import ReactDOM from 'react-dom/client'; import App from './App'; import './styles/global.scss'; -ReactDOM.createRoot(document.getElementById('root')!).render( +const rootElement = document.getElementById('root'); +if (!rootElement) throw new Error('Root element not found'); +ReactDOM.createRoot(rootElement).render( , diff --git a/code/web-client/src/utils/mandateEntries.ts b/code/web-client/src/utils/mandateEntries.ts index b1c90aac..4c9a98e4 100644 --- a/code/web-client/src/utils/mandateEntries.ts +++ b/code/web-client/src/utils/mandateEntries.ts @@ -18,7 +18,14 @@ * chronologically ordered list suitable for the Mandates tab. */ -import type {ChatMessage, MandateChainsFetched, MandateEntry, MandatesSigned, MonitoringStatus, PurchaseComplete, ToolCallArtifact,} from '../types'; +import type { + ChatMessage, + MandateChainsFetched, + MandateEntry, + MandatesSigned, + PurchaseComplete, + ToolCallArtifact, +} from '../types'; type Draft = Omit; @@ -86,9 +93,8 @@ function purchaseEntries(msg: ChatMessage, pc: PurchaseComplete): Draft[] { return out; } -function toolCallEntries(msg: ChatMessage, tc: ToolCallArtifact): Draft[] { +function toolCallEntries(_msg: ChatMessage, _tc: ToolCallArtifact): Draft[] { const out: Draft[] = []; - const args = tc.args ?? {}; return out; } @@ -171,8 +177,6 @@ export function deriveMandateEntries(messages: ChatMessage[]): MandateEntry[] { } } - - const seen = new Set(); const result: MandateEntry[] = []; for (const d of drafts) { From b495e5ab26ccde2c4940731437656ebb7c1bf935 Mon Sep 17 00:00:00 2001 From: iLoveChicken Date: Wed, 29 Apr 2026 19:41:38 +0100 Subject: [PATCH 4/8] chore(cspell): add dedup and sublabel to custom words Both are used as meaningful shorthand terms in the web-client source: - dedup: abbreviation of deduplicate (used in useChat.ts, mandateEntries.ts) - sublabel: UI prop name in UserActionCard / MessageRenderer --- .cspell/custom-words.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/.cspell/custom-words.txt b/.cspell/custom-words.txt index e0d6c617..8f575eaa 100644 --- a/.cspell/custom-words.txt +++ b/.cspell/custom-words.txt @@ -164,6 +164,7 @@ spyproject stablecoins stdr sublabel +dedup Sublabel stretchr superfences From cb9fbbbf9c2d863a0165a649f8d3127a7e418cf9 Mon Sep 17 00:00:00 2001 From: iLoveChicken Date: Wed, 29 Apr 2026 19:47:54 +0100 Subject: [PATCH 5/8] style(web-client): apply Prettier formatting across all changed source files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run prettier --write on all 12 modified TypeScript/TSX files to bring them into full compliance with the project's .prettierrc config. No logic changes — formatting only. Biome lint errors were fixed in the previous commits; this commit solely addresses TYPESCRIPT_PRETTIER CI failures caused by pre-existing style inconsistencies that were surfaced when super-linter checked the modified files. --- code/web-client/src/App.tsx | 83 +- .../src/components/InventoryOptionsCard.tsx | 29 +- .../src/components/MandateApproval.tsx | 90 +- .../web-client/src/components/MandateCard.tsx | 167 ++-- .../src/components/MessageRenderer.tsx | 128 +-- .../src/components/MonitoringCard.tsx | 14 +- .../web-client/src/components/ReceiptCard.tsx | 40 +- .../src/components/UserActionCard.tsx | 4 +- code/web-client/src/hooks/useChat.ts | 774 ++++++++++-------- code/web-client/src/main.tsx | 14 +- code/web-client/src/utils/mandateEntries.ts | 82 +- .../src/utils/productPreviewUnavailable.ts | 24 +- 12 files changed, 770 insertions(+), 679 deletions(-) diff --git a/code/web-client/src/App.tsx b/code/web-client/src/App.tsx index bda39dfc..f01fb8ed 100644 --- a/code/web-client/src/App.tsx +++ b/code/web-client/src/App.tsx @@ -1,31 +1,35 @@ -import {useEffect, useRef, useState} from 'react'; -import './App.scss'; -import {MandateViewer} from './components/MandateViewer'; -import {MessageRenderer} from './components/MessageRenderer'; -import {TypingIndicator} from './components/TypingIndicator'; -import {DEFAULT_CHAT_STARTER_MESSAGE} from './config'; -import {type ChatState, useChat} from './hooks/useChat'; +import { useEffect, useRef, useState } from "react"; +import "./App.scss"; +import { MandateViewer } from "./components/MandateViewer"; +import { MessageRenderer } from "./components/MessageRenderer"; +import { TypingIndicator } from "./components/TypingIndicator"; +import { DEFAULT_CHAT_STARTER_MESSAGE } from "./config"; +import { type ChatState, useChat } from "./hooks/useChat"; // ========================================== // SUB-COMPONENTS // ========================================== -const AppHeader = ({usedServers}: {usedServers: Set}) => { +const AppHeader = ({ usedServers }: { usedServers: Set }) => { const servers = [ { - label: 'Shopping Agent', - key: 'Shopping Agent', - className: 'server-shopping', + label: "Shopping Agent", + key: "Shopping Agent", + className: "server-shopping", }, - {label: 'Merchant MCP', key: 'Merchant MCP', className: 'server-merchant'}, { - label: 'Credential Provider MCP', - key: 'Credential Provider MCP', - className: 'server-credential', + label: "Merchant MCP", + key: "Merchant MCP", + className: "server-merchant", + }, + { + label: "Credential Provider MCP", + key: "Credential Provider MCP", + className: "server-credential", }, ]; - const flow = (import.meta as {env?: {VITE_FLOW?: string}}).env?.VITE_FLOW; + const flow = (import.meta as { env?: { VITE_FLOW?: string } }).env?.VITE_FLOW; return (
@@ -35,8 +39,8 @@ const AppHeader = ({usedServers}: {usedServers: Set}) => {
Delegated Shopper - {flow === 'x402' && x402} - {flow === 'card' && Card} + {flow === "x402" && x402} + {flow === "card" && Card}
A2A · Human-not-present · Merchant MCP · Credential Provider MCP @@ -46,7 +50,7 @@ const AppHeader = ({usedServers}: {usedServers: Set}) => { {servers.map((b) => (
+ className={`server-badge ${usedServers.has(b.key) ? "active" : ""} ${b.className}`}>
{b.label}
@@ -56,7 +60,7 @@ const AppHeader = ({usedServers}: {usedServers: Set}) => { ); }; -type TabKey = 'chat' | 'mandates'; +type TabKey = "chat" | "mandates"; const TabBar = ({ activeTab, @@ -70,14 +74,14 @@ const TabBar = ({
@@ -95,10 +99,10 @@ const EmptyChatState = () => ( via Merchant MCP + Credential Provider MCP

- Try:{' '} + Try:{" "} - "When is the SuperShoe limited edition Gold sneaker drop? I need size 9 - women's." + "When is the SuperShoe limited edition Gold sneaker drop? I need + size 9 women's."

@@ -109,18 +113,23 @@ const EmptyChatState = () => ( type ChatInputProps = Pick< ChatState, - 'handleSend' | 'input' | 'loading' | 'setInput' + "handleSend" | "input" | "loading" | "setInput" >; -const ChatInput = ({input, setInput, handleSend, loading}: ChatInputProps) => ( +const ChatInput = ({ + input, + setInput, + handleSend, + loading, +}: ChatInputProps) => (

setInput(e.target.value)} onKeyDown={(e) => - e.key === 'Enter' && + e.key === "Enter" && !loading && - handleSend({fallbackIfEmpty: DEFAULT_CHAT_STARTER_MESSAGE}) + handleSend({ fallbackIfEmpty: DEFAULT_CHAT_STARTER_MESSAGE }) } placeholder="e.g. When is the SuperShoe limited edition Gold sneaker drop? I need size 9 women's." disabled={loading} @@ -129,7 +138,7 @@ const ChatInput = ({input, setInput, handleSend, loading}: ChatInputProps) => ( -
)} - {state === 'signing' && ( + {state === "signing" && (
Signing with ECDSA P-256…
)} - {state === 'signed' && ( + {state === "signed" && (
diff --git a/code/web-client/src/components/MandateCard.tsx b/code/web-client/src/components/MandateCard.tsx index c8b461ef..d1ac0828 100644 --- a/code/web-client/src/components/MandateCard.tsx +++ b/code/web-client/src/components/MandateCard.tsx @@ -1,27 +1,28 @@ -import { useMemo, useState } from 'react'; -import type { MandateEntry, MandateEntryKind } from '../types'; +import { useMemo, useState } from "react"; +import type { MandateEntry, MandateEntryKind } from "../types"; import { decodeJwt, decodeSdJwtSync, type DecodedJwt, type DecodedSdJwt, -} from '../utils/sdJwtDecoder'; -import './MandateCard.scss'; +} from "../utils/sdJwtDecoder"; +import "./MandateCard.scss"; interface Props { entry: MandateEntry; } -const KIND_LABELS: Record = { - mandate_request: { label: 'Mandate Request', accent: '#60a5fa' }, - open_checkout_mandate: { label: 'Open Checkout', accent: '#a78bfa' }, - open_payment_mandate: { label: 'Open Payment', accent: '#a78bfa' }, - checkout_jwt: { label: 'Checkout JWT', accent: '#34d399' }, - closed_checkout_mandate: { label: 'Closed Checkout', accent: '#fbbf24' }, - closed_payment_mandate: { label: 'Closed Payment', accent: '#fbbf24' }, - presentation: { label: 'Presentation', accent: '#f472b6' }, - mandate_chain: { label: 'Mandate Chain', accent: '#fb7185' }, -}; +const KIND_LABELS: Record = + { + mandate_request: { label: "Mandate Request", accent: "#60a5fa" }, + open_checkout_mandate: { label: "Open Checkout", accent: "#a78bfa" }, + open_payment_mandate: { label: "Open Payment", accent: "#a78bfa" }, + checkout_jwt: { label: "Checkout JWT", accent: "#34d399" }, + closed_checkout_mandate: { label: "Closed Checkout", accent: "#fbbf24" }, + closed_payment_mandate: { label: "Closed Payment", accent: "#fbbf24" }, + presentation: { label: "Presentation", accent: "#f472b6" }, + mandate_chain: { label: "Mandate Chain", accent: "#fb7185" }, + }; function truncate(s: string, n = 48): string { return s.length > n ? `${s.slice(0, n)}…` : s; @@ -29,9 +30,9 @@ function truncate(s: string, n = 48): string { function formatTimestamp(ts: number): string { return new Date(ts).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', + hour: "2-digit", + minute: "2-digit", + second: "2-digit", }); } @@ -39,18 +40,18 @@ function formatTimestamp(ts: number): string { function summarizeEntry( entry: MandateEntry, sd?: DecodedSdJwt, - jwt?: DecodedJwt, + jwt?: DecodedJwt ): Array<{ label: string; value: string }> { const out: Array<{ label: string; value: string }> = []; const delegate = (sd?.issuerJwt.payload.delegate_payload as unknown[]) ?? []; let first = (delegate[0] ?? {}) as Record; if ( - (!first.vct || (Object.keys(first).length === 1 && first['...'])) && + (!first.vct || (Object.keys(first).length === 1 && first["..."])) && sd?.disclosures ) { for (const d of sd.disclosures) { - if (!d.key && typeof d.value === 'object' && d.value !== null) { + if (!d.key && typeof d.value === "object" && d.value !== null) { const obj = d.value as Record; if (obj.vct) { first = obj; @@ -61,129 +62,129 @@ function summarizeEntry( } switch (entry.kind) { - case 'mandate_request': { + case "mandate_request": { const p = entry.rawPayload ?? {}; - if (typeof p.item_id === 'string') - out.push({ label: 'Item', value: String(p.item_id) }); - if (typeof p.price_cap === 'number') - out.push({ label: 'Price Cap', value: `$${p.price_cap}` }); - if (typeof p.qty === 'number') - out.push({ label: 'Qty', value: String(p.qty) }); - if (typeof p.payment_method === 'string') - out.push({ label: 'Method', value: String(p.payment_method) }); + if (typeof p.item_id === "string") + out.push({ label: "Item", value: String(p.item_id) }); + if (typeof p.price_cap === "number") + out.push({ label: "Price Cap", value: `$${p.price_cap}` }); + if (typeof p.qty === "number") + out.push({ label: "Qty", value: String(p.qty) }); + if (typeof p.payment_method === "string") + out.push({ label: "Method", value: String(p.payment_method) }); break; } - case 'open_checkout_mandate': { - out.push({ label: 'VCT', value: String(first.vct ?? '—') }); + case "open_checkout_mandate": { + out.push({ label: "VCT", value: String(first.vct ?? "—") }); const constraints = (first.constraints as unknown[]) ?? []; const merchants = constraints.find( (c) => - (c as Record).type === 'checkout.allowed_merchants', + (c as Record).type === "checkout.allowed_merchants" ) as Record | undefined; const lineItems = constraints.find( - (c) => (c as Record).type === 'checkout.line_items', + (c) => (c as Record).type === "checkout.line_items" ) as Record | undefined; if (merchants) { const arr = (merchants.allowed as unknown[]) ?? []; - out.push({ label: 'Allowed Merchants', value: String(arr.length) }); + out.push({ label: "Allowed Merchants", value: String(arr.length) }); } if (lineItems) { const items = (lineItems.items as unknown[]) ?? []; - out.push({ label: 'Line Item Rules', value: String(items.length) }); + out.push({ label: "Line Item Rules", value: String(items.length) }); } break; } - case 'open_payment_mandate': { - out.push({ label: 'VCT', value: String(first.vct ?? '—') }); + case "open_payment_mandate": { + out.push({ label: "VCT", value: String(first.vct ?? "—") }); const constraints = (first.constraints as unknown[]) ?? []; const amount = constraints.find( - (c) => (c as Record).type === 'payment.amount_range', + (c) => (c as Record).type === "payment.amount_range" ) as Record | undefined; if (amount) { const min = amount.min; const max = amount.max; - const cur = amount.currency ?? ''; + const cur = amount.currency ?? ""; out.push({ - label: 'Amount', - value: `${min ?? 0}–${max ?? '∞'} ${String(cur)}`, + label: "Amount", + value: `${min ?? 0}–${max ?? "∞"} ${String(cur)}`, }); } const payees = constraints.find( - (c) => (c as Record).type === 'payment.allowed_payees', + (c) => (c as Record).type === "payment.allowed_payees" ) as Record | undefined; if (payees) { const arr = (payees.allowed as unknown[]) ?? []; - out.push({ label: 'Allowed Payees', value: String(arr.length) }); + out.push({ label: "Allowed Payees", value: String(arr.length) }); } break; } - case 'checkout_jwt': { + case "checkout_jwt": { const payload = jwt?.payload ?? entry.rawPayload ?? {}; if (payload.cart_id) - out.push({ label: 'Cart', value: String(payload.cart_id) }); - if (typeof payload.total === 'number') + out.push({ label: "Cart", value: String(payload.cart_id) }); + if (typeof payload.total === "number") out.push({ - label: 'Total', + label: "Total", value: `$${(payload.total / 100).toFixed(2)}`, }); if (payload.currency) - out.push({ label: 'Currency', value: String(payload.currency) }); + out.push({ label: "Currency", value: String(payload.currency) }); const merchant = payload.merchant as Record | undefined; if (merchant?.name) - out.push({ label: 'Merchant', value: String(merchant.name) }); + out.push({ label: "Merchant", value: String(merchant.name) }); break; } - case 'closed_checkout_mandate': { - out.push({ label: 'VCT', value: String(first.vct ?? '—') }); + case "closed_checkout_mandate": { + out.push({ label: "VCT", value: String(first.vct ?? "—") }); const ch = first.checkout_hash ?? entry.rawPayload?.checkout_hash; - if (typeof ch === 'string') { - out.push({ label: 'Checkout Hash', value: truncate(ch, 32) }); + if (typeof ch === "string") { + out.push({ label: "Checkout Hash", value: truncate(ch, 32) }); } const inner = first.checkout_jwt; - if (typeof inner === 'string' && inner.split('.').length === 3) { - out.push({ label: 'Binds', value: 'Merchant-signed checkout JWT' }); + if (typeof inner === "string" && inner.split(".").length === 3) { + out.push({ label: "Binds", value: "Merchant-signed checkout JWT" }); } break; } - case 'closed_payment_mandate': { - out.push({ label: 'VCT', value: String(first.vct ?? '—') }); + case "closed_payment_mandate": { + out.push({ label: "VCT", value: String(first.vct ?? "—") }); const src = sd ? first : (entry.rawPayload ?? {}); const tx = src.transaction_id; - if (typeof tx === 'string') { - out.push({ label: 'Transaction', value: truncate(tx, 32) }); + if (typeof tx === "string") { + out.push({ label: "Transaction", value: truncate(tx, 32) }); } const amount = src.amount as Record | undefined; if (amount) { const amt = amount.amount; - const cur = amount.currency ?? ''; - if (typeof amt === 'number') { + const cur = amount.currency ?? ""; + if (typeof amt === "number") { out.push({ - label: 'Amount', + label: "Amount", value: `${(amt / 100).toFixed(2)} ${String(cur)}`, }); } } const payee = src.payee as Record | undefined; - if (payee?.name) out.push({ label: 'Payee', value: String(payee.name) }); + if (payee?.name) out.push({ label: "Payee", value: String(payee.name) }); break; } - case 'mandate_chain': - case 'presentation': { + case "mandate_chain": + case "presentation": { if (sd?.kbJwt?.payload) { const aud = sd.kbJwt.payload.aud; const nonce = sd.kbJwt.payload.nonce; - if (aud) out.push({ label: 'Audience', value: String(aud) }); + if (aud) out.push({ label: "Audience", value: String(aud) }); if (nonce) - out.push({ label: 'Nonce', value: truncate(String(nonce), 24) }); + out.push({ label: "Nonce", value: truncate(String(nonce), 24) }); } else if (entry.rawPayload) { const aud = entry.rawPayload.aud; const nonce = entry.rawPayload.nonce; const chainId = entry.rawPayload.mandate_chain_id; - if (aud) out.push({ label: 'Audience', value: String(aud) }); + if (aud) out.push({ label: "Audience", value: String(aud) }); if (nonce) - out.push({ label: 'Nonce', value: truncate(String(nonce), 24) }); + out.push({ label: "Nonce", value: truncate(String(nonce), 24) }); if (chainId) - out.push({ label: 'Chain', value: truncate(String(chainId), 24) }); + out.push({ label: "Chain", value: truncate(String(chainId), 24) }); } break; } @@ -210,7 +211,7 @@ function CopyButton({ text }: { text: string }) { setCopied(true); setTimeout(() => setCopied(false), 1200); }}> - {copied ? 'Copied' : 'Copy'} + {copied ? "Copied" : "Copy"} ); } @@ -223,25 +224,25 @@ export function MandateCard({ entry }: Props) { return { sd: undefined, jwt: undefined, error: undefined }; } const looksLikeJwt = - entry.rawToken.split('.').length === 3 || entry.rawToken.includes('~'); + entry.rawToken.split(".").length === 3 || entry.rawToken.includes("~"); if (!looksLikeJwt) { return { sd: undefined, jwt: undefined, error: undefined }; } try { let tokenToDecode = entry.rawToken; - if (tokenToDecode.includes('~~')) { + if (tokenToDecode.includes("~~")) { const parts = tokenToDecode.split(/~~+/); tokenToDecode = parts[parts.length - 1]; } - if (entry.kind === 'checkout_jwt') { + if (entry.kind === "checkout_jwt") { return { sd: undefined, jwt: decodeJwt(tokenToDecode), error: undefined, }; } - if (tokenToDecode.includes('~')) { + if (tokenToDecode.includes("~")) { return { sd: decodeSdJwtSync(tokenToDecode), jwt: undefined, @@ -254,7 +255,7 @@ export function MandateCard({ entry }: Props) { } }, [entry.rawToken, entry.kind]); - const isMandateChain = entry.kind === 'mandate_chain'; + const isMandateChain = entry.kind === "mandate_chain"; const issuerJwt = sd?.issuerJwt ?? jwt; const payloadError = issuerJwt?.payloadError; const headerError = issuerJwt?.headerError; @@ -286,7 +287,7 @@ export function MandateCard({ entry }: Props) {
{formatTimestamp(entry.timestamp)} - +
@@ -316,7 +317,7 @@ export function MandateCard({ entry }: Props) { Payload parse failed: {payloadError} {rawPayloadString != null && ( - {' '} + {" "} · Showing raw decoded string below (token may have been truncated or corrupted in transit). @@ -360,15 +361,17 @@ export function MandateCard({ entry }: Props) { Value
{sd.disclosures.map((d, i) => ( -
+
{truncate(d.salt, 18)} - {d.key ?? '(array)'} + {d.key ?? "(array)"} - {typeof d.value === 'object' + style={{ whiteSpace: "pre-wrap" }}> + {typeof d.value === "object" ? JSON.stringify(d.value, null, 2) : String(d.value)} diff --git a/code/web-client/src/components/MessageRenderer.tsx b/code/web-client/src/components/MessageRenderer.tsx index 6e7d6fd8..77081251 100644 --- a/code/web-client/src/components/MessageRenderer.tsx +++ b/code/web-client/src/components/MessageRenderer.tsx @@ -1,7 +1,7 @@ -import ReactMarkdown from 'react-markdown'; -import {MERCHANT_TRIGGER_URL} from '../config'; -import type {ChatState} from '../hooks/useChat'; -import {TrustedSurface} from '../trustedSurface'; +import ReactMarkdown from "react-markdown"; +import { MERCHANT_TRIGGER_URL } from "../config"; +import type { ChatState } from "../hooks/useChat"; +import { TrustedSurface } from "../trustedSurface"; import type { ChatMessage, ErrorArtifact, @@ -11,48 +11,48 @@ import type { ProductPreviewUnavailable, PurchaseComplete, ToolCallArtifact, -} from '../types'; +} from "../types"; import { extractCurrentPriceFromText, extractErrorFromText, extractMandateFromText, extractMonitoringFromText, removeArtifactJsonFromText, -} from '../utils/parsing'; -import {AgentProse} from './AgentProse'; -import {ErrorCard} from './ErrorCard'; -import {InventoryOptionsCard} from './InventoryOptionsCard'; -import {MandateApproval} from './MandateApproval'; -import {MonitoringCard} from './MonitoringCard'; -import {ProductPreviewUnavailableCard} from './ProductPreviewUnavailableCard'; -import {ReceiptCard} from './ReceiptCard'; -import {ToolCallCard} from './ToolCallCard'; -import {UserActionCard} from './UserActionCard'; +} from "../utils/parsing"; +import { AgentProse } from "./AgentProse"; +import { ErrorCard } from "./ErrorCard"; +import { InventoryOptionsCard } from "./InventoryOptionsCard"; +import { MandateApproval } from "./MandateApproval"; +import { MonitoringCard } from "./MonitoringCard"; +import { ProductPreviewUnavailableCard } from "./ProductPreviewUnavailableCard"; +import { ReceiptCard } from "./ReceiptCard"; +import { ToolCallCard } from "./ToolCallCard"; +import { UserActionCard } from "./UserActionCard"; const trustedSurface = new TrustedSurface(); const getArtifactType = (artifactData: unknown): string | undefined => { if ( artifactData && - typeof artifactData === 'object' && - 'type' in artifactData + typeof artifactData === "object" && + "type" in artifactData ) { - return (artifactData as {type: string}).type; + return (artifactData as { type: string }).type; } return undefined; }; type MessageRendererChatState = Pick< ChatState, - | 'handleMandateApprove' - | 'handleMandateReject' - | 'isMonitoring' - | 'lastInventoryMatches' - | 'lastInventoryOptions' - | 'lastSelectedItemName' - | 'pendingTaskId' - | 'sendToAgent' - | 'setLastSelectedItemName' + | "handleMandateApprove" + | "handleMandateReject" + | "isMonitoring" + | "lastInventoryMatches" + | "lastInventoryOptions" + | "lastSelectedItemName" + | "pendingTaskId" + | "sendToAgent" + | "setLastSelectedItemName" >; export const MessageRenderer = ({ @@ -74,26 +74,26 @@ export const MessageRenderer = ({ isMonitoring, } = chatState; - const isUser = msg.role === 'user'; - const isSystem = msg.role === 'system'; + const isUser = msg.role === "user"; + const isSystem = msg.role === "system"; const artifactType = getArtifactType(msg.artifactData); // Skip rendering for internal state artifacts that have no accompanying text const hiddenArtifactTypes = [ - 'mandates_signed', - 'mandates_created', - 'mandate_presented', - 'mandate_chains_fetched', + "mandates_signed", + "mandates_created", + "mandate_presented", + "mandate_chains_fetched", ]; if (artifactType && hiddenArtifactTypes.includes(artifactType) && !msg.text) { return null; } // 1. User Action - if (msg.role === 'user_action') { + if (msg.role === "user_action") { return ( ); @@ -101,7 +101,7 @@ export const MessageRenderer = ({ // 2. Tool Call const toolCall = - artifactType === 'tool_call' + artifactType === "tool_call" ? (msg.artifactData as ToolCallArtifact) : undefined; @@ -109,7 +109,7 @@ export const MessageRenderer = ({ return ( @@ -133,7 +133,7 @@ export const MessageRenderer = ({ } // 3. Inventory Options - if (artifactType === 'inventory_options') { + if (artifactType === "inventory_options") { const inv = msg.artifactData as InventoryOptionsArtifact; const opts = lastInventoryOptions ?? inv; const price_cap = opts?.price_cap; @@ -143,16 +143,16 @@ export const MessageRenderer = ({ price_cap != null && qty != null && pendingTaskId ? (itemId: string) => { setLastSelectedItemName( - inv.matches.find((m) => m.item_id === itemId)?.name, + inv.matches.find((m) => m.item_id === itemId)?.name ); sendToAgent( { - type: 'item_selected', + type: "item_selected", item_id: itemId, price_cap: price_cap, qty: qty, }, - pendingTaskId, + pendingTaskId ); } : undefined; @@ -162,9 +162,9 @@ export const MessageRenderer = ({ // 4. Mandate Request const mandate = - artifactType === 'mandate_request' + artifactType === "mandate_request" ? (msg.artifactData as MandateRequest) - : msg.text && msg.role === 'agent' + : msg.text && msg.role === "agent" ? extractMandateFromText(msg.text) : undefined; @@ -178,7 +178,7 @@ export const MessageRenderer = ({ matches: mandate.matches ?? lastInventoryMatches, }; const proseText = msg.text - ? removeArtifactJsonFromText(msg.text, 'mandate_request') + ? removeArtifactJsonFromText(msg.text, "mandate_request") : undefined; return ( @@ -198,16 +198,16 @@ export const MessageRenderer = ({ // 5. Error or Monitoring const error = - artifactType === 'error' + artifactType === "error" ? (msg.artifactData as ErrorArtifact) - : msg.text && msg.role === 'agent' + : msg.text && msg.role === "agent" ? extractErrorFromText(msg.text) : undefined; const monitoring = - artifactType === 'monitoring' + artifactType === "monitoring" ? (msg.artifactData as MonitoringStatus) - : msg.text && msg.role === 'agent' + : msg.text && msg.role === "agent" ? extractMonitoringFromText(msg.text) : undefined; @@ -217,16 +217,16 @@ export const MessageRenderer = ({ ? () => { sendToAgent( { - type: 'check_product_now', + type: "check_product_now", item_id: monitoring.item_id, price_cap: monitoring.price_cap, qty: monitoring.qty ?? 1, open_checkout_mandate: monitoring.open_checkout_mandate, open_payment_mandate: monitoring.open_payment_mandate, - message: 'Check product now', - source: 'manual', + message: "Check product now", + source: "manual", }, - pendingTaskId, + pendingTaskId ); } : undefined; @@ -248,7 +248,7 @@ export const MessageRenderer = ({ } // 6. Purchase Complete - if (artifactType === 'purchase_complete') { + if (artifactType === "purchase_complete") { return ( +
+ className={`message-content ${isUser ? "user" : isSystem ? "system" : "agent"}`}> {isUser ? ( msg.text ) : (

{children}

, - strong: ({children}) => {children}, - ol: ({children}) =>
    {children}
, - ul: ({children}) =>
    {children}
, - li: ({children}) =>
  • {children}
  • , - code: ({children}) => {children}, + p: ({ children }) =>

    {children}

    , + strong: ({ children }) => {children}, + ol: ({ children }) =>
      {children}
    , + ul: ({ children }) =>
      {children}
    , + li: ({ children }) =>
  • {children}
  • , + code: ({ children }) => {children}, }}> - {msg.text ?? ''} + {msg.text ?? ""}
    )}
    diff --git a/code/web-client/src/components/MonitoringCard.tsx b/code/web-client/src/components/MonitoringCard.tsx index 16801c5e..d67c9f52 100644 --- a/code/web-client/src/components/MonitoringCard.tsx +++ b/code/web-client/src/components/MonitoringCard.tsx @@ -1,5 +1,5 @@ -import type {MonitoringStatus} from '../types'; -import './MonitoringCard.scss'; +import type { MonitoringStatus } from "../types"; +import "./MonitoringCard.scss"; interface Props { status: MonitoringStatus; @@ -39,10 +39,10 @@ export function MonitoringCard({
    Price + className={`cell-value ${status.current_price != null ? "has-price" : "no-price"}`}> {status.current_price != null ? `$${current.toFixed(2)}` - : '— checking'} + : "— checking"}
    @@ -52,14 +52,14 @@ export function MonitoringCard({
    Available - {available ? '✓ In stock' : '✗ Not yet'} + className={`cell-value ${available ? "available-yes" : "available-no"}`}> + {available ? "✓ In stock" : "✗ Not yet"}
    -
    +
    You can close this window. Purchase will execute automatically when diff --git a/code/web-client/src/components/ReceiptCard.tsx b/code/web-client/src/components/ReceiptCard.tsx index 8615c749..14053573 100644 --- a/code/web-client/src/components/ReceiptCard.tsx +++ b/code/web-client/src/components/ReceiptCard.tsx @@ -1,27 +1,27 @@ -import type { PurchaseComplete } from '../types'; -import './ReceiptCard.scss'; +import type { PurchaseComplete } from "../types"; +import "./ReceiptCard.scss"; function getAmountCharge( - closedMandateContent?: Record, + closedMandateContent?: Record ): number { const amountObj = closedMandateContent?.payment_amount as | { amount?: number } | undefined; const amountValue = amountObj?.amount; - return typeof amountValue === 'number' ? amountValue / 100 : 0; + return typeof amountValue === "number" ? amountValue / 100 : 0; } function getPaymentMethod( - closedMandateContent?: Record, + closedMandateContent?: Record ): string { const instrument = closedMandateContent?.payment_instrument as | Record | undefined; - if (instrument?.description && typeof instrument.description === 'string') + if (instrument?.description && typeof instrument.description === "string") return instrument.description; - if (instrument?.type && typeof instrument.type === 'string') + if (instrument?.type && typeof instrument.type === "string") return instrument.type; - return 'Card'; + return "Card"; } interface Props { @@ -35,7 +35,7 @@ export function ReceiptCard({ purchase, itemName }: Props) { | undefined; const amount = getAmountCharge(closedMandateContent); const paymentMethod = getPaymentMethod(closedMandateContent); - const displayName = itemName ?? 'Order'; + const displayName = itemName ?? "Order"; return (
    @@ -79,12 +79,12 @@ export function ReceiptCard({ purchase, itemName }: Props) {
    Transaction chain
    {[ { - label: 'Merchant MCP', - steps: 'check_product → cart → checkout → complete', + label: "Merchant MCP", + steps: "check_product → cart → checkout → complete", }, { - label: 'Credential Provider MCP', - steps: 'issue_payment_credential (verify + issue)', + label: "Credential Provider MCP", + steps: "issue_payment_credential (verify + issue)", }, ].map((s) => (
    @@ -95,13 +95,13 @@ export function ReceiptCard({ purchase, itemName }: Props) {
    - {new Date().toLocaleString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - timeZoneName: 'short', + {new Date().toLocaleString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZoneName: "short", })}
    diff --git a/code/web-client/src/components/UserActionCard.tsx b/code/web-client/src/components/UserActionCard.tsx index 14f7d5a4..6a741228 100644 --- a/code/web-client/src/components/UserActionCard.tsx +++ b/code/web-client/src/components/UserActionCard.tsx @@ -1,11 +1,11 @@ -import './UserActionCard.scss'; +import "./UserActionCard.scss"; interface Props { label: string; sublabel?: string; } -export function UserActionCard({label, sublabel}: Props) { +export function UserActionCard({ label, sublabel }: Props) { return (
    diff --git a/code/web-client/src/hooks/useChat.ts b/code/web-client/src/hooks/useChat.ts index 69ef0b68..403c0191 100644 --- a/code/web-client/src/hooks/useChat.ts +++ b/code/web-client/src/hooks/useChat.ts @@ -1,11 +1,35 @@ -import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; - -import {A2AClient} from '../a2aClient'; -import {AGENT_URL, MERCHANT_TRIGGER_URL} from '../config'; -import type {ChatMessage, InventoryMatch, InventoryOptionsArtifact, MandateApprovalData, MandateChainsFetched, MandateEntry, MandatesSigned, MonitoringStatus, OutgoingDataPayload, Part, ToolCallArtifact} from '../types'; -import {isFunctionResponsePart, isToolCallArtifact} from '../types'; -import {deriveMandateEntries} from '../utils/mandateEntries'; -import {convertToStrictPart, extractErrorFromText, extractInventoryOptionsFromText, extractMandateFromText, extractMonitoringFromText, extractMonitoringJsonFromText, extractProductPreviewUnavailableFromText, extractPurchaseCompleteFromText, parseInvocationParts, parseMainArtifactData, parseToolAndInventoryArtifacts} from '../utils/parsing'; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { A2AClient } from "../a2aClient"; +import { AGENT_URL, MERCHANT_TRIGGER_URL } from "../config"; +import type { + ChatMessage, + InventoryMatch, + InventoryOptionsArtifact, + MandateApprovalData, + MandateChainsFetched, + MandateEntry, + MandatesSigned, + MonitoringStatus, + OutgoingDataPayload, + Part, + ToolCallArtifact, +} from "../types"; +import { isFunctionResponsePart, isToolCallArtifact } from "../types"; +import { deriveMandateEntries } from "../utils/mandateEntries"; +import { + convertToStrictPart, + extractErrorFromText, + extractInventoryOptionsFromText, + extractMandateFromText, + extractMonitoringFromText, + extractMonitoringJsonFromText, + extractProductPreviewUnavailableFromText, + extractPurchaseCompleteFromText, + parseInvocationParts, + parseMainArtifactData, + parseToolAndInventoryArtifacts, +} from "../utils/parsing"; const a2aClient = new A2AClient(AGENT_URL); @@ -14,29 +38,31 @@ const a2aClient = new A2AClient(AGENT_URL); * instructions so the agent doesn't re-ask for product/budget. */ function augmentUserMessageForAgent( - text: string, - messages: ChatMessage[], - ): string { + text: string, + messages: ChatMessage[] +): string { if (text.length >= 220) return text; - const lastUserMsgs = messages.filter((m) => m.role === 'user') - .slice(-8) - .map((m) => m.text ?? '') - .filter(Boolean); - const lastAgentMsgs = messages.filter((m) => m.role === 'agent') - .slice(-4) - .map((m) => m.text ?? '') - .filter(Boolean); + const lastUserMsgs = messages + .filter((m) => m.role === "user") + .slice(-8) + .map((m) => m.text ?? "") + .filter(Boolean); + const lastAgentMsgs = messages + .filter((m) => m.role === "agent") + .slice(-4) + .map((m) => m.text ?? "") + .filter(Boolean); if (lastUserMsgs.length === 0 && lastAgentMsgs.length === 0) return text; const recap = [ - 'Thread context (user last 8):', + "Thread context (user last 8):", ...lastUserMsgs.map((m) => ` U: ${m.slice(0, 200)}`), - 'Agent last 4:', + "Agent last 4:", ...lastAgentMsgs.map((m) => ` A: ${m.slice(0, 300)}`), - '', - 'Do not re-ask for product or budget. If user is confirming after product_preview_unavailable, build slug_0 item_id, call check_product with limited_drop=true, then emit mandate_request — do NOT call search_inventory.', - '', + "", + "Do not re-ask for product or budget. If user is confirming after product_preview_unavailable, build slug_0 item_id, call check_product with limited_drop=true, then emit mandate_request — do NOT call search_inventory.", + "", `User says: ${text}`, - ].join('\n'); + ].join("\n"); return recap; } @@ -45,15 +71,18 @@ function augmentUserMessageForAgent( * or append a new one. */ function upsertMonitoringMessage( - prev: ChatMessage[], - monitoring: MonitoringStatus, - text?: string, - ): ChatMessage[] { - const idx = [...prev].reverse().findIndex( - (m) => m.artifactData && - (m.artifactData as {type?: string}).type === 'monitoring' && - (m.artifactData as MonitoringStatus).item_id === monitoring.item_id, - ); + prev: ChatMessage[], + monitoring: MonitoringStatus, + text?: string +): ChatMessage[] { + const idx = [...prev] + .reverse() + .findIndex( + (m) => + m.artifactData && + (m.artifactData as { type?: string }).type === "monitoring" && + (m.artifactData as MonitoringStatus).item_id === monitoring.item_id + ); if (idx >= 0) { const realIdx = prev.length - 1 - idx; const updated = [...prev]; @@ -69,7 +98,7 @@ function upsertMonitoringMessage( ...prev, { id: crypto.randomUUID(), - role: 'agent' as const, + role: "agent" as const, artifactData: monitoring, text, timestamp: Date.now(), @@ -105,20 +134,23 @@ function upsertMonitoringMessage( export function useChat() { const [messages, setMessages] = useState([]); - const fetchMandate = useCallback(async(id: string): Promise => { + const fetchMandate = useCallback(async (id: string): Promise => { const resp = await fetch(`${AGENT_URL}/mandates/${id}`); if (!resp.ok) throw new Error(`Failed to fetch mandate ${id}`); return resp.text(); }, []); - const [input, setInput] = useState(''); + const [input, setInput] = useState(""); const [loading, setLoading] = useState(false); - const [pendingTaskId, setPendingTaskId] = useState(); - const [lastSelectedItemName, setLastSelectedItemName] = - useState(); - const [lastInventoryMatches, setLastInventoryMatches] = - useState(); - const [lastInventoryOptions, setLastInventoryOptions] = - useState(); + const [pendingTaskId, setPendingTaskId] = useState(); + const [lastSelectedItemName, setLastSelectedItemName] = useState< + string | undefined + >(); + const [lastInventoryMatches, setLastInventoryMatches] = useState< + InventoryMatch[] | undefined + >(); + const [lastInventoryOptions, setLastInventoryOptions] = useState< + InventoryOptionsArtifact | undefined + >(); const loadingRef = useRef(loading); useEffect(() => { @@ -130,8 +162,10 @@ export function useChat() { const usedServers = useMemo(() => { const set = new Set(); for (const msg of messages) { - if (msg.artifactData && - (msg.artifactData as {type?: string}).type === 'tool_call') { + if ( + msg.artifactData && + (msg.artifactData as { type?: string }).type === "tool_call" + ) { set.add((msg.artifactData as ToolCallArtifact).server); } } @@ -142,11 +176,13 @@ export function useChat() { const monitoringData = useMemo(() => { for (let i = messages.length - 1; i >= 0; i--) { const m = messages[i]; - if (m.artifactData && - (m.artifactData as {type?: string}).type === 'monitoring') { + if ( + m.artifactData && + (m.artifactData as { type?: string }).type === "monitoring" + ) { return m.artifactData as MonitoringStatus; } - if (m.text && m.role === 'agent') { + if (m.text && m.role === "agent") { const parsed = extractMonitoringFromText(m.text); if (parsed) return parsed; } @@ -155,373 +191,399 @@ export function useChat() { }, [messages]); const hasPurchaseComplete = messages.some( - (m) => m.artifactData && - (m.artifactData as {type?: string}).type === 'purchase_complete', + (m) => + m.artifactData && + (m.artifactData as { type?: string }).type === "purchase_complete" ); // Derive mandate entries for the Mandates tab by scanning messages. const mandates: MandateEntry[] = useMemo( - () => deriveMandateEntries(messages), - [messages], + () => deriveMandateEntries(messages), + [messages] ); const isMonitoring = - monitoringData != null && !hasPurchaseComplete && !loading; - - const addMessage = useCallback((msg: Omit) => { - setMessages( - (prev) => - [...prev, - {...msg, id: crypto.randomUUID(), timestamp: Date.now()}, - ]); - }, []); + monitoringData != null && !hasPurchaseComplete && !loading; + + const addMessage = useCallback( + (msg: Omit) => { + setMessages((prev) => [ + ...prev, + { ...msg, id: crypto.randomUUID(), timestamp: Date.now() }, + ]); + }, + [] + ); const sendToAgent = useCallback( - async ( - text: string|OutgoingDataPayload, - taskId?: string, - ) => { - setLoading(true); - const tid = taskId ?? crypto.randomUUID(); - setPendingTaskId(tid); - let agentTextBuffer = ''; - const addedToolCallsInThisRun = new Set(); - - // Build a dedup key that distinguishes multiple invocations of the same - // tool with different arguments (e.g. present_mandate_chain called - // once per audience). - const toolCallKey = (tc: ToolCallArtifact): string => - tc.args ? `${tc.tool}:${JSON.stringify(tc.args)}` : tc.tool; - - try { - for await (const event of a2aClient.sendMessage(text, tid)) { - if (event.type === 'status') { - console.log( - '[useChat.ts] Received status event:', - JSON.stringify(event, null, 2)); - if (event.status.state === 'failed') { - addMessage({ - role: 'system', - text: `Agent error: ${JSON.stringify(event.status.message)}`, - }); - } - const statusParts = event.status.message?.parts ?? []; - - // Intercept tool responses to inject mandates and trigger fetches - for (const rawPart of statusParts) { - if (isFunctionResponsePart(rawPart)) { - const toolName = rawPart.data.name; - const resp = rawPart.data.response as Record; - - if (toolName === 'assemble_and_sign_mandates_tool') { - if (typeof resp.open_checkout_mandate === 'string' && - typeof resp.open_payment_mandate === 'string') { - console.log( - '[useChat.ts] Intercepted assemble_and_sign_mandates_tool response, fetching full mandates'); - Promise - .all([ - fetchMandate(resp.open_checkout_mandate), - fetchMandate(resp.open_payment_mandate) - ]) - .then(([openChkToken, openPayToken]) => { - addMessage({ - role: 'agent', - artifactData: { - type: 'mandates_signed', - open_checkout_mandate: openChkToken, - open_payment_mandate: openPayToken, - } as MandatesSigned, - }); - }) - .catch( - e => console.error( - 'Failed to fetch open mandates:', e)); - } - } else if ( - toolName === 'create_checkout_presentation' && - typeof resp.checkout_mandate_chain_id === 'string') { - console.log( - '[useChat.ts] Intercepted create_checkout_presentation response, fetching full chain'); - fetchMandate(resp.checkout_mandate_chain_id) - .then(token => { - addMessage({ - role: 'agent', - artifactData: { - type: 'mandate_chains_fetched', - checkout_mandate_chain: token, - } as MandateChainsFetched, - }); - }) - .catch( - e => console.error( - 'Failed to fetch checkout mandate:', e)); - } else if ( - toolName === 'create_payment_presentation' && - typeof resp.payment_mandate_chain_id === 'string') { + async (text: string | OutgoingDataPayload, taskId?: string) => { + setLoading(true); + const tid = taskId ?? crypto.randomUUID(); + setPendingTaskId(tid); + let agentTextBuffer = ""; + const addedToolCallsInThisRun = new Set(); + + // Build a dedup key that distinguishes multiple invocations of the same + // tool with different arguments (e.g. present_mandate_chain called + // once per audience). + const toolCallKey = (tc: ToolCallArtifact): string => + tc.args ? `${tc.tool}:${JSON.stringify(tc.args)}` : tc.tool; + + try { + for await (const event of a2aClient.sendMessage(text, tid)) { + if (event.type === "status") { + console.log( + "[useChat.ts] Received status event:", + JSON.stringify(event, null, 2) + ); + if (event.status.state === "failed") { + addMessage({ + role: "system", + text: `Agent error: ${JSON.stringify(event.status.message)}`, + }); + } + const statusParts = event.status.message?.parts ?? []; + + // Intercept tool responses to inject mandates and trigger fetches + for (const rawPart of statusParts) { + if (isFunctionResponsePart(rawPart)) { + const toolName = rawPart.data.name; + const resp = rawPart.data.response as Record; + + if (toolName === "assemble_and_sign_mandates_tool") { + if ( + typeof resp.open_checkout_mandate === "string" && + typeof resp.open_payment_mandate === "string" + ) { console.log( - '[useChat.ts] Intercepted create_payment_presentation response, fetching full chain'); - fetchMandate(resp.payment_mandate_chain_id) - .then(token => { - addMessage({ - role: 'agent', - artifactData: { - type: 'mandate_chains_fetched', - payment_mandate_chain: token, - } as MandateChainsFetched, - }); - }) - .catch( - e => console.error( - 'Failed to fetch payment mandate:', e)); - } - } - } - - if (statusParts.length > 0) { - const strictStatusParts = - statusParts.map((p) => convertToStrictPart(p)) - .filter((p): p is Part => p !== undefined); - const explicit = - parseToolAndInventoryArtifacts(strictStatusParts); - const invocations = parseInvocationParts(strictStatusParts); - const toolCalls: ToolCallArtifact[] = [ - ...explicit.filter(isToolCallArtifact), - ...invocations, - ]; - for (const tc of toolCalls) { - const key = toolCallKey(tc); - if (!addedToolCallsInThisRun.has(key)) { - addMessage({role: 'agent', artifactData: tc}); - addedToolCallsInThisRun.add(key); + "[useChat.ts] Intercepted assemble_and_sign_mandates_tool response, fetching full mandates" + ); + Promise.all([ + fetchMandate(resp.open_checkout_mandate), + fetchMandate(resp.open_payment_mandate), + ]) + .then(([openChkToken, openPayToken]) => { + addMessage({ + role: "agent", + artifactData: { + type: "mandates_signed", + open_checkout_mandate: openChkToken, + open_payment_mandate: openPayToken, + } as MandatesSigned, + }); + }) + .catch((e) => + console.error("Failed to fetch open mandates:", e) + ); } + } else if ( + toolName === "create_checkout_presentation" && + typeof resp.checkout_mandate_chain_id === "string" + ) { + console.log( + "[useChat.ts] Intercepted create_checkout_presentation response, fetching full chain" + ); + fetchMandate(resp.checkout_mandate_chain_id) + .then((token) => { + addMessage({ + role: "agent", + artifactData: { + type: "mandate_chains_fetched", + checkout_mandate_chain: token, + } as MandateChainsFetched, + }); + }) + .catch((e) => + console.error("Failed to fetch checkout mandate:", e) + ); + } else if ( + toolName === "create_payment_presentation" && + typeof resp.payment_mandate_chain_id === "string" + ) { + console.log( + "[useChat.ts] Intercepted create_payment_presentation response, fetching full chain" + ); + fetchMandate(resp.payment_mandate_chain_id) + .then((token) => { + addMessage({ + role: "agent", + artifactData: { + type: "mandate_chains_fetched", + payment_mandate_chain: token, + } as MandateChainsFetched, + }); + }) + .catch((e) => + console.error("Failed to fetch payment mandate:", e) + ); } } - } else if (event.type === 'artifact') { - console.log( - '[useChat.ts] Received artifact event:', - JSON.stringify(event, null, 2)); - const parts = event.artifact.parts; - for (const p of parts) { - if (p.text) agentTextBuffer += p.text; - } + } - const explicit = parseToolAndInventoryArtifacts( - parts.map((p) => convertToStrictPart(p)) - .filter((p): p is Part => p !== undefined)); - const invocations = parseInvocationParts( - parts.map((p) => convertToStrictPart(p)) - .filter((p): p is Part => p !== undefined)); + if (statusParts.length > 0) { + const strictStatusParts = statusParts + .map((p) => convertToStrictPart(p)) + .filter((p): p is Part => p !== undefined); + const explicit = + parseToolAndInventoryArtifacts(strictStatusParts); + const invocations = parseInvocationParts(strictStatusParts); const toolCalls: ToolCallArtifact[] = [ ...explicit.filter(isToolCallArtifact), ...invocations, ]; - const inventoryOpts = explicit.filter( - (a): a is InventoryOptionsArtifact => - (a as {type?: string}).type === 'inventory_options', - ); - for (const tc of toolCalls) { const key = toolCallKey(tc); if (!addedToolCallsInThisRun.has(key)) { - addMessage({role: 'agent', artifactData: tc}); + addMessage({ role: "agent", artifactData: tc }); addedToolCallsInThisRun.add(key); } } - for (const inv of inventoryOpts) { - addMessage({role: 'agent', artifactData: inv}); - const sel = inv.matches.find((m) => m.item_id === inv.selected); - if (sel) setLastSelectedItemName(sel.name); - setLastInventoryMatches(inv.matches); - setLastInventoryOptions(inv); - } + } + } else if (event.type === "artifact") { + console.log( + "[useChat.ts] Received artifact event:", + JSON.stringify(event, null, 2) + ); + const parts = event.artifact.parts; + for (const p of parts) { + if (p.text) agentTextBuffer += p.text; + } - // Early monitoring extraction during streaming - if (!event.artifact.lastChunk && agentTextBuffer) { - const earlyMon = extractMonitoringJsonFromText(agentTextBuffer); - if (earlyMon) { - setMessages( - (prev) => upsertMonitoringMessage( - prev, earlyMon, agentTextBuffer)); - } + const explicit = parseToolAndInventoryArtifacts( + parts + .map((p) => convertToStrictPart(p)) + .filter((p): p is Part => p !== undefined) + ); + const invocations = parseInvocationParts( + parts + .map((p) => convertToStrictPart(p)) + .filter((p): p is Part => p !== undefined) + ); + const toolCalls: ToolCallArtifact[] = [ + ...explicit.filter(isToolCallArtifact), + ...invocations, + ]; + const inventoryOpts = explicit.filter( + (a): a is InventoryOptionsArtifact => + (a as { type?: string }).type === "inventory_options" + ); + + for (const tc of toolCalls) { + const key = toolCallKey(tc); + if (!addedToolCallsInThisRun.has(key)) { + addMessage({ role: "agent", artifactData: tc }); + addedToolCallsInThisRun.add(key); } + } + for (const inv of inventoryOpts) { + addMessage({ role: "agent", artifactData: inv }); + const sel = inv.matches.find((m) => m.item_id === inv.selected); + if (sel) setLastSelectedItemName(sel.name); + setLastInventoryMatches(inv.matches); + setLastInventoryOptions(inv); + } - let showedInventoryFromText = false; - if (event.artifact.lastChunk && agentTextBuffer) { - if (inventoryOpts.length === 0) { - const inv = extractInventoryOptionsFromText(agentTextBuffer); - if (inv) { - addMessage({role: 'agent', artifactData: inv}); - showedInventoryFromText = true; - const sel = - inv.matches.find((m) => m.item_id === inv.selected); - if (sel) setLastSelectedItemName(sel.name); - setLastInventoryMatches(inv.matches); - setLastInventoryOptions(inv); - } - } + // Early monitoring extraction during streaming + if (!event.artifact.lastChunk && agentTextBuffer) { + const earlyMon = extractMonitoringJsonFromText(agentTextBuffer); + if (earlyMon) { + setMessages((prev) => + upsertMonitoringMessage(prev, earlyMon, agentTextBuffer) + ); } + } - if (event.artifact.lastChunk) { - const strictParts = - parts.map((p) => convertToStrictPart(p)) - .filter((p): p is Part => p !== undefined); - const mainData = parseMainArtifactData(strictParts) ?? - (agentTextBuffer ? - (extractMandateFromText(agentTextBuffer) ?? - extractProductPreviewUnavailableFromText( - agentTextBuffer) ?? - extractPurchaseCompleteFromText(agentTextBuffer) ?? - extractErrorFromText(agentTextBuffer) ?? - extractMonitoringFromText(agentTextBuffer)) : - undefined); - - // For monitoring artifacts, upsert instead of append - if (mainData && - (mainData as {type?: string}).type === 'monitoring') { - setMessages( - (prev) => upsertMonitoringMessage( - prev, - mainData as MonitoringStatus, - agentTextBuffer || undefined, - ), + let showedInventoryFromText = false; + if (event.artifact.lastChunk && agentTextBuffer) { + if (inventoryOpts.length === 0) { + const inv = extractInventoryOptionsFromText(agentTextBuffer); + if (inv) { + addMessage({ role: "agent", artifactData: inv }); + showedInventoryFromText = true; + const sel = inv.matches.find( + (m) => m.item_id === inv.selected ); - } else if (mainData) { - addMessage({ - role: 'agent', - artifactData: mainData, - text: agentTextBuffer || undefined, - }); - } else if ( - agentTextBuffer && inventoryOpts.length === 0 && - !showedInventoryFromText) { - addMessage({role: 'agent', text: agentTextBuffer}); + if (sel) setLastSelectedItemName(sel.name); + setLastInventoryMatches(inv.matches); + setLastInventoryOptions(inv); } - agentTextBuffer = ''; } } - } - } catch (e) { - addMessage({role: 'system', text: `Connection error: ${String(e)}`}); - } finally { - setLoading(false); - } - }, - [addMessage, fetchMandate]); - // Trigger-state polling: 500ms interval while monitoring - const lastTriggerStateRef = useRef(''); - useEffect( - () => { - if (!isMonitoring || !monitoringData?.item_id) return; - const interval = setInterval(async () => { - try { - const resp = await fetch( - `${MERCHANT_TRIGGER_URL}/state?item_id=${ - encodeURIComponent(monitoringData.item_id)}`, - ); - if (!resp.ok) return; - const json = await resp.json(); - const str = JSON.stringify(json); - if (str !== lastTriggerStateRef.current && - lastTriggerStateRef.current !== '') { - pendingTriggerNudgeRef.current = true; + if (event.artifact.lastChunk) { + const strictParts = parts + .map((p) => convertToStrictPart(p)) + .filter((p): p is Part => p !== undefined); + const mainData = + parseMainArtifactData(strictParts) ?? + (agentTextBuffer + ? (extractMandateFromText(agentTextBuffer) ?? + extractProductPreviewUnavailableFromText(agentTextBuffer) ?? + extractPurchaseCompleteFromText(agentTextBuffer) ?? + extractErrorFromText(agentTextBuffer) ?? + extractMonitoringFromText(agentTextBuffer)) + : undefined); + + // For monitoring artifacts, upsert instead of append + if ( + mainData && + (mainData as { type?: string }).type === "monitoring" + ) { + setMessages((prev) => + upsertMonitoringMessage( + prev, + mainData as MonitoringStatus, + agentTextBuffer || undefined + ) + ); + } else if (mainData) { + addMessage({ + role: "agent", + artifactData: mainData, + text: agentTextBuffer || undefined, + }); + } else if ( + agentTextBuffer && + inventoryOpts.length === 0 && + !showedInventoryFromText + ) { + addMessage({ role: "agent", text: agentTextBuffer }); + } + agentTextBuffer = ""; } - lastTriggerStateRef.current = str; - } catch { - // ignore fetch errors } - }, 500); - return () => clearInterval(interval); - }, - [isMonitoring, monitoringData?.item_id], + } + } catch (e) { + addMessage({ role: "system", text: `Connection error: ${String(e)}` }); + } finally { + setLoading(false); + } + }, + [addMessage, fetchMandate] ); + // Trigger-state polling: 500ms interval while monitoring + const lastTriggerStateRef = useRef(""); + useEffect(() => { + if (!isMonitoring || !monitoringData?.item_id) return; + const interval = setInterval(async () => { + try { + const resp = await fetch( + `${MERCHANT_TRIGGER_URL}/state?item_id=${encodeURIComponent( + monitoringData.item_id + )}` + ); + if (!resp.ok) return; + const json = await resp.json(); + const str = JSON.stringify(json); + if ( + str !== lastTriggerStateRef.current && + lastTriggerStateRef.current !== "" + ) { + pendingTriggerNudgeRef.current = true; + } + lastTriggerStateRef.current = str; + } catch { + // ignore fetch errors + } + }, 500); + return () => clearInterval(interval); + }, [isMonitoring, monitoringData?.item_id]); + // When loading clears and a trigger nudge is pending, send check_product_now - useEffect( - () => { - if (!loading && pendingTriggerNudgeRef.current && - monitoringData?.item_id && monitoringData?.price_cap != null && - !hasPurchaseComplete) { - pendingTriggerNudgeRef.current = false; - sendToAgent( - { - type: 'check_product_now', + useEffect(() => { + if ( + !loading && + pendingTriggerNudgeRef.current && + monitoringData?.item_id && + monitoringData?.price_cap != null && + !hasPurchaseComplete + ) { + pendingTriggerNudgeRef.current = false; + sendToAgent( + { + type: "check_product_now", + item_id: monitoringData.item_id, + price_cap: monitoringData.price_cap, + qty: monitoringData.qty ?? 1, + open_checkout_mandate: monitoringData.open_checkout_mandate, + open_payment_mandate: monitoringData.open_payment_mandate, + message: "Check product now", + source: "trigger_state_watch", + }, + pendingTaskId + ); + } + }, [ + loading, + monitoringData, + hasPurchaseComplete, + sendToAgent, + pendingTaskId, + ]); + + // Auto-poll fallback (15s) + useEffect(() => { + if (!isMonitoring || hasPurchaseComplete || !pendingTaskId) return; + const interval = setInterval(() => { + if (!loadingRef.current) { + const msg = + monitoringData?.item_id != null && monitoringData?.price_cap != null + ? { + type: "check_product_now" as const, item_id: monitoringData.item_id, price_cap: monitoringData.price_cap, qty: monitoringData.qty ?? 1, open_checkout_mandate: monitoringData.open_checkout_mandate, open_payment_mandate: monitoringData.open_payment_mandate, - message: 'Check product now', - source: 'trigger_state_watch', - }, - pendingTaskId, - ); - } - }, - [ - loading, monitoringData, hasPurchaseComplete, sendToAgent, pendingTaskId - ]); - - // Auto-poll fallback (15s) - useEffect( - () => { - if (!isMonitoring || hasPurchaseComplete || !pendingTaskId) return; - const interval = setInterval(() => { - if (!loadingRef.current) { - const msg = monitoringData?.item_id != null && - monitoringData?.price_cap != null ? - { - type: 'check_product_now' as const, - item_id: monitoringData.item_id, - price_cap: monitoringData.price_cap, - qty: monitoringData.qty ?? 1, - open_checkout_mandate: monitoringData.open_checkout_mandate, - open_payment_mandate: monitoringData.open_payment_mandate, - message: 'Check product now', - source: 'auto_poll' as const, - } : - 'Check price now'; - sendToAgent(msg, pendingTaskId); - } - }, 15000); - return () => clearInterval(interval); - }, - [ - isMonitoring, - hasPurchaseComplete, - pendingTaskId, - monitoringData?.item_id, - monitoringData?.price_cap, - monitoringData?.open_checkout_mandate, - monitoringData?.open_payment_mandate, - monitoringData?.qty, - sendToAgent, - ]); + message: "Check product now", + source: "auto_poll" as const, + } + : "Check price now"; + sendToAgent(msg, pendingTaskId); + } + }, 15000); + return () => clearInterval(interval); + }, [ + isMonitoring, + hasPurchaseComplete, + pendingTaskId, + monitoringData?.item_id, + monitoringData?.price_cap, + monitoringData?.open_checkout_mandate, + monitoringData?.open_payment_mandate, + monitoringData?.qty, + sendToAgent, + ]); - async function handleSend(opts?: {fallbackIfEmpty?: string}) { + async function handleSend(opts?: { fallbackIfEmpty?: string }) { const raw = input.trim(); const text = raw || opts?.fallbackIfEmpty; if (!text) return; - setInput(''); + setInput(""); const augmented = augmentUserMessageForAgent(text, messages); - addMessage({role: 'user', text}); + addMessage({ role: "user", text }); await sendToAgent(augmented); } async function handleMandateApprove(mandateRequest: MandateApprovalData) { addMessage({ - role: 'user_action', - userActionLabel: 'Approved mandate', - userActionSublabel: 'User signed over the TS surface with agent provider key', + role: "user_action", + userActionLabel: "Approved mandate", + userActionSublabel: + "User signed over the TS surface with agent provider key", }); await sendToAgent( - {type: 'mandate_approved', mandate_request: mandateRequest}, - pendingTaskId, + { type: "mandate_approved", mandate_request: mandateRequest }, + pendingTaskId ); } function handleMandateReject() { - addMessage({role: 'system', text: 'Mandate rejected. Purchase cancelled.'}); + addMessage({ + role: "system", + text: "Mandate rejected. Purchase cancelled.", + }); } return { diff --git a/code/web-client/src/main.tsx b/code/web-client/src/main.tsx index c3759bb6..e90aa4f1 100644 --- a/code/web-client/src/main.tsx +++ b/code/web-client/src/main.tsx @@ -1,12 +1,12 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import App from './App'; -import './styles/global.scss'; +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import "./styles/global.scss"; -const rootElement = document.getElementById('root'); -if (!rootElement) throw new Error('Root element not found'); +const rootElement = document.getElementById("root"); +if (!rootElement) throw new Error("Root element not found"); ReactDOM.createRoot(rootElement).render( - , + ); diff --git a/code/web-client/src/utils/mandateEntries.ts b/code/web-client/src/utils/mandateEntries.ts index 4c9a98e4..cca558ff 100644 --- a/code/web-client/src/utils/mandateEntries.ts +++ b/code/web-client/src/utils/mandateEntries.ts @@ -25,9 +25,9 @@ import type { MandatesSigned, PurchaseComplete, ToolCallArtifact, -} from '../types'; +} from "../types"; -type Draft = Omit; +type Draft = Omit; /** Stable string key for dedup (same token OR same decoded JSON object). */ function entryKey(d: Draft): string { @@ -36,13 +36,13 @@ function entryKey(d: Draft): string { // For tool-call-derived entries, identify by stable discriminators so we // don't emit duplicates if the stream replays the same tool invocation. const p = d.rawPayload; - if (typeof p.checkout_hash === 'string') { + if (typeof p.checkout_hash === "string") { return `${d.kind}:hash:${p.checkout_hash}`; } - if (typeof p.transaction_id === 'string') { + if (typeof p.transaction_id === "string") { return `${d.kind}:tx:${p.transaction_id}`; } - if (typeof p.mandate_chain_id === 'string' && typeof p.aud === 'string') { + if (typeof p.mandate_chain_id === "string" && typeof p.aud === "string") { return `${d.kind}:${p.aud}:${p.mandate_chain_id}`; } return `${d.kind}:payload:${JSON.stringify(p)}`; @@ -56,10 +56,13 @@ function purchaseEntries(msg: ChatMessage, pc: PurchaseComplete): Draft[] { // Closed payment mandate -- token form. const closedPaymentToken = extra.closed_payment_mandate; - if (typeof closedPaymentToken === 'string' && closedPaymentToken.includes('~')) { + if ( + typeof closedPaymentToken === "string" && + closedPaymentToken.includes("~") + ) { out.push({ - kind: 'closed_payment_mandate', - title: 'Closed Payment Mandate', + kind: "closed_payment_mandate", + title: "Closed Payment Mandate", subtitle: pc.order_id, timestamp: msg.timestamp, rawToken: closedPaymentToken, @@ -68,10 +71,13 @@ function purchaseEntries(msg: ChatMessage, pc: PurchaseComplete): Draft[] { // Closed checkout mandate -- token form. const closedCheckoutToken = extra.closed_checkout_mandate; - if (typeof closedCheckoutToken === 'string' && closedCheckoutToken.includes('~')) { + if ( + typeof closedCheckoutToken === "string" && + closedCheckoutToken.includes("~") + ) { out.push({ - kind: 'closed_checkout_mandate', - title: 'Closed Checkout Mandate', + kind: "closed_checkout_mandate", + title: "Closed Checkout Mandate", subtitle: pc.order_id, timestamp: msg.timestamp, rawToken: closedCheckoutToken, @@ -80,10 +86,10 @@ function purchaseEntries(msg: ChatMessage, pc: PurchaseComplete): Draft[] { // Checkout JWT (merchant-signed, not an SD-JWT). const checkoutJwt = extra.checkout_jwt; - if (typeof checkoutJwt === 'string' && checkoutJwt.split('.').length === 3) { + if (typeof checkoutJwt === "string" && checkoutJwt.split(".").length === 3) { out.push({ - kind: 'checkout_jwt', - title: 'Checkout JWT', + kind: "checkout_jwt", + title: "Checkout JWT", subtitle: pc.order_id, timestamp: msg.timestamp, rawToken: checkoutJwt, @@ -102,70 +108,74 @@ function toolCallEntries(_msg: ChatMessage, _tc: ToolCallArtifact): Draft[] { export function deriveMandateEntries(messages: ChatMessage[]): MandateEntry[] { const drafts: Draft[] = []; for (const msg of messages) { - const data = msg.artifactData as {type?: string} | undefined; + const data = msg.artifactData as { type?: string } | undefined; if (!data) continue; switch (data.type) { - case 'mandates_signed': { + case "mandates_signed": { const ms = data as unknown as MandatesSigned; if (ms.open_checkout_mandate) { drafts.push({ - kind: 'open_checkout_mandate', - title: 'Open Checkout Mandate', + kind: "open_checkout_mandate", + title: "Open Checkout Mandate", timestamp: msg.timestamp, rawToken: ms.open_checkout_mandate, }); } if (ms.open_payment_mandate) { drafts.push({ - kind: 'open_payment_mandate', - title: 'Open Payment Mandate', + kind: "open_payment_mandate", + title: "Open Payment Mandate", timestamp: msg.timestamp, rawToken: ms.open_payment_mandate, }); } break; } - case 'purchase_complete': - drafts.push(...purchaseEntries(msg, data as unknown as PurchaseComplete)); + case "purchase_complete": + drafts.push( + ...purchaseEntries(msg, data as unknown as PurchaseComplete) + ); break; - case 'tool_call': - drafts.push(...toolCallEntries(msg, data as unknown as ToolCallArtifact)); + case "tool_call": + drafts.push( + ...toolCallEntries(msg, data as unknown as ToolCallArtifact) + ); break; - case 'mandate_chains_fetched': { + case "mandate_chains_fetched": { const mcf = data as unknown as MandateChainsFetched; if (mcf.payment_mandate_chain) { drafts.push({ - kind: 'mandate_chain', - title: 'Payment Mandate Chain', + kind: "mandate_chain", + title: "Payment Mandate Chain", timestamp: msg.timestamp, rawToken: mcf.payment_mandate_chain, }); // Extract closed payment mandate - const parts = mcf.payment_mandate_chain.split('~~'); + const parts = mcf.payment_mandate_chain.split("~~"); const closedToken = parts[parts.length - 1]; drafts.push({ - kind: 'closed_payment_mandate', - title: 'Closed Payment Mandate', + kind: "closed_payment_mandate", + title: "Closed Payment Mandate", timestamp: msg.timestamp, rawToken: closedToken, }); } if (mcf.checkout_mandate_chain) { drafts.push({ - kind: 'mandate_chain', - title: 'Checkout Mandate Chain', + kind: "mandate_chain", + title: "Checkout Mandate Chain", timestamp: msg.timestamp, rawToken: mcf.checkout_mandate_chain, }); // Extract closed checkout mandate - const parts = mcf.checkout_mandate_chain.split('~~'); + const parts = mcf.checkout_mandate_chain.split("~~"); const closedToken = parts[parts.length - 1]; drafts.push({ - kind: 'closed_checkout_mandate', - title: 'Closed Checkout Mandate', + kind: "closed_checkout_mandate", + title: "Closed Checkout Mandate", timestamp: msg.timestamp, rawToken: closedToken, }); @@ -183,7 +193,7 @@ export function deriveMandateEntries(messages: ChatMessage[]): MandateEntry[] { const key = entryKey(d); if (seen.has(key)) continue; seen.add(key); - result.push({...d, id: `mandate_${result.length}_${d.timestamp}`}); + result.push({ ...d, id: `mandate_${result.length}_${d.timestamp}` }); } return result; } diff --git a/code/web-client/src/utils/productPreviewUnavailable.ts b/code/web-client/src/utils/productPreviewUnavailable.ts index 69011923..6bc1d97b 100644 --- a/code/web-client/src/utils/productPreviewUnavailable.ts +++ b/code/web-client/src/utils/productPreviewUnavailable.ts @@ -1,4 +1,4 @@ -import type {ProductPreviewUnavailable} from '../types'; +import type { ProductPreviewUnavailable } from "../types"; /** * Coerce loose LLM-emitted JSON into a typed ProductPreviewUnavailable. @@ -6,29 +6,29 @@ import type {ProductPreviewUnavailable} from '../types'; * undefined. */ export function normalizeProductPreviewUnavailable( - raw: Record, - ): ProductPreviewUnavailable|undefined { - if (raw?.type !== 'product_preview_unavailable') return undefined; + raw: Record +): ProductPreviewUnavailable | undefined { + if (raw?.type !== "product_preview_unavailable") return undefined; const productName = - typeof raw.product_name === 'string' ? raw.product_name : undefined; + typeof raw.product_name === "string" ? raw.product_name : undefined; if (!productName) return undefined; - const parsePrice = (v: unknown): number|undefined => { - if (typeof v === 'number') return v; - if (typeof v === 'string') { - const cleaned = v.replace(/[$,]/g, '').trim(); + const parsePrice = (v: unknown): number | undefined => { + if (typeof v === "number") return v; + if (typeof v === "string") { + const cleaned = v.replace(/[$,]/g, "").trim(); const n = Number(cleaned); return Number.isNaN(n) ? undefined : n; } return undefined; }; - const emptyToUndef = (v: unknown): string|undefined => - typeof v === 'string' && v.trim() ? v.trim() : undefined; + const emptyToUndef = (v: unknown): string | undefined => + typeof v === "string" && v.trim() ? v.trim() : undefined; return { - type: 'product_preview_unavailable', + type: "product_preview_unavailable", product_name: productName, product_subtitle: emptyToUndef(raw.product_subtitle), image_emoji: emptyToUndef(raw.image_emoji), From 39f8a0ca16890c8859109409ffee0a868142e0e2 Mon Sep 17 00:00:00 2001 From: iLoveChicken Date: Wed, 29 Apr 2026 20:20:29 +0100 Subject: [PATCH 6/8] ci: disable ESLint-based TSX and TYPESCRIPT_ES linters The project uses Biome for TypeScript/TSX quality (BIOME_LINT). Super-linter itself warns in CI output that running Biome and ESLint simultaneously on TSX files causes conflicts and recommends disabling one. Disable the ESLint-based checkers so Biome is the single source of truth for TypeScript quality. The ESLint checks were also producing false positives: - TSX: react/react-in-jsx-scope is not required for React 17+ JSX transform; super-linter's ESLint also lacks node_modules so every module import is flagged as unresolvable - TYPESCRIPT_ES: browser APIs (fetch, crypto.randomUUID) are flagged as unsupported Node.js built-ins because super-linter treats all TS as server-side Node.js rather than browser code --- .github/workflows/linter.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/linter.yaml b/.github/workflows/linter.yaml index fea1b9c1..99a9c5af 100644 --- a/.github/workflows/linter.yaml +++ b/.github/workflows/linter.yaml @@ -47,3 +47,13 @@ jobs: VALIDATE_TRIVY: false VALIDATE_PYTHON_RUFF: false VALIDATE_PYTHON_RUFF_FORMAT: false + # The project uses Biome for TypeScript/TSX linting (BIOME_LINT above). + # Super-linter warns that running both Biome and ESLint on the same files + # causes conflicts; disable the ESLint-based TS/TSX linters so Biome is + # the single source of truth for TypeScript quality. + # ESLint false-positives also appear because: + # - TSX: super-linter ESLint lacks node_modules so all imports fail + # - TSX: react/react-in-jsx-scope is obsolete for React 17+ JSX transform + # - TYPESCRIPT_ES: browser APIs (fetch, crypto) flagged as unsupported Node builtins + VALIDATE_TSX: false + VALIDATE_TYPESCRIPT_ES: false From 6dcfe53cc1926223814c608173529bb4099298a5 Mon Sep 17 00:00:00 2001 From: iLoveChicken Date: Wed, 29 Apr 2026 20:27:49 +0100 Subject: [PATCH 7/8] fix(web-client): replace block divs inside button with phrasing spans HTML spec forbids flow content (div) inside a button element. Replace the inner layout divs in ItemRow with span elements and add `display: block` to the SCSS rules for .item-name, .item-id, .item-price and .item-stock so they continue to render as block boxes. Also consistently use the destructured `messages` variable throughout App.tsx instead of mixing chatState.messages and messages. Addresses Gemini code-review feedback on PR #249. --- code/web-client/src/App.tsx | 4 +-- .../src/components/InventoryOptionsCard.scss | 14 ++++++---- .../src/components/InventoryOptionsCard.tsx | 26 +++++++++---------- 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/code/web-client/src/App.tsx b/code/web-client/src/App.tsx index f01fb8ed..a12181c5 100644 --- a/code/web-client/src/App.tsx +++ b/code/web-client/src/App.tsx @@ -175,9 +175,9 @@ export default function App() { {activeTab === "chat" ? ( <>
    - {chatState.messages.length > 0 ? ( + {messages.length > 0 ? (
    - {chatState.messages.map((msg) => ( + {messages.map((msg) => ( -
    + {selected && ( -
    + Selected -
    +
    )} - {!selected &&
    } -
    -
    {item.name}
    -
    {item.item_id}
    -
    -
    -
    -
    ${item.price.toFixed(2)}
    + {!selected && } + + {item.name} + {item.item_id} + + + + ${item.price.toFixed(2)} {item.stock != null && ( -
    {item.stock} in stock
    + {item.stock} in stock )} -
    + ); } From 28d4c71039eb58c77eef8f989156c078e022d7fc Mon Sep 17 00:00:00 2001 From: iLoveChicken Date: Wed, 29 Apr 2026 21:57:37 +0100 Subject: [PATCH 8/8] fix(web-client): apply modern CSS color notation and unquote font names Stylelint requires: - rgb() instead of rgba() with alpha-value-notation: percentage (color-function-alias-notation + color-function-notation rules) - Unquoted font-family names for non-generic custom fonts (font-family-name-quotes rule) Converts rgba(52, 211, 153, 0.15/0.3) to rgb(52 211 153 / 15%/30%) and removes quotes from the "Geist" font-family declarations. These were pre-existing violations exposed by the previous commit touching the file. --- code/web-client/src/components/InventoryOptionsCard.scss | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/code/web-client/src/components/InventoryOptionsCard.scss b/code/web-client/src/components/InventoryOptionsCard.scss index b06fa718..94c338b8 100644 --- a/code/web-client/src/components/InventoryOptionsCard.scss +++ b/code/web-client/src/components/InventoryOptionsCard.scss @@ -11,8 +11,8 @@ width: 20px; height: 20px; border-radius: 50%; - background: rgba(52, 211, 153, 0.15); - border: 1px solid rgba(52, 211, 153, 0.3); + background: rgb(52 211 153 / 15%); + border: 1px solid rgb(52 211 153 / 30%); display: flex; align-items: center; justify-content: center; @@ -59,7 +59,7 @@ border: none; padding: 10px 16px; border-radius: 9px; - font-family: "Geist", sans-serif; + font-family: Geist, sans-serif; font-size: 13px; font-weight: 600; cursor: pointer; @@ -122,7 +122,7 @@ .item-details { .item-name { display: block; - font-family: "Geist", sans-serif; + font-family: Geist, sans-serif; font-size: 13px; font-weight: 500; color: #e2e8f0;