feat(chat-ui): redesign composer bottom bar with split Talk control and reasoning-effort chip (#100445)

* feat(chat-ui): redesign composer bottom bar with split Talk control and reasoning-effort chip

Removes the toolbar divider so the composer frame is the only chrome, merges
the Talk toggle and Talk settings gear into a mic+caret split control, shows
the effective reasoning level as a subtle pill next to the model name
(accent-washed only on session override), right-aligns the model/settings
cluster next to Send, and softens active/selected highlight states across the
bar. The combined model menu and reasoning fly-out now anchor right so they
stay on screen.

* fix(chat-ui): keep mobile composer controls usable

* fix(chat-ui): keep reasoning controls in mobile viewport

* fix(chat-ui): simplify composer placeholder

* chore(i18n): refresh composer locale metadata
This commit is contained in:
Peter Steinberger
2026-07-05 23:27:14 +01:00
committed by GitHub
parent c4a862ab53
commit 927bbeb58c
46 changed files with 253 additions and 133 deletions
+2 -2
View File
@@ -240,9 +240,9 @@ The terminal is also available as a full-screen, terminal-only document at `/?vi
<Accordion title="Talk mode (browser realtime)">
Talk mode uses a registered realtime voice provider. Configure OpenAI with `talk.realtime.provider: "openai"` plus an `openai` API-key auth profile, `talk.realtime.providers.openai.apiKey`, or `OPENAI_API_KEY`; OpenAI OAuth profiles do not configure Realtime voice. Configure Google with `talk.realtime.provider: "google"` plus `talk.realtime.providers.google.apiKey`. The browser never receives a standard provider API key: OpenAI receives an ephemeral Realtime client secret for WebRTC, and Google Live receives a one-use constrained Live API auth token for a browser WebSocket session, with instructions and tool declarations locked into the token by the Gateway. Providers that only expose a backend realtime bridge run through the Gateway relay transport, so credentials and vendor sockets stay server-side while browser audio moves through authenticated Gateway RPCs. The Realtime session prompt is assembled by the Gateway; `talk.client.create` does not accept caller-provided instruction overrides.
The Chat composer includes a Talk options button next to the Talk start/stop button. Its compact panel keeps only Voice, Model, and Sensitivity for the next Talk session. **More in Settings** opens **Settings → Communications → Talk**, where persistent provider, transport, reasoning effort, exact VAD threshold, silence duration, and prefix padding defaults live; changing those defaults requires `operator.admin` access. Blank composer values fall back to those configured defaults or the provider default. Configuring Gateway relay forces the backend relay path; configuring WebRTC keeps the session client-owned and fails instead of silently falling back to relay if the provider cannot create a browser session.
The Chat composer includes a Talk options caret next to the Talk start/stop button. Its compact panel keeps only Voice, Model, and Sensitivity for the next Talk session. **More in Settings** opens **Settings → Communications → Talk**, where persistent provider, transport, reasoning effort, exact VAD threshold, silence duration, and prefix padding defaults live; changing those defaults requires `operator.admin` access. Blank composer values fall back to those configured defaults or the provider default. Configuring Gateway relay forces the backend relay path; configuring WebRTC keeps the session client-owned and fails instead of silently falling back to relay if the provider cannot create a browser session.
The Talk control itself is the waves button next to the microphone dictation button. When Talk starts, the composer status row shows `Connecting Talk...`, then `Talk live` while audio is connected, or `Asking OpenClaw...` while a realtime tool call is consulting the configured larger model through `talk.client.toolCall`.
The Talk control itself is the microphone button in the composer toolbar, with a small caret beside it that opens Talk options. When Talk starts, the composer status row shows `Connecting Talk...`, then `Talk live` while audio is connected, or `Asking OpenClaw...` while a realtime tool call is consulting the configured larger model through `talk.client.toolCall`.
Maintainer live smoke: `OPENAI_API_KEY=... GEMINI_API_KEY=... node --import tsx scripts/dev/realtime-talk-live-smoke.ts` verifies the OpenAI backend WebSocket bridge, OpenAI browser WebRTC SDP exchange, Google Live constrained-token browser WebSocket setup, and the Gateway relay browser adapter with fake microphone media. The command prints provider status only and does not log secrets.
+2 -2
View File
@@ -1,10 +1,10 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T21:01:23.261Z",
"generatedAt": "2026-07-05T22:25:08.320Z",
"locale": "ar",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "116e85303569dc538dcd374ee7788ea9664fd8aae5cda4a2f0f2451cfefeaccf",
"sourceHash": "53b87190ae24c60a0c03150797350e7757cb8c3aad65967cf6b64f851774fead",
"totalKeys": 1528,
"translatedKeys": 1528,
"workflow": 1
+2 -2
View File
@@ -1,10 +1,10 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T20:59:33.063Z",
"generatedAt": "2026-07-05T22:25:07.310Z",
"locale": "de",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "116e85303569dc538dcd374ee7788ea9664fd8aae5cda4a2f0f2451cfefeaccf",
"sourceHash": "53b87190ae24c60a0c03150797350e7757cb8c3aad65967cf6b64f851774fead",
"totalKeys": 1528,
"translatedKeys": 1528,
"workflow": 1
+2 -2
View File
@@ -1,10 +1,10 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T21:00:39.848Z",
"generatedAt": "2026-07-05T22:25:07.454Z",
"locale": "es",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "116e85303569dc538dcd374ee7788ea9664fd8aae5cda4a2f0f2451cfefeaccf",
"sourceHash": "53b87190ae24c60a0c03150797350e7757cb8c3aad65967cf6b64f851774fead",
"totalKeys": 1528,
"translatedKeys": 1528,
"workflow": 1
+2 -2
View File
@@ -1,10 +1,10 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T21:03:19.039Z",
"generatedAt": "2026-07-05T22:25:09.693Z",
"locale": "fa",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "116e85303569dc538dcd374ee7788ea9664fd8aae5cda4a2f0f2451cfefeaccf",
"sourceHash": "53b87190ae24c60a0c03150797350e7757cb8c3aad65967cf6b64f851774fead",
"totalKeys": 1528,
"translatedKeys": 1528,
"workflow": 1
+2 -2
View File
@@ -1,10 +1,10 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T21:00:52.467Z",
"generatedAt": "2026-07-05T22:25:07.964Z",
"locale": "fr",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "116e85303569dc538dcd374ee7788ea9664fd8aae5cda4a2f0f2451cfefeaccf",
"sourceHash": "53b87190ae24c60a0c03150797350e7757cb8c3aad65967cf6b64f851774fead",
"totalKeys": 1528,
"translatedKeys": 1528,
"workflow": 1
+2 -2
View File
@@ -1,10 +1,10 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T19:32:32.620Z",
"generatedAt": "2026-07-05T22:25:08.150Z",
"locale": "hi",
"model": "",
"provider": "",
"sourceHash": "116e85303569dc538dcd374ee7788ea9664fd8aae5cda4a2f0f2451cfefeaccf",
"sourceHash": "53b87190ae24c60a0c03150797350e7757cb8c3aad65967cf6b64f851774fead",
"totalKeys": 1528,
"translatedKeys": 1528,
"workflow": 1
+2 -2
View File
@@ -1,10 +1,10 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T21:02:12.906Z",
"generatedAt": "2026-07-05T22:25:08.968Z",
"locale": "id",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "116e85303569dc538dcd374ee7788ea9664fd8aae5cda4a2f0f2451cfefeaccf",
"sourceHash": "53b87190ae24c60a0c03150797350e7757cb8c3aad65967cf6b64f851774fead",
"totalKeys": 1528,
"translatedKeys": 1528,
"workflow": 1
+2 -2
View File
@@ -1,10 +1,10 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T21:01:24.757Z",
"generatedAt": "2026-07-05T22:25:08.470Z",
"locale": "it",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "116e85303569dc538dcd374ee7788ea9664fd8aae5cda4a2f0f2451cfefeaccf",
"sourceHash": "53b87190ae24c60a0c03150797350e7757cb8c3aad65967cf6b64f851774fead",
"totalKeys": 1528,
"translatedKeys": 1528,
"workflow": 1
+2 -2
View File
@@ -1,10 +1,10 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T21:00:35.490Z",
"generatedAt": "2026-07-05T22:25:07.625Z",
"locale": "ja-JP",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "116e85303569dc538dcd374ee7788ea9664fd8aae5cda4a2f0f2451cfefeaccf",
"sourceHash": "53b87190ae24c60a0c03150797350e7757cb8c3aad65967cf6b64f851774fead",
"totalKeys": 1528,
"translatedKeys": 1528,
"workflow": 1
+2 -2
View File
@@ -1,10 +1,10 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T21:00:39.218Z",
"generatedAt": "2026-07-05T22:25:07.784Z",
"locale": "ko",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "116e85303569dc538dcd374ee7788ea9664fd8aae5cda4a2f0f2451cfefeaccf",
"sourceHash": "53b87190ae24c60a0c03150797350e7757cb8c3aad65967cf6b64f851774fead",
"totalKeys": 1528,
"translatedKeys": 1528,
"workflow": 1
+2 -2
View File
@@ -1,10 +1,10 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T21:03:15.146Z",
"generatedAt": "2026-07-05T22:25:09.554Z",
"locale": "nl",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "116e85303569dc538dcd374ee7788ea9664fd8aae5cda4a2f0f2451cfefeaccf",
"sourceHash": "53b87190ae24c60a0c03150797350e7757cb8c3aad65967cf6b64f851774fead",
"totalKeys": 1528,
"translatedKeys": 1528,
"workflow": 1
+2 -2
View File
@@ -1,10 +1,10 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T21:02:29.694Z",
"generatedAt": "2026-07-05T22:25:09.112Z",
"locale": "pl",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "116e85303569dc538dcd374ee7788ea9664fd8aae5cda4a2f0f2451cfefeaccf",
"sourceHash": "53b87190ae24c60a0c03150797350e7757cb8c3aad65967cf6b64f851774fead",
"totalKeys": 1528,
"translatedKeys": 1528,
"workflow": 1
+2 -2
View File
@@ -1,10 +1,10 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T20:59:44.069Z",
"generatedAt": "2026-07-05T22:25:07.155Z",
"locale": "pt-BR",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "116e85303569dc538dcd374ee7788ea9664fd8aae5cda4a2f0f2451cfefeaccf",
"sourceHash": "53b87190ae24c60a0c03150797350e7757cb8c3aad65967cf6b64f851774fead",
"totalKeys": 1528,
"translatedKeys": 1528,
"workflow": 1
+2 -2
View File
@@ -1,10 +1,10 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T19:32:34.753Z",
"generatedAt": "2026-07-05T22:25:09.849Z",
"locale": "ru",
"model": "",
"provider": "",
"sourceHash": "116e85303569dc538dcd374ee7788ea9664fd8aae5cda4a2f0f2451cfefeaccf",
"sourceHash": "53b87190ae24c60a0c03150797350e7757cb8c3aad65967cf6b64f851774fead",
"totalKeys": 1528,
"translatedKeys": 1528,
"workflow": 1
+2 -2
View File
@@ -1,10 +1,10 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T21:02:41.557Z",
"generatedAt": "2026-07-05T22:25:09.261Z",
"locale": "th",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "116e85303569dc538dcd374ee7788ea9664fd8aae5cda4a2f0f2451cfefeaccf",
"sourceHash": "53b87190ae24c60a0c03150797350e7757cb8c3aad65967cf6b64f851774fead",
"totalKeys": 1528,
"translatedKeys": 1528,
"workflow": 1
+2 -2
View File
@@ -1,10 +1,10 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T21:01:42.084Z",
"generatedAt": "2026-07-05T22:25:08.624Z",
"locale": "tr",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "116e85303569dc538dcd374ee7788ea9664fd8aae5cda4a2f0f2451cfefeaccf",
"sourceHash": "53b87190ae24c60a0c03150797350e7757cb8c3aad65967cf6b64f851774fead",
"totalKeys": 1528,
"translatedKeys": 1528,
"workflow": 1
+2 -2
View File
@@ -1,10 +1,10 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T21:01:40.702Z",
"generatedAt": "2026-07-05T22:25:08.790Z",
"locale": "uk",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "116e85303569dc538dcd374ee7788ea9664fd8aae5cda4a2f0f2451cfefeaccf",
"sourceHash": "53b87190ae24c60a0c03150797350e7757cb8c3aad65967cf6b64f851774fead",
"totalKeys": 1528,
"translatedKeys": 1528,
"workflow": 1
+2 -2
View File
@@ -1,10 +1,10 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T21:03:06.663Z",
"generatedAt": "2026-07-05T22:25:09.425Z",
"locale": "vi",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "116e85303569dc538dcd374ee7788ea9664fd8aae5cda4a2f0f2451cfefeaccf",
"sourceHash": "53b87190ae24c60a0c03150797350e7757cb8c3aad65967cf6b64f851774fead",
"totalKeys": 1528,
"translatedKeys": 1528,
"workflow": 1
+2 -2
View File
@@ -1,10 +1,10 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T20:59:46.734Z",
"generatedAt": "2026-07-05T22:25:06.843Z",
"locale": "zh-CN",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "116e85303569dc538dcd374ee7788ea9664fd8aae5cda4a2f0f2451cfefeaccf",
"sourceHash": "53b87190ae24c60a0c03150797350e7757cb8c3aad65967cf6b64f851774fead",
"totalKeys": 1528,
"translatedKeys": 1528,
"workflow": 1
+2 -2
View File
@@ -1,10 +1,10 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T20:59:36.968Z",
"generatedAt": "2026-07-05T22:25:07.009Z",
"locale": "zh-TW",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "116e85303569dc538dcd374ee7788ea9664fd8aae5cda4a2f0f2451cfefeaccf",
"sourceHash": "53b87190ae24c60a0c03150797350e7757cb8c3aad65967cf6b64f851774fead",
"totalKeys": 1528,
"translatedKeys": 1528,
"workflow": 1
+1 -1
View File
@@ -1498,7 +1498,7 @@ export const ar: TranslationMap = {
badge: "منتهي الصلاحية",
},
composer: {
placeholder: "Message {name} (Enter to send)",
placeholder: "Message {name}",
placeholderWithAttachments: "Add a message or paste more images...",
placeholderDisconnected: "Connect to the gateway to start chatting...",
attachFile: "Attach file",
+1 -1
View File
@@ -1525,7 +1525,7 @@ export const de: TranslationMap = {
badge: "Abgelaufen",
},
composer: {
placeholder: "Message {name} (Enter to send)",
placeholder: "Message {name}",
placeholderWithAttachments: "Add a message or paste more images...",
placeholderDisconnected: "Connect to the gateway to start chatting...",
attachFile: "Attach file",
+1 -1
View File
@@ -1504,7 +1504,7 @@ export const en: TranslationMap = {
badge: "Expired",
},
composer: {
placeholder: "Message {name} (Enter to send)",
placeholder: "Message {name}",
placeholderWithAttachments: "Add a message or paste more images...",
placeholderDisconnected: "Connect to the gateway to start chatting...",
attachFile: "Attach file",
+1 -1
View File
@@ -1523,7 +1523,7 @@ export const es: TranslationMap = {
badge: "Caducado",
},
composer: {
placeholder: "Message {name} (Enter to send)",
placeholder: "Message {name}",
placeholderWithAttachments: "Add a message or paste more images...",
placeholderDisconnected: "Connect to the gateway to start chatting...",
attachFile: "Attach file",
+1 -1
View File
@@ -1516,7 +1516,7 @@ export const fa: TranslationMap = {
badge: "منقضی‌شده",
},
composer: {
placeholder: "Message {name} (Enter to send)",
placeholder: "Message {name}",
placeholderWithAttachments: "Add a message or paste more images...",
placeholderDisconnected: "Connect to the gateway to start chatting...",
attachFile: "Attach file",
+1 -1
View File
@@ -1530,7 +1530,7 @@ export const fr: TranslationMap = {
badge: "Expiré",
},
composer: {
placeholder: "Message {name} (Enter to send)",
placeholder: "Message {name}",
placeholderWithAttachments: "Add a message or paste more images...",
placeholderDisconnected: "Connect to the gateway to start chatting...",
attachFile: "Attach file",
+1 -1
View File
@@ -1500,7 +1500,7 @@ export const hi: TranslationMap = {
badge: "समाप्त",
},
composer: {
placeholder: "{name} को संदेश भेजें (भेजने के लिए Enter)",
placeholder: "{name} को संदेश भेजें",
placeholderWithAttachments: "संदेश जोड़ें या और छवियाँ पेस्ट करें...",
placeholderDisconnected: "चैट शुरू करने के लिए gateway से कनेक्ट करें...",
attachFile: "फ़ाइल संलग्न करें",
+1 -1
View File
@@ -1514,7 +1514,7 @@ export const id: TranslationMap = {
badge: "Kedaluwarsa",
},
composer: {
placeholder: "Message {name} (Enter to send)",
placeholder: "Message {name}",
placeholderWithAttachments: "Add a message or paste more images...",
placeholderDisconnected: "Connect to the gateway to start chatting...",
attachFile: "Attach file",
+1 -1
View File
@@ -1523,7 +1523,7 @@ export const it: TranslationMap = {
badge: "Scaduto",
},
composer: {
placeholder: "Message {name} (Enter to send)",
placeholder: "Message {name}",
placeholderWithAttachments: "Add a message or paste more images...",
placeholderDisconnected: "Connect to the gateway to start chatting...",
attachFile: "Attach file",
+1 -1
View File
@@ -1520,7 +1520,7 @@ export const ja_JP: TranslationMap = {
badge: "期限切れ",
},
composer: {
placeholder: "Message {name} (Enter to send)",
placeholder: "Message {name}",
placeholderWithAttachments: "Add a message or paste more images...",
placeholderDisconnected: "Connect to the gateway to start chatting...",
attachFile: "Attach file",
+1 -1
View File
@@ -1505,7 +1505,7 @@ export const ko: TranslationMap = {
badge: "만료됨",
},
composer: {
placeholder: "Message {name} (Enter to send)",
placeholder: "Message {name}",
placeholderWithAttachments: "Add a message or paste more images...",
placeholderDisconnected: "Connect to the gateway to start chatting...",
attachFile: "Attach file",
+1 -1
View File
@@ -1520,7 +1520,7 @@ export const nl: TranslationMap = {
badge: "Verlopen",
},
composer: {
placeholder: "Message {name} (Enter to send)",
placeholder: "Message {name}",
placeholderWithAttachments: "Add a message or paste more images...",
placeholderDisconnected: "Connect to the gateway to start chatting...",
attachFile: "Attach file",
+1 -1
View File
@@ -1519,7 +1519,7 @@ export const pl: TranslationMap = {
badge: "Wygasł",
},
composer: {
placeholder: "Message {name} (Enter to send)",
placeholder: "Message {name}",
placeholderWithAttachments: "Add a message or paste more images...",
placeholderDisconnected: "Connect to the gateway to start chatting...",
attachFile: "Attach file",
+1 -1
View File
@@ -1516,7 +1516,7 @@ export const pt_BR: TranslationMap = {
badge: "Expirado",
},
composer: {
placeholder: "Message {name} (Enter to send)",
placeholder: "Message {name}",
placeholderWithAttachments: "Add a message or paste more images...",
placeholderDisconnected: "Connect to the gateway to start chatting...",
attachFile: "Attach file",
+1 -1
View File
@@ -1525,7 +1525,7 @@ export const ru: TranslationMap = {
badge: "Истек",
},
composer: {
placeholder: "Сообщение {name} (Enter для отправки)",
placeholder: "Сообщение {name}",
placeholderWithAttachments: "Добавьте сообщение или вставьте еще изображения...",
placeholderDisconnected: "Подключитесь к шлюзу, чтобы начать общение...",
attachFile: "Прикрепить файл",
+1 -1
View File
@@ -1482,7 +1482,7 @@ export const th: TranslationMap = {
badge: "หมดอายุ",
},
composer: {
placeholder: "Message {name} (Enter to send)",
placeholder: "Message {name}",
placeholderWithAttachments: "Add a message or paste more images...",
placeholderDisconnected: "Connect to the gateway to start chatting...",
attachFile: "Attach file",
+1 -1
View File
@@ -1520,7 +1520,7 @@ export const tr: TranslationMap = {
badge: "Süresi doldu",
},
composer: {
placeholder: "Message {name} (Enter to send)",
placeholder: "Message {name}",
placeholderWithAttachments: "Add a message or paste more images...",
placeholderDisconnected: "Connect to the gateway to start chatting...",
attachFile: "Attach file",
+1 -1
View File
@@ -1518,7 +1518,7 @@ export const uk: TranslationMap = {
badge: "Застарів",
},
composer: {
placeholder: "Message {name} (Enter to send)",
placeholder: "Message {name}",
placeholderWithAttachments: "Add a message or paste more images...",
placeholderDisconnected: "Connect to the gateway to start chatting...",
attachFile: "Attach file",
+1 -1
View File
@@ -1506,7 +1506,7 @@ export const vi: TranslationMap = {
badge: "Đã hết hạn",
},
composer: {
placeholder: "Message {name} (Enter to send)",
placeholder: "Message {name}",
placeholderWithAttachments: "Add a message or paste more images...",
placeholderDisconnected: "Connect to the gateway to start chatting...",
attachFile: "Attach file",
+1 -1
View File
@@ -1477,7 +1477,7 @@ export const zh_CN: TranslationMap = {
badge: "已过期",
},
composer: {
placeholder: "给 {name} 发消息Enter 发送)",
placeholder: "给 {name} 发消息",
placeholderWithAttachments: "添加消息或继续粘贴图片...",
placeholderDisconnected: "连接到 Gateway 后开始聊天...",
attachFile: "附加文件",
+1 -1
View File
@@ -1479,7 +1479,7 @@ export const zh_TW: TranslationMap = {
badge: "已過期",
},
composer: {
placeholder: "Message {name} (Enter to send)",
placeholder: "Message {name}",
placeholderWithAttachments: "Add a message or paste more images...",
placeholderDisconnected: "Connect to the gateway to start chatting...",
attachFile: "Attach file",
@@ -177,7 +177,8 @@ function composerControlsHtml() {
<div class="chat-composer-model-control">
<details class="chat-controls__session chat-controls__inline-select chat-controls__model">
<summary class="chat-controls__inline-select-trigger" data-chat-composer-model="true" aria-label="Chat model">
<span class="chat-controls__inline-select-label">Default model · Off</span>
<span class="chat-controls__inline-select-label">Default model</span>
<span class="chat-controls__effort-chip">Off</span>
<span class="chat-controls__inline-select-icon">${iconSvg()}</span>
</summary>
<div class="chat-controls__inline-select-menu chat-controls__inline-select-menu--combined">
@@ -186,6 +187,7 @@ function composerControlsHtml() {
<button class="chat-controls__inline-select-option chat-controls__combined-model-option">gpt-5.5</button>
<button class="chat-controls__inline-select-option chat-controls__combined-model-option">claude-sonnet-4-6</button>
</div>
<div class="chat-controls__reasoning-panel">Reasoning</div>
</div>
</details>
</div>
@@ -334,8 +336,10 @@ function chatHtml(
<div class="agent-chat__toolbar">
<div class="agent-chat__toolbar-left">
<button class="agent-chat__input-btn">${iconSvg()}</button>
<button class="agent-chat__input-btn">${iconSvg()}</button>
<button class="agent-chat__input-btn">${iconSvg()}</button>
<div class="agent-chat__talk-group">
<button class="agent-chat__input-btn agent-chat__talk-toggle">${iconSvg()}</button>
<button class="agent-chat__input-btn agent-chat__talk-caret">${iconSvg()}</button>
</div>
<span class="agent-chat__token-count">8</span>
</div>
${composerControlsHtml()}
@@ -804,6 +808,29 @@ describeBrowserLayout("chat responsive browser layout", () => {
}
});
it.each([
[320, 568],
[375, 812],
[667, 375],
[768, 500],
] as const)(
"keeps the composer model menu inside the mobile viewport at %sx%s",
async (width, height) => {
const page = await openFixture(width, height);
try {
await page.locator('[data-chat-composer-model="true"]').click();
const menu = await getBoundingBox(page, ".chat-controls__inline-select-menu--combined");
const reasoning = await getBoundingBox(page, ".chat-controls__reasoning-panel");
expect(menu.x).toBeGreaterThanOrEqual(0);
expect(menu.x + menu.width).toBeLessThanOrEqual(width + 1);
expect(reasoning.x).toBeGreaterThanOrEqual(0);
expect(reasoning.x + reasoning.width).toBeLessThanOrEqual(width + 1);
} finally {
await closeBrowserPage(page);
}
},
);
it.each([
[320, 568],
[393, 852],
+48 -42
View File
@@ -2000,49 +2000,55 @@ export function renderChatComposer(props: ChatComposerProps) {
</button>
</openclaw-tooltip>
${props.onToggleRealtimeTalk
${props.onToggleRealtimeTalk || props.onToggleRealtimeTalkOptions
? html`
<openclaw-tooltip
.content=${props.realtimeTalkActive
? t("chat.composer.stopTalk")
: t("chat.composer.startTalk")}
>
<button
class="agent-chat__input-btn ${props.realtimeTalkActive
? "agent-chat__input-btn--talk"
: ""}"
@click=${props.onToggleRealtimeTalk}
aria-label=${props.realtimeTalkActive
? t("chat.composer.stopTalk")
: t("chat.composer.startTalk")}
?disabled=${!canCompose && !props.realtimeTalkActive}
>
${props.realtimeTalkActive ? icons.volume2 : icons.radio}
<span class="agent-chat__control-label"
>${props.realtimeTalkActive
? t("chat.composer.stopTalk")
: t("chat.composer.startTalk")}</span
>
</button>
</openclaw-tooltip>
`
: nothing}
${props.onToggleRealtimeTalkOptions
? html`
<openclaw-tooltip content="Talk settings">
<button
class="agent-chat__input-btn ${props.realtimeTalkOptionsOpen
? "agent-chat__input-btn--talk"
: ""}"
@click=${props.onToggleRealtimeTalkOptions}
aria-label="Talk settings"
aria-expanded=${props.realtimeTalkOptionsOpen ? "true" : "false"}
?disabled=${!canCompose || props.realtimeTalkActive}
>
${icons.settings}
<span class="agent-chat__control-label">Talk settings</span>
</button>
</openclaw-tooltip>
<div class="agent-chat__talk-group">
${props.onToggleRealtimeTalk
? html`
<openclaw-tooltip
.content=${props.realtimeTalkActive
? t("chat.composer.stopTalk")
: t("chat.composer.startTalk")}
>
<button
class="agent-chat__input-btn agent-chat__talk-toggle ${props.realtimeTalkActive
? "agent-chat__input-btn--talk"
: ""}"
@click=${props.onToggleRealtimeTalk}
aria-label=${props.realtimeTalkActive
? t("chat.composer.stopTalk")
: t("chat.composer.startTalk")}
?disabled=${!canCompose && !props.realtimeTalkActive}
>
${props.realtimeTalkActive ? icons.volume2 : icons.mic}
<span class="agent-chat__control-label"
>${props.realtimeTalkActive
? t("chat.composer.stopTalk")
: t("chat.composer.startTalk")}</span
>
</button>
</openclaw-tooltip>
`
: nothing}
${props.onToggleRealtimeTalkOptions
? html`
<openclaw-tooltip content="Talk settings">
<button
class="agent-chat__input-btn agent-chat__talk-caret ${props.realtimeTalkOptionsOpen
? "agent-chat__input-btn--open"
: ""}"
@click=${props.onToggleRealtimeTalkOptions}
aria-label="Talk settings"
aria-expanded=${props.realtimeTalkOptionsOpen ? "true" : "false"}
?disabled=${!canCompose || props.realtimeTalkActive}
>
${icons.chevronDown}
<span class="agent-chat__control-label">Talk settings</span>
</button>
</openclaw-tooltip>
`
: nothing}
</div>
`
: nothing}
${tokens ? html`<span class="agent-chat__token-count">${tokens}</span>` : nothing}
@@ -153,8 +153,6 @@ function renderChatModelReasoningSelect(params: {
const triggerModel = formatCombinedPickerModelLabel(selectedModelLabel);
const triggerThinking = formatCombinedPickerThinkingLabel(selectedThinkingLabel);
const triggerTitle = `${triggerModel} · ${triggerThinking}`;
const triggerLabel =
selectedThinkingValue === "" ? triggerModel : `${triggerModel} · ${triggerThinking}`;
const sliderStops = thinkingOptions.filter((option) => option.value !== "");
const defaultStopIndex = sliderStops.findIndex((option) => option.value === thinkingDefaultValue);
const hasThinkingOverride = selectedThinkingValue !== "";
@@ -206,7 +204,16 @@ function renderChatModelReasoningSelect(params: {
}
}}
>
<span class="chat-controls__inline-select-label">${triggerLabel}</span>
<span class="chat-controls__inline-select-label">${triggerModel}</span>
${showReasoning || hasThinkingOverride
? html`<span
class="chat-controls__effort-chip ${hasThinkingOverride
? "chat-controls__effort-chip--override"
: ""}"
aria-hidden="true"
>${triggerThinking}</span
>`
: ""}
<span class="chat-controls__inline-select-icon" aria-hidden="true">
${icons.chevronDown}
</span>
+102 -22
View File
@@ -819,13 +819,15 @@ openclaw-chat-page {
margin: 0;
}
/* Model/effort/settings cluster hugs the send button on the right; the left
toolbar half absorbs all spare width. */
.agent-chat__composer-controls {
display: flex;
position: relative;
align-items: center;
justify-content: flex-start;
justify-content: flex-end;
gap: 6px;
flex: 1 1 auto;
flex: 0 1 auto;
min-width: 0;
padding: 0;
}
@@ -902,7 +904,7 @@ openclaw-chat-page {
.chat-settings-chip:hover,
.chat-settings-chip--open {
background: var(--bg-hover);
background: color-mix(in srgb, var(--text) 7%, transparent);
color: var(--text);
}
@@ -1013,13 +1015,14 @@ openclaw-chat-page {
overflow-wrap: anywhere;
}
/* Chromeless status row: the composer frame is the only chrome, so the
toolbar sits flush inside it with no divider line. */
.agent-chat__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
padding: 2px 8px 8px;
}
.agent-chat__toolbar-left,
@@ -1079,14 +1082,44 @@ openclaw-chat-page {
cursor: not-allowed;
}
.agent-chat__input-btn--active {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, transparent);
/* Selected/open states stay a quiet neutral wash; only live recording gets a
(soft) danger tint so "active" chrome never competes with the send button. */
.agent-chat__input-btn--active,
.agent-chat__input-btn--open {
color: var(--text);
background: color-mix(in srgb, var(--text) 7%, transparent);
}
.agent-chat__input-btn--talk {
color: var(--danger, #ef4444);
background: color-mix(in srgb, var(--danger, #ef4444) 14%, transparent);
background: color-mix(in srgb, var(--danger, #ef4444) 9%, transparent);
}
/* Split talk control: mic toggles Talk, the narrow caret opens Talk settings
(mirrors a mic-with-dropdown control; negative margin keeps the pair reading
as one unit without a wrapper border). */
.agent-chat__talk-group {
display: inline-flex;
align-items: center;
}
.agent-chat__talk-group .agent-chat__talk-toggle {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.agent-chat__talk-group .agent-chat__talk-caret {
min-width: 20px;
width: 20px;
margin-left: -2px;
padding: 0;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.agent-chat__talk-group .agent-chat__talk-caret svg {
width: 13px;
height: 13px;
}
.agent-chat__talk-status {
@@ -1414,9 +1447,11 @@ openclaw-chat-page {
text-overflow: ellipsis;
}
/* Status chips keep their semantic hue in the text/icon only; borders stay
near-neutral so the row reads as quiet metadata, not alerts. */
.agent-chat__run-status--in-progress {
color: var(--info);
border-color: color-mix(in srgb, var(--info) 32%, transparent);
border-color: color-mix(in srgb, var(--info) 18%, transparent);
}
.agent-chat__run-status--in-progress svg {
@@ -1425,12 +1460,12 @@ openclaw-chat-page {
.agent-chat__run-status--done {
color: var(--ok);
border-color: color-mix(in srgb, var(--ok) 32%, transparent);
border-color: color-mix(in srgb, var(--ok) 18%, transparent);
}
.agent-chat__run-status--interrupted {
color: var(--warn);
border-color: color-mix(in srgb, var(--warn) 36%, transparent);
border-color: color-mix(in srgb, var(--warn) 22%, transparent);
}
@keyframes chat-run-status-spin {
@@ -2260,7 +2295,7 @@ openclaw-chat-page {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-start;
gap: 6px;
width: 100%;
height: 30px;
@@ -2301,10 +2336,37 @@ openclaw-chat-page {
white-space: nowrap;
}
/* Effort chip inside the model trigger: shows the effective thinking level as
a muted pill (accent-washed when the session overrides the default). It is
decorative (aria-hidden); the trigger aria-label carries the full state. */
.chat-controls__effort-chip {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
height: 18px;
padding: 0 7px;
border-radius: var(--radius-full);
background: color-mix(in srgb, var(--text) 6%, transparent);
color: var(--muted);
font-size: 11px;
font-weight: 600;
line-height: 1;
white-space: nowrap;
}
.chat-controls__effort-chip--override {
background: color-mix(in srgb, var(--accent) 10%, transparent);
color: color-mix(in srgb, var(--accent) 72%, var(--text));
}
/* Chevron pins right so label+chip stay grouped when the trigger stretches
(mobile grid cell); on desktop the trigger is content-sized so this is a
no-op vs the old space-between layout. */
.chat-controls__inline-select-icon {
display: inline-flex;
width: 14px;
height: 14px;
margin-left: auto;
color: var(--muted);
flex: 0 0 auto;
}
@@ -2368,7 +2430,12 @@ openclaw-chat-page {
background: color-mix(in srgb, var(--text) 6%, transparent);
}
/* The combined model picker anchors to a right-aligned trigger, so the menu
grows leftward and the reasoning panel flies out to the left; anchoring
left like the generic menu would clip both at the viewport edge. */
.chat-controls__inline-select-menu--combined {
left: auto;
right: 0;
width: max(100%, min(320px, calc(100vw - 32px)));
max-height: none;
overflow: visible;
@@ -2403,7 +2470,7 @@ openclaw-chat-page {
.chat-controls__reasoning-panel {
position: absolute;
bottom: 0;
left: calc(100% + 8px);
right: calc(100% + 8px);
z-index: 90;
display: grid;
gap: 6px;
@@ -2418,7 +2485,7 @@ openclaw-chat-page {
overflow-y: auto;
opacity: 0;
pointer-events: none;
transform: translateX(-4px);
transform: translateX(4px);
transition:
opacity var(--duration-fast) ease,
transform var(--duration-fast) ease;
@@ -2800,6 +2867,26 @@ openclaw-chat-page {
height: 44px;
}
/* Preserve the phone touch target despite the narrower desktop split caret. */
.agent-chat__talk-group .agent-chat__talk-caret {
width: 44px;
min-width: 44px;
}
/* The model trigger owns the left mobile grid column; grow its menu rightward. */
.chat-controls__inline-select-menu--combined {
left: 0;
right: auto;
}
.chat-controls__reasoning-panel {
top: auto;
bottom: calc(100% + 8px);
left: 0;
right: auto;
width: min(220px, calc(100vw - 48px));
}
.agent-chat__input {
margin: 0 8px calc(14px + var(--safe-area-bottom));
}
@@ -2849,13 +2936,6 @@ openclaw-chat-page {
overflow: visible;
}
.chat-controls__reasoning-panel {
top: auto;
bottom: calc(100% + 8px);
left: 0;
width: min(220px, calc(100vw - 48px));
}
.chat-controls {
flex-wrap: wrap;
gap: 8px;