diff --git a/chat-frontend/scripts/asyncJob.smoke.mjs b/chat-frontend/scripts/asyncJob.smoke.mjs
new file mode 100644
index 000000000..b18592929
--- /dev/null
+++ b/chat-frontend/scripts/asyncJob.smoke.mjs
@@ -0,0 +1,256 @@
+// Wire-level smoke test for src/lib/asyncJob.js against a real NATS server.
+// Not part of the standing test suite — invoke ad-hoc when verifying that
+// the helper survives contact with the actual nats.ws WebSocket transport,
+// header serialization, and response-subject delivery.
+//
+// Prereqs: a NATS server with WebSocket on ws://localhost:8443.
+// /root/go/bin/nats-server -c /tmp/nats.conf
+//
+// Usage: node scripts/asyncJob.smoke.mjs
+
+import { connect, StringCodec } from 'nats.ws'
+import { requestWithAsyncResult, ASYNC_JOB_ERROR_KINDS } from '../src/lib/asyncJob.js'
+import { isDMExistsReply } from '../src/lib/constants.js'
+
+const WS_URL = process.env.NATS_WS_URL || 'ws://localhost:8443'
+const sc = StringCodec()
+
+let pass = 0
+let fail = 0
+
+function check(label, ok, detail = '') {
+ const tag = ok ? 'PASS' : 'FAIL'
+ console.log(` [${tag}] ${label}${detail ? ' — ' + detail : ''}`)
+ if (ok) pass++
+ else fail++
+}
+
+async function withResponder(nc, subject, handler) {
+ const sub = nc.subscribe(subject)
+ ;(async () => {
+ for await (const msg of sub) {
+ try {
+ await handler(msg)
+ } catch (err) {
+ console.error('responder error:', err)
+ }
+ }
+ })()
+ return sub
+}
+
+async function main() {
+ console.log(`Connecting to ${WS_URL}…`)
+ const nc = await connect({ servers: WS_URL })
+
+ // ── case 1: full happy path ────────────────────────────────────────────────
+ console.log('\n[1] happy-path: sync accept + async ok')
+ {
+ let observedHeader = null
+ let observedSubject = null
+ const responder = await withResponder(
+ nc,
+ 'chat.user.*.request.room.*.create',
+ async (msg) => {
+ observedSubject = msg.subject
+ observedHeader = msg.headers?.get('X-Request-ID') ?? null
+ // sync reply: accepted
+ msg.respond(sc.encode(JSON.stringify({
+ status: 'accepted',
+ roomId: 'r-new',
+ roomType: 'channel',
+ })))
+ // async reply: publish to response subject — derive account from the
+ // request subject (chat.user.{a}.request.room.{site}.create).
+ const acct = msg.subject.split('.')[2]
+ const responseSubject = `chat.user.${acct}.response.${observedHeader}`
+ // Tiny gap so the client's async-result wait actually waits.
+ setTimeout(() => {
+ nc.publish(responseSubject, sc.encode(JSON.stringify({
+ requestId: observedHeader,
+ operation: 'room.create',
+ status: 'ok',
+ roomId: 'r-new',
+ timestamp: Date.now(),
+ })))
+ }, 50)
+ }
+ )
+
+ const result = await requestWithAsyncResult(
+ nc,
+ 'alice',
+ 'chat.user.alice.request.room.site-A.create',
+ { name: 'frontend', users: [], orgs: [], channels: [] }
+ )
+ check('request reached responder', !!observedSubject, observedSubject)
+ check('X-Request-ID header survived the wire', !!observedHeader && observedHeader.length >= 30,
+ observedHeader)
+ check('sync reply parsed', result.sync.status === 'accepted',
+ `roomId=${result.sync.roomId} roomType=${result.sync.roomType}`)
+ check('async result delivered', result.async?.status === 'ok',
+ `operation=${result.async?.operation} roomId=${result.async?.roomId}`)
+ check('requestId on AsyncJobResult matches header',
+ result.async?.requestId === observedHeader,
+ `${result.async?.requestId} vs ${observedHeader}`)
+
+ await responder.unsubscribe()
+ }
+
+ // ── case 2: DM-exists treatAsSuccess branch ────────────────────────────────
+ console.log('\n[2] DM-exists: {error, roomId} reply is treated as success')
+ {
+ const responder = await withResponder(
+ nc,
+ 'chat.user.*.request.room.*.create',
+ async (msg) => {
+ msg.respond(sc.encode(JSON.stringify({
+ error: 'dm already exists',
+ roomId: 'r-existing',
+ })))
+ }
+ )
+
+ let err
+ let result
+ try {
+ result = await requestWithAsyncResult(
+ nc,
+ 'alice',
+ 'chat.user.alice.request.room.site-A.create',
+ { name: '', users: ['bob'], orgs: [], channels: [] },
+ { treatAsSuccess: isDMExistsReply }
+ )
+ } catch (e) {
+ err = e
+ }
+ check('did not throw', !err, err?.message)
+ check('sync.error preserved', result?.sync?.error === 'dm already exists')
+ check('sync.roomId preserved', result?.sync?.roomId === 'r-existing')
+ check('async is null (no follow-up)', result?.async === null)
+ await responder.unsubscribe()
+ }
+
+ // ── case 3: async error ────────────────────────────────────────────────────
+ console.log('\n[3] async error: AsyncJobResult.status=error bubbles up')
+ {
+ const responder = await withResponder(
+ nc,
+ 'chat.user.*.request.room.*.*.member.add',
+ async (msg) => {
+ const reqId = msg.headers?.get('X-Request-ID')
+ msg.respond(sc.encode(JSON.stringify({ status: 'accepted' })))
+ const acct = msg.subject.split('.')[2]
+ setTimeout(() => {
+ nc.publish(`chat.user.${acct}.response.${reqId}`, sc.encode(JSON.stringify({
+ requestId: reqId,
+ operation: 'room.member.add',
+ status: 'error',
+ error: 'only owners can add members',
+ timestamp: Date.now(),
+ })))
+ }, 25)
+ }
+ )
+
+ let err
+ try {
+ await requestWithAsyncResult(
+ nc,
+ 'alice',
+ 'chat.user.alice.request.room.r1.site-A.member.add',
+ { roomId: 'r1', users: ['bob'], orgs: [], channels: [], history: { mode: 'all' } }
+ )
+ } catch (e) {
+ err = e
+ }
+ check('threw with server error message', /only owners/.test(err?.message ?? ''), err?.message)
+ check('err.kind = async-error', err?.kind === ASYNC_JOB_ERROR_KINDS.AsyncError, err?.kind)
+ await responder.unsubscribe()
+ }
+
+ // ── case 4: async timeout (no responder publishes AsyncJobResult) ──────────
+ console.log('\n[4] async timeout: helper rejects + cleans up subscription')
+ {
+ const responder = await withResponder(
+ nc,
+ 'chat.user.*.request.room.*.*.member.remove',
+ async (msg) => {
+ msg.respond(sc.encode(JSON.stringify({ status: 'accepted' })))
+ // deliberately do NOT publish an AsyncJobResult
+ }
+ )
+
+ const t0 = Date.now()
+ let err
+ try {
+ await requestWithAsyncResult(
+ nc,
+ 'alice',
+ 'chat.user.alice.request.room.r1.site-A.member.remove',
+ { roomId: 'r1', account: 'bob' },
+ { asyncTimeout: 400 }
+ )
+ } catch (e) {
+ err = e
+ }
+ const dt = Date.now() - t0
+ check('err.kind = async-timeout', err?.kind === ASYNC_JOB_ERROR_KINDS.AsyncTimeout, err?.kind)
+ check('rejection happened ~asyncTimeout (≥350ms, ≤2000ms)', dt >= 350 && dt <= 2000, `${dt}ms`)
+ await responder.unsubscribe()
+ }
+
+ // ── case 5: subscribe-before-publish ordering ──────────────────────────────
+ console.log('\n[5] subscribe-before-publish: AsyncJobResult published immediately is still received')
+ {
+ // Race condition probe: responder publishes the async result the same tick
+ // it sends the sync reply. If the helper subscribes AFTER the request, the
+ // async result is lost. If it subscribes BEFORE (as designed), it lands.
+ const responder = await withResponder(
+ nc,
+ 'chat.user.*.request.room.*.create',
+ async (msg) => {
+ const reqId = msg.headers?.get('X-Request-ID')
+ const acct = msg.subject.split('.')[2]
+ // Publish async result FIRST, then sync reply. The helper must have
+ // already subscribed to the response subject for this to work.
+ nc.publish(`chat.user.${acct}.response.${reqId}`, sc.encode(JSON.stringify({
+ requestId: reqId,
+ operation: 'room.create',
+ status: 'ok',
+ roomId: 'r-fast',
+ timestamp: Date.now(),
+ })))
+ msg.respond(sc.encode(JSON.stringify({
+ status: 'accepted',
+ roomId: 'r-fast',
+ roomType: 'channel',
+ })))
+ }
+ )
+
+ let result, err
+ try {
+ result = await requestWithAsyncResult(
+ nc,
+ 'alice',
+ 'chat.user.alice.request.room.site-A.create',
+ { name: 'fast', users: [], orgs: [], channels: [] },
+ { asyncTimeout: 1000 }
+ )
+ } catch (e) { err = e }
+ check('completed without timeout (sub was open in time)', !err, err?.message)
+ check('async.status=ok', result?.async?.status === 'ok')
+ await responder.unsubscribe()
+ }
+
+ await nc.drain()
+
+ console.log(`\n=== ${pass} passed, ${fail} failed ===`)
+ process.exit(fail === 0 ? 0 : 1)
+}
+
+main().catch((err) => {
+ console.error('FATAL:', err)
+ process.exit(2)
+})
diff --git a/chat-frontend/scripts/liveStack.smoke.mjs b/chat-frontend/scripts/liveStack.smoke.mjs
new file mode 100644
index 000000000..ca3da707a
--- /dev/null
+++ b/chat-frontend/scripts/liveStack.smoke.mjs
@@ -0,0 +1,173 @@
+// End-to-end smoke against the LIVE stack: auth-service + room-service + room-worker.
+// Verifies that what the unit suite mocks actually happens on the wire:
+// - POST /auth (dev mode) returns a NATS JWT
+// - the frontend's requestWithAsyncResult creates a real channel via
+// chat.user.{a}.request.room.{site}.create
+// - room-worker materializes the room and emits AsyncJobResult
+// - chat.user.{a}.request.room.{r}.{s}.member.list?enrich=true returns
+// the seeded individuals + the requester as owner
+//
+// Prereqs:
+// * NATS running with WebSocket on ws://localhost:9222
+// * auth-service in DEV_MODE on http://localhost:8080
+// * room-service + room-worker connected to NATS + Mongo
+// * Users alice/bob/charlie seeded in Mongo
+//
+// Usage: npx vite-node scripts/liveStack.smoke.mjs
+
+import { connect, StringCodec, jwtAuthenticator } from 'nats.ws'
+import { createUser } from 'nkeys.js'
+import { requestWithAsyncResult } from '../src/lib/asyncJob.js'
+import { roomCreate, memberList } from '../src/lib/subjects.js'
+import { isDMExistsReply } from '../src/lib/constants.js'
+
+const AUTH_URL = process.env.AUTH_URL || 'http://localhost:8080'
+const NATS_WS = process.env.NATS_WS_URL || 'ws://localhost:9222'
+const sc = StringCodec()
+
+let pass = 0
+let fail = 0
+function check(label, ok, detail = '') {
+ const tag = ok ? 'PASS' : 'FAIL'
+ console.log(` [${tag}] ${label}${detail ? ' — ' + detail : ''}`)
+ if (ok) pass++; else fail++
+}
+
+async function devLogin(account) {
+ const nkey = createUser()
+ const natsPublicKey = nkey.getPublicKey()
+ const resp = await fetch(`${AUTH_URL}/auth`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ account, natsPublicKey }),
+ })
+ if (!resp.ok) throw new Error(`auth failed: ${resp.status} ${await resp.text()}`)
+ const { natsJwt, user } = await resp.json()
+ return { natsJwt, user, seed: nkey.getSeed() }
+}
+
+async function main() {
+ console.log(`Auth: ${AUTH_URL} | NATS-ws: ${NATS_WS}`)
+
+ // ── 1. dev-login as alice ──────────────────────────────────────────────────
+ console.log('\n[1] dev-login as alice')
+ const { natsJwt, user, seed } = await devLogin('alice')
+ check('auth returned a JWT', !!natsJwt && natsJwt.length > 50)
+ check('auth returned user.account=alice', user?.account === 'alice', `id=${user?.id}`)
+
+ // ── 2. open a NATS WS connection authenticated by the JWT ──────────────────
+ console.log('\n[2] connect NATS WebSocket with returned JWT')
+ const nc = await connect({
+ servers: NATS_WS,
+ authenticator: jwtAuthenticator(natsJwt, seed),
+ })
+ check('connected', !!nc, nc.getServer())
+
+ // ── 3. create a channel — real room-service + room-worker ──────────────────
+ console.log('\n[3] create a channel via the frontend helper, live stack')
+ const channelName = `e2e-${Date.now()}`
+ let createResult, createErr
+ try {
+ createResult = await requestWithAsyncResult(
+ nc,
+ 'alice',
+ roomCreate('alice', 'site-local'),
+ { name: channelName, users: ['bob', 'charlie'], orgs: [], channels: [] },
+ { asyncTimeout: 10000 }
+ )
+ } catch (e) { createErr = e }
+ if (createErr) {
+ check('create succeeded', false, createErr.message)
+ } else {
+ check('sync.status = accepted', createResult.sync?.status === 'accepted', `roomId=${createResult.sync?.roomId}`)
+ check('sync.roomType = channel', createResult.sync?.roomType === 'channel')
+ check('async result is ok', createResult.async?.status === 'ok',
+ `op=${createResult.async?.operation} requestId=${createResult.async?.requestId?.slice(0, 8)}…`)
+ check('async.roomId matches sync.roomId', createResult.async?.roomId === createResult.sync?.roomId)
+ }
+
+ // ── 4. member.list?enrich on the new room ──────────────────────────────────
+ console.log('\n[4] member.list?enrich on the new room')
+ if (createResult?.sync?.roomId) {
+ const roomId = createResult.sync.roomId
+ const listSubject = memberList('alice', roomId, 'site-local')
+ let listResult
+ try {
+ const resp = await nc.request(listSubject, sc.encode(JSON.stringify({ enrich: true })), { timeout: 5000 })
+ listResult = JSON.parse(sc.decode(resp.data))
+ } catch (e) {
+ listResult = { error: e.message }
+ }
+
+ check('member.list responded', !listResult.error, listResult.error)
+ if (!listResult.error) {
+ const members = listResult.members ?? []
+ const individuals = members.filter((m) => m.member?.type === 'individual')
+ const accounts = individuals.map((m) => m.member?.account).sort()
+ check('individuals include alice + bob + charlie',
+ accounts.join(',') === 'alice,bob,charlie',
+ accounts.join(','))
+ const alice = individuals.find((m) => m.member?.account === 'alice')
+ check('alice is owner', alice?.member?.isOwner === true)
+ const bob = individuals.find((m) => m.member?.account === 'bob')
+ check('bob is member (not owner)', bob?.member?.isOwner !== true)
+ check('display names enriched (engName populated)',
+ !!alice?.member?.engName && !!bob?.member?.engName,
+ `${alice?.member?.engName}, ${bob?.member?.engName}`)
+ }
+ } else {
+ check('member.list', false, 'skipped: no roomId from create')
+ }
+
+ // ── 5. DM-exists dedup: create the same DM twice ───────────────────────────
+ console.log('\n[5] DM dedup: second create with same counterpart returns existing roomId')
+ let dm1Result, dm2Result
+ try {
+ dm1Result = await requestWithAsyncResult(
+ nc, 'alice',
+ roomCreate('alice', 'site-local'),
+ { name: '', users: ['bob'], orgs: [], channels: [] },
+ { asyncTimeout: 10000 }
+ )
+ } catch (e) { dm1Result = { error: e.message } }
+ check('first DM accepted', dm1Result?.sync?.status === 'accepted', `roomId=${dm1Result?.sync?.roomId}`)
+
+ try {
+ dm2Result = await requestWithAsyncResult(
+ nc, 'alice',
+ roomCreate('alice', 'site-local'),
+ { name: '', users: ['bob'], orgs: [], channels: [] },
+ { asyncTimeout: 10000, treatAsSuccess: isDMExistsReply }
+ )
+ } catch (e) { dm2Result = { error: e.message } }
+ const sameRoomId = dm2Result?.sync?.roomId === dm1Result?.sync?.roomId
+ check('second DM reply carries existing roomId',
+ sameRoomId,
+ `first=${dm1Result?.sync?.roomId} second=${dm2Result?.sync?.roomId}`)
+ check('second DM reply is the "dm already exists" shape',
+ dm2Result?.sync?.error === 'dm already exists',
+ dm2Result?.sync?.error)
+ check('second DM async is null (no follow-up)', dm2Result?.async === null)
+
+ // ── 6. validation: empty create rejected ──────────────────────────────────
+ console.log('\n[6] empty payload rejected with classification error')
+ let empty
+ try {
+ await requestWithAsyncResult(
+ nc, 'alice',
+ roomCreate('alice', 'site-local'),
+ { name: '', users: [], orgs: [], channels: [] },
+ { asyncTimeout: 3000 }
+ )
+ } catch (e) { empty = e }
+ check('threw with empty-create error', !!empty, empty?.message)
+
+ await nc.drain()
+ console.log(`\n=== ${pass} passed, ${fail} failed ===`)
+ process.exit(fail === 0 ? 0 : 1)
+}
+
+main().catch((err) => {
+ console.error('FATAL:', err)
+ process.exit(2)
+})
diff --git a/chat-frontend/src/components/CreateRoomDialog.jsx b/chat-frontend/src/components/CreateRoomDialog.jsx
index db71bf895..678537ec2 100644
--- a/chat-frontend/src/components/CreateRoomDialog.jsx
+++ b/chat-frontend/src/components/CreateRoomDialog.jsx
@@ -1,52 +1,132 @@
-import { useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { useNats } from '../context/NatsContext'
-import { roomsCreate } from '../lib/subjects'
-import { parseList } from '../lib/parseList'
+import { useRoomSummaries } from '../context/RoomEventsContext'
+import { roomCreate } from '../lib/subjects'
+import { isDMExistsReply } from '../lib/constants'
+import { formatAsyncJobError } from '../lib/asyncJob'
+import MemberPicker from './MemberPicker'
+// How long to wait for the server's subscription.update event before
+// giving up and closing the dialog anyway. With the event the user lands
+// in a room that already has its channel subscription opened (so messages
+// echo back immediately); without it, the message stream is racy for the
+// first second or so, but at least the dialog doesn't hang forever.
+const SUBSCRIPTION_WAIT_TIMEOUT_MS = 3000
+
+// Type inferred server-side from payload shape (name vs. members).
export default function CreateRoomDialog({ onClose, onCreated }) {
- const { user, request } = useNats()
+ const { user, requestWithAsyncResult } = useNats()
+ const { summaries } = useRoomSummaries()
const [name, setName] = useState('')
- const [roomType, setRoomType] = useState('channel')
- const [members, setMembers] = useState('')
+ const [users, setUsers] = useState([])
+ const [orgs, setOrgs] = useState([])
+ const [channels, setChannels] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
+ // The just-created room we're waiting for confirmation on. Holds the
+ // {id, type, siteId, name} payload destined for onCreated. While set,
+ // the dialog stays open with the "Waiting for server confirmation…"
+ // label; once `summaries` contains a row matching `id` we fire the
+ // callback. ROOM_ADDED is only dispatched via the subscription.update
+ // event handler, which also calls openChannelSub() for channels — so by
+ // the time we close, the channel subscription is already open and the
+ // user's first message will echo back live.
+ const [pendingRoom, setPendingRoom] = useState(null)
+ const pickerRef = useRef(null)
+
+ // Close when the room shows up in summaries (subscription.update arrived
+ // → ROOM_ADDED dispatched → channel sub opened). Idempotent: stays open
+ // until match.
+ useEffect(() => {
+ if (!pendingRoom) return
+ if (summaries.some((r) => r.id === pendingRoom.id)) {
+ onCreated(pendingRoom)
+ onClose()
+ }
+ }, [summaries, pendingRoom, onCreated, onClose])
+
+ // Safety net: if subscription.update never arrives (server bug, NATS
+ // outage), don't auto-select the would-be room. Calling onCreated here
+ // would set selectedRoom on ChatPage, but summaries doesn't contain the
+ // room yet so the page's auto-deselect effect would bounce the user
+ // back to the empty state; even if it didn't, the channel subscription
+ // for the new room hasn't been opened (also gated on
+ // subscription.update), so any message the user tried to send would
+ // not echo back live. Surface an error inside the dialog instead,
+ // clear pendingRoom so the wait state unwinds (re-enabling Cancel),
+ // and let the user dismiss. If the room actually was created, it'll
+ // appear in their sidebar shortly when subscription.update finally
+ // lands — they can click it there.
+ useEffect(() => {
+ if (!pendingRoom) return
+ const t = setTimeout(() => {
+ setError(
+ 'Room creation is taking longer than expected. If it succeeds, the room will appear in your sidebar shortly — you can dismiss this dialog.'
+ )
+ setPendingRoom(null)
+ }, SUBSCRIPTION_WAIT_TIMEOUT_MS)
+ return () => clearTimeout(t)
+ }, [pendingRoom])
+
+ const trimmedName = name.trim()
const handleSubmit = async (e) => {
e.preventDefault()
- if (!name.trim() || !user) return
-
+ if (!user) return
+ // Auto-commit any typed-but-not-Entered text in the picker so users
+ // don't have to remember to press Enter before clicking Create. Use the
+ // returned values for this submit because the state updates queued by
+ // flushAndGetEntries don't land until the next render.
+ const { users: finalUsers, orgs: finalOrgs, channels: finalChannels } =
+ pickerRef.current?.flushAndGetEntries() ?? { users, orgs, channels }
+ if (!trimmedName && finalUsers.length + finalOrgs.length + finalChannels.length === 0) return
setLoading(true)
setError(null)
-
- const account = user.account
- const siteId = user.siteId
-
- const memberList = parseList(members)
-
try {
- const room = await request(roomsCreate(account), {
- name: name.trim(),
- type: roomType,
- createdBy: account,
- createdByAccount: account,
- siteId: siteId,
- members: memberList,
- })
- onCreated(room)
- onClose()
+ const { sync } = await requestWithAsyncResult(
+ roomCreate(user.account, user.siteId),
+ { name: trimmedName, users: finalUsers, orgs: finalOrgs, channels: finalChannels },
+ { treatAsSuccess: isDMExistsReply }
+ )
+ const roomId = sync.roomId
+ // On the dedup branch the server only tells us the existing roomId, not
+ // its type — could be either dm or botDM. Default to 'dm'; the canonical
+ // type arrives shortly via subscription.update.
+ const roomType = sync.roomType || (isDMExistsReply(sync) ? 'dm' : undefined)
+ // For DM/BotDM the name is empty; fall back to the counterpart account
+ // so the sidebar + header have something to show until the canonical
+ // name arrives via subscription.update.
+ const displayName = trimmedName || finalUsers[0] || ''
+ // Request is done; flip loading off so Cancel becomes re-enabled
+ // during the subscription.update wait. The pendingRoom state marks
+ // the wait phase — see the two useEffects above for the resolution
+ // paths (summaries-match success vs timeout error).
+ setPendingRoom({ id: roomId, type: roomType, siteId: user.siteId, name: displayName })
} catch (err) {
- setError(err.message)
+ setError(formatAsyncJobError(err))
} finally {
setLoading(false)
}
}
+ const buttonLabel = pendingRoom
+ ? 'Waiting for server confirmation…'
+ : loading
+ ? 'Creating…'
+ : 'Create'
+
return (
-
-
e.stopPropagation()}>
+
{
+ if (loading) return
+ onClose()
+ }}
+ >
+
e.stopPropagation()}>
Create Room
diff --git a/chat-frontend/src/components/CreateRoomDialog.test.jsx b/chat-frontend/src/components/CreateRoomDialog.test.jsx
new file mode 100644
index 000000000..335e465e6
--- /dev/null
+++ b/chat-frontend/src/components/CreateRoomDialog.test.jsx
@@ -0,0 +1,268 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { act, render, screen, fireEvent, waitFor } from '@testing-library/react'
+import CreateRoomDialog from './CreateRoomDialog'
+
+vi.mock('../context/NatsContext', () => ({
+ useNats: vi.fn(),
+}))
+// useRoomSummaries is consumed by the dialog so it can wait for the
+// just-created room to appear in summaries (i.e. for the server's
+// subscription.update event to arrive) before closing. Tests default the
+// mock to summaries already containing every roomId the various success
+// fixtures return; tests that exercise the wait path override this.
+vi.mock('../context/RoomEventsContext', () => ({
+ useRoomSummaries: vi.fn(),
+}))
+
+import { useNats } from '../context/NatsContext'
+import { useRoomSummaries } from '../context/RoomEventsContext'
+
+// Pre-populate summaries with the roomIds the success fixtures return so
+// the dialog's "wait for subscription.update" useEffect resolves on the
+// first render after submit and the happy-path tests pass synchronously.
+const DEFAULT_SUMMARIES = [
+ { id: 'r-new', name: 'frontend', type: 'channel', siteId: 'site-A' },
+ { id: 'r-dm', name: '', type: 'dm', siteId: 'site-A' },
+ { id: 'r-existing', name: '', type: 'dm', siteId: 'site-A' },
+]
+
+function setup(overrides = {}) {
+ const requestWithAsyncResult = vi.fn().mockResolvedValue({
+ sync: { status: 'accepted', roomId: 'r-new', roomType: 'channel' },
+ async: { status: 'ok', roomId: 'r-new', operation: 'room.create' },
+ })
+ const request = vi.fn()
+ useNats.mockReturnValue({
+ user: { account: 'alice', siteId: 'site-A' },
+ request,
+ requestWithAsyncResult,
+ ...overrides,
+ })
+ useRoomSummaries.mockReturnValue({
+ summaries: overrides.summaries ?? DEFAULT_SUMMARIES,
+ })
+ const onClose = vi.fn()
+ const onCreated = vi.fn()
+ render(
)
+ return { requestWithAsyncResult, request, onClose, onCreated }
+}
+
+describe('CreateRoomDialog', () => {
+ beforeEach(() => {
+ useNats.mockReset()
+ useRoomSummaries.mockReset()
+ })
+
+ it('does not show a room-type dropdown — type is inferred from inputs', () => {
+ setup()
+ expect(screen.queryByLabelText(/^Type$/i)).not.toBeInTheDocument()
+ })
+
+ it('submit is always clickable; clicking on a fully-empty form is a no-op', async () => {
+ // Submit-button gating used to be `name || chip-count > 0`, but that meant
+ // a user who typed "alice" in Users (without pressing Enter) saw a
+ // disabled Create and wondered why. Drop the visual gate; the submit
+ // handler does the actual empty-check after flushing pending text.
+ const { requestWithAsyncResult } = setup()
+ const submit = screen.getByRole('button', { name: /Create/i })
+ expect(submit).not.toBeDisabled()
+ fireEvent.click(submit)
+ await new Promise((r) => setTimeout(r, 30))
+ expect(requestWithAsyncResult).not.toHaveBeenCalled()
+ })
+
+ it('submits a channel-shaped payload to room.{siteId}.create when a name is given', async () => {
+ const { requestWithAsyncResult, onCreated, onClose } = setup()
+ fireEvent.change(screen.getByLabelText(/Name/i), { target: { value: 'frontend' } })
+ fireEvent.change(screen.getByLabelText(/Users/i), { target: { value: 'bob' } })
+ fireEvent.keyDown(screen.getByLabelText(/Users/i), { key: 'Enter' })
+ fireEvent.click(screen.getByRole('button', { name: /Create/i }))
+ await waitFor(() => expect(requestWithAsyncResult).toHaveBeenCalledTimes(1))
+ expect(requestWithAsyncResult).toHaveBeenCalledWith(
+ 'chat.user.alice.request.room.site-A.create',
+ { name: 'frontend', users: ['bob'], orgs: [], channels: [] },
+ expect.objectContaining({ treatAsSuccess: expect.any(Function) })
+ )
+ await waitFor(() => expect(onCreated).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 'r-new', type: 'channel' })
+ ))
+ await waitFor(() => expect(onClose).toHaveBeenCalled())
+ })
+
+ it('submits a DM-shaped payload (empty name, one user) and surfaces roomType=dm', async () => {
+ const requestWithAsyncResult = vi.fn().mockResolvedValue({
+ sync: { status: 'accepted', roomId: 'r-dm', roomType: 'dm' },
+ async: { status: 'ok', roomId: 'r-dm', operation: 'room.create' },
+ })
+ useNats.mockReturnValue({
+ user: { account: 'alice', siteId: 'site-A' },
+ request: vi.fn(),
+ requestWithAsyncResult,
+ })
+ useRoomSummaries.mockReturnValue({ summaries: DEFAULT_SUMMARIES })
+ const onCreated = vi.fn()
+ render(
)
+ fireEvent.change(screen.getByLabelText(/Users/i), { target: { value: 'bob' } })
+ fireEvent.keyDown(screen.getByLabelText(/Users/i), { key: 'Enter' })
+ fireEvent.click(screen.getByRole('button', { name: /Create/i }))
+ await waitFor(() =>
+ expect(requestWithAsyncResult).toHaveBeenCalledWith(
+ 'chat.user.alice.request.room.site-A.create',
+ { name: '', users: ['bob'], orgs: [], channels: [] },
+ expect.objectContaining({ treatAsSuccess: expect.any(Function) })
+ )
+ )
+ await waitFor(() => expect(onCreated).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 'r-dm', type: 'dm' })
+ ))
+ })
+
+ it('uses the counterpart account as the optimistic DM display name', async () => {
+ // The server will send the canonical name via subscription.update,
+ // but until then the room header would render blank if we passed
+ // the empty `name` field through. Fall back to the counterpart so
+ // the sidebar and header have something to show.
+ const { onCreated } = setup({
+ requestWithAsyncResult: vi.fn().mockResolvedValue({
+ sync: { status: 'accepted', roomId: 'r-dm', roomType: 'dm' },
+ async: { status: 'ok', roomId: 'r-dm', operation: 'room.create' },
+ }),
+ })
+ fireEvent.change(screen.getByLabelText(/Users/i), { target: { value: 'bob' } })
+ fireEvent.keyDown(screen.getByLabelText(/Users/i), { key: 'Enter' })
+ fireEvent.click(screen.getByRole('button', { name: /Create/i }))
+ await waitFor(() =>
+ expect(onCreated).toHaveBeenCalledWith(expect.objectContaining({ name: 'bob' }))
+ )
+ })
+
+ it('treats a "dm already exists" reply as success and navigates to the existing room', async () => {
+ const requestWithAsyncResult = vi.fn().mockResolvedValue({
+ sync: { error: 'dm already exists', roomId: 'r-existing' },
+ async: null,
+ })
+ useNats.mockReturnValue({
+ user: { account: 'alice', siteId: 'site-A' },
+ request: vi.fn(),
+ requestWithAsyncResult,
+ })
+ useRoomSummaries.mockReturnValue({ summaries: DEFAULT_SUMMARIES })
+ const onCreated = vi.fn()
+ const onClose = vi.fn()
+ render(
)
+ fireEvent.change(screen.getByLabelText(/Users/i), { target: { value: 'bob' } })
+ fireEvent.keyDown(screen.getByLabelText(/Users/i), { key: 'Enter' })
+ fireEvent.click(screen.getByRole('button', { name: /Create/i }))
+ await waitFor(() => expect(onCreated).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 'r-existing', type: 'dm' })
+ ))
+ await waitFor(() => expect(onClose).toHaveBeenCalled())
+ expect(requestWithAsyncResult.mock.calls[0][2]).toMatchObject({
+ treatAsSuccess: expect.any(Function),
+ })
+ })
+
+ it('auto-flushes typed-but-not-Entered text into the request payload', async () => {
+ const { requestWithAsyncResult } = setup()
+ fireEvent.change(screen.getByLabelText(/Name/i), { target: { value: 'team' } })
+ // User types but does NOT press Enter on any picker field.
+ fireEvent.change(screen.getByLabelText(/Users/i), { target: { value: 'alice, bob' } })
+ fireEvent.change(screen.getByLabelText(/Orgs/i), { target: { value: 'eng-org' } })
+ fireEvent.change(screen.getByLabelText(/Channels/i), { target: { value: 'r-x, r-y' } })
+ fireEvent.click(screen.getByRole('button', { name: /Create/i }))
+ await waitFor(() => expect(requestWithAsyncResult).toHaveBeenCalledTimes(1))
+ expect(requestWithAsyncResult.mock.calls[0][1]).toEqual({
+ name: 'team',
+ users: ['alice', 'bob'],
+ orgs: ['eng-org'],
+ channels: [
+ { roomId: 'r-x', siteId: 'site-A' },
+ { roomId: 'r-y', siteId: 'site-A' },
+ ],
+ })
+ })
+
+ describe('subscription.update wait', () => {
+ it('shows "Waiting for server confirmation…" and holds the dialog open after sync ack until summaries contains the room', async () => {
+ // Start with the new room absent from summaries — emulates the
+ // window between the synchronous create ack and the asynchronous
+ // subscription.update event landing.
+ const { requestWithAsyncResult, onCreated, onClose } = setup({ summaries: [] })
+ fireEvent.change(screen.getByLabelText(/Name/i), { target: { value: 'frontend' } })
+ fireEvent.click(screen.getByRole('button', { name: /Create/i }))
+ // Wait for the sync ack to be processed (request fires + we set
+ // pendingRoom).
+ await waitFor(() => expect(requestWithAsyncResult).toHaveBeenCalledTimes(1))
+ // After sync ack the button label flips to "Waiting…" and neither
+ // callback has fired yet — the dialog is parked, watching summaries.
+ expect(await screen.findByRole('button', { name: /Waiting for server confirmation/i })).toBeInTheDocument()
+ expect(onCreated).not.toHaveBeenCalled()
+ expect(onClose).not.toHaveBeenCalled()
+ })
+
+ it('after the 3-second timeout, surfaces an error in the dialog and does NOT auto-select the room', async () => {
+ // Calling onCreated on timeout used to bounce the user back to the
+ // empty state — ChatPage's auto-deselect effect kicks in because
+ // summaries still doesn't contain the new roomId, and the channel
+ // subscription wasn't opened either (so messages wouldn't echo
+ // back). The dialog now surfaces the slow-server condition inside
+ // itself; the user dismisses with Cancel, and if the room was
+ // actually created they pick it up from the sidebar when
+ // subscription.update finally lands.
+ vi.useFakeTimers({ shouldAdvanceTime: true })
+ try {
+ const { onCreated, onClose } = setup({ summaries: [] })
+ fireEvent.change(screen.getByLabelText(/Name/i), { target: { value: 'frontend' } })
+ fireEvent.click(screen.getByRole('button', { name: /Create/i }))
+ await screen.findByRole('button', { name: /Waiting/i })
+ expect(onCreated).not.toHaveBeenCalled()
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(3001)
+ })
+ // Error appears, dialog stays open, neither callback fired.
+ expect(await screen.findByText(/taking longer than expected/i)).toBeInTheDocument()
+ expect(onCreated).not.toHaveBeenCalled()
+ expect(onClose).not.toHaveBeenCalled()
+ // Submit button is back to "Create" — pendingRoom has been cleared
+ // so the wait state is over. User can dismiss with Cancel.
+ expect(screen.getByRole('button', { name: /^Create$/ })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /^Cancel$/ })).not.toBeDisabled()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('Cancel is re-enabled during the "Waiting…" state so the user can back out without waiting for the timeout', async () => {
+ // While the request was in flight (loading=true) Cancel stays
+ // disabled — we can't safely abort a published NATS request. Once
+ // the sync ack lands and we're just waiting for subscription.update
+ // (pendingRoom set, loading=false), Cancel should re-enable.
+ const { onClose } = setup({ summaries: [] })
+ fireEvent.change(screen.getByLabelText(/Name/i), { target: { value: 'frontend' } })
+ fireEvent.click(screen.getByRole('button', { name: /Create/i }))
+ // Wait for the request to settle and the dialog to enter the
+ // pending state.
+ await screen.findByRole('button', { name: /Waiting/i })
+ const cancel = screen.getByRole('button', { name: /^Cancel$/ })
+ expect(cancel).not.toBeDisabled()
+ fireEvent.click(cancel)
+ expect(onClose).toHaveBeenCalled()
+ })
+ })
+
+ it('shows the server error on a failed create and does not close', async () => {
+ const requestWithAsyncResult = vi.fn().mockRejectedValue(new Error('exceeds maximum capacity (50)'))
+ useNats.mockReturnValue({
+ user: { account: 'alice', siteId: 'site-A' },
+ request: vi.fn(),
+ requestWithAsyncResult,
+ })
+ useRoomSummaries.mockReturnValue({ summaries: DEFAULT_SUMMARIES })
+ const onClose = vi.fn()
+ render(
)
+ fireEvent.change(screen.getByLabelText(/Name/i), { target: { value: 'huge' } })
+ fireEvent.click(screen.getByRole('button', { name: /Create/i }))
+ expect(await screen.findByText(/exceeds maximum capacity/i)).toBeInTheDocument()
+ expect(onClose).not.toHaveBeenCalled()
+ })
+})
diff --git a/chat-frontend/src/components/ManageMembersDialog.jsx b/chat-frontend/src/components/ManageMembersDialog.jsx
index fd3abb5bc..7f9432f5c 100644
--- a/chat-frontend/src/components/ManageMembersDialog.jsx
+++ b/chat-frontend/src/components/ManageMembersDialog.jsx
@@ -1,20 +1,14 @@
import { useState } from 'react'
import AddMembersForm from './manageMembers/AddMembersForm'
-import RemoveMemberForm from './manageMembers/RemoveMemberForm'
-import RoleUpdateForm from './manageMembers/RoleUpdateForm'
-import RemoveOrgForm from './manageMembers/RemoveOrgForm'
+import MemberRoster from './manageMembers/MemberRoster'
const TABS = [
- { id: 'add', label: 'Add', Form: AddMembersForm },
- { id: 'remove', label: 'Remove', Form: RemoveMemberForm },
- { id: 'role', label: 'Role', Form: RoleUpdateForm },
- { id: 'removeOrg', label: 'Remove Org', Form: RemoveOrgForm },
+ { id: 'roster', label: 'Members' },
+ { id: 'add', label: 'Add' },
]
export default function ManageMembersDialog({ room, onClose }) {
- const [mode, setMode] = useState('add')
- const active = TABS.find((t) => t.id === mode)
- const ActiveForm = active.Form
+ const [mode, setMode] = useState('roster')
return (
@@ -36,7 +30,7 @@ export default function ManageMembersDialog({ room, onClose }) {
))}
-
+ {mode === 'roster' ?
:
}