feat: P4.6 agent demo + escalate button + support seed/smoke (P4 complete)

apps/agent-demo (Vite): agent sets AVAILABLE, sees assigned tickets, opens the
thread, replies. message-demo customer pane gains 'escalate to support' (via
SupportProvider/useEscalate). seed-support + smoke-support prove the loop:
escalate -> auto-assign -> agent reply -> customer receives. 45 tests; realtime/
inbox/support smokes all pass; both demos build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 12:09:29 +05:30
parent c53258c8eb
commit 7441d0ec36
11 changed files with 309 additions and 3 deletions
+106
View File
@@ -0,0 +1,106 @@
import { useEffect, useState } from 'react';
import {
SupportProvider,
useAssignedTickets,
useAvailability,
useThread,
useMessages,
type Ticket,
} from '@insignia/iios-support-web';
const SERVICE = 'http://localhost:3200';
const APP_ID = 'portal-demo';
const AGENT_ID = 'agent1';
async function devToken(userId: string): Promise<string> {
const r = await fetch(`${SERVICE}/v1/dev/token`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ appId: APP_ID, userId, name: userId }),
});
if (!r.ok) throw new Error(`devToken ${r.status} (service running with IIOS_DEV_TOKENS=1?)`);
return ((await r.json()) as { token: string }).token;
}
function AgentChat({ threadId }: { threadId: string }) {
const { open } = useThread();
useEffect(() => {
void open(threadId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [threadId]);
const { messages, send } = useMessages(threadId);
const [text, setText] = useState('');
return (
<div style={{ flex: 1, padding: 12, fontFamily: 'sans-serif' }}>
<h3 style={{ marginTop: 0 }}>Thread {threadId.slice(0, 8)}</h3>
<div style={{ height: 280, overflow: 'auto', background: '#fafafa', padding: 8 }}>
{messages.map((m) => (
<div key={m.id} style={{ padding: '2px 0' }}>
<b>{m.senderActorId.slice(0, 6)}:</b> {m.content}
</div>
))}
</div>
<input
value={text}
placeholder="reply as agent + Enter"
style={{ width: '100%', padding: 6, marginTop: 8 }}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && text.trim()) {
void send(text.trim());
setText('');
}
}}
/>
</div>
);
}
function AgentInner() {
const setAvailability = useAvailability();
const { tickets } = useAssignedTickets();
const [threadId, setThreadId] = useState<string | null>(null);
useEffect(() => {
void setAvailability('AVAILABLE');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div style={{ display: 'flex', fontFamily: 'sans-serif' }}>
<div style={{ width: 240, borderRight: '1px solid #ddd', padding: 12 }}>
<b>Assigned tickets ({tickets.length})</b>
{tickets.length === 0 && <div style={{ color: '#999', marginTop: 6 }}>waiting for escalations</div>}
{tickets.map((t: Ticket) => {
const tid = t.threadLinks?.[0]?.threadId ?? null;
return (
<div key={t.id} style={{ padding: '6px 0', borderBottom: '1px solid #eee' }}>
<div style={{ fontWeight: 600 }}>{t.subject}</div>
<div style={{ color: '#999' }}>{t.state}</div>
<button disabled={!tid} onClick={() => setThreadId(tid)}>
open thread
</button>
</div>
);
})}
</div>
{threadId ? <AgentChat threadId={threadId} /> : <div style={{ flex: 1, padding: 12 }}>Select a ticket.</div>}
</div>
);
}
export function App() {
const [token, setToken] = useState<string | null>(null);
useEffect(() => {
void devToken(AGENT_ID).then(setToken);
}, []);
if (!token) return <div style={{ padding: 16, fontFamily: 'sans-serif' }}>loading agent</div>;
return (
<div style={{ padding: 16 }}>
<h2 style={{ fontFamily: 'sans-serif' }}>IIOS P4 Agent Dashboard ({AGENT_ID})</h2>
<SupportProvider serviceUrl={SERVICE} token={token}>
<AgentInner />
</SupportProvider>
</div>
);
}