diff --git a/.cspell/custom-words.txt b/.cspell/custom-words.txt index ce73c361..8f575eaa 100644 --- a/.cspell/custom-words.txt +++ b/.cspell/custom-words.txt @@ -163,6 +163,9 @@ spyderproject spyproject stablecoins stdr +sublabel +dedup +Sublabel stretchr superfences Truelayer 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 diff --git a/code/web-client/src/App.tsx b/code/web-client/src/App.tsx index a6ddb758..a12181c5 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 any).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, @@ -69,13 +73,15 @@ const TabBar = ({ }) => (
@@ -93,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."

@@ -107,26 +113,32 @@ 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} className="chat-input" />
+ + ); } -export function InventoryOptionsCard({inventory, onSelect}: Props) { +export function InventoryOptionsCard({ inventory, onSelect }: Props) { const [userSelected, setUserSelected] = useState( - inventory.selected, + inventory.selected ); const [hasConfirmed, setHasConfirmed] = useState(false); - const selected = userSelected ?? inventory.selected ?? ''; + const selected = userSelected ?? inventory.selected ?? ""; const canConfirm = !!onSelect && !!selected && !hasConfirmed; return ( @@ -66,6 +65,7 @@ export function InventoryOptionsCard({inventory, onSelect}: Props) {
+ Inventory available

- I've queried the merchant inventory via Merchant MCP and found{' '} + I've queried the merchant inventory via Merchant MCP and found{" "} {inventory.matches.length} option - {inventory.matches.length === 1 ? '' : 's'} above. Please select which + {inventory.matches.length === 1 ? "" : "s"} above. Please select which item you want, then I'll create the purchase mandate and start monitoring the price.

@@ -100,20 +100,21 @@ export function InventoryOptionsCard({inventory, onSelect}: Props) { <> Selected {selected} {hasConfirmed - ? '. Creating mandate…' - : '. Click "Confirm selection" to create the mandate.'} + ? ". Creating mandate…" + : ". Click "Confirm selection" to create the mandate."} ) : ( - 'Choose an option above.' + "Choose an option above." ) ) : ( <> - Selected {selected || '—'} + Selected {selected || "—"} )}
{canConfirm && ( -
)} - {state === 'signing' && ( + {state === "signing" && (
Signing with ECDSA P-256…
)} - {state === 'signed' && ( + {state === "signed" && (
+ Signed = { - 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; } @@ -203,13 +204,14 @@ function CopyButton({ text }: { text: string }) { const [copied, setCopied] = useState(false); return ( ); } @@ -222,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, @@ -253,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; @@ -268,6 +270,7 @@ export function MandateCard({ entry }: Props) { return (
{formatTimestamp(entry.timestamp)} - +
@@ -314,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). @@ -358,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 afaf4e55..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 ( - {proseText && proseText.trim() && } + {proseText?.trim() && }
); } // 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,12 +178,12 @@ export const MessageRenderer = ({ matches: mandate.matches ?? lastInventoryMatches, }; const proseText = msg.text - ? removeArtifactJsonFromText(msg.text, 'mandate_request') + ? removeArtifactJsonFromText(msg.text, "mandate_request") : undefined; return (
- {proseText && proseText.trim() && } + {proseText?.trim() && } { 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 5cd066cf..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 @@ -77,7 +77,7 @@ export function MonitoringCard({

    )} {onCheckNow && ( - )} diff --git a/code/web-client/src/components/ReceiptCard.tsx b/code/web-client/src/components/ReceiptCard.tsx index 5a61a6f6..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 (
    @@ -43,6 +43,7 @@ export function ReceiptCard({ purchase, itemName }: Props) {
    + Purchase confirmed 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) => (
    @@ -94,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 c6c5e628..6a741228 100644 --- a/code/web-client/src/components/UserActionCard.tsx +++ b/code/web-client/src/components/UserActionCard.tsx @@ -1,16 +1,17 @@ -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 (
    + Action confirmed = 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,372 +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]); - // 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, - 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 94418abe..e90aa4f1 100644 --- a/code/web-client/src/main.tsx +++ b/code/web-client/src/main.tsx @@ -1,10 +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"; -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..cca558ff 100644 --- a/code/web-client/src/utils/mandateEntries.ts +++ b/code/web-client/src/utils/mandateEntries.ts @@ -18,9 +18,16 @@ * 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; +type Draft = Omit; /** Stable string key for dedup (same token OR same decoded JSON object). */ function entryKey(d: Draft): string { @@ -29,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)}`; @@ -49,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, @@ -61,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, @@ -73,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, @@ -86,9 +99,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; } @@ -96,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, }); @@ -171,15 +187,13 @@ export function deriveMandateEntries(messages: ChatMessage[]): MandateEntry[] { } } - - const seen = new Set(); const result: MandateEntry[] = []; for (const d of drafts) { 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 d043a20f..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 isNaN(n) ? undefined : n; + 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),