Control UI explicit action feedback

Add explicit Control UI feedback for repeated actions: session switches now announce through the chat controls live-status path and flash the active session selector, config actions show inline busy state, and session list empty states distinguish filtered results with a Show all reset. Also refresh generated Control UI locale metadata and fallback markers.
This commit is contained in:
Val Alexander
2026-05-04 06:58:31 -05:00
committed by GitHub
parent 14f756c05b
commit 8469a51326
53 changed files with 558 additions and 89 deletions

View File

@@ -110,6 +110,7 @@ Docs: https://docs.openclaw.ai
- Agents/trajectory: bound runtime trajectory capture and yield queued sidecar writes so oversized traces stop recording instead of monopolizing Gateway cleanup. Fixes #77124. Thanks @loyur.
- Telegram/streaming: sanitize tool-progress draft preview backticks before shared compaction, so long backtick-heavy progress text still renders inside the safe code-formatted preview instead of collapsing to an ellipsis.
- UI/chat: remove the unsupported `line-clamp` declaration from the chat queue text rule to eliminate Firefox console noise without changing visible truncation behavior. Thanks @ZanderH-code.
- Control UI: add explicit feedback for repeated actions by announcing session switches, flashing the active session selector, showing inline Save/Apply/Update progress, and distinguishing filtered-empty session lists from genuinely empty session stores. Thanks @BunsDev.
- Agents/Pi: suppress persistence for synthetic mid-turn overflow continuation prompts, so transcript-retry recovery does not write the "continue from transcript" prompt as a new user turn. Thanks @vincentkoc.
- Agents/tools: strip reasoning text from visible rich presentation titles, blocks, buttons, and select labels before message-tool sends, so structured channel payloads cannot leak hidden planning. Thanks @vincentkoc.
- Telegram: keep reply-dispatch lazy provider runtime chunks behind stable dist names and delete `/reasoning stream` previews after final delivery so package updates and live reasoning drafts do not leave Telegram turns broken or noisy. Thanks @BunsDev.

View File

@@ -1,11 +1,15 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-04T07:26:59.541Z",
"fallbackKeys": [
"chat.switchedSession",
"sessionsView.noSessionsMatchFilters",
"sessionsView.showAll"
],
"generatedAt": "2026-05-04T08:21:48.100Z",
"locale": "ar",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "9924252f06872c0217b810e072249991a908cda8329977622a2afd16f846fcd6",
"totalKeys": 1001,
"sourceHash": "926c835b1e931594ec63598a966c91906ca98425cc6bd89fe9787668bd442c01",
"totalKeys": 1004,
"translatedKeys": 1001,
"workflow": 1
}

View File

@@ -1,11 +1,15 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-04T07:26:57.765Z",
"fallbackKeys": [
"chat.switchedSession",
"sessionsView.noSessionsMatchFilters",
"sessionsView.showAll"
],
"generatedAt": "2026-05-04T08:21:46.702Z",
"locale": "de",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "9924252f06872c0217b810e072249991a908cda8329977622a2afd16f846fcd6",
"totalKeys": 1001,
"sourceHash": "926c835b1e931594ec63598a966c91906ca98425cc6bd89fe9787668bd442c01",
"totalKeys": 1004,
"translatedKeys": 1001,
"workflow": 1
}

View File

@@ -1,11 +1,15 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-04T07:26:58.121Z",
"fallbackKeys": [
"chat.switchedSession",
"sessionsView.noSessionsMatchFilters",
"sessionsView.showAll"
],
"generatedAt": "2026-05-04T08:21:46.979Z",
"locale": "es",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "9924252f06872c0217b810e072249991a908cda8329977622a2afd16f846fcd6",
"totalKeys": 1001,
"sourceHash": "926c835b1e931594ec63598a966c91906ca98425cc6bd89fe9787668bd442c01",
"totalKeys": 1004,
"translatedKeys": 1001,
"workflow": 1
}

View File

@@ -1,11 +1,15 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-04T07:44:21.069Z",
"fallbackKeys": [
"chat.switchedSession",
"sessionsView.noSessionsMatchFilters",
"sessionsView.showAll"
],
"generatedAt": "2026-05-04T08:21:50.613Z",
"locale": "fa",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "9924252f06872c0217b810e072249991a908cda8329977622a2afd16f846fcd6",
"totalKeys": 1001,
"sourceHash": "926c835b1e931594ec63598a966c91906ca98425cc6bd89fe9787668bd442c01",
"totalKeys": 1004,
"translatedKeys": 1001,
"workflow": 1
}

View File

@@ -1,11 +1,15 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-04T07:26:59.152Z",
"fallbackKeys": [
"chat.switchedSession",
"sessionsView.noSessionsMatchFilters",
"sessionsView.showAll"
],
"generatedAt": "2026-05-04T08:21:47.819Z",
"locale": "fr",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "9924252f06872c0217b810e072249991a908cda8329977622a2afd16f846fcd6",
"totalKeys": 1001,
"sourceHash": "926c835b1e931594ec63598a966c91906ca98425cc6bd89fe9787668bd442c01",
"totalKeys": 1004,
"translatedKeys": 1001,
"workflow": 1
}

View File

@@ -1,11 +1,15 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-04T07:44:19.311Z",
"fallbackKeys": [
"chat.switchedSession",
"sessionsView.noSessionsMatchFilters",
"sessionsView.showAll"
],
"generatedAt": "2026-05-04T08:21:49.215Z",
"locale": "id",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "9924252f06872c0217b810e072249991a908cda8329977622a2afd16f846fcd6",
"totalKeys": 1001,
"sourceHash": "926c835b1e931594ec63598a966c91906ca98425cc6bd89fe9787668bd442c01",
"totalKeys": 1004,
"translatedKeys": 1001,
"workflow": 1
}

View File

