Add DM typing indicator to the game chat
The server broadcasts a "typing" event to the whole room right when it starts the DM turn, and broadcasts turn failures to everyone too (previously only the sender saw them, which would've left other players' indicators stuck forever). The frontend shows a pulsing "Dungeon Master schreibt …" line that clears once the real reply or an error arrives. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -118,12 +118,13 @@ async def game_websocket(websocket: WebSocket, game_id: uuid.UUID, ticket: str)
|
|||||||
game_id,
|
game_id,
|
||||||
{"type": "message", "message": await _serialize_message(session, player_message)},
|
{"type": "message", "message": await _serialize_message(session, player_message)},
|
||||||
)
|
)
|
||||||
|
await manager.broadcast(game_id, {"type": "typing"})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
dm_message = await run_dm_turn(session, game_id, latest_player_message=content)
|
dm_message = await run_dm_turn(session, game_id, latest_player_message=content)
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
logger.exception("DM turn failed for game %s", game_id)
|
logger.exception("DM turn failed for game %s", game_id)
|
||||||
await websocket.send_json({"type": "error", "detail": "dm_turn_failed"})
|
await manager.broadcast(game_id, {"type": "error", "detail": "dm_turn_failed"})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
await manager.broadcast(
|
await manager.broadcast(
|
||||||
|
|||||||
@@ -9,12 +9,18 @@ function senderLabel(message: Message): string {
|
|||||||
return message.player_name ?? "Spieler";
|
return message.player_name ?? "Spieler";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TranscriptPane({ messages }: { messages: Message[] }) {
|
export default function TranscriptPane({
|
||||||
|
messages,
|
||||||
|
typing,
|
||||||
|
}: {
|
||||||
|
messages: Message[];
|
||||||
|
typing?: boolean;
|
||||||
|
}) {
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bottomRef.current?.scrollIntoView({ block: "end" });
|
bottomRef.current?.scrollIntoView({ block: "end" });
|
||||||
}, [messages]);
|
}, [messages, typing]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col gap-3 overflow-y-auto p-4">
|
<div className="flex h-full flex-col gap-3 overflow-y-auto p-4">
|
||||||
@@ -34,6 +40,11 @@ export default function TranscriptPane({ messages }: { messages: Message[] }) {
|
|||||||
<span className="whitespace-pre-wrap">{m.content}</span>
|
<span className="whitespace-pre-wrap">{m.content}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{typing && (
|
||||||
|
<div className="text-slate-100">
|
||||||
|
<span className="animate-pulse font-semibold text-amber-400">Dungeon Master schreibt …</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ export function useGameSocket(
|
|||||||
gameId: string | undefined,
|
gameId: string | undefined,
|
||||||
onMessage: (message: Message) => void,
|
onMessage: (message: Message) => void,
|
||||||
onError: (detail: string) => void,
|
onError: (detail: string) => void,
|
||||||
|
onTyping: () => void,
|
||||||
) {
|
) {
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const onMessageRef = useRef(onMessage);
|
const onMessageRef = useRef(onMessage);
|
||||||
onMessageRef.current = onMessage;
|
onMessageRef.current = onMessage;
|
||||||
const onErrorRef = useRef(onError);
|
const onErrorRef = useRef(onError);
|
||||||
onErrorRef.current = onError;
|
onErrorRef.current = onError;
|
||||||
|
const onTypingRef = useRef(onTyping);
|
||||||
|
onTypingRef.current = onTyping;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!gameId) return;
|
if (!gameId) return;
|
||||||
@@ -31,6 +34,7 @@ export function useGameSocket(
|
|||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
if (data.type === "message") onMessageRef.current(data.message);
|
if (data.type === "message") onMessageRef.current(data.message);
|
||||||
else if (data.type === "error") onErrorRef.current(data.detail);
|
else if (data.type === "error") onErrorRef.current(data.detail);
|
||||||
|
else if (data.type === "typing") onTypingRef.current();
|
||||||
};
|
};
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
if (!intentionalClose) onErrorRef.current("connection_lost");
|
if (!intentionalClose) onErrorRef.current("connection_lost");
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export default function GameSession() {
|
|||||||
const [joinCode, setJoinCode] = useState("");
|
const [joinCode, setJoinCode] = useState("");
|
||||||
const [selectedCharacterId, setSelectedCharacterId] = useState("");
|
const [selectedCharacterId, setSelectedCharacterId] = useState("");
|
||||||
const [joinError, setJoinError] = useState<string | null>(null);
|
const [joinError, setJoinError] = useState<string | null>(null);
|
||||||
|
const [isTyping, setIsTyping] = useState(false);
|
||||||
|
|
||||||
async function loadGame() {
|
async function loadGame() {
|
||||||
if (!gameId) return;
|
if (!gameId) return;
|
||||||
@@ -69,12 +70,14 @@ export default function GameSession() {
|
|||||||
const { send } = useGameSocket(
|
const { send } = useGameSocket(
|
||||||
game?.is_participant ? gameId : undefined,
|
game?.is_participant ? gameId : undefined,
|
||||||
(incoming) => {
|
(incoming) => {
|
||||||
|
setIsTyping(false);
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
const next = [...prev, incoming];
|
const next = [...prev, incoming];
|
||||||
return showingFull ? next : next.slice(-5);
|
return showingFull ? next : next.slice(-5);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
(detail) => {
|
(detail) => {
|
||||||
|
setIsTyping(false);
|
||||||
const text =
|
const text =
|
||||||
detail === "connection_lost"
|
detail === "connection_lost"
|
||||||
? "Verbindung zum Server verloren. Bitte lade die Seite neu."
|
? "Verbindung zum Server verloren. Bitte lade die Seite neu."
|
||||||
@@ -94,6 +97,7 @@ export default function GameSession() {
|
|||||||
return showingFull ? next : next.slice(-5);
|
return showingFull ? next : next.slice(-5);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
() => setIsTyping(true),
|
||||||
);
|
);
|
||||||
|
|
||||||
async function handleSend(content: string) {
|
async function handleSend(content: string) {
|
||||||
@@ -173,7 +177,7 @@ export default function GameSession() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border border-slate-800 bg-slate-900">
|
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border border-slate-800 bg-slate-900">
|
||||||
<TranscriptPane messages={messages} />
|
<TranscriptPane messages={messages} typing={isTyping} />
|
||||||
<SendMessageForm onSend={handleSend} />
|
<SendMessageForm onSend={handleSend} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user