From 234059811c392d931521de2d6b1469f0bca7bb95 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 5 Jan 2026 16:16:27 +0000 Subject: [PATCH] feat(ui): add chat reading indicator --- CHANGELOG.md | 1 + ui/src/styles/components.css | 50 ++++++++++++++++++++++++++++++++++++ ui/src/ui/views/chat.ts | 34 +++++++++++++++++------- 3 files changed, 76 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2176cffb..7a8763e17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Model: `/model` output now includes auth source location (env/auth.json/models.json). - Docs: clarify auth storage, migration, and OpenAI Codex OAuth onboarding. - Sandbox: copy inbound media into sandbox workspaces so agent tools can read attachments. +- Control UI: show a reading indicator bubble while the assistant is responding. - Status: show runtime (docker/direct) and move shortcuts to `/help`. - Status: show model auth source (api-key/oauth). diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 78a14c295..7c7c4df03 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -595,6 +595,56 @@ } } +.chat-bubble.chat-reading-indicator { + width: fit-content; + padding: 10px 14px; +} + +.chat-reading-indicator__dots { + display: inline-flex; + align-items: center; + gap: 6px; + height: 10px; +} + +.chat-reading-indicator__dots > span { + width: 6px; + height: 6px; + border-radius: 999px; + background: var(--chat-text); + opacity: 0.55; + transform: translateY(0); + animation: chatReadingDot 1.1s ease-in-out infinite; +} + +.chat-reading-indicator__dots > span:nth-child(2) { + animation-delay: 0.12s; +} + +.chat-reading-indicator__dots > span:nth-child(3) { + animation-delay: 0.24s; +} + +@keyframes chatReadingDot { + 0%, + 80%, + 100% { + opacity: 0.45; + transform: translateY(0); + } + 40% { + opacity: 0.95; + transform: translateY(-2px); + } +} + +@media (prefers-reduced-motion: reduce) { + .chat-reading-indicator__dots > span { + animation: none; + opacity: 0.75; + } +} + .chat-text { overflow-wrap: anywhere; word-break: break-word; diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 90660d319..f78a67a8f 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -78,15 +78,17 @@ export function renderChat(props: ChatProps) {
${props.loading ? html`
Loading chat…
` : nothing} ${props.messages.map((m) => renderMessage(m))} - ${props.stream - ? renderMessage( - { - role: "assistant", - content: [{ type: "text", text: props.stream }], - timestamp: Date.now(), - }, - { streaming: true }, - ) + ${props.stream !== null + ? props.stream.trim().length > 0 + ? renderMessage( + { + role: "assistant", + content: [{ type: "text", text: props.stream }], + timestamp: Date.now(), + }, + { streaming: true }, + ) + : renderReadingIndicator() : nothing}
@@ -171,6 +173,20 @@ function resolveSessionOptions( return result; } +function renderReadingIndicator() { + return html` +
+
+ +
+
+ `; +} + function renderMessage(message: unknown, opts?: { streaming?: boolean }) { const m = message as Record; const role = typeof m.role === "string" ? m.role : "unknown";