feat(demo): P3.5 inbox sidebar + inbox smoke (P3 complete)
message-demo gains a per-person Inbox rail (useInbox, done/snooze); sending surfaces a NEEDS_REPLY item in the other pane; reading auto-resolves it. smoke-inbox.mjs proves the event-driven flow end-to-end (send -> relay -> projector -> inbox -> read -> DONE). Realtime smoke still passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@insignia/iios-kernel-client": "workspace:*",
|
"@insignia/iios-kernel-client": "workspace:*",
|
||||||
"@insignia/iios-message-web": "workspace:*",
|
"@insignia/iios-message-web": "workspace:*",
|
||||||
|
"@insignia/iios-inbox-web": "workspace:*",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { MessageProvider, useThread, useMessages } from '@insignia/iios-message-web';
|
import { MessageProvider, useThread, useMessages } from '@insignia/iios-message-web';
|
||||||
|
import { InboxProvider, useInbox } from '@insignia/iios-inbox-web';
|
||||||
|
|
||||||
const SERVICE = 'http://localhost:3200';
|
const SERVICE = 'http://localhost:3200';
|
||||||
const APP_ID = 'portal-demo';
|
const APP_ID = 'portal-demo';
|
||||||
@@ -83,12 +84,35 @@ function ChatInner({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 }) {
|
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>;
|
if (!props.token) return <div style={{ flex: 1, margin: 8 }}>loading {props.label}…</div>;
|
||||||
return (
|
return (
|
||||||
<MessageProvider serviceUrl={SERVICE} token={props.token}>
|
<div style={{ flex: 1, display: 'flex', margin: 8, border: '1px solid #ccc', borderRadius: 8, overflow: 'hidden' }}>
|
||||||
<ChatInner label={props.label} threadId={props.threadId} onCreated={props.onCreated} />
|
<InboxProvider serviceUrl={SERVICE} token={props.token}>
|
||||||
</MessageProvider>
|
<InboxSidebar />
|
||||||
|
</InboxProvider>
|
||||||
|
<MessageProvider serviceUrl={SERVICE} token={props.token}>
|
||||||
|
<ChatInner label={props.label} threadId={props.threadId} onCreated={props.onCreated} />
|
||||||
|
</MessageProvider>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// P3 inbox smoke: Alice sends → Bob's inbox gets a NEEDS_REPLY item → Bob reads
|
||||||
|
// → item auto-resolves to DONE. Requires the service running (relay timer on),
|
||||||
|
// IIOS_DEV_TOKENS not needed (we sign locally). Run: node scripts/smoke-inbox.mjs
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { io } from 'socket.io-client';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
const SERVICE = process.env.SMOKE_URL ?? 'http://localhost:3200';
|
||||||
|
const APP_ID = 'portal-demo';
|
||||||
|
const SECRET = JSON.parse(process.env.APP_SECRETS ?? '{"portal-demo":"dev-secret"}')[APP_ID];
|
||||||
|
|
||||||
|
const sign = (u) => jwt.sign({ sub: u, name: u, appId: APP_ID, orgId: `org_${APP_ID}` }, SECRET, { algorithm: 'HS256', expiresIn: '1h' });
|
||||||
|
const connect = (u) => io(`${SERVICE}/message`, { auth: { token: sign(u) }, transports: ['websocket'], forceNew: true });
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const assert = (c, m) => {
|
||||||
|
if (!c) { console.error('✗', m); process.exit(1); }
|
||||||
|
console.log('✓', m);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function inbox(token, state) {
|
||||||
|
const r = await fetch(`${SERVICE}/v1/inbox/items${state ? `?state=${state}` : ''}`, {
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(`inbox ${r.status}`);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
async function pollUntil(fn, ms = 8000) {
|
||||||
|
const end = Date.now() + ms;
|
||||||
|
while (Date.now() < end) {
|
||||||
|
const v = await fn();
|
||||||
|
if (v) return v;
|
||||||
|
await sleep(300);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bobToken = sign('bob');
|
||||||
|
const alice = connect('alice');
|
||||||
|
const bob = connect('bob');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { threadId } = await alice.emitWithAck('open_thread', {});
|
||||||
|
await bob.emitWithAck('open_thread', { threadId }); // Bob joins → participant
|
||||||
|
const sent = await alice.emitWithAck('send_message', { threadId, content: 'hi bob' });
|
||||||
|
|
||||||
|
const open = await pollUntil(async () => {
|
||||||
|
const items = await inbox(bobToken, 'OPEN');
|
||||||
|
return items.find((i) => i.threadId === threadId && i.kind === 'NEEDS_REPLY') ?? null;
|
||||||
|
});
|
||||||
|
assert(open, `Bob's inbox got an OPEN NEEDS_REPLY item (${open?.id})`);
|
||||||
|
|
||||||
|
await bob.emitWithAck('read', { threadId, interactionId: sent.id });
|
||||||
|
const done = await pollUntil(async () => {
|
||||||
|
const items = await inbox(bobToken);
|
||||||
|
const it = items.find((i) => i.id === open.id);
|
||||||
|
return it && it.state === 'DONE' ? it : null;
|
||||||
|
});
|
||||||
|
assert(done, "Bob's item auto-resolved to DONE after reading");
|
||||||
|
|
||||||
|
console.log('\nP3 inbox smoke: PASS');
|
||||||
|
} finally {
|
||||||
|
alice.close();
|
||||||
|
bob.close();
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
Generated
+3
@@ -20,6 +20,9 @@ importers:
|
|||||||
|
|
||||||
apps/message-demo:
|
apps/message-demo:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@insignia/iios-inbox-web':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/iios-inbox-web
|
||||||
'@insignia/iios-kernel-client':
|
'@insignia/iios-kernel-client':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/iios-kernel-client
|
version: link:../../packages/iios-kernel-client
|
||||||
|
|||||||
Reference in New Issue
Block a user