@@ -1,11 +1,15 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-04T07:26:59.895Z",
"fallbackKeys": [
"chat.switchedSession",
"sessionsView.noSessionsMatchFilters",
"sessionsView.showAll"
],
"generatedAt": "2026-05-04T08:21:48.376Z",
"locale": "it",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "9924252f06872c0217b810e072249991a908cda8329977622a2afd16f846fcd6",
"totalKeys": 1001,
"sourceHash": "926c835b1e931594ec63598a966c91906ca98425cc6bd89fe9787668bd442c01",
"totalKeys": 1004,
"translatedKeys": 1001,
"workflow": 1
}

View File

@@ -1,11 +1,15 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-04T07:26:58.455Z",
"fallbackKeys": [
"chat.switchedSession",
"sessionsView.noSessionsMatchFilters",
"sessionsView.showAll"
],
"generatedAt": "2026-05-04T08:21:47.256Z",
"locale": "ja-JP",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "9924252f06872c0217b810e072249991a908cda8329977622a2afd16f846fcd6",
"totalKeys": 1001,
"sourceHash": "926c835b1e931594ec63598a966c91906ca98425cc6bd89fe9787668bd442c01",
"totalKeys": 1004,
"translatedKeys": 1001,
"workflow": 1
}

View File

@@ -1,11 +1,15 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-04T07:26:58.813Z",
"fallbackKeys": [
"chat.switchedSession",
"sessionsView.noSessionsMatchFilters",
"sessionsView.showAll"
],
"generatedAt": "2026-05-04T08:21:47.535Z",
"locale": "ko",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "9924252f06872c0217b810e072249991a908cda8329977622a2afd16f846fcd6",
"totalKeys": 1001,
"sourceHash": "926c835b1e931594ec63598a966c91906ca98425cc6bd89fe9787668bd442c01",
"totalKeys": 1004,
"translatedKeys": 1001,
"workflow": 1
}

View File

@@ -1,11 +1,15 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-04T07:44:20.725Z",
"fallbackKeys": [
"chat.switchedSession",
"sessionsView.noSessionsMatchFilters",
"sessionsView.showAll"
],
"generatedAt": "2026-05-04T08:21:50.321Z",
"locale": "nl",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "9924252f06872c0217b810e072249991a908cda8329977622a2afd16f846fcd6",
"totalKeys": 1001,
"sourceHash": "926c835b1e931594ec63598a966c91906ca98425cc6bd89fe9787668bd442c01",
"totalKeys": 1004,
"translatedKeys": 1001,
"workflow": 1
}

View File

@@ -1,11 +1,15 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-04T07:44:19.638Z",
"fallbackKeys": [
"chat.switchedSession",
"sessionsView.noSessionsMatchFilters",
"sessionsView.showAll"
],
"generatedAt": "2026-05-04T08:21:49.488Z",
"locale": "pl",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "9924252f06872c0217b810e072249991a908cda8329977622a2afd16f846fcd6",
"totalKeys": 1001,
"sourceHash": "926c835b1e931594ec63598a966c91906ca98425cc6bd89fe9787668bd442c01",
"totalKeys": 1004,
"translatedKeys": 1001,
"workflow": 1
}

View File

@@ -1,11 +1,15 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-04T07:26:57.413Z",
"fallbackKeys": [
"chat.switchedSession",
"sessionsView.noSessionsMatchFilters",
"sessionsView.showAll"
],
"generatedAt": "2026-05-04T08:21:46.427Z",
"locale": "pt-BR",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "9924252f06872c0217b810e072249991a908cda8329977622a2afd16f846fcd6",
"totalKeys": 1001,
"sourceHash": "926c835b1e931594ec63598a966c91906ca98425cc6bd89fe9787668bd442c01",
"totalKeys": 1004,
"translatedKeys": 1001,
"workflow": 1
}

View File

@@ -309,6 +309,13 @@
"path": "ui/src/ui/chat/session-controls.ts",
"text": "Chat model"
},
{
"count": 1,
"kind": "html-attribute",
"name": "aria-label",
"path": "ui/src/ui/chat/session-controls.ts",
"text": "Chat session"
},
{
"count": 1,
"kind": "html-attribute",
@@ -316,6 +323,13 @@
"path": "ui/src/ui/chat/session-controls.ts",
"text": "Chat thinking level"
},
{
"count": 1,
"kind": "html-attribute",
"name": "aria-label",
"path": "ui/src/ui/chat/session-controls.ts",
"text": "Filter sessions by agent"
},
{
"count": 1,
"kind": "html-attribute",

View File

@@ -1,11 +1,15 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-04T07:44:19.987Z",
"fallbackKeys": [
"chat.switchedSession",
"sessionsView.noSessionsMatchFilters",
"sessionsView.showAll"
],
"generatedAt": "2026-05-04T08:21:49.767Z",
"locale": "th",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "9924252f06872c0217b810e072249991a908cda8329977622a2afd16f846fcd6",
"totalKeys": 1001,
"sourceHash": "926c835b1e931594ec63598a966c91906ca98425cc6bd89fe9787668bd442c01",
"totalKeys": 1004,
"translatedKeys": 1001,
"workflow": 1
}

View File

@@ -1,11 +1,15 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-04T07:27:00.334Z",
"fallbackKeys": [
"chat.switchedSession",
"sessionsView.noSessionsMatchFilters",
"sessionsView.showAll"
],
"generatedAt": "2026-05-04T08:21:48.658Z",
"locale": "tr",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "9924252f06872c0217b810e072249991a908cda8329977622a2afd16f846fcd6",
"totalKeys": 1001,
"sourceHash": "926c835b1e931594ec63598a966c91906ca98425cc6bd89fe9787668bd442c01",
"totalKeys": 1004,
"translatedKeys": 1001,
"workflow": 1
}

View File

