From 05fef0b4b6fc8b0005bdc38861b0f2d22a58bed7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 03:48:48 +0000 Subject: [PATCH] lobby: chat rides the room, and a reconnect drops a room the hub lost Two lobby bugs. The chat panel only appeared once the wire delivered a message, so a fresh room had no composer until someone else spoke. It is now up whenever the session is in a room. A reconnect whose sessionReady put the session somewhere other than the room on screen (the room reaped while the seat was away, or a fresh seat) left that room's panel standing, offering tables the hub then refused as "not in a room". The panel and chat now clear when the hub's room differs from the one shown; a resume back into the same room keeps it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NdXhNV1y5Rp7JU9gWjKApW --- src/apps/lobby/components/LobbyGame.tsx | 2 +- .../components/__tests__/LobbyGame.test.tsx | 14 +++- .../components/__tests__/LobbyPanel.test.tsx | 2 +- src/hooks/__tests__/useLobby.test.tsx | 70 +++++++++++++++++-- src/hooks/useLobby.ts | 23 +++--- 5 files changed, 96 insertions(+), 15 deletions(-) diff --git a/src/apps/lobby/components/LobbyGame.tsx b/src/apps/lobby/components/LobbyGame.tsx index 821f7f1..7727404 100644 --- a/src/apps/lobby/components/LobbyGame.tsx +++ b/src/apps/lobby/components/LobbyGame.tsx @@ -77,7 +77,7 @@ const LobbyGame = (props: UseLobbyProps) => { )} - {chat.available && ( + {room !== null && (
({ })) vi.mock('@/apps/thoughts/components/ThoughtsGame', () => ({ default: () =>
world
})) vi.mock('@/apps/castle/components/CastleTable', () => ({ default: () =>
table
})) +vi.mock('../RoomChat', () => ({ default: () =>
chat
})) vi.mock('@/apps/golf/components/GolfTable', () => ({ default: ({ shareUrl }: { shareUrl: string | null }) =>
golf table {shareUrl}
})) @@ -70,6 +71,17 @@ describe('LobbyGame', () => { expect(screen.getByRole('complementary', { name: 'lobby' })).toBeTruthy() }) + it('chat is up whenever the session is in a room, before anyone has spoken', () => { + const { rerender } = render() + expect(screen.queryByText('chat')).toBeNull() + state.room = { roomId: 'R1', players: [], games: [] } + rerender() + expect(screen.getByText('chat')).toBeTruthy() + state.room = null + rerender() + expect(screen.queryByText('chat')).toBeNull() + }) + it('a lost hub is said, not hidden', () => { state.lost = 'Lost connection to the games hub' render() diff --git a/src/apps/lobby/components/__tests__/LobbyPanel.test.tsx b/src/apps/lobby/components/__tests__/LobbyPanel.test.tsx index fb2935d..ba08b30 100644 --- a/src/apps/lobby/components/__tests__/LobbyPanel.test.tsx +++ b/src/apps/lobby/components/__tests__/LobbyPanel.test.tsx @@ -29,7 +29,7 @@ const lobby = (over: Partial = {}): UseLobby => connected: true, lost: null, room: null, - chat: { messages: [], available: false, replayUpTo: 0, rejection: null }, + chat: { messages: [], replayUpTo: 0, rejection: null }, notice: '', roomCode: '', setRoomCode: vi.fn(), diff --git a/src/hooks/__tests__/useLobby.test.tsx b/src/hooks/__tests__/useLobby.test.tsx index 52b3363..707f30b 100644 --- a/src/hooks/__tests__/useLobby.test.tsx +++ b/src/hooks/__tests__/useLobby.test.tsx @@ -324,13 +324,11 @@ describe('useLobby', () => { expect(result.current.castle.opening).toBe(false) }) - it('chat appears only once the wire delivers it, merged by id, and is the room\'s', async () => { + it("chat is merged by id, and is the room's", async () => { const { result, ws } = await open() act(() => ws.receive('roomState', roomState('R1'))) - expect(result.current.chat.available).toBe(false) const message = { messageId: 3, playerId: 'bob', text: 'hi', sentAtUnixMillis: 1 } act(() => ws.receive('roomChatHistory', { messages: [message] })) - expect(result.current.chat.available).toBe(true) expect(result.current.chat.replayUpTo).toBe(3) act(() => ws.receive('roomChat', message)) expect(result.current.chat.messages).toHaveLength(1) @@ -339,7 +337,7 @@ describe('useLobby', () => { act(() => ws.receive('commandRejected', { reason: 'slow down' })) expect(result.current.chat.rejection).toEqual({ seq: 1, reason: 'slow down' }) act(() => ws.receive('roomState', roomState('R2'))) - expect(result.current.chat).toEqual({ messages: [], available: false, replayUpTo: 0, rejection: null }) + expect(result.current.chat).toEqual({ messages: [], replayUpTo: 0, rejection: null }) act(() => ws.receive('roomChat', message)) act(() => ws.receive('roomLeft', { roomId: 'R2' })) expect(result.current.chat.messages).toEqual([]) @@ -379,6 +377,70 @@ describe('useLobby', () => { } }) + it('a reconnect that lands outside the room drops the room it was showing', async () => { + vi.useFakeTimers() + try { + const { result, pathname } = mount({ permalinkRoomId: 'R1' }, '/games/room/R1') + let ws!: FakeWebSocket + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + ws = FakeWebSocket.instances[0] + ws.open() + ws.receive('sessionReady', { playerId: 'alice', resumed: true, roomId: 'R1' }) + }) + act(() => ws.receive('roomState', roomState('R1'))) + act(() => ws.receive('roomChat', { messageId: 1, playerId: 'bob', text: 'hi', sentAtUnixMillis: 1 })) + expect(result.current.room?.roomId).toBe('R1') + act(() => ws.close()) + await act(async () => { + await vi.advanceTimersByTimeAsync(2000) + }) + // The room was reaped while the seat was away: the hub admits the + // session into the plaza, and the link's room is refused. + const next = FakeWebSocket.instances[FakeWebSocket.instances.length - 1] + act(() => { + next.open() + next.receive('sessionReady', { playerId: 'alice', resumed: true }) + }) + expect(result.current.room).toBeNull() + expect(result.current.chat.messages).toEqual([]) + expect(next.lastSent()).toEqual({ event: 'joinRoom', payload: { roomId: 'R1' } }) + act(() => next.receive('commandRejected', { reason: 'room not found' })) + expect(result.current.room).toBeNull() + expect(result.current.notice).toBe('Room R1 is gone') + expect(pathname()).toBe('/games') + } finally { + vi.useRealTimers() + } + }) + + it('a reconnect back into the same room keeps the room on screen', async () => { + vi.useFakeTimers() + try { + const { result } = mount() + let ws!: FakeWebSocket + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + ws = FakeWebSocket.instances[0] + ws.open() + ws.receive('sessionReady', { playerId: 'alice', resumed: false }) + }) + act(() => ws.receive('roomState', roomState('R1'))) + act(() => ws.close()) + await act(async () => { + await vi.advanceTimersByTimeAsync(2000) + }) + const next = FakeWebSocket.instances[FakeWebSocket.instances.length - 1] + act(() => { + next.open() + next.receive('sessionReady', { playerId: 'alice', resumed: true, roomId: 'R1' }) + }) + expect(result.current.room?.roomId).toBe('R1') + } finally { + vi.useRealTimers() + } + }) + it('a link refused while leaving the resumed room gives up rather than looping', async () => { const { result, ws, pathname } = await open({ permalinkRoomId: 'R2' }, '/games/room/R2', 'R1') expect(ws.lastSent()).toEqual({ event: 'leaveRoom', payload: {} }) diff --git a/src/hooks/useLobby.ts b/src/hooks/useLobby.ts index 4227228..2ef597b 100644 --- a/src/hooks/useLobby.ts +++ b/src/hooks/useLobby.ts @@ -32,11 +32,11 @@ export const lobbyTablePath = (roomId: string, gameId: string) => const NOTICE_MS = 3000 +// The room's chat; it is open the whole time the session is in a room. +// A fresh room has no history to replay, so the wire says nothing +// until someone speaks. export interface LobbyChat { messages: ChatMessage[] - // True once the room's wire has delivered chat (the join replay or a - // live message); a UI ahead of its server renders no composer. - available: boolean replayUpTo: number rejection: { seq: number; reason: string } | null } @@ -87,7 +87,7 @@ export const useLobby = ({ const [room, setRoom] = useState(null) const [notice, setNotice] = useState('') const [roomCode, setRoomCode] = useState('') - const [chat, setChat] = useState({ messages: [], available: false, replayUpTo: 0, rejection: null }) + const [chat, setChat] = useState({ messages: [], replayUpTo: 0, rejection: null }) const streamRef = useRef(null) const noticeTimeoutRef = useRef(null) @@ -118,7 +118,7 @@ export const useLobby = ({ }, []) const resetChat = useCallback(() => { - setChat({ messages: [], available: false, replayUpTo: 0, rejection: null }) + setChat({ messages: [], replayUpTo: 0, rejection: null }) }, []) // One link for the life of the hook; the renderer attaches to it when @@ -209,6 +209,14 @@ export const useLobby = ({ switchRef.current = null tablePendingRef.current = null const here = ready.roomId ?? null + // A reconnect that lands somewhere other than the room this + // session was showing — the room reaped while it was away, or a + // fresh seat — leaves nothing of that room behind: a panel still + // showing it offers tables the hub refuses as "not in a room". + if (here !== roomIdRef.current) { + setRoom(null) + resetChat() + } roomIdRef.current = here const wanted = permalinkRef.current if (wanted.roomId && wanted.roomId !== here) { @@ -224,7 +232,7 @@ export const useLobby = ({ if (here !== null && !wanted.roomId) navigate(lobbyRoomPath(here), { replace: true }) enterWorld(here) }, - [clearTables, enterWorld, navigate, onPlayerIdChange, world] + [clearTables, enterWorld, navigate, onPlayerIdChange, resetChat, world] ) const handleRoom = useCallback( @@ -326,11 +334,10 @@ export const useLobby = ({ onRoom: handleRoom, onRoomLeft: handleRoomLeft, onChat: message => - setChat(prev => ({ ...prev, available: true, messages: mergeChatMessages(prev.messages, [message]) })), + setChat(prev => ({ ...prev, messages: mergeChatMessages(prev.messages, [message]) })), onChatHistory: messages => setChat(prev => ({ ...prev, - available: true, messages: mergeChatMessages(prev.messages, messages), replayUpTo: Math.max(prev.replayUpTo, ...messages.map(m => m.messageId)) })),