7441d0ec36
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>
153 lines
5.2 KiB
TypeScript
153 lines
5.2 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
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';
|
|
|
|
async function devToken(userId: string, name: 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 }),
|
|
});
|
|
if (!r.ok) throw new Error(`devToken ${r.status} (is the service running with IIOS_DEV_TOKENS=1?)`);
|
|
return ((await r.json()) as { token: string }).token;
|
|
}
|
|
|
|
function ChatInner({
|
|
label,
|
|
threadId,
|
|
onCreated,
|
|
}: {
|
|
label: string;
|
|
threadId: string | null;
|
|
onCreated?: (id: string) => void;
|
|
}) {
|
|
const { open } = useThread();
|
|
const [tid, setTid] = useState<string | null>(threadId);
|
|
const [text, setText] = useState('');
|
|
|
|
useEffect(() => {
|
|
if (threadId) {
|
|
setTid(threadId);
|
|
return;
|
|
}
|
|
if (onCreated) {
|
|
void open().then((id) => {
|
|
setTid(id);
|
|
onCreated(id);
|
|
});
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [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' }}>
|
|
<h3 style={{ marginTop: 0 }}>{label}</h3>
|
|
<div style={{ color: '#888', fontSize: 12 }}>thread: {tid ?? '…'}</div>
|
|
<div style={{ height: 240, overflow: 'auto', background: '#fafafa', padding: 8, margin: '8px 0' }}>
|
|
{messages.map((m) => (
|
|
<div key={m.id} style={{ padding: '2px 0' }}>
|
|
<b>{m.senderActorId.slice(0, 6)}:</b> {m.content} {reads.includes(m.id) ? '✓✓' : ''}
|
|
</div>
|
|
))}
|
|
{typingUsers.length > 0 && <em style={{ color: '#888' }}>{typingUsers.join(', ')} typing…</em>}
|
|
</div>
|
|
<input
|
|
value={text}
|
|
placeholder="type a message + Enter"
|
|
style={{ width: '100%', padding: 6 }}
|
|
onChange={(e) => {
|
|
setText(e.target.value);
|
|
typing();
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' && text.trim()) {
|
|
void send(text.trim());
|
|
setText('');
|
|
}
|
|
}}
|
|
/>
|
|
<button
|
|
style={{ marginTop: 8 }}
|
|
onClick={() => {
|
|
const last = messages.at(-1);
|
|
if (last) void markRead(last.id);
|
|
}}
|
|
>
|
|
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>
|
|
);
|
|
}
|
|
|
|
function InboxSidebar() {
|
|
const { items, done, snooze } = useInbox({ state: 'OPEN' });
|
|
return (
|
|
<div style={{ width: 170, borderRight: '1px solid #ddd', padding: 8, fontSize: 12, fontFamily: 'sans-serif' }}>
|
|
<b>Inbox ({items.length})</b>
|
|
{items.length === 0 && <div style={{ color: '#999', marginTop: 6 }}>nothing to reply to</div>}
|
|
{items.map((i) => (
|
|
<div key={i.id} style={{ padding: '6px 0', borderBottom: '1px solid #eee' }}>
|
|
<div style={{ fontWeight: 600 }}>{i.title}</div>
|
|
<div style={{ color: '#999' }}>{i.kind}</div>
|
|
<button style={{ marginRight: 4 }} onClick={() => void done(i.id)}>done</button>
|
|
<button onClick={() => void snooze(i.id)}>snooze</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Pane(props: { token: string | null; label: string; threadId: string | null; onCreated?: (id: string) => void }) {
|
|
if (!props.token) return <div style={{ flex: 1, margin: 8 }}>loading {props.label}…</div>;
|
|
return (
|
|
<div style={{ flex: 1, display: 'flex', margin: 8, border: '1px solid #ccc', borderRadius: 8, overflow: 'hidden' }}>
|
|
<InboxProvider serviceUrl={SERVICE} token={props.token}>
|
|
<InboxSidebar />
|
|
</InboxProvider>
|
|
<SupportProvider serviceUrl={SERVICE} token={props.token}>
|
|
<ChatInner label={props.label} threadId={props.threadId} onCreated={props.onCreated} />
|
|
</SupportProvider>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function App() {
|
|
const [alice, setAlice] = useState<string | null>(null);
|
|
const [bob, setBob] = useState<string | null>(null);
|
|
const [threadId, setThreadId] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
void devToken('alice', 'Alice').then(setAlice);
|
|
void devToken('bob', 'Bob').then(setBob);
|
|
}, []);
|
|
|
|
return (
|
|
<div style={{ padding: 16 }}>
|
|
<h2 style={{ fontFamily: 'sans-serif' }}>IIOS P2 — realtime demo (Alice ↔ Bob)</h2>
|
|
<p style={{ fontFamily: 'sans-serif', color: '#666' }}>
|
|
Type in either pane — it appears live in the other. ✓✓ = read receipt.
|
|
</p>
|
|
<div style={{ display: 'flex' }}>
|
|
<Pane token={alice} label="Alice (creates thread)" threadId={null} onCreated={setThreadId} />
|
|
<Pane token={bob} label="Bob (joins)" threadId={threadId} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|