@@ -1,11 +1,15 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-04T07:44:18.980Z",
"fallbackKeys": [
"chat.switchedSession",
"sessionsView.noSessionsMatchFilters",
"sessionsView.showAll"
],
"generatedAt": "2026-05-04T08:21:48.943Z",
"locale": "uk",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "9924252f06872c0217b810e072249991a908cda8329977622a2afd16f846fcd6",
"totalKeys": 1001,
"sourceHash": "926c835b1e931594ec63598a966c91906ca98425cc6bd89fe9787668bd442c01",
"totalKeys": 1004,
"translatedKeys": 1001,
"workflow": 1
}

View File

@@ -1,11 +1,15 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-04T07:44:20.379Z",
"fallbackKeys": [
"chat.switchedSession",
"sessionsView.noSessionsMatchFilters",
"sessionsView.showAll"
],
"generatedAt": "2026-05-04T08:21:50.047Z",
"locale": "vi",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "9924252f06872c0217b810e072249991a908cda8329977622a2afd16f846fcd6",
"totalKeys": 1001,
"sourceHash": "926c835b1e931594ec63598a966c91906ca98425cc6bd89fe9787668bd442c01",
"totalKeys": 1004,
"translatedKeys": 1001,
"workflow": 1
}

View File

@@ -1,11 +1,15 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-04T07:26:56.358Z",
"fallbackKeys": [
"chat.switchedSession",
"sessionsView.noSessionsMatchFilters",
"sessionsView.showAll"
],
"generatedAt": "2026-05-04T08:21:45.830Z",
"locale": "zh-CN",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "9924252f06872c0217b810e072249991a908cda8329977622a2afd16f846fcd6",
"totalKeys": 1001,
"sourceHash": "926c835b1e931594ec63598a966c91906ca98425cc6bd89fe9787668bd442c01",
"totalKeys": 1004,
"translatedKeys": 1001,
"workflow": 1
}

View File

@@ -1,11 +1,15 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-04T07:26:57.083Z",
"fallbackKeys": [
"chat.switchedSession",
"sessionsView.noSessionsMatchFilters",
"sessionsView.showAll"
],
"generatedAt": "2026-05-04T08:21:46.149Z",
"locale": "zh-TW",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "9924252f06872c0217b810e072249991a908cda8329977622a2afd16f846fcd6",
"totalKeys": 1001,
"sourceHash": "926c835b1e931594ec63598a966c91906ca98425cc6bd89fe9787668bd442c01",
"totalKeys": 1004,
"translatedKeys": 1001,
"workflow": 1
}

View File

