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:
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>IIOS P4 — Agent Demo</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "agent-demo",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5174",
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@insignia/iios-kernel-client": "workspace:*",
|
||||
"@insignia/iios-message-web": "workspace:*",
|
||||
"@insignia/iios-support-web": "workspace:*",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
|
||||
const el = document.getElementById('root');
|
||||
if (el) createRoot(el).render(<App />);
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"jsx": "react-jsx",
|
||||
"types": ["react", "react-dom"],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { port: 5174 },
|
||||
});
|
||||
@@ -11,6 +11,7 @@
|
||||
"@insignia/iios-kernel-client": "workspace:*",
|
||||
"@insignia/iios-message-web": "workspace:*",
|
||||
"@insignia/iios-inbox-web": "workspace:*",
|
||||
"@insignia/iios-support-web": "workspace:*",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { MessageProvider, useThread, useMessages } from '@insignia/iios-message-web';
|
||||
import { useThread, useMessages } from '@insignia/iios-message-web';
|
||||
import { InboxProvider, useInbox } from '@insignia/iios-inbox-web';
|
||||
import { SupportProvider, useEscalate } from '@insignia/iios-support-web';
|
||||
|
||||
const SERVICE = 'http://localhost:3200';
|
||||
const APP_ID = 'portal-demo';
|
||||
@@ -43,6 +44,7 @@ function ChatInner({
|
||||
}, [threadId]);
|
||||
|
||||
const { messages, send, typing, typingUsers, markRead, reads } = useMessages(tid);
|
||||
const escalate = useEscalate();
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, border: '1px solid #ccc', borderRadius: 8, padding: 12, margin: 8, fontFamily: 'sans-serif' }}>
|
||||
@@ -80,6 +82,15 @@ function ChatInner({
|
||||
>
|
||||
mark last read
|
||||
</button>
|
||||
<button
|
||||
style={{ marginTop: 8, marginLeft: 4 }}
|
||||
disabled={!tid}
|
||||
onClick={() => {
|
||||
if (tid) void escalate(tid, 'Support request');
|
||||
}}
|
||||
>
|
||||
escalate to support
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -109,9 +120,9 @@ function Pane(props: { token: string | null; label: string; threadId: string | n
|
||||
<InboxProvider serviceUrl={SERVICE} token={props.token}>
|
||||
<InboxSidebar />
|
||||
</InboxProvider>
|
||||
<MessageProvider serviceUrl={SERVICE} token={props.token}>
|
||||
<SupportProvider serviceUrl={SERVICE} token={props.token}>
|
||||
<ChatInner label={props.label} threadId={props.threadId} onCreated={props.onCreated} />
|
||||
</MessageProvider>
|
||||
</SupportProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user