diff --git a/src/handlers/chat.rs b/src/handlers/chat.rs index b91568a..281f6d6 100644 --- a/src/handlers/chat.rs +++ b/src/handlers/chat.rs @@ -284,7 +284,7 @@ pub async fn new_session( hx-target=\"#messages\" \ hx-swap=\"beforeend\" \ hx-on=\"htmx:afterRequest: this.reset(); scrollMessages()\">\ - \ + \ \ \ \ diff --git a/src/handlers/ws.rs b/src/handlers/ws.rs index 2d330fd..96dac2d 100644 --- a/src/handlers/ws.rs +++ b/src/handlers/ws.rs @@ -9,8 +9,10 @@ use std::sync::Arc; use crate::state::{AppState, RESET_EVENT}; -const USER_TYPING_PREFIX: &str = "typing:user:"; -const SUPPORTER_TYPING_PREFIX: &str = "typing:supporter:"; +const USER_TYPING_START_PREFIX: &str = "typing:user:start:"; +const USER_TYPING_STOP_PREFIX: &str = "typing:user:stop:"; +const SUPPORTER_TYPING_START_PREFIX: &str = "typing:supporter:start:"; +const SUPPORTER_TYPING_STOP_PREFIX: &str = "typing:supporter:stop:"; /// WebSocket for user — pushes notifications when supporter replies pub async fn user_ws( @@ -21,6 +23,13 @@ pub async fn user_ws( ws.on_upgrade(move |socket| handle_user_socket(socket, session_id, state)) } +fn is_typing_event_for_session(text: &str, session_id: &str) -> bool { + text.strip_prefix(USER_TYPING_START_PREFIX) == Some(session_id) + || text.strip_prefix(USER_TYPING_STOP_PREFIX) == Some(session_id) + || text.strip_prefix(SUPPORTER_TYPING_START_PREFIX) == Some(session_id) + || text.strip_prefix(SUPPORTER_TYPING_STOP_PREFIX) == Some(session_id) +} + async fn handle_user_socket(mut socket: WebSocket, session_id: String, state: Arc) { let tx = state.get_or_create_notifier(&session_id); let mut rx = tx.subscribe(); @@ -28,9 +37,9 @@ async fn handle_user_socket(mut socket: WebSocket, session_id: String, state: Ar loop { tokio::select! { // Forward broadcast events to the user browser. - Ok(sid) = rx.recv() => { - if sid == session_id || sid == RESET_EVENT || sid.starts_with(SUPPORTER_TYPING_PREFIX) || sid.starts_with(USER_TYPING_PREFIX) { - if socket.send(Message::Text(sid)).await.is_err() { + Ok(event) = rx.recv() => { + if event == session_id || event == RESET_EVENT || is_typing_event_for_session(&event, &session_id) { + if socket.send(Message::Text(event)).await.is_err() { break; } } @@ -39,8 +48,9 @@ async fn handle_user_socket(mut socket: WebSocket, session_id: String, state: Ar msg = socket.recv() => { match msg { Some(Ok(Message::Text(text))) => { - let expected = format!("{USER_TYPING_PREFIX}{session_id}"); - if text == expected { + if is_typing_event_for_session(&text, &session_id) + && (text.starts_with(USER_TYPING_START_PREFIX) || text.starts_with(USER_TYPING_STOP_PREFIX)) + { let _ = tx.send(text.clone()); let _ = state.supporter_notifier.send(text); } @@ -62,20 +72,25 @@ pub async fn supporter_ws( ws.on_upgrade(move |socket| handle_supporter_socket(socket, state)) } +fn supporter_typing_session_id(text: &str) -> Option<&str> { + text.strip_prefix(SUPPORTER_TYPING_START_PREFIX) + .or_else(|| text.strip_prefix(SUPPORTER_TYPING_STOP_PREFIX)) +} + async fn handle_supporter_socket(mut socket: WebSocket, state: Arc) { let mut rx = state.supporter_notifier.subscribe(); loop { tokio::select! { - Ok(session_id) = rx.recv() => { - if socket.send(Message::Text(session_id)).await.is_err() { + Ok(event) = rx.recv() => { + if socket.send(Message::Text(event)).await.is_err() { break; } } msg = socket.recv() => { match msg { Some(Ok(Message::Text(text))) => { - if let Some(session_id) = text.strip_prefix(SUPPORTER_TYPING_PREFIX) { + if let Some(session_id) = supporter_typing_session_id(&text) { if !session_id.is_empty() && state.sessions.contains_key(session_id) { let tx = state.get_or_create_notifier(session_id); let _ = tx.send(text); diff --git a/templates/index.html b/templates/index.html index 5ba4fcc..a9fa163 100644 --- a/templates/index.html +++ b/templates/index.html @@ -14,6 +14,9 @@ --surface-soft: #20242c; --border: #2b303a; --accent: #8f9cff; + --danger: #d93d3d; + --danger-soft: rgba(217, 61, 61, 0.16); + --danger-text: #ffd1d1; --text: #eceff4; --muted: #9aa3b2; --radius: 14px; @@ -21,13 +24,14 @@ body { min-height: 100vh; + min-height: 100dvh; background: var(--bg); color: var(--text); font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; display: flex; align-items: center; justify-content: center; - padding: 1.5rem; + padding: 1.25rem; } .card { @@ -42,24 +46,27 @@ .timer { display: inline-flex; margin-bottom: 1rem; - color: var(--muted); - border: 1px solid var(--border); + color: var(--danger-text); + border: 1px solid var(--danger); border-radius: 999px; - padding: 0.35rem 0.75rem; - font-size: 0.78rem; - background: var(--bg); + padding: 0.38rem 0.8rem; + font-size: 0.82rem; + font-weight: 800; + background: var(--danger-soft); + box-shadow: 0 0 0 1px rgba(217, 61, 61, 0.08), 0 8px 24px rgba(217, 61, 61, 0.08); } h1 { - font-size: clamp(1.6rem, 4vw, 2.15rem); + font-size: clamp(1.55rem, 4vw, 2.1rem); letter-spacing: -0.035em; - margin-bottom: 0.5rem; + margin-bottom: 0.55rem; } p { color: var(--muted); - line-height: 1.6; - margin-bottom: 1.5rem; + line-height: 1.55; + margin-bottom: 1.25rem; + font-size: 0.95rem; } .choices { @@ -77,7 +84,7 @@ background: var(--surface-soft); border: 1px solid var(--border); border-radius: var(--radius); - padding: 1.1rem; + padding: 1.05rem; transition: transform 0.15s, border-color 0.15s, background 0.15s; } @@ -88,11 +95,13 @@ } .role-title { font-weight: 700; font-size: 1rem; } - .role-detail { color: var(--muted); font-size: 0.88rem; } + .role-detail { color: var(--muted); font-size: 0.88rem; line-height: 1.45; } @media (max-width: 560px) { + body { align-items: stretch; padding: 1rem; } .choices { grid-template-columns: 1fr; } - .card { padding: 1.5rem; } + .card { padding: 1.35rem; border-radius: 18px; align-self: center; } + .timer { width: 100%; justify-content: center; } } @@ -100,12 +109,12 @@
Reset in --:--

Support chat

-

Open the side you want to test. Each option opens in a new tab.

+

A small in-memory chat demo with separate user and supporter views. Conversations, messages, and rate limits reset every 3 minutes.

User - Start a new conversation + Start a conversation Supporter diff --git a/templates/supporter.html b/templates/supporter.html index a95470c..595f427 100644 --- a/templates/supporter.html +++ b/templates/supporter.html @@ -15,6 +15,9 @@ --border: #2b303a; --accent: #8f9cff; --accent-dim: #737fd3; + --danger: #d93d3d; + --danger-soft: rgba(217, 61, 61, 0.16); + --danger-text: #ffd1d1; --text: #eceff4; --muted: #9aa3b2; --user: #8f9cff; @@ -32,9 +35,10 @@ .layout { display: grid; - grid-template-columns: 280px 1fr; - grid-template-rows: 56px 1fr; + grid-template-columns: 280px minmax(0, 1fr); + grid-template-rows: 56px minmax(0, 1fr); height: 100vh; + height: 100dvh; } .topbar { @@ -42,7 +46,7 @@ display: flex; align-items: center; gap: 0.75rem; - padding: 0 1.5rem; + padding: 0 1.25rem; border-bottom: 1px solid var(--border); background: var(--surface); } @@ -54,13 +58,20 @@ } .topbar-timer { + display: inline-flex; + align-items: center; + justify-content: center; margin-left: auto; - font-size: 0.78rem; - color: var(--muted); - background: var(--bg); - padding: 0.25rem 0.65rem; + font-size: 0.82rem; + color: var(--danger-text); + background: var(--danger-soft); + padding: 0.34rem 0.75rem; border-radius: 20px; - border: 1px solid var(--border); + border: 1px solid var(--danger); + font-weight: 800; + letter-spacing: 0.01em; + box-shadow: 0 0 0 1px rgba(217, 61, 61, 0.08), 0 8px 24px rgba(217, 61, 61, 0.08); + white-space: nowrap; } .sidebar { @@ -68,6 +79,7 @@ display: flex; flex-direction: column; overflow: hidden; + min-width: 0; } .sidebar-header { @@ -89,8 +101,10 @@ gap: 0.3rem; } - #session-list::-webkit-scrollbar { width: 4px; } - #session-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } + #session-list::-webkit-scrollbar, + #supporter-messages::-webkit-scrollbar { width: 4px; height: 4px; } + #session-list::-webkit-scrollbar-thumb, + #supporter-messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } .session-item { width: 100%; @@ -117,6 +131,7 @@ display: flex; align-items: center; gap: 0.5rem; + flex-wrap: wrap; } .session-badge { @@ -145,41 +160,55 @@ .main-panel { display: flex; flex-direction: column; + align-items: center; overflow: hidden; + min-width: 0; + background: var(--bg); + } + + .main-header, + #supporter-messages, + .typing-bubble, + #reply-area { + width: min(760px, 100%); } .main-header { - padding: 0.9rem 1.5rem; + padding: 0.85rem 1rem; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 0.5rem; - min-height: 56px; + min-height: 54px; + background: var(--bg); } #active-session-name { font-weight: 700; font-size: 1rem; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } #active-session-meta { - font-size: 0.8rem; + font-size: 0.78rem; color: var(--muted); + white-space: nowrap; } #supporter-messages { flex: 1; + min-height: 0; overflow-y: auto; - padding: 1.2rem 1.5rem; + padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem; scroll-behavior: smooth; } - #supporter-messages::-webkit-scrollbar { width: 4px; } - #supporter-messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } - .empty-state { flex: 1; display: flex; @@ -189,6 +218,8 @@ color: var(--muted); gap: 0.5rem; font-size: 0.9rem; + text-align: center; + padding: 1rem; } .msg-wrap { display: flex; } @@ -196,11 +227,12 @@ .msg-supporter-wrap { justify-content: flex-start; } .msg-user, .msg-supporter { - max-width: 65%; - padding: 0.6rem 0.9rem; + max-width: min(72%, 520px); + padding: 0.58rem 0.85rem; border-radius: 16px; font-size: 0.88rem; - line-height: 1.5; + line-height: 1.48; + overflow-wrap: anywhere; } .msg-user { @@ -217,7 +249,7 @@ .msg-label { display: block; - font-size: 0.7rem; + font-size: 0.68rem; font-weight: 600; opacity: 0.7; margin-bottom: 2px; @@ -229,7 +261,7 @@ .msg-time { display: block; - font-size: 0.68rem; + font-size: 0.66rem; opacity: 0.55; margin-top: 4px; text-align: right; @@ -239,30 +271,44 @@ .msg-system-text { font-size: 0.75rem; color: var(--muted); - background: var(--bg); + background: var(--surface); padding: 0.25rem 0.75rem; border-radius: 20px; + text-align: center; } .typing-bubble { display: none; - padding: 0 1.5rem 0.55rem; + padding: 0 1rem 0.55rem; justify-content: flex-start; } .typing-bubble.visible { display: flex; } .typing-bubble-inner { + position: relative; display: inline-flex; align-items: center; gap: 4px; min-width: 48px; - padding: 0.65rem 0.85rem; + padding: 0.58rem 0.78rem; background: var(--supporter); border-radius: 16px; border-bottom-left-radius: 4px; } + .typing-bubble-inner::after { + content: ""; + position: absolute; + left: 7px; + bottom: -4px; + width: 9px; + height: 9px; + background: var(--supporter); + transform: rotate(45deg); + border-radius: 2px; + } + .typing-dot { width: 6px; height: 6px; @@ -281,15 +327,17 @@ #reply-area { border-top: 1px solid var(--border); - padding: 0.75rem 1.5rem; + padding: 0.65rem 1rem 0.8rem; display: none; + background: var(--bg); } - #reply-area.visible { display: flex; gap: 0.6rem; } + #reply-area.visible { display: flex; gap: 0.5rem; } #reply-area input[type="text"] { flex: 1; - padding: 0.65rem 1rem; + min-width: 0; + padding: 0.58rem 0.85rem; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); @@ -302,7 +350,7 @@ #reply-area input[type="text"]:focus { border-color: var(--accent); } #reply-area button { - padding: 0.65rem 1.2rem; + padding: 0.58rem 1rem; background: var(--accent); color: #111318; border: none; @@ -311,9 +359,70 @@ font-size: 0.88rem; cursor: pointer; transition: background 0.2s; + white-space: nowrap; } #reply-area button:hover { background: var(--accent-dim); } + + @media (max-width: 820px) { + html, body { overflow: hidden; } + + .layout { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto auto minmax(0, 1fr); + } + + .topbar { + min-height: 56px; + padding: 0.65rem 0.85rem; + flex-wrap: wrap; + } + + .topbar-timer { + margin-left: 0; + font-size: 0.78rem; + padding: 0.3rem 0.65rem; + } + + .sidebar { + border-right: 0; + border-bottom: 1px solid var(--border); + max-height: 30vh; + } + + .sidebar-header { padding: 0.7rem 0.85rem; } + + #session-list { + flex-direction: row; + overflow-x: auto; + overflow-y: hidden; + padding: 0.5rem 0.75rem 0.65rem; + } + + .session-item { + min-width: 190px; + max-width: 230px; + } + + .main-header, + #supporter-messages, + .typing-bubble, + #reply-area { width: 100%; } + + .main-header { padding: 0.75rem 0.85rem; min-height: 48px; } + #supporter-messages { padding: 0.85rem; } + .msg-user, .msg-supporter { max-width: 86%; } + #reply-area { padding: 0.6rem 0.75rem 0.75rem; } + } + + @media (max-width: 460px) { + .topbar { align-items: flex-start; flex-direction: column; gap: 0.45rem; } + .topbar-timer { width: 100%; justify-content: center; text-align: center; } + .sidebar { max-height: 28vh; } + .session-item { min-width: 165px; } + #reply-area.visible { gap: 0.4rem; } + #reply-area button { padding-inline: 0.85rem; } + } @@ -362,8 +471,8 @@