@@ -189,6 +189,8 @@ export const ar: TranslationMap = {
verbose: "مطوّل",
reasoning: "الاستدلال",
noSessions: "لم يتم العثور على جلسات.",
noSessionsMatchFilters: "No sessions match your filters.",
showAll: "Show all",
inherit: "وراثة",
defaultOption: "الافتراضي ({value})",
offExplicit: "إيقاف (صريح)",
@@ -925,6 +927,7 @@ export const ar: TranslationMap = {
updating: "جارٍ التحديث…",
updateNow: "التحديث الآن",
dismissUpdateBanner: "إغلاق لافتة التحديث",
switchedSession: "Switched to {session}",
},
languages: {
en: "English (الإنجليزية)",

View File

@@ -193,6 +193,8 @@ export const de: TranslationMap = {
verbose: "Ausführlich",
reasoning: "Reasoning",
noSessions: "Keine Sitzungen gefunden.",
noSessionsMatchFilters: "No sessions match your filters.",
showAll: "Show all",
inherit: "Übernehmen",
defaultOption: "Standard ({value})",
offExplicit: "Aus (explizit)",
@@ -939,6 +941,7 @@ export const de: TranslationMap = {
updating: "Wird aktualisiert…",
updateNow: "Jetzt aktualisieren",
dismissUpdateBanner: "Update-Banner ausblenden",
switchedSession: "Switched to {session}",
},
languages: {
en: "Englisch",

View File

@@ -188,6 +188,8 @@ export const en: TranslationMap = {
verbose: "Verbose",
reasoning: "Reasoning",
noSessions: "No sessions found.",
noSessionsMatchFilters: "No sessions match your filters.",
showAll: "Show all",
inherit: "inherit",
defaultOption: "Default ({value})",
offExplicit: "off (explicit)",
@@ -926,6 +928,7 @@ export const en: TranslationMap = {
updating: "Updating…",
updateNow: "Update now",
dismissUpdateBanner: "Dismiss update banner",
switchedSession: "Switched to {session}",
},
languages: {
en: "English",

View File

@@ -190,6 +190,8 @@ export const es: TranslationMap = {
verbose: "Detallado",
reasoning: "Razonamiento",
noSessions: "No se encontraron sesiones.",
noSessionsMatchFilters: "No sessions match your filters.",
showAll: "Show all",
inherit: "heredar",
defaultOption: "Predeterminado ({value})",
offExplicit: "desactivado (explícito)",
@@ -938,6 +940,7 @@ export const es: TranslationMap = {
updating: "Actualizando…",
updateNow: "Actualizar ahora",
dismissUpdateBanner: "Descartar banner de actualización",
switchedSession: "Switched to {session}",
},
languages: {
en: "Inglés (English)",

View File

@@ -191,6 +191,8 @@ export const fa: TranslationMap = {
verbose: "پرگویی",
reasoning: "استدلال",
noSessions: "هیچ نشستی پیدا نشد.",
noSessionsMatchFilters: "No sessions match your filters.",
showAll: "Show all",
inherit: "ارث‌بری",
defaultOption: "پیش‌فرض ({value})",
offExplicit: "خاموش (صریح)",
@@ -934,6 +936,7 @@ export const fa: TranslationMap = {
updating: "در حال به‌روزرسانی…",
updateNow: "اکنون به‌روزرسانی کن",
dismissUpdateBanner: "بستن بنر به‌روزرسانی",
switchedSession: "Switched to {session}",
},
languages: {
en: "English (انگلیسی)",

View File

@@ -192,6 +192,8 @@ export const fr: TranslationMap = {
verbose: "Détaillé",
reasoning: "Raisonnement",
noSessions: "Aucune session trouvée.",
noSessionsMatchFilters: "No sessions match your filters.",
showAll: "Show all",
inherit: "hériter",
defaultOption: "Par défaut ({value})",
offExplicit: "désactivé (explicite)",
@@ -940,6 +942,7 @@ export const fr: TranslationMap = {
updating: "Mise à jour…",
updateNow: "Mettre à jour maintenant",
dismissUpdateBanner: "Ignorer la bannière de mise à jour",
switchedSession: "Switched to {session}",
},
languages: {
en: "Anglais",

View File

@@ -190,6 +190,8 @@ export const id: TranslationMap = {
verbose: "Verbose",
reasoning: "Penalaran",
noSessions: "Tidak ada sesi yang ditemukan.",
noSessionsMatchFilters: "No sessions match your filters.",
showAll: "Show all",
inherit: "warisi",
defaultOption: "Default ({value})",
offExplicit: "nonaktif (eksplisit)",
@@ -933,6 +935,7 @@ export const id: TranslationMap = {
updating: "Memperbarui…",
updateNow: "Perbarui sekarang",
dismissUpdateBanner: "Tutup banner pembaruan",
switchedSession: "Switched to {session}",
},
languages: {
en: "Inggris",

View File

@@ -190,6 +190,8 @@ export const it: TranslationMap = {
verbose: "Dettagliato",
reasoning: "Ragionamento",
noSessions: "Nessuna sessione trovata.",
noSessionsMatchFilters: "No sessions match your filters.",
showAll: "Show all",
inherit: "eredita",
defaultOption: "Predefinito ({value})",
offExplicit: "disattivato (esplicito)",
@@ -938,6 +940,7 @@ export const it: TranslationMap = {
updating: "Aggiornamento…",
updateNow: "Aggiorna ora",
dismissUpdateBanner: "Ignora banner di aggiornamento",
switchedSession: "Switched to {session}",
},
languages: {
en: "English (Inglese)",

View File

@@ -193,6 +193,8 @@ export const ja_JP: TranslationMap = {
verbose: "詳細",
reasoning: "推論",
noSessions: "セッションが見つかりません。",
noSessionsMatchFilters: "No sessions match your filters.",
showAll: "Show all",
inherit: "継承",
defaultOption: "デフォルト({value}",
offExplicit: "オフ(明示)",
@@ -936,6 +938,7 @@ export const ja_JP: TranslationMap = {
updating: "更新中…",
updateNow: "今すぐ更新",
dismissUpdateBanner: "更新バナーを閉じる",
switchedSession: "Switched to {session}",
},
languages: {
en: "英語",

View File

@@ -189,6 +189,8 @@ export const ko: TranslationMap = {
verbose: "상세",
reasoning: "추론",
noSessions: "세션을 찾을 수 없습니다.",
noSessionsMatchFilters: "No sessions match your filters.",
showAll: "Show all",
inherit: "상속",
defaultOption: "기본값({value})",
offExplicit: "꺼짐(명시적)",
@@ -929,6 +931,7 @@ export const ko: TranslationMap = {
updating: "업데이트 중…",
updateNow: "지금 업데이트",
dismissUpdateBanner: "업데이트 배너 닫기",
switchedSession: "Switched to {session}",
},
languages: {
en: "영어",

View File

@@ -192,6 +192,8 @@ export const nl: TranslationMap = {
verbose: "Uitgebreid",
reasoning: "Redenering",
noSessions: "Geen sessies gevonden.",
noSessionsMatchFilters: "No sessions match your filters.",
showAll: "Show all",
inherit: "overnemen",
defaultOption: "Standaard ({value})",
offExplicit: "uit (expliciet)",
@@ -936,6 +938,7 @@ export const nl: TranslationMap = {
updating: "Bijwerken…",
updateNow: "Nu bijwerken",
dismissUpdateBanner: "Updatebanner sluiten",
switchedSession: "Switched to {session}",
},
languages: {
en: "English (Engels)",

View File

@@ -191,6 +191,8 @@ export const pl: TranslationMap = {
verbose: "Szczegółowo",
reasoning: "Rozumowanie",
noSessions: "Nie znaleziono sesji.",
noSessionsMatchFilters: "No sessions match your filters.",
showAll: "Show all",
inherit: "dziedzicz",
defaultOption: "Domyślnie ({value})",
offExplicit: "wył. (jawnie)",
@@ -939,6 +941,7 @@ export const pl: TranslationMap = {
updating: "Aktualizowanie…",
updateNow: "Aktualizuj teraz",
dismissUpdateBanner: "Odrzuć baner aktualizacji",
switchedSession: "Switched to {session}",
},
languages: {
en: "Angielski (English)",

View File

@@ -190,6 +190,8 @@ export const pt_BR: TranslationMap = {
verbose: "Detalhado",
reasoning: "Raciocínio",
noSessions: "Nenhuma sessão encontrada.",
noSessionsMatchFilters: "No sessions match your filters.",
showAll: "Show all",
inherit: "herdar",
defaultOption: "Padrão ({value})",
offExplicit: "desativado (explícito)",
@@ -935,6 +937,7 @@ export const pt_BR: TranslationMap = {
updating: "Atualizando…",
updateNow: "Atualizar agora",
dismissUpdateBanner: "Dispensar banner de atualização",
switchedSession: "Switched to {session}",
},
languages: {
en: "Inglês",

View File

@@ -188,6 +188,8 @@ export const th: TranslationMap = {
verbose: "ละเอียด",
reasoning: "การให้เหตุผล",
noSessions: "ไม่พบเซสชัน",
noSessionsMatchFilters: "No sessions match your filters.",
showAll: "Show all",
inherit: "สืบทอด",
defaultOption: "ค่าเริ่มต้น ({value})",
offExplicit: "ปิด (ระบุชัดเจน)",
@@ -919,6 +921,7 @@ export const th: TranslationMap = {
updating: "กำลังอัปเดต…",
updateNow: "อัปเดตตอนนี้",
dismissUpdateBanner: "ปิดแบนเนอร์อัปเดต",
switchedSession: "Switched to {session}",
},
languages: {
en: "อังกฤษ",

View File

@@ -192,6 +192,8 @@ export const tr: TranslationMap = {
verbose: "Ayrıntılı",
reasoning: "Akıl yürütme",
noSessions: "Oturum bulunamadı.",
noSessionsMatchFilters: "No sessions match your filters.",
showAll: "Show all",
inherit: "devral",
defaultOption: "Varsayılan ({value})",
offExplicit: "kapalı (açıkça)",
@@ -938,6 +940,7 @@ export const tr: TranslationMap = {
updating: "Güncelleniyor…",
updateNow: "Şimdi güncelle",
dismissUpdateBanner: "Güncelleme başlığını kapat",
switchedSession: "Switched to {session}",
},
languages: {
en: "İngilizce",

View File

@@ -191,6 +191,8 @@ export const uk: TranslationMap = {
verbose: "Докладно",
reasoning: "Міркування",
noSessions: "Сеансів не знайдено.",
noSessionsMatchFilters: "No sessions match your filters.",
showAll: "Show all",
inherit: "успадковувати",
defaultOption: "За замовчуванням ({value})",
offExplicit: "вимкнено (явно)",
@@ -937,6 +939,7 @@ export const uk: TranslationMap = {
updating: "Оновлення…",
updateNow: "Оновити зараз",
dismissUpdateBanner: "Закрити банер оновлення",
switchedSession: "Switched to {session}",
},
languages: {
en: "Англійська",

View File

@@ -190,6 +190,8 @@ export const vi: TranslationMap = {
verbose: "Chi tiết",
reasoning: "Suy luận",
noSessions: "Không tìm thấy phiên nào.",
noSessionsMatchFilters: "No sessions match your filters.",
showAll: "Show all",
inherit: "kế thừa",
defaultOption: "Mặc định ({value})",
offExplicit: "tắt (rõ ràng)",
@@ -930,6 +932,7 @@ export const vi: TranslationMap = {
updating: "Đang cập nhật…",
updateNow: "Cập nhật ngay",
dismissUpdateBanner: "Bỏ qua banner cập nhật",
switchedSession: "Switched to {session}",
},
languages: {
en: "English (Tiếng Anh)",

View File

@@ -188,6 +188,8 @@ export const zh_CN: TranslationMap = {
verbose: "详细",
reasoning: "推理",
noSessions: "未找到会话。",
noSessionsMatchFilters: "No sessions match your filters.",
showAll: "Show all",
inherit: "继承",
defaultOption: "默认({value}",
offExplicit: "关闭(显式)",
@@ -918,6 +920,7 @@ export const zh_CN: TranslationMap = {
updating: "正在更新…",
updateNow: "立即更新",
dismissUpdateBanner: "关闭更新横幅",
switchedSession: "Switched to {session}",
},
languages: {
en: "英语",

View File

@@ -188,6 +188,8 @@ export const zh_TW: TranslationMap = {
verbose: "詳細",
reasoning: "推理",
noSessions: "找不到工作階段。",
noSessionsMatchFilters: "No sessions match your filters.",
showAll: "Show all",
inherit: "繼承",
defaultOption: "預設({value}",
offExplicit: "關閉(明確)",
@@ -919,6 +921,7 @@ export const zh_TW: TranslationMap = {
updating: "正在更新…",
updateNow: "立即更新",
dismissUpdateBanner: "關閉更新橫幅",
switchedSession: "Switched to {session}",
},
languages: {
en: "英文",

View File

@@ -1072,6 +1072,39 @@
grid-area: agent;
}
.chat-controls__session-row--flash .chat-controls__session-picker select {
animation: chat-session-switch-flash 0.2s ease-out;
}
.chat-controls__session-notice {
width: 100%;
min-height: 16px;
font-size: 12px;
line-height: 16px;
color: var(--muted);
}
@keyframes chat-session-switch-flash {
0% {
border-color: color-mix(in srgb, var(--accent) 72%, var(--border));
background: color-mix(in srgb, var(--accent-subtle) 62%, var(--card));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
}
100% {
border-color: var(--border);
background: var(--card);
box-shadow: none;
}
}
@media (prefers-reduced-motion: reduce) {
.chat-controls__session-row--flash .chat-controls__session-picker select {
animation: none;
border-color: color-mix(in srgb, var(--accent) 70%, var(--border));
box-shadow: var(--focus-ring);
}
}
.chat-controls__model {
grid-area: model;
min-width: 0;

View File

@@ -2509,6 +2509,20 @@
vertical-align: middle;
}
.data-table-empty-cell {
padding: 46px 16px !important;
color: var(--muted);
text-align: center;
}
.data-table-empty-state {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
}
.data-table tbody tr {
transition: background var(--duration-fast) ease;
}

View File

@@ -242,6 +242,26 @@
flex: 0 0 auto;
}
.config-action-spinner {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
}
.config-action-spinner svg {
width: 14px;
height: 14px;
animation: spin 0.7s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.config-action-spinner svg {
animation: none;
}
}
.config-changes-badge {
padding: 4px 10px;
border-radius: var(--radius-full);

View File

@@ -820,7 +820,18 @@ describe("switchChatSession", () => {
sessionsShowArchived: false,
chatSideResultTerminalRuns: new Set(["btw-run-1"]),
chatStreamStartedAt: 1,
sessionsResult: {
ts: 0,
path: "",
count: 2,
defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null },
sessions: [
row({ key: "main" }),
row({ key: "agent:main:test-b", label: "Review Session" }),
],
},
settings,
announceSessionSwitch: vi.fn(),
applySettings(next: typeof settings) {
state.settings = next;
},
@@ -861,6 +872,10 @@ describe("switchChatSession", () => {
includeUnknown: true,
showArchived: false,
});
expect(
(state as unknown as { announceSessionSwitch: ReturnType<typeof vi.fn> })
.announceSessionSwitch,
).toHaveBeenCalledWith("agent:main:test-b", "Review Session");
});
it("restores queued messages when switching back to their session", async () => {
@@ -886,6 +901,7 @@ describe("switchChatSession", () => {
chatSideResultTerminalRuns: new Set<string>(),
chatStreamStartedAt: 1,
settings,
announceSessionSwitch: vi.fn(),
applySettings(next: typeof settings) {
state.settings = next;
},
@@ -931,6 +947,7 @@ describe("switchChatSession", () => {
chatSideResultTerminalRuns: new Set<string>(),
chatStreamStartedAt: null,
settings,
announceSessionSwitch: vi.fn(),
applySettings(next: typeof settings) {
state.settings = next;
},
@@ -949,6 +966,10 @@ describe("switchChatSession", () => {
switchChatSession(state, "main");
await Promise.resolve();
expect(
(state as unknown as { announceSessionSwitch: ReturnType<typeof vi.fn> })
.announceSessionSwitch,
).not.toHaveBeenCalled();
expect(refreshSlashCommandsMock).toHaveBeenCalledWith({
client: state.client,
agentId: undefined,

View File

@@ -574,7 +574,13 @@ export function renderChatMobileToggle(state: AppViewState) {
}
export function switchChatSession(state: AppViewState, nextSessionKey: string) {
const previousSessionKey = state.sessionKey;
const nextSessionRow = state.sessionsResult?.sessions.find((row) => row.key === nextSessionKey);
const nextSessionLabel = resolveSessionDisplayName(nextSessionKey, nextSessionRow);
resetChatStateForSessionSwitch(state, nextSessionKey);
if (previousSessionKey !== nextSessionKey) {
state.announceSessionSwitch?.(nextSessionKey, nextSessionLabel);
}
void state.loadAssistantIdentity();
void refreshChatAvatar(state);
void refreshSlashCommands({

View File

@@ -1721,6 +1721,23 @@ export function renderApp(state: AppViewState) {
onToggleFiltersCollapsed: () => {
state.sessionsFiltersCollapsed = !state.sessionsFiltersCollapsed;
},
onClearFilters: () => {
state.sessionsFilterActive = "";
state.sessionsFilterLimit = "";
state.sessionsIncludeGlobal = true;
state.sessionsIncludeUnknown = true;
state.sessionsShowArchived = true;
state.sessionsSearchQuery = "";
state.sessionsSelectedKeys = new Set();
state.sessionsPage = 0;
void loadSessions(state, {
activeMinutes: 0,
limit: 0,
includeGlobal: true,
includeUnknown: true,
showArchived: true,
});
},
onSearchChange: (q) => {
state.sessionsSearchQuery = q;
state.sessionsPage = 0;

View File

@@ -108,6 +108,9 @@ export type AppViewState = {
chatModelOverrides: Record<string, ChatModelOverride | null>;
chatModelsLoading: boolean;
chatModelCatalog: ModelCatalogEntry[];
sessionSwitchNotice: { id: number; text: string } | null;
sessionSwitchFlashKey: string | null;
announceSessionSwitch?: (sessionKey: string, label: string) => void;
chatQueue: ChatQueueItem[];
chatQueueBySession: Record<string, ChatQueueItem[]>;
chatLocalInputHistoryBySession: Record<string, Array<{ text: string; ts: number }>>;

View File

@@ -1,6 +1,6 @@
import { LitElement } from "lit";
import { state } from "lit/decorators.js";
import { i18n, I18nController, isSupportedLocale } from "../i18n/index.ts";
import { i18n, I18nController, isSupportedLocale, t } from "../i18n/index.ts";
import {
handleChannelConfigReload as handleChannelConfigReloadInternal,
handleChannelConfigSave as handleChannelConfigSaveInternal,
@@ -217,6 +217,11 @@ export class OpenClawApp extends LitElement {
@state() chatModelOverrides: Record<string, ChatModelOverride | null> = {};
@state() chatModelsLoading = false;
@state() chatModelCatalog: ModelCatalogEntry[] = [];
@state() sessionSwitchNotice: { id: number; text: string } | null = null;
@state() sessionSwitchFlashKey: string | null = null;
private sessionSwitchNoticeSeq = 0;
private sessionSwitchNoticeTimer: number | null = null;
private sessionSwitchFlashTimer: number | null = null;
@state() chatQueue: ChatQueueItem[] = [];
@state() chatQueueBySession: Record<string, ChatQueueItem[]> = {};
@state() chatAttachments: ChatAttachment[] = [];
@@ -651,6 +656,14 @@ export class OpenClawApp extends LitElement {
document.removeEventListener("keydown", this.globalKeydownHandler);
document.removeEventListener("keydown", this.chatMobileControlsKeydownHandler);
document.removeEventListener("pointerdown", this.chatMobileControlsPointerdownHandler);
if (this.sessionSwitchNoticeTimer !== null) {
window.clearTimeout(this.sessionSwitchNoticeTimer);
this.sessionSwitchNoticeTimer = null;
}
if (this.sessionSwitchFlashTimer !== null) {
window.clearTimeout(this.sessionSwitchFlashTimer);
this.sessionSwitchFlashTimer = null;
}
this.chatMobileControlsTrigger = null;
handleDisconnected(this as unknown as Parameters<typeof handleDisconnected>[0]);
super.disconnectedCallback();
@@ -852,6 +865,33 @@ export class OpenClawApp extends LitElement {
this.requestUpdate();
}
announceSessionSwitch(sessionKey: string, label: string) {
const id = ++this.sessionSwitchNoticeSeq;
if (this.sessionSwitchNoticeTimer !== null) {
window.clearTimeout(this.sessionSwitchNoticeTimer);
}
if (this.sessionSwitchFlashTimer !== null) {
window.clearTimeout(this.sessionSwitchFlashTimer);
}
this.sessionSwitchNotice = {
id,
text: t("chat.switchedSession", { session: label }),
};
this.sessionSwitchFlashKey = sessionKey;
this.sessionSwitchFlashTimer = window.setTimeout(() => {
if (this.sessionSwitchNotice?.id === id) {
this.sessionSwitchFlashKey = null;
}
this.sessionSwitchFlashTimer = null;
}, 200);
this.sessionSwitchNoticeTimer = window.setTimeout(() => {
if (this.sessionSwitchNotice?.id === id) {
this.sessionSwitchNotice = null;
}
this.sessionSwitchNoticeTimer = null;
}, 2800);
}
buildThemeOrder(active: ThemeName): ThemeName[] {
const all = [...VALID_THEME_NAMES];
const rest = all.filter((id) => id !== active);

View File

@@ -37,12 +37,16 @@ export function renderChatSessionSelect(
const selectedSessionLabel =
sessionGroups.flatMap((group) => group.options).find((entry) => entry.key === state.sessionKey)
?.label ?? state.sessionKey;
const flashSession = state.sessionSwitchFlashKey === state.sessionKey;
const rowClass = [
"chat-controls__session-row",
hasAgentSelect ? "" : "chat-controls__session-row--single-agent",
flashSession ? "chat-controls__session-row--flash" : "",
]
.filter(Boolean)
.join(" ");
return html`
<div
class=${hasAgentSelect
? "chat-controls__session-row"
: "chat-controls__session-row chat-controls__session-row--single-agent"}
>
<div class=${rowClass}>
${agentSelect}
<label class="field chat-controls__session chat-controls__session-picker">
<select
@@ -82,6 +86,9 @@ export function renderChatSessionSelect(
</label>
${modelSelect} ${thinkingSelect}
</div>
<div class="chat-controls__session-notice" role="status" aria-live="polite">
${state.sessionSwitchNotice?.text ?? ""}
</div>
`;
}

View File

@@ -873,6 +873,21 @@ describe("chat session controls", () => {
expect(onSwitchSession).toHaveBeenCalledWith(state, "agent:beta:main");
});
it("renders session switch feedback in the chat controls live region", () => {
const { state } = createChatHeaderState();
state.sessionSwitchNotice = { id: 1, text: "Switched to Coding" };
state.sessionSwitchFlashKey = state.sessionKey;
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const notice = container.querySelector<HTMLElement>(".chat-controls__session-notice");
expect(notice?.getAttribute("role")).toBe("status");
expect(notice?.getAttribute("aria-live")).toBe("polite");
expect(notice?.textContent?.trim()).toBe("Switched to Coding");
expect(container.querySelector(".chat-controls__session-row--flash")).not.toBeNull();
});
it("shows the active agent main session instead of a blank select when no row exists yet", () => {
const { state } = createChatHeaderState();
state.sessionKey = "agent:main:main";

View File

@@ -65,12 +65,14 @@ describe("config view", () => {
clearButton?: HTMLButtonElement;
saveButton?: HTMLButtonElement;
applyButton?: HTMLButtonElement;
updateButton?: HTMLButtonElement;
} {
const buttons = Array.from(container.querySelectorAll("button"));
return {
clearButton: buttons.find((btn) => btn.textContent?.trim() === "Clear"),
saveButton: buttons.find((btn) => btn.textContent?.trim() === "Save"),
applyButton: buttons.find((btn) => btn.textContent?.trim() === "Apply"),
updateButton: buttons.find((btn) => btn.textContent?.trim() === "Update"),
};
}
@@ -171,6 +173,58 @@ describe("config view", () => {
expect(onReset).toHaveBeenCalledTimes(1);
});
it("renders inline progress inside busy action buttons without locking adjacent controls", () => {
const container = document.createElement("div");
const renderCase = (overrides: Partial<ConfigProps>) =>
render(
renderConfig({
...baseProps(),
schema: {
type: "object",
properties: {
gateway: { type: "object", properties: { mode: { type: "string" } } },
},
},
formValue: { gateway: { mode: "remote" } },
originalValue: { gateway: { mode: "local" } },
...overrides,
}),
container,
);
renderCase({ saving: true });
let busyButton = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Saving…"),
);
let { clearButton, applyButton } = findActionButtons(container);
expect(busyButton).toBeTruthy();
expect(busyButton?.disabled).toBe(true);
expect(busyButton?.getAttribute("aria-busy")).toBe("true");
expect(busyButton?.querySelector(".config-action-spinner")).not.toBeNull();
expect(clearButton?.disabled).toBe(false);
expect(applyButton?.disabled).toBe(false);
renderCase({ applying: true });
busyButton = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Applying…"),
);
({ clearButton } = findActionButtons(container));
expect(busyButton).toBeTruthy();
expect(busyButton?.disabled).toBe(true);
expect(busyButton?.querySelector(".config-action-spinner")).not.toBeNull();
expect(clearButton?.disabled).toBe(false);
renderCase({ updating: true });
busyButton = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Updating…"),
);
({ clearButton } = findActionButtons(container));
expect(busyButton).toBeTruthy();
expect(busyButton?.disabled).toBe(true);
expect(busyButton?.querySelector(".config-action-spinner")).not.toBeNull();
expect(clearButton?.disabled).toBe(false);
});
it("switches mode via the sidebar toggle", () => {
const container = document.createElement("div");
const onFormModeChange = vi.fn();

View File

@@ -1377,6 +1377,11 @@ export function renderConfig(props: ConfigProps) {
hasChanges &&
(formMode === "raw" ? true : canSaveForm);
const canUpdate = props.connected && !props.applying && !props.updating;
const renderActionButtonContent = (busy: boolean, label: string, busyLabel: string) =>
busy
? html`<span class="config-action-spinner" aria-hidden="true">${icons.loader}</span
>${busyLabel}`
: label;
const showAppearanceOnRoot =
includeVirtualSections &&
@@ -1449,14 +1454,29 @@ export function renderConfig(props: ConfigProps) {
<button class="btn btn--sm" ?disabled=${!hasChanges} @click=${props.onReset}>
Clear
</button>
<button class="btn btn--sm primary" ?disabled=${!canSave} @click=${props.onSave}>
${props.saving ? "Saving…" : "Save"}
<button
class="btn btn--sm primary"
?disabled=${!canSave}
aria-busy=${props.saving ? "true" : "false"}
@click=${props.onSave}
>
${renderActionButtonContent(props.saving, "Save", "Saving…")}
</button>
<button class="btn btn--sm" ?disabled=${!canApply} @click=${props.onApply}>
${props.applying ? "Applying…" : "Apply"}
<button
class="btn btn--sm"
?disabled=${!canApply}
aria-busy=${props.applying ? "true" : "false"}
@click=${props.onApply}
>
${renderActionButtonContent(props.applying, "Apply", "Applying…")}
</button>
<button class="btn btn--sm" ?disabled=${!canUpdate} @click=${props.onUpdate}>
${props.updating ? "Updating…" : "Update"}
<button
class="btn btn--sm"
?disabled=${!canUpdate}
aria-busy=${props.updating ? "true" : "false"}
@click=${props.onUpdate}
>
${renderActionButtonContent(props.updating, "Update", "Updating…")}
</button>
</div>
</div>

View File

@@ -51,6 +51,7 @@ function buildProps(result: SessionsListResult): SessionsProps {
checkpointErrorByKey: {},
onFiltersChange: () => undefined,
onToggleFiltersCollapsed: () => undefined,
onClearFilters: () => undefined,
onSearchChange: () => undefined,
onSortChange: () => undefined,
onPageChange: () => undefined,
@@ -565,4 +566,53 @@ describe("sessions view", () => {
expect(onDeselectAll).not.toHaveBeenCalled();
expect(onSelectPage).not.toHaveBeenCalled();
});
it("shows a reset action when filters hide every session", async () => {
const container = document.createElement("div");
const onClearFilters = vi.fn();
render(
renderSessions({
...buildProps(
buildMultiResult([
{
key: "agent:main:main",
kind: "direct",
updatedAt: Date.now(),
},
]),
),
searchQuery: "missing",
onClearFilters,
}),
container,
);
await Promise.resolve();
expect(container.textContent).toContain("No sessions match your filters.");
const showAll = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Show all",
);
expect(showAll).toBeTruthy();
showAll?.click();
expect(onClearFilters).toHaveBeenCalledTimes(1);
});
it("keeps the plain empty state when no filters are active", async () => {
const container = document.createElement("div");
render(
renderSessions({
...buildProps(buildMultiResult([])),
activeMinutes: "",
limit: "",
includeGlobal: true,
includeUnknown: true,
showArchived: true,
}),
container,
);
await Promise.resolve();
expect(container.textContent).toContain("No sessions found.");
expect(container.textContent).not.toContain("Show all");
});
});

View File

@@ -45,6 +45,7 @@ export type SessionsProps = {
showArchived: boolean;
}) => void;
onToggleFiltersCollapsed: () => void;
onClearFilters: () => void;
onSearchChange: (query: string) => void;
onSortChange: (column: "key" | "kind" | "updated" | "tokens", dir: "asc" | "desc") => void;
onPageChange: (page: number) => void;
@@ -225,6 +226,22 @@ function paginateRows<T>(rows: T[], page: number, pageSize: number): T[] {
return rows.slice(start, start + pageSize);
}
function hasPositiveNumberFilter(value: string): boolean {
const parsed = Number(value.trim());
return Number.isFinite(parsed) && parsed > 0;
}
function hasActiveFilters(props: SessionsProps): boolean {
return (
normalizeLowercaseStringOrEmpty(props.searchQuery).length > 0 ||
hasPositiveNumberFilter(props.activeMinutes) ||
hasPositiveNumberFilter(props.limit) ||
!props.includeGlobal ||
!props.includeUnknown ||
!props.showArchived
);
}
function formatCheckpointReason(reason: SessionCompactionCheckpoint["reason"]): string {
switch (reason) {
case "manual":
@@ -304,6 +321,8 @@ export function renderSessions(props: SessionsProps) {
const totalPages = Math.max(1, Math.ceil(totalRows / props.pageSize));
const page = Math.min(props.page, totalPages - 1);
const paginated = paginateRows(sorted, page, props.pageSize);
const emptyBecauseFiltered =
rawRows.length === 0 ? hasActiveFilters(props) : filtered.length === 0;
const activeTooltip = t("sessionsView.activeTooltip", { count: props.activeMinutes.trim() });
const limitTooltip = t("sessionsView.limitTooltip");
const globalTooltip = t("sessionsView.globalTooltip");
@@ -537,11 +556,17 @@ export function renderSessions(props: SessionsProps) {
${paginated.length === 0
? html`
<tr>
<td
colspan="11"
style="text-align: center; padding: 48px 16px; color: var(--muted)"
>
${t("sessionsView.noSessions")}
<td colspan="11" class="data-table-empty-cell">
${emptyBecauseFiltered
? html`
<div class="data-table-empty-state" role="status" aria-live="polite">
<div>${t("sessionsView.noSessionsMatchFilters")}</div>
<button class="btn btn--sm" @click=${props.onClearFilters}>
${t("sessionsView.showAll")}
</button>
</div>
`
: t("sessionsView.noSessions")}
</td>
</tr>
`