feat(apps,ui): question surface parity and composer takeover (#110681)

* feat(apple): question parity — persistent summaries, skip, reconnect fallback, macOS keyboard

* feat(android): question parity — persistent summaries, skip, reconnect fallback

* feat(ui): question panel replaces composer while expanded

* fix(apps): regenerate question parity contracts

* fix(apps): preserve question reconciliation state

* fix(apps): keep question terminal state monotonic

* test(android): satisfy question test formatting

* fix(security): restore skill audit file reads

* fix(apps): harden question parity edge cases

* fix(apps): preserve expired question summaries

* fix(apps): keep question recovery retries current

* fix(apps): retry stale question recovery snapshots

* fix(android): reset stale question retry budgets

* docs(tools): clarify question composer takeover

* fix(apps): satisfy question recovery release gates

* fix(apps): preserve unknown question outcomes

* fix(apps): harden question action state

* fix(apps): keep unavailable questions terminal

* fix(apps): finalize local question outcomes

* fix(apps): preserve question card API

* fix(apps): reconcile external question answers

* fix(apps): reset question backoff budget

* chore(apps): refresh native i18n inventory

* fix(android): distinguish question skip progress

* fix(android): reconcile locally expired questions

* fix(apps): keep question recovery state fresh

* docs: note question surface parity

* fix(ui): reconcile IME draft before question takeover

* docs: leave question parity notes to release workflow
This commit is contained in:
Peter Steinberger
2026-07-18 21:42:15 +01:00
committed by GitHub
parent 09a64f7ab0
commit 81362bf643
77 changed files with 5270 additions and 1661 deletions
@@ -9098,6 +9098,14 @@
"Ta bort från arkiv"
]
},
{
"locale": "ja-JP",
"source": "Unavailable",
"translations": [
"利用できません",
"利用不可"
]
},
{
"locale": "sv",
"source": "Unavailable",
File diff suppressed because it is too large Load Diff
+71 -41
View File
@@ -7388,11 +7388,36 @@
"source": "Other answer",
"translated": "إجابة أخرى"
},
{
"id": "native.android.de591e6e2e440ecb",
"source": "Skip",
"translated": "تخطي"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "جارٍ الإرسال…"
},
{
"id": "native.android.fa81739f52e7697f",
"source": "Submit",
"translated": "إرسال"
},
{
"id": "native.android.7da074848cacfe4b",
"source": "Skipped",
"translated": "تم التخطي"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "انتهت الصلاحية"
},
{
"id": "native.android.86c8d6113d07ef82",
"source": "Unavailable",
"translated": "غير متاح"
},
{
"id": "native.android.f68cd7a84be4089e",
"source": "Answered",
@@ -7403,36 +7428,6 @@
"source": "Answered elsewhere",
"translated": "تمت الإجابة في مكان آخر"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "انتهت الصلاحية"
},
{
"id": "native.android.d6d8fc83cec68418",
"source": "Cancelled",
"translated": "تم الإلغاء"
},
{
"id": "native.android.73f64d0d54b7f9c0",
"source": "Pending",
"translated": "قيد الانتظار"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "جارٍ الإرسال…"
},
{
"id": "native.android.c5e9ee99f9d9c612",
"source": "Expires in ${seconds / 60}m ${seconds % 60}s",
"translated": "تنتهي الصلاحية خلال ${seconds / 60}د ${seconds % 60}ث"
},
{
"id": "native.android.2ca602fbdf96f1e7",
"source": "Expires in ${seconds}s",
"translated": "تنتهي الصلاحية خلال ${seconds}ث"
},
{
"id": "native.android.118e8d239b18895e",
"source": "Model",
@@ -23038,6 +23033,41 @@
"source": "Expand plan",
"translated": "توسيع الخطة"
},
{
"id": "native.apple.ce899320a84d4086",
"source": "Answered",
"translated": "تمت الإجابة"
},
{
"id": "native.apple.f019b6a07d5a33d8",
"source": "Answered elsewhere",
"translated": "تمت الإجابة في مكان آخر"
},
{
"id": "native.apple.ebdedeffad570c43",
"source": "Skipped",
"translated": "تم التخطي"
},
{
"id": "native.apple.8dde2eaaf6b0dfa9",
"source": "Expired",
"translated": "انتهت الصلاحية"
},
{
"id": "native.apple.6dff9e9194347f3b",
"source": "Unavailable",
"translated": "غير متاح"
},
{
"id": "native.apple.b34cd890366824f0",
"source": "Pending",
"translated": "قيد الانتظار"
},
{
"id": "native.apple.b97da55b7e16e60c",
"source": "Question summary",
"translated": "ملخص السؤال"
},
{
"id": "native.apple.da4494f9e875169d",
"source": "Other answer",
@@ -23054,9 +23084,14 @@
"translated": "محدد"
},
{
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "إرسال"
"id": "native.apple.1bbbbfd1ad3842ce",
"source": "Skipping…",
"translated": "جارٍ التخطي…"
},
{
"id": "native.apple.14a4bf7b6b7d40dc",
"source": "Skip",
"translated": "تخطي"
},
{
"id": "native.apple.6c1adc16e0a846e5",
@@ -23064,14 +23099,9 @@
"translated": "جارٍ الإرسال…"
},
{
"id": "native.apple.698bdb0d6537edb6",
"source": "Expires in \\(seconds / 60)m \\(seconds % 60)s",
"translated": "تنتهي الصلاحية خلال \\(seconds / 60)د \\(seconds % 60)ث"
},
{
"id": "native.apple.30e1c342b3345290",
"source": "Expires in \\(seconds)s",
"translated": "تنتهي الصلاحية خلال \\(seconds)ث"
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "إرسال"
},
{
"id": "native.apple.7cd6fb01b9f5d14a",
+71 -41
View File
@@ -7388,11 +7388,36 @@
"source": "Other answer",
"translated": "Andere Antwort"
},
{
"id": "native.android.de591e6e2e440ecb",
"source": "Skip",
"translated": "Überspringen"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "Wird gesendet…"
},
{
"id": "native.android.fa81739f52e7697f",
"source": "Submit",
"translated": "Absenden"
},
{
"id": "native.android.7da074848cacfe4b",
"source": "Skipped",
"translated": "Übersprungen"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "Abgelaufen"
},
{
"id": "native.android.86c8d6113d07ef82",
"source": "Unavailable",
"translated": "Nicht verfügbar"
},
{
"id": "native.android.f68cd7a84be4089e",
"source": "Answered",
@@ -7403,36 +7428,6 @@
"source": "Answered elsewhere",
"translated": "Anderswo beantwortet"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "Abgelaufen"
},
{
"id": "native.android.d6d8fc83cec68418",
"source": "Cancelled",
"translated": "Abgebrochen"
},
{
"id": "native.android.73f64d0d54b7f9c0",
"source": "Pending",
"translated": "Ausstehend"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "Wird gesendet…"
},
{
"id": "native.android.c5e9ee99f9d9c612",
"source": "Expires in ${seconds / 60}m ${seconds % 60}s",
"translated": "Läuft in ${seconds / 60}m ${seconds % 60}s ab"
},
{
"id": "native.android.2ca602fbdf96f1e7",
"source": "Expires in ${seconds}s",
"translated": "Läuft in ${seconds}s ab"
},
{
"id": "native.android.118e8d239b18895e",
"source": "Model",
@@ -23038,6 +23033,41 @@
"source": "Expand plan",
"translated": "Plan ausklappen"
},
{
"id": "native.apple.ce899320a84d4086",
"source": "Answered",
"translated": "Beantwortet"
},
{
"id": "native.apple.f019b6a07d5a33d8",
"source": "Answered elsewhere",
"translated": "Anderswo beantwortet"
},
{
"id": "native.apple.ebdedeffad570c43",
"source": "Skipped",
"translated": "Übersprungen"
},
{
"id": "native.apple.8dde2eaaf6b0dfa9",
"source": "Expired",
"translated": "Abgelaufen"
},
{
"id": "native.apple.6dff9e9194347f3b",
"source": "Unavailable",
"translated": "Nicht verfügbar"
},
{
"id": "native.apple.b34cd890366824f0",
"source": "Pending",
"translated": "Ausstehend"
},
{
"id": "native.apple.b97da55b7e16e60c",
"source": "Question summary",
"translated": "Zusammenfassung der Frage"
},
{
"id": "native.apple.da4494f9e875169d",
"source": "Other answer",
@@ -23054,9 +23084,14 @@
"translated": "Ausgewählt"
},
{
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "Absenden"
"id": "native.apple.1bbbbfd1ad3842ce",
"source": "Skipping…",
"translated": "Wird übersprungen"
},
{
"id": "native.apple.14a4bf7b6b7d40dc",
"source": "Skip",
"translated": "Überspringen"
},
{
"id": "native.apple.6c1adc16e0a846e5",
@@ -23064,14 +23099,9 @@
"translated": "Wird gesendet…"
},
{
"id": "native.apple.698bdb0d6537edb6",
"source": "Expires in \\(seconds / 60)m \\(seconds % 60)s",
"translated": "Läuft in \\(seconds / 60)m \\(seconds % 60)s ab"
},
{
"id": "native.apple.30e1c342b3345290",
"source": "Expires in \\(seconds)s",
"translated": "Läuft in \\(seconds)s ab"
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "Absenden"
},
{
"id": "native.apple.7cd6fb01b9f5d14a",
+71 -41
View File
@@ -7388,11 +7388,36 @@
"source": "Other answer",
"translated": "Otra respuesta"
},
{
"id": "native.android.de591e6e2e440ecb",
"source": "Skip",
"translated": "Omitir"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "Enviando…"
},
{
"id": "native.android.fa81739f52e7697f",
"source": "Submit",
"translated": "Enviar"
},
{
"id": "native.android.7da074848cacfe4b",
"source": "Skipped",
"translated": "Omitido"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "Caducada"
},
{
"id": "native.android.86c8d6113d07ef82",
"source": "Unavailable",
"translated": "No disponible"
},
{
"id": "native.android.f68cd7a84be4089e",
"source": "Answered",
@@ -7403,36 +7428,6 @@
"source": "Answered elsewhere",
"translated": "Respondida en otro lugar"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "Caducada"
},
{
"id": "native.android.d6d8fc83cec68418",
"source": "Cancelled",
"translated": "Cancelada"
},
{
"id": "native.android.73f64d0d54b7f9c0",
"source": "Pending",
"translated": "Pendiente"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "Enviando…"
},
{
"id": "native.android.c5e9ee99f9d9c612",
"source": "Expires in ${seconds / 60}m ${seconds % 60}s",
"translated": "Caduca en ${seconds / 60}m ${seconds % 60}s"
},
{
"id": "native.android.2ca602fbdf96f1e7",
"source": "Expires in ${seconds}s",
"translated": "Caduca en ${seconds}s"
},
{
"id": "native.android.118e8d239b18895e",
"source": "Model",
@@ -23038,6 +23033,41 @@
"source": "Expand plan",
"translated": "Expandir el plan"
},
{
"id": "native.apple.ce899320a84d4086",
"source": "Answered",
"translated": "Respondida"
},
{
"id": "native.apple.f019b6a07d5a33d8",
"source": "Answered elsewhere",
"translated": "Respondida en otro lugar"
},
{
"id": "native.apple.ebdedeffad570c43",
"source": "Skipped",
"translated": "Omitido"
},
{
"id": "native.apple.8dde2eaaf6b0dfa9",
"source": "Expired",
"translated": "Caducada"
},
{
"id": "native.apple.6dff9e9194347f3b",
"source": "Unavailable",
"translated": "No disponible"
},
{
"id": "native.apple.b34cd890366824f0",
"source": "Pending",
"translated": "Pendiente"
},
{
"id": "native.apple.b97da55b7e16e60c",
"source": "Question summary",
"translated": "Resumen de la pregunta"
},
{
"id": "native.apple.da4494f9e875169d",
"source": "Other answer",
@@ -23054,9 +23084,14 @@
"translated": "Seleccionada"
},
{
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "Enviar"
"id": "native.apple.1bbbbfd1ad3842ce",
"source": "Skipping…",
"translated": "Omitiendo…"
},
{
"id": "native.apple.14a4bf7b6b7d40dc",
"source": "Skip",
"translated": "Omitir"
},
{
"id": "native.apple.6c1adc16e0a846e5",
@@ -23064,14 +23099,9 @@
"translated": "Enviando…"
},
{
"id": "native.apple.698bdb0d6537edb6",
"source": "Expires in \\(seconds / 60)m \\(seconds % 60)s",
"translated": "Caduca en \\(seconds / 60)m \\(seconds % 60)s"
},
{
"id": "native.apple.30e1c342b3345290",
"source": "Expires in \\(seconds)s",
"translated": "Caduca en \\(seconds)s"
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "Enviar"
},
{
"id": "native.apple.7cd6fb01b9f5d14a",
+71 -41
View File
@@ -7388,11 +7388,36 @@
"source": "Other answer",
"translated": "پاسخ دیگر"
},
{
"id": "native.android.de591e6e2e440ecb",
"source": "Skip",
"translated": "رد کردن"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "در حال ارسال…"
},
{
"id": "native.android.fa81739f52e7697f",
"source": "Submit",
"translated": "ارسال"
},
{
"id": "native.android.7da074848cacfe4b",
"source": "Skipped",
"translated": "رد شد"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "منقضی شده"
},
{
"id": "native.android.86c8d6113d07ef82",
"source": "Unavailable",
"translated": "در دسترس نیست"
},
{
"id": "native.android.f68cd7a84be4089e",
"source": "Answered",
@@ -7403,36 +7428,6 @@
"source": "Answered elsewhere",
"translated": "در جای دیگری پاسخ داده شده"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "منقضی شده"
},
{
"id": "native.android.d6d8fc83cec68418",
"source": "Cancelled",
"translated": "لغو شده"
},
{
"id": "native.android.73f64d0d54b7f9c0",
"source": "Pending",
"translated": "در انتظار"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "در حال ارسال…"
},
{
"id": "native.android.c5e9ee99f9d9c612",
"source": "Expires in ${seconds / 60}m ${seconds % 60}s",
"translated": "انقضا تا ${seconds / 60}m ${seconds % 60}s دیگر"
},
{
"id": "native.android.2ca602fbdf96f1e7",
"source": "Expires in ${seconds}s",
"translated": "انقضا تا ${seconds}s دیگر"
},
{
"id": "native.android.118e8d239b18895e",
"source": "Model",
@@ -23038,6 +23033,41 @@
"source": "Expand plan",
"translated": "بازکردن برنامه"
},
{
"id": "native.apple.ce899320a84d4086",
"source": "Answered",
"translated": "پاسخ داده شده"
},
{
"id": "native.apple.f019b6a07d5a33d8",
"source": "Answered elsewhere",
"translated": "در جای دیگری پاسخ داده شده"
},
{
"id": "native.apple.ebdedeffad570c43",
"source": "Skipped",
"translated": "رد شد"
},
{
"id": "native.apple.8dde2eaaf6b0dfa9",
"source": "Expired",
"translated": "منقضی شده"
},
{
"id": "native.apple.6dff9e9194347f3b",
"source": "Unavailable",
"translated": "در دسترس نیست"
},
{
"id": "native.apple.b34cd890366824f0",
"source": "Pending",
"translated": "در انتظار"
},
{
"id": "native.apple.b97da55b7e16e60c",
"source": "Question summary",
"translated": "خلاصه پرسش"
},
{
"id": "native.apple.da4494f9e875169d",
"source": "Other answer",
@@ -23054,9 +23084,14 @@
"translated": "انتخاب شده"
},
{
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "ارسال"
"id": "native.apple.1bbbbfd1ad3842ce",
"source": "Skipping…",
"translated": "در حال رد کردن…"
},
{
"id": "native.apple.14a4bf7b6b7d40dc",
"source": "Skip",
"translated": "رد کردن"
},
{
"id": "native.apple.6c1adc16e0a846e5",
@@ -23064,14 +23099,9 @@
"translated": "در حال ارسال…"
},
{
"id": "native.apple.698bdb0d6537edb6",
"source": "Expires in \\(seconds / 60)m \\(seconds % 60)s",
"translated": "انقضا تا \\(seconds / 60)m \\(seconds % 60)s دیگر"
},
{
"id": "native.apple.30e1c342b3345290",
"source": "Expires in \\(seconds)s",
"translated": "انقضا تا \\(seconds)s دیگر"
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "ارسال"
},
{
"id": "native.apple.7cd6fb01b9f5d14a",
+71 -41
View File
@@ -7388,11 +7388,36 @@
"source": "Other answer",
"translated": "Autre réponse"
},
{
"id": "native.android.de591e6e2e440ecb",
"source": "Skip",
"translated": "Ignorer"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "Envoi…"
},
{
"id": "native.android.fa81739f52e7697f",
"source": "Submit",
"translated": "Envoyer"
},
{
"id": "native.android.7da074848cacfe4b",
"source": "Skipped",
"translated": "Ignoré"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "Expiré"
},
{
"id": "native.android.86c8d6113d07ef82",
"source": "Unavailable",
"translated": "Indisponible"
},
{
"id": "native.android.f68cd7a84be4089e",
"source": "Answered",
@@ -7403,36 +7428,6 @@
"source": "Answered elsewhere",
"translated": "Répondu ailleurs"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "Expiré"
},
{
"id": "native.android.d6d8fc83cec68418",
"source": "Cancelled",
"translated": "Annulé"
},
{
"id": "native.android.73f64d0d54b7f9c0",
"source": "Pending",
"translated": "En attente"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "Envoi…"
},
{
"id": "native.android.c5e9ee99f9d9c612",
"source": "Expires in ${seconds / 60}m ${seconds % 60}s",
"translated": "Expire dans ${seconds / 60} min ${seconds % 60} s"
},
{
"id": "native.android.2ca602fbdf96f1e7",
"source": "Expires in ${seconds}s",
"translated": "Expire dans ${seconds} s"
},
{
"id": "native.android.118e8d239b18895e",
"source": "Model",
@@ -23038,6 +23033,41 @@
"source": "Expand plan",
"translated": "Développer le plan"
},
{
"id": "native.apple.ce899320a84d4086",
"source": "Answered",
"translated": "Répondu"
},
{
"id": "native.apple.f019b6a07d5a33d8",
"source": "Answered elsewhere",
"translated": "Répondu ailleurs"
},
{
"id": "native.apple.ebdedeffad570c43",
"source": "Skipped",
"translated": "Ignoré"
},
{
"id": "native.apple.8dde2eaaf6b0dfa9",
"source": "Expired",
"translated": "Expiré"
},
{
"id": "native.apple.6dff9e9194347f3b",
"source": "Unavailable",
"translated": "Indisponible"
},
{
"id": "native.apple.b34cd890366824f0",
"source": "Pending",
"translated": "En attente"
},
{
"id": "native.apple.b97da55b7e16e60c",
"source": "Question summary",
"translated": "Résumé de la question"
},
{
"id": "native.apple.da4494f9e875169d",
"source": "Other answer",
@@ -23054,9 +23084,14 @@
"translated": "Sélectionné"
},
{
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "Envoyer"
"id": "native.apple.1bbbbfd1ad3842ce",
"source": "Skipping…",
"translated": "Ignoré…"
},
{
"id": "native.apple.14a4bf7b6b7d40dc",
"source": "Skip",
"translated": "Ignorer"
},
{
"id": "native.apple.6c1adc16e0a846e5",
@@ -23064,14 +23099,9 @@
"translated": "Envoi…"
},
{
"id": "native.apple.698bdb0d6537edb6",
"source": "Expires in \\(seconds / 60)m \\(seconds % 60)s",
"translated": "Expire dans \\(seconds / 60) min \\(seconds % 60) s"
},
{
"id": "native.apple.30e1c342b3345290",
"source": "Expires in \\(seconds)s",
"translated": "Expire dans \\(seconds) s"
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "Envoyer"
},
{
"id": "native.apple.7cd6fb01b9f5d14a",
+71 -41
View File
@@ -7388,11 +7388,36 @@
"source": "Other answer",
"translated": "अन्य उत्तर"
},
{
"id": "native.android.de591e6e2e440ecb",
"source": "Skip",
"translated": "छोड़ें"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "सबमिट किया जा रहा है…"
},
{
"id": "native.android.fa81739f52e7697f",
"source": "Submit",
"translated": "सबमिट करें"
},
{
"id": "native.android.7da074848cacfe4b",
"source": "Skipped",
"translated": "छोड़ा गया"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "समय समाप्त"
},
{
"id": "native.android.86c8d6113d07ef82",
"source": "Unavailable",
"translated": "अनुपलब्ध"
},
{
"id": "native.android.f68cd7a84be4089e",
"source": "Answered",
@@ -7403,36 +7428,6 @@
"source": "Answered elsewhere",
"translated": "कहीं और उत्तर दिया गया"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "समय समाप्त"
},
{
"id": "native.android.d6d8fc83cec68418",
"source": "Cancelled",
"translated": "रद्द किया गया"
},
{
"id": "native.android.73f64d0d54b7f9c0",
"source": "Pending",
"translated": "लंबित"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "सबमिट किया जा रहा है…"
},
{
"id": "native.android.c5e9ee99f9d9c612",
"source": "Expires in ${seconds / 60}m ${seconds % 60}s",
"translated": "${seconds / 60}m ${seconds % 60}s में समाप्त होगा"
},
{
"id": "native.android.2ca602fbdf96f1e7",
"source": "Expires in ${seconds}s",
"translated": "${seconds}s में समाप्त होगा"
},
{
"id": "native.android.118e8d239b18895e",
"source": "Model",
@@ -23038,6 +23033,41 @@
"source": "Expand plan",
"translated": "योजना विस्तृत करें"
},
{
"id": "native.apple.ce899320a84d4086",
"source": "Answered",
"translated": "उत्तर दिया गया"
},
{
"id": "native.apple.f019b6a07d5a33d8",
"source": "Answered elsewhere",
"translated": "कहीं और उत्तर दिया गया"
},
{
"id": "native.apple.ebdedeffad570c43",
"source": "Skipped",
"translated": "छोड़ा गया"
},
{
"id": "native.apple.8dde2eaaf6b0dfa9",
"source": "Expired",
"translated": "समय समाप्त"
},
{
"id": "native.apple.6dff9e9194347f3b",
"source": "Unavailable",
"translated": "अनुपलब्ध"
},
{
"id": "native.apple.b34cd890366824f0",
"source": "Pending",
"translated": "लंबित"
},
{
"id": "native.apple.b97da55b7e16e60c",
"source": "Question summary",
"translated": "प्रश्न का सारांश"
},
{
"id": "native.apple.da4494f9e875169d",
"source": "Other answer",
@@ -23054,9 +23084,14 @@
"translated": "चयनित"
},
{
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "सबमिट करें"
"id": "native.apple.1bbbbfd1ad3842ce",
"source": "Skipping…",
"translated": "छोड़ा जा रहा है…"
},
{
"id": "native.apple.14a4bf7b6b7d40dc",
"source": "Skip",
"translated": "छोड़ें"
},
{
"id": "native.apple.6c1adc16e0a846e5",
@@ -23064,14 +23099,9 @@
"translated": "सबमिट किया जा रहा है…"
},
{
"id": "native.apple.698bdb0d6537edb6",
"source": "Expires in \\(seconds / 60)m \\(seconds % 60)s",
"translated": "\\(seconds / 60)m \\(seconds % 60)s में समाप्त होगा"
},
{
"id": "native.apple.30e1c342b3345290",
"source": "Expires in \\(seconds)s",
"translated": "\\(seconds)s में समाप्त होगा"
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "सबमिट करें"
},
{
"id": "native.apple.7cd6fb01b9f5d14a",
+71 -41
View File
@@ -7388,11 +7388,36 @@
"source": "Other answer",
"translated": "Jawaban lain"
},
{
"id": "native.android.de591e6e2e440ecb",
"source": "Skip",
"translated": "Lewati"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "Mengirim…"
},
{
"id": "native.android.fa81739f52e7697f",
"source": "Submit",
"translated": "Kirim"
},
{
"id": "native.android.7da074848cacfe4b",
"source": "Skipped",
"translated": "Dilewati"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "Kedaluwarsa"
},
{
"id": "native.android.86c8d6113d07ef82",
"source": "Unavailable",
"translated": "Tidak tersedia"
},
{
"id": "native.android.f68cd7a84be4089e",
"source": "Answered",
@@ -7403,36 +7428,6 @@
"source": "Answered elsewhere",
"translated": "Dijawab di tempat lain"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "Kedaluwarsa"
},
{
"id": "native.android.d6d8fc83cec68418",
"source": "Cancelled",
"translated": "Dibatalkan"
},
{
"id": "native.android.73f64d0d54b7f9c0",
"source": "Pending",
"translated": "Menunggu"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "Mengirim…"
},
{
"id": "native.android.c5e9ee99f9d9c612",
"source": "Expires in ${seconds / 60}m ${seconds % 60}s",
"translated": "Kedaluwarsa dalam ${seconds / 60}m ${seconds % 60}s"
},
{
"id": "native.android.2ca602fbdf96f1e7",
"source": "Expires in ${seconds}s",
"translated": "Kedaluwarsa dalam ${seconds}s"
},
{
"id": "native.android.118e8d239b18895e",
"source": "Model",
@@ -23038,6 +23033,41 @@
"source": "Expand plan",
"translated": "Luaskan rencana"
},
{
"id": "native.apple.ce899320a84d4086",
"source": "Answered",
"translated": "Sudah dijawab"
},
{
"id": "native.apple.f019b6a07d5a33d8",
"source": "Answered elsewhere",
"translated": "Dijawab di tempat lain"
},
{
"id": "native.apple.ebdedeffad570c43",
"source": "Skipped",
"translated": "Dilewati"
},
{
"id": "native.apple.8dde2eaaf6b0dfa9",
"source": "Expired",
"translated": "Kedaluwarsa"
},
{
"id": "native.apple.6dff9e9194347f3b",
"source": "Unavailable",
"translated": "Tidak tersedia"
},
{
"id": "native.apple.b34cd890366824f0",
"source": "Pending",
"translated": "Menunggu"
},
{
"id": "native.apple.b97da55b7e16e60c",
"source": "Question summary",
"translated": "Ringkasan pertanyaan"
},
{
"id": "native.apple.da4494f9e875169d",
"source": "Other answer",
@@ -23054,9 +23084,14 @@
"translated": "Dipilih"
},
{
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "Kirim"
"id": "native.apple.1bbbbfd1ad3842ce",
"source": "Skipping…",
"translated": "Melewati…"
},
{
"id": "native.apple.14a4bf7b6b7d40dc",
"source": "Skip",
"translated": "Lewati"
},
{
"id": "native.apple.6c1adc16e0a846e5",
@@ -23064,14 +23099,9 @@
"translated": "Mengirim…"
},
{
"id": "native.apple.698bdb0d6537edb6",
"source": "Expires in \\(seconds / 60)m \\(seconds % 60)s",
"translated": "Kedaluwarsa dalam \\(seconds / 60)m \\(seconds % 60)s"
},
{
"id": "native.apple.30e1c342b3345290",
"source": "Expires in \\(seconds)s",
"translated": "Kedaluwarsa dalam \\(seconds)s"
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "Kirim"
},
{
"id": "native.apple.7cd6fb01b9f5d14a",
+71 -41
View File
@@ -7388,11 +7388,36 @@
"source": "Other answer",
"translated": "Altra risposta"
},
{
"id": "native.android.de591e6e2e440ecb",
"source": "Skip",
"translated": "Salta"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "Invio in corso…"
},
{
"id": "native.android.fa81739f52e7697f",
"source": "Submit",
"translated": "Invia"
},
{
"id": "native.android.7da074848cacfe4b",
"source": "Skipped",
"translated": "Saltato"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "Scaduto"
},
{
"id": "native.android.86c8d6113d07ef82",
"source": "Unavailable",
"translated": "Non disponibile"
},
{
"id": "native.android.f68cd7a84be4089e",
"source": "Answered",
@@ -7403,36 +7428,6 @@
"source": "Answered elsewhere",
"translated": "Risposto altrove"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "Scaduto"
},
{
"id": "native.android.d6d8fc83cec68418",
"source": "Cancelled",
"translated": "Annullato"
},
{
"id": "native.android.73f64d0d54b7f9c0",
"source": "Pending",
"translated": "In attesa"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "Invio in corso…"
},
{
"id": "native.android.c5e9ee99f9d9c612",
"source": "Expires in ${seconds / 60}m ${seconds % 60}s",
"translated": "Scade tra ${seconds / 60}m ${seconds % 60}s"
},
{
"id": "native.android.2ca602fbdf96f1e7",
"source": "Expires in ${seconds}s",
"translated": "Scade tra ${seconds}s"
},
{
"id": "native.android.118e8d239b18895e",
"source": "Model",
@@ -23038,6 +23033,41 @@
"source": "Expand plan",
"translated": "Espandi il piano"
},
{
"id": "native.apple.ce899320a84d4086",
"source": "Answered",
"translated": "Risposto"
},
{
"id": "native.apple.f019b6a07d5a33d8",
"source": "Answered elsewhere",
"translated": "Risposto altrove"
},
{
"id": "native.apple.ebdedeffad570c43",
"source": "Skipped",
"translated": "Saltato"
},
{
"id": "native.apple.8dde2eaaf6b0dfa9",
"source": "Expired",
"translated": "Scaduto"
},
{
"id": "native.apple.6dff9e9194347f3b",
"source": "Unavailable",
"translated": "Non disponibile"
},
{
"id": "native.apple.b34cd890366824f0",
"source": "Pending",
"translated": "In sospeso"
},
{
"id": "native.apple.b97da55b7e16e60c",
"source": "Question summary",
"translated": "Riepilogo della domanda"
},
{
"id": "native.apple.da4494f9e875169d",
"source": "Other answer",
@@ -23054,9 +23084,14 @@
"translated": "Selezionato"
},
{
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "Invia"
"id": "native.apple.1bbbbfd1ad3842ce",
"source": "Skipping…",
"translated": "Ignoro…"
},
{
"id": "native.apple.14a4bf7b6b7d40dc",
"source": "Skip",
"translated": "Salta"
},
{
"id": "native.apple.6c1adc16e0a846e5",
@@ -23064,14 +23099,9 @@
"translated": "Invio in corso…"
},
{
"id": "native.apple.698bdb0d6537edb6",
"source": "Expires in \\(seconds / 60)m \\(seconds % 60)s",
"translated": "Scade tra \\(seconds / 60)m \\(seconds % 60)s"
},
{
"id": "native.apple.30e1c342b3345290",
"source": "Expires in \\(seconds)s",
"translated": "Scade tra \\(seconds)s"
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "Invia"
},
{
"id": "native.apple.7cd6fb01b9f5d14a",
+71 -41
View File
@@ -7388,11 +7388,36 @@
"source": "Other answer",
"translated": "その他の回答"
},
{
"id": "native.android.de591e6e2e440ecb",
"source": "Skip",
"translated": "スキップ"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "送信中…"
},
{
"id": "native.android.fa81739f52e7697f",
"source": "Submit",
"translated": "送信"
},
{
"id": "native.android.7da074848cacfe4b",
"source": "Skipped",
"translated": "スキップ済み"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "期限切れ"
},
{
"id": "native.android.86c8d6113d07ef82",
"source": "Unavailable",
"translated": "利用できません"
},
{
"id": "native.android.f68cd7a84be4089e",
"source": "Answered",
@@ -7403,36 +7428,6 @@
"source": "Answered elsewhere",
"translated": "他で回答済み"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "期限切れ"
},
{
"id": "native.android.d6d8fc83cec68418",
"source": "Cancelled",
"translated": "キャンセル済み"
},
{
"id": "native.android.73f64d0d54b7f9c0",
"source": "Pending",
"translated": "保留中"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "送信中…"
},
{
"id": "native.android.c5e9ee99f9d9c612",
"source": "Expires in ${seconds / 60}m ${seconds % 60}s",
"translated": "期限切れまで${seconds / 60}m ${seconds % 60}s"
},
{
"id": "native.android.2ca602fbdf96f1e7",
"source": "Expires in ${seconds}s",
"translated": "期限切れまで${seconds}s"
},
{
"id": "native.android.118e8d239b18895e",
"source": "Model",
@@ -23038,6 +23033,41 @@
"source": "Expand plan",
"translated": "計画を展開"
},
{
"id": "native.apple.ce899320a84d4086",
"source": "Answered",
"translated": "回答済み"
},
{
"id": "native.apple.f019b6a07d5a33d8",
"source": "Answered elsewhere",
"translated": "他で回答済み"
},
{
"id": "native.apple.ebdedeffad570c43",
"source": "Skipped",
"translated": "スキップ済み"
},
{
"id": "native.apple.8dde2eaaf6b0dfa9",
"source": "Expired",
"translated": "期限切れ"
},
{
"id": "native.apple.6dff9e9194347f3b",
"source": "Unavailable",
"translated": "利用できません"
},
{
"id": "native.apple.b34cd890366824f0",
"source": "Pending",
"translated": "保留中"
},
{
"id": "native.apple.b97da55b7e16e60c",
"source": "Question summary",
"translated": "質問の概要"
},
{
"id": "native.apple.da4494f9e875169d",
"source": "Other answer",
@@ -23054,9 +23084,14 @@
"translated": "選択済み"
},
{
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "送信"
"id": "native.apple.1bbbbfd1ad3842ce",
"source": "Skipping…",
"translated": "スキップ中…"
},
{
"id": "native.apple.14a4bf7b6b7d40dc",
"source": "Skip",
"translated": "スキップ"
},
{
"id": "native.apple.6c1adc16e0a846e5",
@@ -23064,14 +23099,9 @@
"translated": "送信中…"
},
{
"id": "native.apple.698bdb0d6537edb6",
"source": "Expires in \\(seconds / 60)m \\(seconds % 60)s",
"translated": "期限切れまで\\(seconds / 60)m \\(seconds % 60)s"
},
{
"id": "native.apple.30e1c342b3345290",
"source": "Expires in \\(seconds)s",
"translated": "期限切れまで\\(seconds)s"
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "送信"
},
{
"id": "native.apple.7cd6fb01b9f5d14a",
+71 -41
View File
@@ -7388,11 +7388,36 @@
"source": "Other answer",
"translated": "기타 답변"
},
{
"id": "native.android.de591e6e2e440ecb",
"source": "Skip",
"translated": "건너뛰기"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "제출 중…"
},
{
"id": "native.android.fa81739f52e7697f",
"source": "Submit",
"translated": "제출"
},
{
"id": "native.android.7da074848cacfe4b",
"source": "Skipped",
"translated": "건너뜀"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "만료됨"
},
{
"id": "native.android.86c8d6113d07ef82",
"source": "Unavailable",
"translated": "사용할 수 없음"
},
{
"id": "native.android.f68cd7a84be4089e",
"source": "Answered",
@@ -7403,36 +7428,6 @@
"source": "Answered elsewhere",
"translated": "다른 곳에서 응답 완료"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "만료됨"
},
{
"id": "native.android.d6d8fc83cec68418",
"source": "Cancelled",
"translated": "취소됨"
},
{
"id": "native.android.73f64d0d54b7f9c0",
"source": "Pending",
"translated": "대기 중"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "제출 중…"
},
{
"id": "native.android.c5e9ee99f9d9c612",
"source": "Expires in ${seconds / 60}m ${seconds % 60}s",
"translated": "${seconds / 60}분 ${seconds % 60}초 후 만료"
},
{
"id": "native.android.2ca602fbdf96f1e7",
"source": "Expires in ${seconds}s",
"translated": "${seconds}초 후 만료"
},
{
"id": "native.android.118e8d239b18895e",
"source": "Model",
@@ -23038,6 +23033,41 @@
"source": "Expand plan",
"translated": "계획 펼치기"
},
{
"id": "native.apple.ce899320a84d4086",
"source": "Answered",
"translated": "응답 완료"
},
{
"id": "native.apple.f019b6a07d5a33d8",
"source": "Answered elsewhere",
"translated": "다른 곳에서 응답 완료"
},
{
"id": "native.apple.ebdedeffad570c43",
"source": "Skipped",
"translated": "건너뜀"
},
{
"id": "native.apple.8dde2eaaf6b0dfa9",
"source": "Expired",
"translated": "만료됨"
},
{
"id": "native.apple.6dff9e9194347f3b",
"source": "Unavailable",
"translated": "사용할 수 없음"
},
{
"id": "native.apple.b34cd890366824f0",
"source": "Pending",
"translated": "대기 중"
},
{
"id": "native.apple.b97da55b7e16e60c",
"source": "Question summary",
"translated": "질문 요약"
},
{
"id": "native.apple.da4494f9e875169d",
"source": "Other answer",
@@ -23054,9 +23084,14 @@
"translated": "선택됨"
},
{
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "제출"
"id": "native.apple.1bbbbfd1ad3842ce",
"source": "Skipping…",
"translated": "건너뛰는 중…"
},
{
"id": "native.apple.14a4bf7b6b7d40dc",
"source": "Skip",
"translated": "건너뛰기"
},
{
"id": "native.apple.6c1adc16e0a846e5",
@@ -23064,14 +23099,9 @@
"translated": "제출 중…"
},
{
"id": "native.apple.698bdb0d6537edb6",
"source": "Expires in \\(seconds / 60)m \\(seconds % 60)s",
"translated": "\\(seconds / 60)분 \\(seconds % 60)초 후 만료"
},
{
"id": "native.apple.30e1c342b3345290",
"source": "Expires in \\(seconds)s",
"translated": "\\(seconds)초 후 만료"
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "제출"
},
{
"id": "native.apple.7cd6fb01b9f5d14a",
+71 -41
View File
@@ -7388,11 +7388,36 @@
"source": "Other answer",
"translated": "Ander antwoord"
},
{
"id": "native.android.de591e6e2e440ecb",
"source": "Skip",
"translated": "Overslaan"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "Bezig met indienen…"
},
{
"id": "native.android.fa81739f52e7697f",
"source": "Submit",
"translated": "Indienen"
},
{
"id": "native.android.7da074848cacfe4b",
"source": "Skipped",
"translated": "Overgeslagen"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "Verlopen"
},
{
"id": "native.android.86c8d6113d07ef82",
"source": "Unavailable",
"translated": "Niet beschikbaar"
},
{
"id": "native.android.f68cd7a84be4089e",
"source": "Answered",
@@ -7403,36 +7428,6 @@
"source": "Answered elsewhere",
"translated": "Elders beantwoord"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "Verlopen"
},
{
"id": "native.android.d6d8fc83cec68418",
"source": "Cancelled",
"translated": "Geannuleerd"
},
{
"id": "native.android.73f64d0d54b7f9c0",
"source": "Pending",
"translated": "In afwachting"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "Bezig met indienen…"
},
{
"id": "native.android.c5e9ee99f9d9c612",
"source": "Expires in ${seconds / 60}m ${seconds % 60}s",
"translated": "Verloopt over ${seconds / 60}m ${seconds % 60}s"
},
{
"id": "native.android.2ca602fbdf96f1e7",
"source": "Expires in ${seconds}s",
"translated": "Verloopt over ${seconds}s"
},
{
"id": "native.android.118e8d239b18895e",
"source": "Model",
@@ -23038,6 +23033,41 @@
"source": "Expand plan",
"translated": "Plan uitklappen"
},
{
"id": "native.apple.ce899320a84d4086",
"source": "Answered",
"translated": "Beantwoord"
},
{
"id": "native.apple.f019b6a07d5a33d8",
"source": "Answered elsewhere",
"translated": "Elders beantwoord"
},
{
"id": "native.apple.ebdedeffad570c43",
"source": "Skipped",
"translated": "Overgeslagen"
},
{
"id": "native.apple.8dde2eaaf6b0dfa9",
"source": "Expired",
"translated": "Verlopen"
},
{
"id": "native.apple.6dff9e9194347f3b",
"source": "Unavailable",
"translated": "Niet beschikbaar"
},
{
"id": "native.apple.b34cd890366824f0",
"source": "Pending",
"translated": "In behandeling"
},
{
"id": "native.apple.b97da55b7e16e60c",
"source": "Question summary",
"translated": "Samenvatting van de vraag"
},
{
"id": "native.apple.da4494f9e875169d",
"source": "Other answer",
@@ -23054,9 +23084,14 @@
"translated": "Geselecteerd"
},
{
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "Indienen"
"id": "native.apple.1bbbbfd1ad3842ce",
"source": "Skipping…",
"translated": "Overslaan…"
},
{
"id": "native.apple.14a4bf7b6b7d40dc",
"source": "Skip",
"translated": "Overslaan"
},
{
"id": "native.apple.6c1adc16e0a846e5",
@@ -23064,14 +23099,9 @@
"translated": "Bezig met indienen…"
},
{
"id": "native.apple.698bdb0d6537edb6",
"source": "Expires in \\(seconds / 60)m \\(seconds % 60)s",
"translated": "Verloopt over \\(seconds / 60)m \\(seconds % 60)s"
},
{
"id": "native.apple.30e1c342b3345290",
"source": "Expires in \\(seconds)s",
"translated": "Verloopt over \\(seconds)s"
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "Indienen"
},
{
"id": "native.apple.7cd6fb01b9f5d14a",
+71 -41
View File
@@ -7388,11 +7388,36 @@
"source": "Other answer",
"translated": "Inna odpowiedź"
},
{
"id": "native.android.de591e6e2e440ecb",
"source": "Skip",
"translated": "Pomiń"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "Przesyłanie…"
},
{
"id": "native.android.fa81739f52e7697f",
"source": "Submit",
"translated": "Prześlij"
},
{
"id": "native.android.7da074848cacfe4b",
"source": "Skipped",
"translated": "Pominięto"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "Wygasło"
},
{
"id": "native.android.86c8d6113d07ef82",
"source": "Unavailable",
"translated": "Niedostępne"
},
{
"id": "native.android.f68cd7a84be4089e",
"source": "Answered",
@@ -7403,36 +7428,6 @@
"source": "Answered elsewhere",
"translated": "Odpowiedziano gdzie indziej"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "Wygasło"
},
{
"id": "native.android.d6d8fc83cec68418",
"source": "Cancelled",
"translated": "Anulowano"
},
{
"id": "native.android.73f64d0d54b7f9c0",
"source": "Pending",
"translated": "Oczekuje"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "Przesyłanie…"
},
{
"id": "native.android.c5e9ee99f9d9c612",
"source": "Expires in ${seconds / 60}m ${seconds % 60}s",
"translated": "Wygasa za ${seconds / 60}m ${seconds % 60}s"
},
{
"id": "native.android.2ca602fbdf96f1e7",
"source": "Expires in ${seconds}s",
"translated": "Wygasa za ${seconds}s"
},
{
"id": "native.android.118e8d239b18895e",
"source": "Model",
@@ -23038,6 +23033,41 @@
"source": "Expand plan",
"translated": "Rozwiń plan"
},
{
"id": "native.apple.ce899320a84d4086",
"source": "Answered",
"translated": "Udzielono odpowiedzi"
},
{
"id": "native.apple.f019b6a07d5a33d8",
"source": "Answered elsewhere",
"translated": "Odpowiedziano gdzie indziej"
},
{
"id": "native.apple.ebdedeffad570c43",
"source": "Skipped",
"translated": "Pominięto"
},
{
"id": "native.apple.8dde2eaaf6b0dfa9",
"source": "Expired",
"translated": "Wygasło"
},
{
"id": "native.apple.6dff9e9194347f3b",
"source": "Unavailable",
"translated": "Niedostępne"
},
{
"id": "native.apple.b34cd890366824f0",
"source": "Pending",
"translated": "Oczekujące"
},
{
"id": "native.apple.b97da55b7e16e60c",
"source": "Question summary",
"translated": "Podsumowanie pytania"
},
{
"id": "native.apple.da4494f9e875169d",
"source": "Other answer",
@@ -23054,9 +23084,14 @@
"translated": "Wybrano"
},
{
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "Prześlij"
"id": "native.apple.1bbbbfd1ad3842ce",
"source": "Skipping…",
"translated": "Pomijanie…"
},
{
"id": "native.apple.14a4bf7b6b7d40dc",
"source": "Skip",
"translated": "Pomiń"
},
{
"id": "native.apple.6c1adc16e0a846e5",
@@ -23064,14 +23099,9 @@
"translated": "Przesyłanie…"
},
{
"id": "native.apple.698bdb0d6537edb6",
"source": "Expires in \\(seconds / 60)m \\(seconds % 60)s",
"translated": "Wygasa za \\(seconds / 60)m \\(seconds % 60)s"
},
{
"id": "native.apple.30e1c342b3345290",
"source": "Expires in \\(seconds)s",
"translated": "Wygasa za \\(seconds)s"
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "Prześlij"
},
{
"id": "native.apple.7cd6fb01b9f5d14a",
+71 -41
View File
@@ -7388,11 +7388,36 @@
"source": "Other answer",
"translated": "Outra resposta"
},
{
"id": "native.android.de591e6e2e440ecb",
"source": "Skip",
"translated": "Pular"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "Enviando…"
},
{
"id": "native.android.fa81739f52e7697f",
"source": "Submit",
"translated": "Enviar"
},
{
"id": "native.android.7da074848cacfe4b",
"source": "Skipped",
"translated": "Pulado"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "Expirado"
},
{
"id": "native.android.86c8d6113d07ef82",
"source": "Unavailable",
"translated": "Indisponível"
},
{
"id": "native.android.f68cd7a84be4089e",
"source": "Answered",
@@ -7403,36 +7428,6 @@
"source": "Answered elsewhere",
"translated": "Respondido em outro lugar"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "Expirado"
},
{
"id": "native.android.d6d8fc83cec68418",
"source": "Cancelled",
"translated": "Cancelado"
},
{
"id": "native.android.73f64d0d54b7f9c0",
"source": "Pending",
"translated": "Pendente"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "Enviando…"
},
{
"id": "native.android.c5e9ee99f9d9c612",
"source": "Expires in ${seconds / 60}m ${seconds % 60}s",
"translated": "Expira em ${seconds / 60}m ${seconds % 60}s"
},
{
"id": "native.android.2ca602fbdf96f1e7",
"source": "Expires in ${seconds}s",
"translated": "Expira em ${seconds}s"
},
{
"id": "native.android.118e8d239b18895e",
"source": "Model",
@@ -23038,6 +23033,41 @@
"source": "Expand plan",
"translated": "Expandir plano"
},
{
"id": "native.apple.ce899320a84d4086",
"source": "Answered",
"translated": "Respondido"
},
{
"id": "native.apple.f019b6a07d5a33d8",
"source": "Answered elsewhere",
"translated": "Respondido em outro lugar"
},
{
"id": "native.apple.ebdedeffad570c43",
"source": "Skipped",
"translated": "Pulado"
},
{
"id": "native.apple.8dde2eaaf6b0dfa9",
"source": "Expired",
"translated": "Expirado"
},
{
"id": "native.apple.6dff9e9194347f3b",
"source": "Unavailable",
"translated": "Indisponível"
},
{
"id": "native.apple.b34cd890366824f0",
"source": "Pending",
"translated": "Pendente"
},
{
"id": "native.apple.b97da55b7e16e60c",
"source": "Question summary",
"translated": "Resumo da pergunta"
},
{
"id": "native.apple.da4494f9e875169d",
"source": "Other answer",
@@ -23054,9 +23084,14 @@
"translated": "Selecionado"
},
{
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "Enviar"
"id": "native.apple.1bbbbfd1ad3842ce",
"source": "Skipping…",
"translated": "Pulando…"
},
{
"id": "native.apple.14a4bf7b6b7d40dc",
"source": "Skip",
"translated": "Pular"
},
{
"id": "native.apple.6c1adc16e0a846e5",
@@ -23064,14 +23099,9 @@
"translated": "Enviando…"
},
{
"id": "native.apple.698bdb0d6537edb6",
"source": "Expires in \\(seconds / 60)m \\(seconds % 60)s",
"translated": "Expira em \\(seconds / 60)m \\(seconds % 60)s"
},
{
"id": "native.apple.30e1c342b3345290",
"source": "Expires in \\(seconds)s",
"translated": "Expira em \\(seconds)s"
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "Enviar"
},
{
"id": "native.apple.7cd6fb01b9f5d14a",
+71 -41
View File
@@ -7388,11 +7388,36 @@
"source": "Other answer",
"translated": "Другой ответ"
},
{
"id": "native.android.de591e6e2e440ecb",
"source": "Skip",
"translated": "Пропустить"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "Отправка…"
},
{
"id": "native.android.fa81739f52e7697f",
"source": "Submit",
"translated": "Отправить"
},
{
"id": "native.android.7da074848cacfe4b",
"source": "Skipped",
"translated": "Пропущено"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "Срок истёк"
},
{
"id": "native.android.86c8d6113d07ef82",
"source": "Unavailable",
"translated": "Недоступно"
},
{
"id": "native.android.f68cd7a84be4089e",
"source": "Answered",
@@ -7403,36 +7428,6 @@
"source": "Answered elsewhere",
"translated": "Отвечено в другом месте"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "Срок истёк"
},
{
"id": "native.android.d6d8fc83cec68418",
"source": "Cancelled",
"translated": "Отменено"
},
{
"id": "native.android.73f64d0d54b7f9c0",
"source": "Pending",
"translated": "Ожидает ответа"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "Отправка…"
},
{
"id": "native.android.c5e9ee99f9d9c612",
"source": "Expires in ${seconds / 60}m ${seconds % 60}s",
"translated": "Истекает через ${seconds / 60} мин ${seconds % 60} с"
},
{
"id": "native.android.2ca602fbdf96f1e7",
"source": "Expires in ${seconds}s",
"translated": "Истекает через ${seconds} с"
},
{
"id": "native.android.118e8d239b18895e",
"source": "Model",
@@ -23038,6 +23033,41 @@
"source": "Expand plan",
"translated": "Развернуть план"
},
{
"id": "native.apple.ce899320a84d4086",
"source": "Answered",
"translated": "Отвечено"
},
{
"id": "native.apple.f019b6a07d5a33d8",
"source": "Answered elsewhere",
"translated": "Отвечено в другом месте"
},
{
"id": "native.apple.ebdedeffad570c43",
"source": "Skipped",
"translated": "Пропущено"
},
{
"id": "native.apple.8dde2eaaf6b0dfa9",
"source": "Expired",
"translated": "Срок истёк"
},
{
"id": "native.apple.6dff9e9194347f3b",
"source": "Unavailable",
"translated": "Недоступно"
},
{
"id": "native.apple.b34cd890366824f0",
"source": "Pending",
"translated": "Ожидание"
},
{
"id": "native.apple.b97da55b7e16e60c",
"source": "Question summary",
"translated": "Краткое описание вопроса"
},
{
"id": "native.apple.da4494f9e875169d",
"source": "Other answer",
@@ -23054,9 +23084,14 @@
"translated": "Выбрано"
},
{
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "Отправить"
"id": "native.apple.1bbbbfd1ad3842ce",
"source": "Skipping…",
"translated": "Пропуск…"
},
{
"id": "native.apple.14a4bf7b6b7d40dc",
"source": "Skip",
"translated": "Пропустить"
},
{
"id": "native.apple.6c1adc16e0a846e5",
@@ -23064,14 +23099,9 @@
"translated": "Отправка…"
},
{
"id": "native.apple.698bdb0d6537edb6",
"source": "Expires in \\(seconds / 60)m \\(seconds % 60)s",
"translated": "Истекает через \\(seconds / 60) мин \\(seconds % 60) с"
},
{
"id": "native.apple.30e1c342b3345290",
"source": "Expires in \\(seconds)s",
"translated": "Истекает через \\(seconds) с"
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "Отправить"
},
{
"id": "native.apple.7cd6fb01b9f5d14a",
+71 -41
View File
@@ -7388,11 +7388,36 @@
"source": "Other answer",
"translated": "Annat svar"
},
{
"id": "native.android.de591e6e2e440ecb",
"source": "Skip",
"translated": "Hoppa över"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "Skickar…"
},
{
"id": "native.android.fa81739f52e7697f",
"source": "Submit",
"translated": "Skicka"
},
{
"id": "native.android.7da074848cacfe4b",
"source": "Skipped",
"translated": "Överhoppad"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "Utgången"
},
{
"id": "native.android.86c8d6113d07ef82",
"source": "Unavailable",
"translated": "Inte tillgänglig"
},
{
"id": "native.android.f68cd7a84be4089e",
"source": "Answered",
@@ -7403,36 +7428,6 @@
"source": "Answered elsewhere",
"translated": "Besvarad på annat håll"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "Utgången"
},
{
"id": "native.android.d6d8fc83cec68418",
"source": "Cancelled",
"translated": "Avbruten"
},
{
"id": "native.android.73f64d0d54b7f9c0",
"source": "Pending",
"translated": "Väntande"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "Skickar…"
},
{
"id": "native.android.c5e9ee99f9d9c612",
"source": "Expires in ${seconds / 60}m ${seconds % 60}s",
"translated": "Upphör om ${seconds / 60}m ${seconds % 60}s"
},
{
"id": "native.android.2ca602fbdf96f1e7",
"source": "Expires in ${seconds}s",
"translated": "Upphör om ${seconds}s"
},
{
"id": "native.android.118e8d239b18895e",
"source": "Model",
@@ -23038,6 +23033,41 @@
"source": "Expand plan",
"translated": "Visa plan"
},
{
"id": "native.apple.ce899320a84d4086",
"source": "Answered",
"translated": "Besvarad"
},
{
"id": "native.apple.f019b6a07d5a33d8",
"source": "Answered elsewhere",
"translated": "Besvarad på annat håll"
},
{
"id": "native.apple.ebdedeffad570c43",
"source": "Skipped",
"translated": "Överhoppad"
},
{
"id": "native.apple.8dde2eaaf6b0dfa9",
"source": "Expired",
"translated": "Utgången"
},
{
"id": "native.apple.6dff9e9194347f3b",
"source": "Unavailable",
"translated": "Inte tillgänglig"
},
{
"id": "native.apple.b34cd890366824f0",
"source": "Pending",
"translated": "Väntande"
},
{
"id": "native.apple.b97da55b7e16e60c",
"source": "Question summary",
"translated": "Frågesammanfattning"
},
{
"id": "native.apple.da4494f9e875169d",
"source": "Other answer",
@@ -23054,9 +23084,14 @@
"translated": "Valt"
},
{
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "Skicka"
"id": "native.apple.1bbbbfd1ad3842ce",
"source": "Skipping…",
"translated": "Hoppar över…"
},
{
"id": "native.apple.14a4bf7b6b7d40dc",
"source": "Skip",
"translated": "Hoppa över"
},
{
"id": "native.apple.6c1adc16e0a846e5",
@@ -23064,14 +23099,9 @@
"translated": "Skickar…"
},
{
"id": "native.apple.698bdb0d6537edb6",
"source": "Expires in \\(seconds / 60)m \\(seconds % 60)s",
"translated": "Upphör om \\(seconds / 60)m \\(seconds % 60)s"
},
{
"id": "native.apple.30e1c342b3345290",
"source": "Expires in \\(seconds)s",
"translated": "Upphör om \\(seconds)s"
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "Skicka"
},
{
"id": "native.apple.7cd6fb01b9f5d14a",
+71 -41
View File
@@ -7388,11 +7388,36 @@
"source": "Other answer",
"translated": "คำตอบอื่น"
},
{
"id": "native.android.de591e6e2e440ecb",
"source": "Skip",
"translated": "ข้าม"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "กำลังส่ง…"
},
{
"id": "native.android.fa81739f52e7697f",
"source": "Submit",
"translated": "ส่ง"
},
{
"id": "native.android.7da074848cacfe4b",
"source": "Skipped",
"translated": "ข้ามแล้ว"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "หมดอายุแล้ว"
},
{
"id": "native.android.86c8d6113d07ef82",
"source": "Unavailable",
"translated": "ไม่พร้อมใช้งาน"
},
{
"id": "native.android.f68cd7a84be4089e",
"source": "Answered",
@@ -7403,36 +7428,6 @@
"source": "Answered elsewhere",
"translated": "ตอบจากที่อื่นแล้ว"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "หมดอายุแล้ว"
},
{
"id": "native.android.d6d8fc83cec68418",
"source": "Cancelled",
"translated": "ยกเลิกแล้ว"
},
{
"id": "native.android.73f64d0d54b7f9c0",
"source": "Pending",
"translated": "รอดำเนินการ"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "กำลังส่ง…"
},
{
"id": "native.android.c5e9ee99f9d9c612",
"source": "Expires in ${seconds / 60}m ${seconds % 60}s",
"translated": "หมดอายุใน ${seconds / 60} นาที ${seconds % 60} วินาที"
},
{
"id": "native.android.2ca602fbdf96f1e7",
"source": "Expires in ${seconds}s",
"translated": "หมดอายุใน ${seconds} วินาที"
},
{
"id": "native.android.118e8d239b18895e",
"source": "Model",
@@ -23038,6 +23033,41 @@
"source": "Expand plan",
"translated": "ขยายแผน"
},
{
"id": "native.apple.ce899320a84d4086",
"source": "Answered",
"translated": "ตอบแล้ว"
},
{
"id": "native.apple.f019b6a07d5a33d8",
"source": "Answered elsewhere",
"translated": "ตอบจากที่อื่นแล้ว"
},
{
"id": "native.apple.ebdedeffad570c43",
"source": "Skipped",
"translated": "ข้ามแล้ว"
},
{
"id": "native.apple.8dde2eaaf6b0dfa9",
"source": "Expired",
"translated": "หมดอายุแล้ว"
},
{
"id": "native.apple.6dff9e9194347f3b",
"source": "Unavailable",
"translated": "ไม่พร้อมใช้งาน"
},
{
"id": "native.apple.b34cd890366824f0",
"source": "Pending",
"translated": "รอดำเนินการ"
},
{
"id": "native.apple.b97da55b7e16e60c",
"source": "Question summary",
"translated": "สรุปคำถาม"
},
{
"id": "native.apple.da4494f9e875169d",
"source": "Other answer",
@@ -23054,9 +23084,14 @@
"translated": "เลือกแล้ว"
},
{
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "ส่ง"
"id": "native.apple.1bbbbfd1ad3842ce",
"source": "Skipping…",
"translated": "กำลังข้าม…"
},
{
"id": "native.apple.14a4bf7b6b7d40dc",
"source": "Skip",
"translated": "ข้าม"
},
{
"id": "native.apple.6c1adc16e0a846e5",
@@ -23064,14 +23099,9 @@
"translated": "กำลังส่ง…"
},
{
"id": "native.apple.698bdb0d6537edb6",
"source": "Expires in \\(seconds / 60)m \\(seconds % 60)s",
"translated": "หมดอายุใน \\(seconds / 60) นาที \\(seconds % 60) วินาที"
},
{
"id": "native.apple.30e1c342b3345290",
"source": "Expires in \\(seconds)s",
"translated": "หมดอายุใน \\(seconds) วินาที"
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "ส่ง"
},
{
"id": "native.apple.7cd6fb01b9f5d14a",
+71 -41
View File
@@ -7388,11 +7388,36 @@
"source": "Other answer",
"translated": "Diğer yanıt"
},
{
"id": "native.android.de591e6e2e440ecb",
"source": "Skip",
"translated": "Atla"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "Gönderiliyor…"
},
{
"id": "native.android.fa81739f52e7697f",
"source": "Submit",
"translated": "Gönder"
},
{
"id": "native.android.7da074848cacfe4b",
"source": "Skipped",
"translated": "Atlandı"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "Süresi doldu"
},
{
"id": "native.android.86c8d6113d07ef82",
"source": "Unavailable",
"translated": "Kullanılamıyor"
},
{
"id": "native.android.f68cd7a84be4089e",
"source": "Answered",
@@ -7403,36 +7428,6 @@
"source": "Answered elsewhere",
"translated": "Başka yerde yanıtlandı"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "Süresi doldu"
},
{
"id": "native.android.d6d8fc83cec68418",
"source": "Cancelled",
"translated": "İptal edildi"
},
{
"id": "native.android.73f64d0d54b7f9c0",
"source": "Pending",
"translated": "Bekliyor"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "Gönderiliyor…"
},
{
"id": "native.android.c5e9ee99f9d9c612",
"source": "Expires in ${seconds / 60}m ${seconds % 60}s",
"translated": "Sona ermesine ${seconds / 60} dk ${seconds % 60} sn"
},
{
"id": "native.android.2ca602fbdf96f1e7",
"source": "Expires in ${seconds}s",
"translated": "Sona ermesine ${seconds} sn"
},
{
"id": "native.android.118e8d239b18895e",
"source": "Model",
@@ -23038,6 +23033,41 @@
"source": "Expand plan",
"translated": "Planı genişlet"
},
{
"id": "native.apple.ce899320a84d4086",
"source": "Answered",
"translated": "Yanıtlandı"
},
{
"id": "native.apple.f019b6a07d5a33d8",
"source": "Answered elsewhere",
"translated": "Başka yerde yanıtlandı"
},
{
"id": "native.apple.ebdedeffad570c43",
"source": "Skipped",
"translated": "Atlandı"
},
{
"id": "native.apple.8dde2eaaf6b0dfa9",
"source": "Expired",
"translated": "Süresi doldu"
},
{
"id": "native.apple.6dff9e9194347f3b",
"source": "Unavailable",
"translated": "Kullanılamıyor"
},
{
"id": "native.apple.b34cd890366824f0",
"source": "Pending",
"translated": "Beklemede"
},
{
"id": "native.apple.b97da55b7e16e60c",
"source": "Question summary",
"translated": "Soru özeti"
},
{
"id": "native.apple.da4494f9e875169d",
"source": "Other answer",
@@ -23054,9 +23084,14 @@
"translated": "Seçildi"
},
{
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "Gönder"
"id": "native.apple.1bbbbfd1ad3842ce",
"source": "Skipping…",
"translated": "Atlanıyor…"
},
{
"id": "native.apple.14a4bf7b6b7d40dc",
"source": "Skip",
"translated": "Atla"
},
{
"id": "native.apple.6c1adc16e0a846e5",
@@ -23064,14 +23099,9 @@
"translated": "Gönderiliyor…"
},
{
"id": "native.apple.698bdb0d6537edb6",
"source": "Expires in \\(seconds / 60)m \\(seconds % 60)s",
"translated": "Sona ermesine \\(seconds / 60) dk \\(seconds % 60) sn"
},
{
"id": "native.apple.30e1c342b3345290",
"source": "Expires in \\(seconds)s",
"translated": "Sona ermesine \\(seconds) sn"
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "Gönder"
},
{
"id": "native.apple.7cd6fb01b9f5d14a",
+71 -41
View File
@@ -7388,11 +7388,36 @@
"source": "Other answer",
"translated": "Інша відповідь"
},
{
"id": "native.android.de591e6e2e440ecb",
"source": "Skip",
"translated": "Пропустити"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "Надсилання…"
},
{
"id": "native.android.fa81739f52e7697f",
"source": "Submit",
"translated": "Надіслати"
},
{
"id": "native.android.7da074848cacfe4b",
"source": "Skipped",
"translated": "Пропущено"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "Термін дії минув"
},
{
"id": "native.android.86c8d6113d07ef82",
"source": "Unavailable",
"translated": "Недоступно"
},
{
"id": "native.android.f68cd7a84be4089e",
"source": "Answered",
@@ -7403,36 +7428,6 @@
"source": "Answered elsewhere",
"translated": "Відповідь надано в іншому місці"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "Термін дії минув"
},
{
"id": "native.android.d6d8fc83cec68418",
"source": "Cancelled",
"translated": "Скасовано"
},
{
"id": "native.android.73f64d0d54b7f9c0",
"source": "Pending",
"translated": "Очікує"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "Надсилання…"
},
{
"id": "native.android.c5e9ee99f9d9c612",
"source": "Expires in ${seconds / 60}m ${seconds % 60}s",
"translated": "Залишилося ${seconds / 60} хв ${seconds % 60} с"
},
{
"id": "native.android.2ca602fbdf96f1e7",
"source": "Expires in ${seconds}s",
"translated": "Залишилося ${seconds} с"
},
{
"id": "native.android.118e8d239b18895e",
"source": "Model",
@@ -23038,6 +23033,41 @@
"source": "Expand plan",
"translated": "Розгорнути план"
},
{
"id": "native.apple.ce899320a84d4086",
"source": "Answered",
"translated": "Відповідь надано"
},
{
"id": "native.apple.f019b6a07d5a33d8",
"source": "Answered elsewhere",
"translated": "Відповідь надано в іншому місці"
},
{
"id": "native.apple.ebdedeffad570c43",
"source": "Skipped",
"translated": "Пропущено"
},
{
"id": "native.apple.8dde2eaaf6b0dfa9",
"source": "Expired",
"translated": "Термін дії минув"
},
{
"id": "native.apple.6dff9e9194347f3b",
"source": "Unavailable",
"translated": "Недоступно"
},
{
"id": "native.apple.b34cd890366824f0",
"source": "Pending",
"translated": "Очікує"
},
{
"id": "native.apple.b97da55b7e16e60c",
"source": "Question summary",
"translated": "Підсумок запитання"
},
{
"id": "native.apple.da4494f9e875169d",
"source": "Other answer",
@@ -23054,9 +23084,14 @@
"translated": "Вибрано"
},
{
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "Надіслати"
"id": "native.apple.1bbbbfd1ad3842ce",
"source": "Skipping…",
"translated": "Пропуск…"
},
{
"id": "native.apple.14a4bf7b6b7d40dc",
"source": "Skip",
"translated": "Пропустити"
},
{
"id": "native.apple.6c1adc16e0a846e5",
@@ -23064,14 +23099,9 @@
"translated": "Надсилання…"
},
{
"id": "native.apple.698bdb0d6537edb6",
"source": "Expires in \\(seconds / 60)m \\(seconds % 60)s",
"translated": "Залишилося \\(seconds / 60) хв \\(seconds % 60) с"
},
{
"id": "native.apple.30e1c342b3345290",
"source": "Expires in \\(seconds)s",
"translated": "Залишилося \\(seconds) с"
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "Надіслати"
},
{
"id": "native.apple.7cd6fb01b9f5d14a",
+71 -41
View File
@@ -7388,11 +7388,36 @@
"source": "Other answer",
"translated": "Câu trả lời khác"
},
{
"id": "native.android.de591e6e2e440ecb",
"source": "Skip",
"translated": "Bỏ qua"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "Đang gửi…"
},
{
"id": "native.android.fa81739f52e7697f",
"source": "Submit",
"translated": "Gửi"
},
{
"id": "native.android.7da074848cacfe4b",
"source": "Skipped",
"translated": "Đã bỏ qua"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "Đã hết hạn"
},
{
"id": "native.android.86c8d6113d07ef82",
"source": "Unavailable",
"translated": "Không khả dụng"
},
{
"id": "native.android.f68cd7a84be4089e",
"source": "Answered",
@@ -7403,36 +7428,6 @@
"source": "Answered elsewhere",
"translated": "Đã trả lời ở nơi khác"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "Đã hết hạn"
},
{
"id": "native.android.d6d8fc83cec68418",
"source": "Cancelled",
"translated": "Đã hủy"
},
{
"id": "native.android.73f64d0d54b7f9c0",
"source": "Pending",
"translated": "Đang chờ"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "Đang gửi…"
},
{
"id": "native.android.c5e9ee99f9d9c612",
"source": "Expires in ${seconds / 60}m ${seconds % 60}s",
"translated": "Hết hạn sau ${seconds / 60}m ${seconds % 60}s"
},
{
"id": "native.android.2ca602fbdf96f1e7",
"source": "Expires in ${seconds}s",
"translated": "Hết hạn sau ${seconds}s"
},
{
"id": "native.android.118e8d239b18895e",
"source": "Model",
@@ -23038,6 +23033,41 @@
"source": "Expand plan",
"translated": "Mở rộng kế hoạch"
},
{
"id": "native.apple.ce899320a84d4086",
"source": "Answered",
"translated": "Đã trả lời"
},
{
"id": "native.apple.f019b6a07d5a33d8",
"source": "Answered elsewhere",
"translated": "Đã trả lời ở nơi khác"
},
{
"id": "native.apple.ebdedeffad570c43",
"source": "Skipped",
"translated": "Đã bỏ qua"
},
{
"id": "native.apple.8dde2eaaf6b0dfa9",
"source": "Expired",
"translated": "Đã hết hạn"
},
{
"id": "native.apple.6dff9e9194347f3b",
"source": "Unavailable",
"translated": "Không khả dụng"
},
{
"id": "native.apple.b34cd890366824f0",
"source": "Pending",
"translated": "Đang chờ"
},
{
"id": "native.apple.b97da55b7e16e60c",
"source": "Question summary",
"translated": "Tóm tắt câu hỏi"
},
{
"id": "native.apple.da4494f9e875169d",
"source": "Other answer",
@@ -23054,9 +23084,14 @@
"translated": "Đã chọn"
},
{
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "Gửi"
"id": "native.apple.1bbbbfd1ad3842ce",
"source": "Skipping…",
"translated": "Đang bỏ qua…"
},
{
"id": "native.apple.14a4bf7b6b7d40dc",
"source": "Skip",
"translated": "Bỏ qua"
},
{
"id": "native.apple.6c1adc16e0a846e5",
@@ -23064,14 +23099,9 @@
"translated": "Đang gửi…"
},
{
"id": "native.apple.698bdb0d6537edb6",
"source": "Expires in \\(seconds / 60)m \\(seconds % 60)s",
"translated": "Hết hạn sau \\(seconds / 60)m \\(seconds % 60)s"
},
{
"id": "native.apple.30e1c342b3345290",
"source": "Expires in \\(seconds)s",
"translated": "Hết hạn sau \\(seconds)s"
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "Gửi"
},
{
"id": "native.apple.7cd6fb01b9f5d14a",
+71 -41
View File
@@ -7388,11 +7388,36 @@
"source": "Other answer",
"translated": "其他答案"
},
{
"id": "native.android.de591e6e2e440ecb",
"source": "Skip",
"translated": "跳过"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "正在提交…"
},
{
"id": "native.android.fa81739f52e7697f",
"source": "Submit",
"translated": "提交"
},
{
"id": "native.android.7da074848cacfe4b",
"source": "Skipped",
"translated": "已跳过"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "已过期"
},
{
"id": "native.android.86c8d6113d07ef82",
"source": "Unavailable",
"translated": "不可用"
},
{
"id": "native.android.f68cd7a84be4089e",
"source": "Answered",
@@ -7403,36 +7428,6 @@
"source": "Answered elsewhere",
"translated": "已在其他地方回答"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "已过期"
},
{
"id": "native.android.d6d8fc83cec68418",
"source": "Cancelled",
"translated": "已取消"
},
{
"id": "native.android.73f64d0d54b7f9c0",
"source": "Pending",
"translated": "待处理"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "正在提交…"
},
{
"id": "native.android.c5e9ee99f9d9c612",
"source": "Expires in ${seconds / 60}m ${seconds % 60}s",
"translated": "${seconds / 60}分${seconds % 60}秒后过期"
},
{
"id": "native.android.2ca602fbdf96f1e7",
"source": "Expires in ${seconds}s",
"translated": "${seconds}秒后过期"
},
{
"id": "native.android.118e8d239b18895e",
"source": "Model",
@@ -23038,6 +23033,41 @@
"source": "Expand plan",
"translated": "展开计划"
},
{
"id": "native.apple.ce899320a84d4086",
"source": "Answered",
"translated": "已回答"
},
{
"id": "native.apple.f019b6a07d5a33d8",
"source": "Answered elsewhere",
"translated": "已在其他地方回答"
},
{
"id": "native.apple.ebdedeffad570c43",
"source": "Skipped",
"translated": "已跳过"
},
{
"id": "native.apple.8dde2eaaf6b0dfa9",
"source": "Expired",
"translated": "已过期"
},
{
"id": "native.apple.6dff9e9194347f3b",
"source": "Unavailable",
"translated": "不可用"
},
{
"id": "native.apple.b34cd890366824f0",
"source": "Pending",
"translated": "待处理"
},
{
"id": "native.apple.b97da55b7e16e60c",
"source": "Question summary",
"translated": "问题摘要"
},
{
"id": "native.apple.da4494f9e875169d",
"source": "Other answer",
@@ -23054,9 +23084,14 @@
"translated": "已选择"
},
{
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "提交"
"id": "native.apple.1bbbbfd1ad3842ce",
"source": "Skipping…",
"translated": "正在跳过…"
},
{
"id": "native.apple.14a4bf7b6b7d40dc",
"source": "Skip",
"translated": "跳过"
},
{
"id": "native.apple.6c1adc16e0a846e5",
@@ -23064,14 +23099,9 @@
"translated": "正在提交…"
},
{
"id": "native.apple.698bdb0d6537edb6",
"source": "Expires in \\(seconds / 60)m \\(seconds % 60)s",
"translated": "\\(seconds / 60)分\\(seconds % 60)秒后过期"
},
{
"id": "native.apple.30e1c342b3345290",
"source": "Expires in \\(seconds)s",
"translated": "\\(seconds)秒后过期"
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "提交"
},
{
"id": "native.apple.7cd6fb01b9f5d14a",
+71 -41
View File
@@ -7388,11 +7388,36 @@
"source": "Other answer",
"translated": "其他答案"
},
{
"id": "native.android.de591e6e2e440ecb",
"source": "Skip",
"translated": "略過"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "正在提交…"
},
{
"id": "native.android.fa81739f52e7697f",
"source": "Submit",
"translated": "提交"
},
{
"id": "native.android.7da074848cacfe4b",
"source": "Skipped",
"translated": "已略過"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "已過期"
},
{
"id": "native.android.86c8d6113d07ef82",
"source": "Unavailable",
"translated": "無法使用"
},
{
"id": "native.android.f68cd7a84be4089e",
"source": "Answered",
@@ -7403,36 +7428,6 @@
"source": "Answered elsewhere",
"translated": "已在其他地方回答"
},
{
"id": "native.android.1a0e6b33059a63aa",
"source": "Expired",
"translated": "已過期"
},
{
"id": "native.android.d6d8fc83cec68418",
"source": "Cancelled",
"translated": "已取消"
},
{
"id": "native.android.73f64d0d54b7f9c0",
"source": "Pending",
"translated": "待處理"
},
{
"id": "native.android.92dfa7979d04989c",
"source": "Submitting…",
"translated": "正在提交…"
},
{
"id": "native.android.c5e9ee99f9d9c612",
"source": "Expires in ${seconds / 60}m ${seconds % 60}s",
"translated": "將於 ${seconds / 60}m ${seconds % 60}s 後過期"
},
{
"id": "native.android.2ca602fbdf96f1e7",
"source": "Expires in ${seconds}s",
"translated": "將於 ${seconds}s 後過期"
},
{
"id": "native.android.118e8d239b18895e",
"source": "Model",
@@ -23038,6 +23033,41 @@
"source": "Expand plan",
"translated": "展開計畫"
},
{
"id": "native.apple.ce899320a84d4086",
"source": "Answered",
"translated": "已回答"
},
{
"id": "native.apple.f019b6a07d5a33d8",
"source": "Answered elsewhere",
"translated": "已在其他地方回答"
},
{
"id": "native.apple.ebdedeffad570c43",
"source": "Skipped",
"translated": "已略過"
},
{
"id": "native.apple.8dde2eaaf6b0dfa9",
"source": "Expired",
"translated": "已過期"
},
{
"id": "native.apple.6dff9e9194347f3b",
"source": "Unavailable",
"translated": "無法使用"
},
{
"id": "native.apple.b34cd890366824f0",
"source": "Pending",
"translated": "待處理"
},
{
"id": "native.apple.b97da55b7e16e60c",
"source": "Question summary",
"translated": "問題摘要"
},
{
"id": "native.apple.da4494f9e875169d",
"source": "Other answer",
@@ -23054,9 +23084,14 @@
"translated": "已選取"
},
{
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "提交"
"id": "native.apple.1bbbbfd1ad3842ce",
"source": "Skipping…",
"translated": "正在略過…"
},
{
"id": "native.apple.14a4bf7b6b7d40dc",
"source": "Skip",
"translated": "略過"
},
{
"id": "native.apple.6c1adc16e0a846e5",
@@ -23064,14 +23099,9 @@
"translated": "正在提交…"
},
{
"id": "native.apple.698bdb0d6537edb6",
"source": "Expires in \\(seconds / 60)m \\(seconds % 60)s",
"translated": "將於 \\(seconds / 60)m \\(seconds % 60)s 後過期"
},
{
"id": "native.apple.30e1c342b3345290",
"source": "Expires in \\(seconds)s",
"translated": "將於 \\(seconds)s 後過期"
"id": "native.apple.e3095b651fc46b52",
"source": "Submit",
"translated": "提交"
},
{
"id": "native.apple.7cd6fb01b9f5d14a",
@@ -1667,6 +1667,10 @@ class MainViewModel private constructor(
ensureRuntime().resolveChatQuestion(id, answers)
}
fun skipChatQuestion(id: String) {
ensureRuntime().skipChatQuestion(id)
}
suspend fun listBackgroundTasks(agentId: String): List<BackgroundTask> = ensureRuntime().listBackgroundTasks(agentId)
suspend fun getBackgroundTask(taskId: String): BackgroundTask = ensureRuntime().getBackgroundTask(taskId)
@@ -2434,6 +2434,8 @@ class NodeRuntime private constructor(
answers: Map<String, List<String>>,
) = chat.resolveQuestion(id, answers)
fun skipChatQuestion(id: String) = chat.skipQuestion(id)
private fun applyScreenshotFixture() {
check(BuildConfig.DEBUG) { "Android screenshot fixtures require a debug build" }
_serverName.value = "OpenClaw Gateway"
@@ -6,6 +6,9 @@ import ai.openclaw.app.gateway.GatewayRequestNotEnqueued
import ai.openclaw.app.gateway.GatewayRequestOutcomeUnknown
import ai.openclaw.app.gateway.GatewayRequestRejected
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.gateway.QuestionAnswers
import ai.openclaw.app.gateway.QuestionAnswersAnswersValue
import ai.openclaw.app.gateway.QuestionGetResult
import ai.openclaw.app.gateway.QuestionListResult
import ai.openclaw.app.gateway.QuestionRecord
import ai.openclaw.app.gateway.parseChatSendAck
@@ -37,6 +40,7 @@ import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonObject
import java.util.Base64
import java.util.Locale
@@ -47,6 +51,7 @@ import java.util.concurrent.atomic.AtomicLong
// Bounds one-shot search list fetches like the primary session list.
internal const val SESSION_LIST_FETCH_LIMIT = 200
private val QUESTION_REFRESH_RETRY_DELAYS_MS = longArrayOf(1_000L, 2_000L, 4_000L)
internal fun chatOutboxQueueFailureText(): NativeText = ChatController.queueFailureText()
@@ -2100,40 +2105,70 @@ class ChatController internal constructor(
fun resolveQuestion(
id: String,
answers: Map<String, List<String>>,
) = resolveQuestion(id = id, answers = answers, cancel = false)
fun skipQuestion(id: String) = resolveQuestion(id = id, answers = null, cancel = true)
private fun resolveQuestion(
id: String,
answers: Map<String, List<String>>?,
cancel: Boolean,
) {
val gatewayId = currentCacheScope()?.gatewayId
var claimed = false
updateQuestions { prompts ->
prompts.map { prompt ->
if (prompt.record.id == id) prompt.copy(submitting = true, errorText = null) else prompt
if (prompt.record.id == id && prompt.status() == ChatQuestionStatus.Pending) {
claimed = true
prompt.copy(submitting = true, skipping = cancel, errorText = null)
} else {
prompt
}
}
}
// updateQuestions owns the question-state lock, so competing answer/skip callbacks
// observe the first claim as Submitting and cannot launch a second mutation.
if (!claimed) return
scope.launch {
try {
val params =
buildJsonObject {
put("id", JsonPrimitive(id))
put(
"answers",
buildJsonObject {
put(
"answers",
buildJsonObject {
answers.forEach { (questionId, values) ->
put(questionId, buildJsonObject { put("answers", JsonArray(values.map(::JsonPrimitive))) })
}
},
)
},
)
if (cancel) {
put("cancel", JsonPrimitive(true))
} else {
put(
"answers",
buildJsonObject {
put(
"answers",
buildJsonObject {
answers.orEmpty().forEach { (questionId, values) ->
put(questionId, buildJsonObject { put("answers", JsonArray(values.map(::JsonPrimitive))) })
}
},
)
},
)
}
}
requestGatewayBound(gatewayId, "question.resolve", params.toString())
updateQuestions { prompts ->
prompts.map { prompt ->
if (prompt.record.id == id) {
prompt.copy(
record = prompt.record.copy(status = "answered"),
record =
prompt.record.copy(
status = if (cancel) "cancelled" else "answered",
answers =
answers?.let { values ->
QuestionAnswers(values.mapValues { QuestionAnswersAnswersValue(it.value) })
},
),
submitting = false,
answeredLocally = true,
skipping = false,
answeredLocally = !cancel,
recoveryUnavailable = false,
terminalObservedAtMs = prompt.terminalObservedAtMs ?: System.currentTimeMillis(),
)
} else {
@@ -2144,7 +2179,11 @@ class ChatController internal constructor(
} catch (error: Throwable) {
updateQuestions { prompts ->
prompts.map { prompt ->
if (prompt.record.id == id) prompt.copy(submitting = false, errorText = error.message ?: "Question failed") else prompt
if (prompt.record.id == id) {
prompt.copy(submitting = false, skipping = false, errorText = error.message ?: "Question failed")
} else {
prompt
}
}
}
}
@@ -2153,69 +2192,163 @@ class ChatController internal constructor(
private fun refreshQuestions() {
val gatewayScope = currentCacheScope()
val (refreshGeneration, stateRevision) =
val refreshGeneration =
synchronized(questionStateLock) {
questionRefreshGeneration += 1
questionRefreshGeneration to questionStateRevision
questionRefreshGeneration
}
scope.launch {
try {
val response = requestGatewayBound(gatewayScope?.gatewayId, "question.list", "{}")
if (gatewayScope != currentCacheScope()) return@launch
val records = json.decodeFromString<QuestionListResult>(response).questions
synchronized(questionStateLock) {
if (refreshGeneration != questionRefreshGeneration || stateRevision != questionStateRevision) {
return@synchronized
}
val current = _questions.value
val existing = current.associateBy { it.record.id }
val nowMs = System.currentTimeMillis()
val listedIds = records.mapTo(mutableSetOf()) { it.id }
val retainedTerminal = current.filter { it.record.id !in listedIds && it.shouldRetainAfterList(nowMs) }
val next =
records.map { record ->
existing[record.id]?.let { prompt ->
prompt.copy(
record = record,
submitting = prompt.submitting && record.status == "pending",
terminalObservedAtMs =
if (record.status == "pending" && nowMs < record.expiresAtMs) {
null
} else {
prompt.terminalObservedAtMs ?: nowMs
},
)
} ?: ChatQuestionPrompt(
record = record,
terminalObservedAtMs = nowMs.takeIf { record.status != "pending" },
)
} + retainedTerminal
if (next != current) {
_questions.value = next
questionStateRevision += 1
}
syncQuestionEvictionsLocked()
var retryIndex = 0
var retryStateRevision: Long? = null
while (true) {
val expectedStateRevision = questionRefreshCurrentRevision(refreshGeneration, gatewayScope) ?: return@launch
if (retryStateRevision != expectedStateRevision) {
retryStateRevision = expectedStateRevision
retryIndex = 0
}
} catch (_: Throwable) {
// Older gateways and tokens without operator.questions keep ordinary chat usable.
val complete =
runCatching {
refreshQuestions(refreshGeneration, expectedStateRevision, gatewayScope)
}.getOrDefault(false)
if (complete) return@launch
val currentStateRevision = questionRefreshCurrentRevision(refreshGeneration, gatewayScope) ?: return@launch
if (currentStateRevision != expectedStateRevision) {
// A local mutation invalidates the whole lookup snapshot, not one transport attempt.
// Restart the bounded budget so the last attempt cannot strand another question.
retryStateRevision = currentStateRevision
retryIndex = 0
}
val retryDelayMs = QUESTION_REFRESH_RETRY_DELAYS_MS.getOrNull(retryIndex) ?: return@launch
retryIndex += 1
delay(retryDelayMs)
}
}
}
private suspend fun refreshQuestions(
refreshGeneration: Long,
stateRevision: Long,
gatewayScope: ChatCacheScope?,
): Boolean {
val response = requestGatewayBound(gatewayScope?.gatewayId, "question.list", "{}")
if (!questionRefreshIsCurrent(refreshGeneration, stateRevision, gatewayScope)) return false
val listedRecords = json.decodeFromString<QuestionListResult>(response).questions
val listedIds = listedRecords.mapTo(mutableSetOf()) { it.id }
val missingPendingRecords =
synchronized(questionStateLock) {
if (!questionRefreshIsCurrentLocked(refreshGeneration, stateRevision)) return false
_questions.value
.filter { prompt ->
prompt.record.id !in listedIds &&
prompt.record.status == "pending" &&
!prompt.recoveryUnavailable
}.map { it.record }
}
val fallbackRecords = mutableListOf<QuestionRecord>()
val unresolvedIds = mutableSetOf<String>()
val unavailableIds = mutableSetOf<String>()
for (record in missingPendingRecords) {
val params = buildJsonObject { put("id", JsonPrimitive(record.id)) }
try {
val fallback = requestGatewayBound(gatewayScope?.gatewayId, "question.get", params.toString())
fallbackRecords += json.decodeFromString<QuestionGetResult>(fallback).question
} catch (err: CancellationException) {
throw err
} catch (err: GatewayRequestRejected) {
if (err.gatewayError.details?.reason == "QUESTION_NOT_FOUND") {
// The terminal tombstone has aged out, so the question is no longer actionable,
// but its answered/cancelled/expired outcome cannot be reconstructed.
unavailableIds += record.id
} else {
unresolvedIds += record.id
}
} catch (_: Throwable) {
unresolvedIds += record.id
}
}
if (!questionRefreshIsCurrent(refreshGeneration, stateRevision, gatewayScope)) return false
val records = listedRecords + fallbackRecords.filter { it.id !in listedIds }
return synchronized(questionStateLock) {
if (!questionRefreshIsCurrentLocked(refreshGeneration, stateRevision)) return@synchronized false
val current = _questions.value
val existing = current.associateBy { it.record.id }
val nowMs = System.currentTimeMillis()
val refreshedIds = records.mapTo(mutableSetOf()) { it.id }
val retainedCandidates =
current
.filter { prompt ->
val status = prompt.status(nowMs)
prompt.record.id !in refreshedIds &&
(
prompt.record.id in unresolvedIds ||
prompt.record.id in unavailableIds ||
(
status != ChatQuestionStatus.Pending &&
status != ChatQuestionStatus.Submitting
)
)
}
val retainedPrompts =
retainedCandidates.map { prompt ->
if (prompt.record.id in unavailableIds) {
prompt.copy(
submitting = false,
skipping = false,
terminalObservedAtMs = prompt.terminalObservedAtMs ?: nowMs,
recoveryUnavailable = true,
)
} else {
prompt
}
}
val next =
records.map { record ->
existing[record.id]?.let { prompt ->
mergeQuestionPrompt(prompt, record, nowMs)
} ?: ChatQuestionPrompt(
record = record,
terminalObservedAtMs = nowMs.takeIf { record.status != "pending" || nowMs >= record.expiresAtMs },
)
} + retainedPrompts
if (next != current) {
_questions.value = next
questionStateRevision += 1
}
syncQuestionEvictionsLocked()
unresolvedIds.isEmpty()
}
}
private fun questionRefreshCurrentRevision(
refreshGeneration: Long,
gatewayScope: ChatCacheScope?,
): Long? {
if (gatewayScope != currentCacheScope()) return null
return synchronized(questionStateLock) {
questionStateRevision.takeIf { refreshGeneration == questionRefreshGeneration }
}
}
private fun questionRefreshIsCurrent(
refreshGeneration: Long,
stateRevision: Long,
gatewayScope: ChatCacheScope?,
): Boolean =
gatewayScope == currentCacheScope() &&
synchronized(questionStateLock) { questionRefreshIsCurrentLocked(refreshGeneration, stateRevision) }
private fun questionRefreshIsCurrentLocked(
refreshGeneration: Long,
stateRevision: Long,
): Boolean = refreshGeneration == questionRefreshGeneration && stateRevision == questionStateRevision
private fun handleQuestionRequested(payloadJson: String) {
val record = runCatching { json.decodeFromString<QuestionRecord>(payloadJson) }.getOrNull() ?: return
updateQuestions { prompts ->
if (prompts.any { it.record.id == record.id }) {
prompts.map { prompt ->
if (prompt.record.id == record.id) {
prompt.copy(
record = record,
submitting = prompt.submitting && record.status == "pending",
// A replayed pending event must drop stale terminal retention or
// the zero-delay eviction loop respins without ever removing it.
terminalObservedAtMs =
if (record.status == "pending") null else prompt.terminalObservedAtMs,
)
mergeQuestionPrompt(prompt, record, System.currentTimeMillis())
} else {
prompt
}
@@ -2227,17 +2360,43 @@ class ChatController internal constructor(
refreshQuestions()
}
private fun mergeQuestionPrompt(
prompt: ChatQuestionPrompt,
record: QuestionRecord,
nowMs: Long,
): ChatQuestionPrompt {
// Gateway terminal state is monotonic. A delayed requested/list replay must not
// make an already resolved question actionable again.
if ((prompt.record.status != "pending" || prompt.recoveryUnavailable) && record.status == "pending") return prompt
return prompt.copy(
record = record.copy(answers = record.answers ?: prompt.record.answers),
submitting = prompt.submitting && record.status == "pending",
skipping = prompt.skipping && record.status == "pending",
answeredLocally = prompt.answeredLocally && record.status == "answered",
recoveryUnavailable = false,
terminalObservedAtMs =
if (record.status == "pending" && nowMs < record.expiresAtMs) {
null
} else {
prompt.terminalObservedAtMs ?: nowMs
},
)
}
private fun handleQuestionResolved(payloadJson: String) {
val payload = runCatching { json.parseToJsonElement(payloadJson).jsonObject }.getOrNull() ?: return
val id = payload["id"].asStringOrNull() ?: return
val status = payload["status"].asStringOrNull() ?: return
val answers = payload["answers"]?.let { runCatching { json.decodeFromJsonElement<QuestionAnswers>(it) }.getOrNull() }
val nowMs = System.currentTimeMillis()
updateQuestions { prompts ->
prompts.map { prompt ->
if (prompt.record.id == id) {
prompt.copy(
record = prompt.record.copy(status = status),
record = prompt.record.copy(status = status, answers = answers ?: prompt.record.answers),
submitting = false,
skipping = false,
recoveryUnavailable = false,
terminalObservedAtMs = prompt.terminalObservedAtMs ?: nowMs,
)
} else {
@@ -2274,49 +2433,33 @@ class ChatController internal constructor(
if (questionEvictionJobs.containsKey(prompt.record.id)) continue
val id = prompt.record.id
val observedAt = prompt.terminalObservedAtMs
val remainingMs =
if (observedAt == null && prompt.record.status == "pending" && prompt.record.expiresAtMs != Long.MAX_VALUE) {
(prompt.record.expiresAtMs - nowMs).coerceAtLeast(0)
} else if (observedAt != null) {
(QUESTION_TERMINAL_RETENTION_MS - (nowMs - observedAt)).coerceAtLeast(0)
} else {
continue
}
if (observedAt != null || prompt.record.status != "pending" || prompt.record.expiresAtMs == Long.MAX_VALUE) continue
val remainingMs = (prompt.record.expiresAtMs - nowMs).coerceAtLeast(0)
val job =
scope.launch(start = CoroutineStart.LAZY) {
delay(remainingMs)
var shouldRefresh = false
synchronized(questionStateLock) {
questionEvictionJobs.remove(id)
val current = _questions.value
if (observedAt == null) {
val next =
current.map {
if (it.record.id == id && it.record.status == "pending" && it.terminalObservedAtMs == null) {
it.copy(terminalObservedAtMs = System.currentTimeMillis())
} else {
it
}
}
if (next != current) {
_questions.value = next
questionStateRevision += 1
}
syncQuestionEvictionsLocked()
return@synchronized
}
val next =
current.filterNot {
it.record.id == id &&
it.terminalObservedAtMs == observedAt &&
(it.status() == ChatQuestionStatus.Expired || it.record.status != "pending")
current.map {
if (it.record.id == id && it.record.status == "pending" && it.terminalObservedAtMs == null) {
it.copy(terminalObservedAtMs = System.currentTimeMillis())
} else {
it
}
}
if (next != current) {
_questions.value = next
questionStateRevision += 1
} else {
syncQuestionEvictionsLocked()
shouldRefresh = true
}
syncQuestionEvictionsLocked()
}
// The local deadline is only a presentation fallback. Reconcile outside
// the state lock in case another surface supplied the terminal outcome.
if (shouldRefresh) refreshQuestions()
}
questionEvictionJobs[id] = QuestionEvictionJob(job, observedAt)
job.start()
@@ -3,8 +3,6 @@ package ai.openclaw.app.chat
import ai.openclaw.app.gateway.Question
import ai.openclaw.app.gateway.QuestionRecord
internal const val QUESTION_TERMINAL_RETENTION_MS = 15_000L
enum class ChatQuestionStatus {
Pending,
Submitting,
@@ -12,29 +10,34 @@ enum class ChatQuestionStatus {
AnsweredElsewhere,
Expired,
Cancelled,
Unavailable,
}
data class ChatQuestionPrompt(
val record: QuestionRecord,
val submitting: Boolean = false,
val skipping: Boolean = false,
val answeredLocally: Boolean = false,
val errorText: String? = null,
val terminalObservedAtMs: Long? = null,
val recoveryUnavailable: Boolean = false,
) {
fun status(nowMs: Long = System.currentTimeMillis()): ChatQuestionStatus =
when (record.status) {
"answered" -> if (answeredLocally) ChatQuestionStatus.Answered else ChatQuestionStatus.AnsweredElsewhere
"cancelled" -> ChatQuestionStatus.Cancelled
"expired" -> ChatQuestionStatus.Expired
else ->
when {
nowMs >= record.expiresAtMs -> ChatQuestionStatus.Expired
submitting -> ChatQuestionStatus.Submitting
else -> ChatQuestionStatus.Pending
}
if (recoveryUnavailable) {
ChatQuestionStatus.Unavailable
} else {
when (record.status) {
"answered" -> if (answeredLocally) ChatQuestionStatus.Answered else ChatQuestionStatus.AnsweredElsewhere
"cancelled" -> ChatQuestionStatus.Cancelled
"expired" -> ChatQuestionStatus.Expired
else ->
when {
nowMs >= record.expiresAtMs -> ChatQuestionStatus.Expired
submitting -> ChatQuestionStatus.Submitting
else -> ChatQuestionStatus.Pending
}
}
}
fun shouldRetainAfterList(nowMs: Long): Boolean = terminalObservedAtMs?.let { nowMs - it < QUESTION_TERMINAL_RETENTION_MS } == true
}
data class ChatQuestionDraft(
@@ -108,6 +108,11 @@ data class QuestionRecord(
val resolvedBy: String? = null,
)
@Serializable
data class QuestionGetResult(
val question: QuestionRecord,
)
@Serializable
data class QuestionListResult(
val questions: List<QuestionRecord>,
@@ -231,7 +231,6 @@ internal val nativeStringResourceIds: Map<String, Int> =
"Camera enabled" to R.string.native_29d5c526bab8bf50,
"Cancel" to R.string.cancel,
"Cancel voice note" to R.string.native_fae8fa3bc050ab7e,
"Cancelled" to R.string.native_d353a99eb4556847,
"Cannot reach gateway" to R.string.native_d8f247890b029a85,
"Canvas" to R.string.native_3824a9f4dafe92c6,
"Canvas Status" to R.string.native_584162ac73e99ee6,
@@ -485,8 +484,6 @@ internal val nativeStringResourceIds: Map<String, Int> =
"Expand plan checklist" to R.string.native_d30f33731adb6b40,
"Expired" to R.string.native_424a2551d356754c,
"Expires \${duration}" to R.string.native_48377001408f13be,
"Expires in \${seconds / 60}m \${seconds % 60}s" to R.string.native_9dca3c2b01b40c0b,
"Expires in \${seconds}s" to R.string.native_e9e3a4b23345cb57,
"Expression" to R.string.native_c67415bcff328a59,
"Extra" to R.string.native_2e4aaf05df1cbfb3,
"Failed" to R.string.native_031a8f0f659df890,
@@ -1140,6 +1137,7 @@ internal val nativeStringResourceIds: Map<String, Int> =
"Skill detail is not available in the current skills status." to R.string.native_90bf81305006e6ed,
"Skills" to R.string.native_66d0f523a379b2de,
"Skills installed on the gateway will appear here." to R.string.native_4d5e590b23745b4d,
"Skip" to R.string.native_28d03596d24eeb4e,
"Skipped" to R.string.native_12698ce1ea5cd4ab,
"Smart delivery" to R.string.native_ab3c45669917ec6f,
"Some channel status checks did not complete." to R.string.native_64f24041b18e3654,
@@ -21,6 +21,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -38,12 +39,17 @@ import kotlinx.coroutines.delay
internal fun ChatQuestionCard(
prompt: ChatQuestionPrompt,
onSubmit: (String, Map<String, List<String>>) -> Unit,
onSkip: (String) -> Unit,
modifier: Modifier = Modifier,
) {
var draft by remember(prompt.record.id) { mutableStateOf(ChatQuestionDraft()) }
var nowMs by remember(prompt.record.id) { mutableLongStateOf(System.currentTimeMillis()) }
val status = prompt.status(nowMs)
val pending = status == ChatQuestionStatus.Pending
if (!pending && status != ChatQuestionStatus.Submitting) {
ChatQuestionSummary(prompt = prompt, status = status, modifier = modifier)
return
}
LaunchedEffect(prompt.record.id, prompt.record.expiresAtMs, status) {
while (status == ChatQuestionStatus.Pending || status == ChatQuestionStatus.Submitting) {
delay(1000)
@@ -75,11 +81,47 @@ internal fun ChatQuestionCard(
status = status,
nowMs = nowMs,
onSubmit = onSubmit,
onSkip = onSkip,
)
}
}
}
@Composable
private fun ChatQuestionSummary(
prompt: ChatQuestionPrompt,
status: ChatQuestionStatus,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(ClawTheme.radii.row),
color = ClawTheme.colors.surfaceRaised,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Column(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
prompt.record.questions.forEach { question ->
Column {
Text(
text = question.header + ':',
style = ClawTheme.type.caption,
color = ClawTheme.colors.text,
fontWeight = FontWeight.SemiBold,
)
Text(
text = terminalQuestionAnswer(prompt, question, status),
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
)
}
}
}
}
}
@Composable
private fun QuestionSection(
question: Question,
@@ -143,6 +185,7 @@ private fun QuestionFooter(
status: ChatQuestionStatus,
nowMs: Long,
onSubmit: (String, Map<String, List<String>>) -> Unit,
onSkip: (String) -> Unit,
) {
val answers = draft.answers(prompt.record.questions)
if (status == ChatQuestionStatus.Pending || status == ChatQuestionStatus.Submitting) {
@@ -153,44 +196,51 @@ private fun QuestionFooter(
color = ClawTheme.colors.textMuted,
)
Spacer(Modifier.weight(1f))
TextButton(
onClick = { onSkip(prompt.record.id) },
enabled = status == ChatQuestionStatus.Pending,
) {
Text(nativeString("Skip"))
}
Button(
onClick = { answers?.let { onSubmit(prompt.record.id, it) } },
enabled = answers != null && status == ChatQuestionStatus.Pending,
) {
Text(if (status == ChatQuestionStatus.Submitting) nativeString("Submitting…") else nativeString("Submit"))
Text(
if (status == ChatQuestionStatus.Submitting && !prompt.skipping) {
nativeString("Submitting…")
} else {
nativeString("Submit")
},
)
}
}
prompt.errorText?.let { error ->
Text(text = error, style = ClawTheme.type.caption, color = ClawTheme.colors.danger)
}
} else {
Text(
text =
when (status) {
ChatQuestionStatus.Answered -> nativeString("Answered")
ChatQuestionStatus.AnsweredElsewhere -> nativeString("Answered elsewhere")
ChatQuestionStatus.Expired -> nativeString("Expired")
ChatQuestionStatus.Cancelled -> nativeString("Cancelled")
ChatQuestionStatus.Pending -> nativeString("Pending")
ChatQuestionStatus.Submitting -> nativeString("Submitting…")
},
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
fontWeight = FontWeight.SemiBold,
)
}
}
internal fun terminalQuestionAnswer(
prompt: ChatQuestionPrompt,
question: Question,
status: ChatQuestionStatus,
): String {
if (status == ChatQuestionStatus.Cancelled) return nativeString("Skipped")
if (status == ChatQuestionStatus.Expired) return nativeString("Expired")
if (status == ChatQuestionStatus.Unavailable) return nativeString("Unavailable")
prompt.record.answers?.answers?.get(question.id)?.answers?.takeIf { it.isNotEmpty() }?.let {
return it.joinToString(", ")
}
return if (status == ChatQuestionStatus.AnsweredElsewhere) nativeString("Answered elsewhere") else nativeString("Answered")
}
// nativeString is the non-composable resource accessor (nativeStringResource
// is the @Composable variant), so this helper is safe outside composition.
private fun questionCountdown(
internal fun questionCountdown(
expiresAtMs: Long,
nowMs: Long,
): String {
val seconds = ((expiresAtMs - nowMs).coerceAtLeast(0) + 999) / 1000
return if (seconds >= 60) {
nativeString("Expires in \${seconds / 60}m \${seconds % 60}s", seconds / 60, seconds % 60)
} else {
nativeString("Expires in \${seconds}s", seconds)
}
return (seconds / 60).toString() + ':' + (seconds % 60).toString().padStart(2, '0')
}
@@ -583,6 +583,7 @@ fun ChatScreen(
onRetryOutbox = viewModel::retryChatOutboxCommand,
onDeleteOutbox = viewModel::deleteChatOutboxCommand,
onResolveQuestion = viewModel::resolveChatQuestion,
onSkipQuestion = viewModel::skipChatQuestion,
onStarterPrompt = { prompt -> inputDrafts[composerOwner] = prompt },
onReplyMessage = { value -> viewModel.setChatReplyDraft(value, composerOwner) },
speechState = messageSpeechState,
@@ -1044,6 +1045,7 @@ private fun ChatMessageList(
onRetryOutbox: (String) -> Unit,
onDeleteOutbox: (String) -> Unit,
onResolveQuestion: (String, Map<String, List<String>>) -> Unit,
onSkipQuestion: (String) -> Unit,
onStarterPrompt: (String) -> Unit,
onReplyMessage: (String) -> Unit,
speechState: MessageSpeechState?,
@@ -1117,7 +1119,7 @@ private fun ChatMessageList(
)
is ChatTimelineItem.PendingTools -> ToolBubble(toolCalls = item.toolCalls)
is ChatTimelineItem.QuestionPrompt ->
ChatQuestionCard(prompt = item.prompt, onSubmit = onResolveQuestion)
ChatQuestionCard(prompt = item.prompt, onSubmit = onResolveQuestion, onSkip = onSkipQuestion)
is ChatTimelineItem.StreamingAssistant ->
ChatBubble(
messageId = null,
@@ -251,9 +251,15 @@ private fun latestContentVersion(
questions.forEach { prompt ->
append(prompt.record.id)
append(',')
append(prompt.record.status)
append(prompt.status())
append(',')
append(prompt.submitting)
append(',')
append(prompt.skipping)
append(',')
append(prompt.errorText?.hashCode() ?: 0)
append(',')
append(prompt.record.answers.hashCode())
append(';')
}
}
@@ -247,6 +247,7 @@
<string name="native_2821f0168d6dd9df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"حوّل هذا الجهاز إلى عقدة OpenClaw آمنة للدردشة والصوت والكاميرا وأدوات الجهاز."</string>
<string name="native_2831546121359362" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"إعداد يدوي"</string>
<string name="native_28bb849671cab958" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"تلميح: أوقف الاستماع لإرسال الجزء الملتقط."</string>
<string name="native_28d03596d24eeb4e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"تخطي"</string>
<string name="native_28d55ec6974d84e8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"فشل الطلب الصوتي"</string>
<string name="native_29129d736e62995a" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"سيؤدي هذا إلى رفض \"%1$s\" وتحديث حالة ورشة Skills من Gateway."</string>
<string name="native_2937013f21818106" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"update"</string>
@@ -924,7 +925,6 @@
<string name="native_9d60841e0a7891df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"تحقق"</string>
<string name="native_9d988c13c67e88e6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"قراءة موقع هذا الهاتف"</string>
<string name="native_9db0df0afb47b562" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"مفتاح المهارة"</string>
<string name="native_9dca3c2b01b40c0b" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"تنتهي الصلاحية خلال %1$sد %2$sث"</string>
<string name="native_9e37aa0704a3cf44" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"تم تثبيت %1$s."</string>
<string name="native_9e4635769f0aaf0d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"بوابات Gateway"</string>
<string name="native_9e9bef85bb320b81" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"وضع إعادة التوجيه"</string>
@@ -1221,7 +1221,6 @@
<string name="native_d2ee46edf00c8eb6" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"... +%1$s أخرى"</string>
<string name="native_d30f33731adb6b40" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"توسيع قائمة التحقق الخاصة بالخطة"</string>
<string name="native_d31de1a5c5c8ba2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"المتصفح"</string>
<string name="native_d353a99eb4556847" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"تم الإلغاء"</string>
<string name="native_d354ee936f59115e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"screen record"</string>
<string name="native_d3974fd6d3b6c81f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"التشغيل معلّق"</string>
<string name="native_d3f75e24411053c4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ذكاء اصطناعي شخصي على أجهزتك"</string>
@@ -1344,7 +1343,6 @@
<string name="native_e89bdf093e8a6756" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"المزوّدون والنماذج"</string>
<string name="native_e93372533f323b2f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"العقدة"</string>
<string name="native_e98feb46bd5d7792" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s "</string>
<string name="native_e9e3a4b23345cb57" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"تنتهي الصلاحية خلال %1$sث"</string>
<string name="native_ea14eb224e89a810" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"الموجّه غير متاح"</string>
<string name="native_ea2100dc89ae9fe2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"السجلات"</string>
<string name="native_ea5cddf7a04d7b94" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"اتصل بـ Gateway لفحص مقترحات ورشة عمل Skills."</string>
@@ -247,6 +247,7 @@
<string name="native_2821f0168d6dd9df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Verwandeln Sie dieses Gerät in einen sicheren OpenClaw-Knoten für Chat, Sprache, Kamera und Gerätetools."</string>
<string name="native_2831546121359362" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Manuelle Einrichtung"</string>
<string name="native_28bb849671cab958" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tipp: Beenden Sie das Zuhören, um den erfassten Beitrag zu senden."</string>
<string name="native_28d03596d24eeb4e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Überspringen"</string>
<string name="native_28d55ec6974d84e8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sprachanfrage fehlgeschlagen"</string>
<string name="native_29129d736e62995a" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dadurch wird \"%1$s\" abgelehnt und der Status von Skill Workshop über das Gateway aktualisiert."</string>
<string name="native_2937013f21818106" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"update"</string>
@@ -924,7 +925,6 @@
<string name="native_9d60841e0a7891df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Prüfen"</string>
<string name="native_9d988c13c67e88e6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Standort dieses Telefons abrufen"</string>
<string name="native_9db0df0afb47b562" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Skill-Schlüssel"</string>
<string name="native_9dca3c2b01b40c0b" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Läuft in %1$sm %2$ss ab"</string>
<string name="native_9e37aa0704a3cf44" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s wurde installiert."</string>
<string name="native_9e4635769f0aaf0d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateways"</string>
<string name="native_9e9bef85bb320b81" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Weiterleitungsmodus"</string>
@@ -1221,7 +1221,6 @@
<string name="native_d2ee46edf00c8eb6" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"... +%1$s weitere"</string>
<string name="native_d30f33731adb6b40" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Plan-Checkliste ausklappen"</string>
<string name="native_d31de1a5c5c8ba2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Browser"</string>
<string name="native_d353a99eb4556847" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Abgebrochen"</string>
<string name="native_d354ee936f59115e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"screen record"</string>
<string name="native_d3974fd6d3b6c81f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ausführung ausstehend"</string>
<string name="native_d3f75e24411053c4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Persönliche KI auf deinen Geräten"</string>
@@ -1344,7 +1343,6 @@
<string name="native_e89bdf093e8a6756" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Anbieter &amp; Modelle"</string>
<string name="native_e93372533f323b2f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Knoten"</string>
<string name="native_e98feb46bd5d7792" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s "</string>
<string name="native_e9e3a4b23345cb57" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Läuft in %1$ss ab"</string>
<string name="native_ea14eb224e89a810" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Prompt nicht verfügbar"</string>
<string name="native_ea2100dc89ae9fe2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Protokolle"</string>
<string name="native_ea5cddf7a04d7b94" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Verbinden Sie das Gateway, um Vorschläge aus Skill Workshop zu prüfen."</string>
@@ -247,6 +247,7 @@
<string name="native_2821f0168d6dd9df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Convierte este dispositivo en un nodo seguro de OpenClaw para chat, voz, cámara y herramientas del dispositivo."</string>
<string name="native_2831546121359362" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Configuración manual"</string>
<string name="native_28bb849671cab958" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Consejo: deja de escuchar para enviar el turno capturado."</string>
<string name="native_28d03596d24eeb4e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Omitir"</string>
<string name="native_28d55ec6974d84e8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Error en la solicitud de voz"</string>
<string name="native_29129d736e62995a" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Esto rechazará \"%1$s\" y actualizará el estado de Skill Workshop desde el Gateway."</string>
<string name="native_2937013f21818106" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"update"</string>
@@ -924,7 +925,6 @@
<string name="native_9d60841e0a7891df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Comprobar"</string>
<string name="native_9d988c13c67e88e6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Leer la ubicación de este teléfono"</string>
<string name="native_9db0df0afb47b562" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Clave de Skill"</string>
<string name="native_9dca3c2b01b40c0b" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Caduca en %1$sm %2$ss"</string>
<string name="native_9e37aa0704a3cf44" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Se instaló %1$s."</string>
<string name="native_9e4635769f0aaf0d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateways"</string>
<string name="native_9e9bef85bb320b81" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Modo de reenvío"</string>
@@ -1221,7 +1221,6 @@
<string name="native_d2ee46edf00c8eb6" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"... +%1$s más"</string>
<string name="native_d30f33731adb6b40" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Expandir la lista de verificación del plan"</string>
<string name="native_d31de1a5c5c8ba2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Navegador"</string>
<string name="native_d353a99eb4556847" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Cancelada"</string>
<string name="native_d354ee936f59115e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"screen record"</string>
<string name="native_d3974fd6d3b6c81f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ejecución pendiente"</string>
<string name="native_d3f75e24411053c4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"IA personal en tus dispositivos"</string>
@@ -1344,7 +1343,6 @@
<string name="native_e89bdf093e8a6756" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Proveedores y modelos"</string>
<string name="native_e93372533f323b2f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nodo"</string>
<string name="native_e98feb46bd5d7792" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s "</string>
<string name="native_e9e3a4b23345cb57" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Caduca en %1$ss"</string>
<string name="native_ea14eb224e89a810" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Indicación no disponible"</string>
<string name="native_ea2100dc89ae9fe2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Registros"</string>
<string name="native_ea5cddf7a04d7b94" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Conecta el Gateway para consultar las propuestas de Skill Workshop."</string>
@@ -247,6 +247,7 @@
<string name="native_2821f0168d6dd9df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"این دستگاه را به یک گره امن OpenClaw برای چت، صدا، دوربین و ابزارهای دستگاه تبدیل کنید."</string>
<string name="native_2831546121359362" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"راه‌اندازی دستی"</string>
<string name="native_28bb849671cab958" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"نکته: برای ارسال نوبت ضبط‌شده، گوش دادن را متوقف کنید."</string>
<string name="native_28d03596d24eeb4e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"رد کردن"</string>
<string name="native_28d55ec6974d84e8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"درخواست صوتی ناموفق بود"</string>
<string name="native_29129d736e62995a" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"این کار \"%1$s\" را رد می‌کند و وضعیت Skill Workshop را از Gateway تازه‌سازی می‌کند."</string>
<string name="native_2937013f21818106" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"update"</string>
@@ -924,7 +925,6 @@
<string name="native_9d60841e0a7891df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"بررسی"</string>
<string name="native_9d988c13c67e88e6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"خواندن موقعیت مکانی این تلفن"</string>
<string name="native_9db0df0afb47b562" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"کلید مهارت"</string>
<string name="native_9dca3c2b01b40c0b" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"انقضا تا %1$sm %2$ss دیگر"</string>
<string name="native_9e37aa0704a3cf44" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s نصب شد."</string>
<string name="native_9e4635769f0aaf0d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gatewayها"</string>
<string name="native_9e9bef85bb320b81" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"حالت بازارسال"</string>
@@ -1221,7 +1221,6 @@
<string name="native_d2ee46edf00c8eb6" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"... +%1$s مورد دیگر"</string>
<string name="native_d30f33731adb6b40" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"بازکردن فهرست بررسی برنامه"</string>
<string name="native_d31de1a5c5c8ba2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"مرورگر"</string>
<string name="native_d353a99eb4556847" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"لغو شده"</string>
<string name="native_d354ee936f59115e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"screen record"</string>
<string name="native_d3974fd6d3b6c81f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"اجرای در انتظار"</string>
<string name="native_d3f75e24411053c4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"هوش مصنوعی شخصی روی دستگاه‌های شما"</string>
@@ -1344,7 +1343,6 @@
<string name="native_e89bdf093e8a6756" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ارائه‌دهندگان و مدل‌ها"</string>
<string name="native_e93372533f323b2f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"گره"</string>
<string name="native_e98feb46bd5d7792" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s "</string>
<string name="native_e9e3a4b23345cb57" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"انقضا تا %1$ss دیگر"</string>
<string name="native_ea14eb224e89a810" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"درخواست در دسترس نیست"</string>
<string name="native_ea2100dc89ae9fe2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"گزارش‌ها"</string>
<string name="native_ea5cddf7a04d7b94" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"برای بررسی پیشنهادهای Skill Workshop، به Gateway متصل شوید."</string>
@@ -247,6 +247,7 @@
<string name="native_2821f0168d6dd9df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Transformez cet appareil en nœud OpenClaw sécurisé pour le chat, la voix, la caméra et les outils de lappareil."</string>
<string name="native_2831546121359362" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Configuration manuelle"</string>
<string name="native_28bb849671cab958" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Astuce : arrêtez l’écoute pour envoyer lintervention capturée."</string>
<string name="native_28d03596d24eeb4e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ignorer"</string>
<string name="native_28d55ec6974d84e8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Échec de la requête vocale"</string>
<string name="native_29129d736e62995a" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Cela rejettera \"%1$s\" et actualisera l’état de Skill Workshop depuis le Gateway."</string>
<string name="native_2937013f21818106" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"update"</string>
@@ -924,7 +925,6 @@
<string name="native_9d60841e0a7891df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Vérifier"</string>
<string name="native_9d988c13c67e88e6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Lire la position de ce téléphone"</string>
<string name="native_9db0df0afb47b562" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Clé du Skill"</string>
<string name="native_9dca3c2b01b40c0b" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Expire dans %1$s min %2$s s"</string>
<string name="native_9e37aa0704a3cf44" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s a été installé."</string>
<string name="native_9e4635769f0aaf0d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateways"</string>
<string name="native_9e9bef85bb320b81" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mode de transfert"</string>
@@ -1221,7 +1221,6 @@
<string name="native_d2ee46edf00c8eb6" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"... +%1$s de plus"</string>
<string name="native_d30f33731adb6b40" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Développer la liste de contrôle du plan"</string>
<string name="native_d31de1a5c5c8ba2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Navigateur"</string>
<string name="native_d353a99eb4556847" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Annulé"</string>
<string name="native_d354ee936f59115e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"screen record"</string>
<string name="native_d3974fd6d3b6c81f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Exécution en attente"</string>
<string name="native_d3f75e24411053c4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"IA personnelle sur vos appareils"</string>
@@ -1344,7 +1343,6 @@
<string name="native_e89bdf093e8a6756" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Fournisseurs et modèles"</string>
<string name="native_e93372533f323b2f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nœud"</string>
<string name="native_e98feb46bd5d7792" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s "</string>
<string name="native_e9e3a4b23345cb57" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Expire dans %1$s s"</string>
<string name="native_ea14eb224e89a810" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Invite indisponible"</string>
<string name="native_ea2100dc89ae9fe2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Journaux"</string>
<string name="native_ea5cddf7a04d7b94" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Connectez le Gateway pour examiner les propositions de Skill Workshop."</string>
@@ -247,6 +247,7 @@
<string name="native_2821f0168d6dd9df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"इस डिवाइस को chat, voice, camera, और device tools के लिए सुरक्षित OpenClaw नोड में बदलें।"</string>
<string name="native_2831546121359362" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"मैनुअल सेटअप"</string>
<string name="native_28bb849671cab958" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"सुझाव: कैप्चर किया गया टर्न भेजने के लिए सुनना बंद करें।"</string>
<string name="native_28d03596d24eeb4e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"छोड़ें"</string>
<string name="native_28d55ec6974d84e8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"वॉइस अनुरोध विफल रहा"</string>
<string name="native_29129d736e62995a" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"यह \"%1$s\" अस्वीकार करेगा और Gateway से Skill Workshop की स्थिति रीफ़्रेश करेगा।"</string>
<string name="native_2937013f21818106" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"update"</string>
@@ -924,7 +925,6 @@
<string name="native_9d60841e0a7891df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"जाँचें"</string>
<string name="native_9d988c13c67e88e6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"इस फ़ोन का स्थान पढ़ें"</string>
<string name="native_9db0df0afb47b562" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Skill कुंजी"</string>
<string name="native_9dca3c2b01b40c0b" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$sm %2$ss में समाप्त होगा"</string>
<string name="native_9e37aa0704a3cf44" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s इंस्टॉल किया गया।"</string>
<string name="native_9e4635769f0aaf0d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateways"</string>
<string name="native_9e9bef85bb320b81" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"फ़ॉरवर्डिंग मोड"</string>
@@ -1221,7 +1221,6 @@
<string name="native_d2ee46edf00c8eb6" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"... +%1$s और"</string>
<string name="native_d30f33731adb6b40" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"योजना चेकलिस्ट विस्तृत करें"</string>
<string name="native_d31de1a5c5c8ba2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Browser"</string>
<string name="native_d353a99eb4556847" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"रद्द किया गया"</string>
<string name="native_d354ee936f59115e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"screen record"</string>
<string name="native_d3974fd6d3b6c81f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"रन लंबित है"</string>
<string name="native_d3f75e24411053c4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"आपके डिवाइसों पर निजी AI"</string>
@@ -1344,7 +1343,6 @@
<string name="native_e89bdf093e8a6756" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"प्रदाता और मॉडल"</string>
<string name="native_e93372533f323b2f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"नोड"</string>
<string name="native_e98feb46bd5d7792" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s "</string>
<string name="native_e9e3a4b23345cb57" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$ss में समाप्त होगा"</string>
<string name="native_ea14eb224e89a810" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"प्रॉम्प्ट उपलब्ध नहीं है"</string>
<string name="native_ea2100dc89ae9fe2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"लॉग"</string>
<string name="native_ea5cddf7a04d7b94" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Skill Workshop प्रस्तावों की जाँच करने के लिए Gateway कनेक्ट करें।"</string>
@@ -247,6 +247,7 @@
<string name="native_2821f0168d6dd9df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ubah perangkat ini menjadi node OpenClaw yang aman untuk chat, suara, kamera, dan alat perangkat."</string>
<string name="native_2831546121359362" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Penyiapan manual"</string>
<string name="native_28bb849671cab958" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tips: hentikan mendengarkan untuk mengirim giliran yang direkam."</string>
<string name="native_28d03596d24eeb4e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Lewati"</string>
<string name="native_28d55ec6974d84e8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Permintaan suara gagal"</string>
<string name="native_29129d736e62995a" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tindakan ini akan menolak \"%1$s\" dan memuat ulang status Skill Workshop dari Gateway."</string>
<string name="native_2937013f21818106" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"update"</string>
@@ -924,7 +925,6 @@
<string name="native_9d60841e0a7891df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Periksa"</string>
<string name="native_9d988c13c67e88e6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Baca lokasi ponsel ini"</string>
<string name="native_9db0df0afb47b562" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kunci Skill"</string>
<string name="native_9dca3c2b01b40c0b" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kedaluwarsa dalam %1$sm %2$ss"</string>
<string name="native_9e37aa0704a3cf44" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s telah diinstal."</string>
<string name="native_9e4635769f0aaf0d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway"</string>
<string name="native_9e9bef85bb320b81" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mode Penerusan"</string>
@@ -1221,7 +1221,6 @@
<string name="native_d2ee46edf00c8eb6" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"... +%1$s lagi"</string>
<string name="native_d30f33731adb6b40" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Luaskan daftar periksa rencana"</string>
<string name="native_d31de1a5c5c8ba2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Browser"</string>
<string name="native_d353a99eb4556847" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dibatalkan"</string>
<string name="native_d354ee936f59115e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"screen record"</string>
<string name="native_d3974fd6d3b6c81f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Eksekusi Tertunda"</string>
<string name="native_d3f75e24411053c4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"AI pribadi di perangkat Anda"</string>
@@ -1344,7 +1343,6 @@
<string name="native_e89bdf093e8a6756" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Penyedia &amp; Model"</string>
<string name="native_e93372533f323b2f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Node"</string>
<string name="native_e98feb46bd5d7792" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s "</string>
<string name="native_e9e3a4b23345cb57" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kedaluwarsa dalam %1$ss"</string>
<string name="native_ea14eb224e89a810" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Perintah tidak tersedia"</string>
<string name="native_ea2100dc89ae9fe2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Log"</string>
<string name="native_ea5cddf7a04d7b94" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Hubungkan Gateway untuk memeriksa proposal Skill Workshop."</string>
@@ -247,6 +247,7 @@
<string name="native_2821f0168d6dd9df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Trasforma questo dispositivo in un nodo OpenClaw sicuro per chat, voce, fotocamera e strumenti del dispositivo."</string>
<string name="native_2831546121359362" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Configurazione manuale"</string>
<string name="native_28bb849671cab958" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Suggerimento: interrompi l\'ascolto per inviare il turno acquisito."</string>
<string name="native_28d03596d24eeb4e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Salta"</string>
<string name="native_28d55ec6974d84e8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Richiesta vocale non riuscita"</string>
<string name="native_29129d736e62995a" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Verrà rifiutata \"%1$s\" e lo stato di Skill Workshop verrà aggiornato dal gateway."</string>
<string name="native_2937013f21818106" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"update"</string>
@@ -924,7 +925,6 @@
<string name="native_9d60841e0a7891df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Controlla"</string>
<string name="native_9d988c13c67e88e6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Leggi la posizione di questo telefono"</string>
<string name="native_9db0df0afb47b562" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Chiave della skill"</string>
<string name="native_9dca3c2b01b40c0b" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Scade tra %1$sm %2$ss"</string>
<string name="native_9e37aa0704a3cf44" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s installata."</string>
<string name="native_9e4635769f0aaf0d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway"</string>
<string name="native_9e9bef85bb320b81" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Modalità di inoltro"</string>
@@ -1221,7 +1221,6 @@
<string name="native_d2ee46edf00c8eb6" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"... +%1$s altri"</string>
<string name="native_d30f33731adb6b40" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Espandi l\'elenco di controllo del piano"</string>
<string name="native_d31de1a5c5c8ba2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Browser"</string>
<string name="native_d353a99eb4556847" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Annullato"</string>
<string name="native_d354ee936f59115e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"screen record"</string>
<string name="native_d3974fd6d3b6c81f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Esecuzione in sospeso"</string>
<string name="native_d3f75e24411053c4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"AI personale sui tuoi dispositivi"</string>
@@ -1344,7 +1343,6 @@
<string name="native_e89bdf093e8a6756" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Provider e modelli"</string>
<string name="native_e93372533f323b2f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nodo"</string>
<string name="native_e98feb46bd5d7792" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s "</string>
<string name="native_e9e3a4b23345cb57" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Scade tra %1$ss"</string>
<string name="native_ea14eb224e89a810" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Prompt non disponibile"</string>
<string name="native_ea2100dc89ae9fe2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Log"</string>
<string name="native_ea5cddf7a04d7b94" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Connetti il Gateway per esaminare le proposte di Skill Workshop."</string>
@@ -247,6 +247,7 @@
<string name="native_2821f0168d6dd9df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"このデバイスを、チャット、音声、カメラ、デバイスツール用の安全な OpenClaw ノードにします。"</string>
<string name="native_2831546121359362" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"手動セットアップ"</string>
<string name="native_28bb849671cab958" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ヒント: 聞き取りを停止すると、キャプチャした発話が送信されます。"</string>
<string name="native_28d03596d24eeb4e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"スキップ"</string>
<string name="native_28d55ec6974d84e8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"音声リクエストに失敗しました"</string>
<string name="native_29129d736e62995a" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"「%1$s」を却下し、GatewayからSkill Workshopの状態を更新します。"</string>
<string name="native_2937013f21818106" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"update"</string>
@@ -924,7 +925,6 @@
<string name="native_9d60841e0a7891df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"確認"</string>
<string name="native_9d988c13c67e88e6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"このスマートフォンの位置情報を読み取る"</string>
<string name="native_9db0df0afb47b562" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Skillキー"</string>
<string name="native_9dca3c2b01b40c0b" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"期限切れまで%1$sm %2$ss"</string>
<string name="native_9e37aa0704a3cf44" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$sをインストールしました。"</string>
<string name="native_9e4635769f0aaf0d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway"</string>
<string name="native_9e9bef85bb320b81" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"転送モード"</string>
@@ -1221,7 +1221,6 @@
<string name="native_d2ee46edf00c8eb6" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"... +%1$s 件追加"</string>
<string name="native_d30f33731adb6b40" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"計画チェックリストを展開"</string>
<string name="native_d31de1a5c5c8ba2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ブラウザ"</string>
<string name="native_d353a99eb4556847" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"キャンセル済み"</string>
<string name="native_d354ee936f59115e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"screen record"</string>
<string name="native_d3974fd6d3b6c81f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"実行保留中"</string>
<string name="native_d3f75e24411053c4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"あなたのデバイス上のパーソナル AI"</string>
@@ -1344,7 +1343,6 @@
<string name="native_e89bdf093e8a6756" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"プロバイダーとモデル"</string>
<string name="native_e93372533f323b2f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ノード"</string>
<string name="native_e98feb46bd5d7792" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s "</string>
<string name="native_e9e3a4b23345cb57" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"期限切れまで%1$ss"</string>
<string name="native_ea14eb224e89a810" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"プロンプトを利用できません"</string>
<string name="native_ea2100dc89ae9fe2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ログ"</string>
<string name="native_ea5cddf7a04d7b94" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Skill Workshopの提案を確認するには、Gatewayに接続してください。"</string>
@@ -247,6 +247,7 @@
<string name="native_2821f0168d6dd9df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"이 기기를 채팅, 음성, 카메라 및 기기 도구를 위한 안전한 OpenClaw 노드로 전환하세요."</string>
<string name="native_2831546121359362" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"수동 설정"</string>
<string name="native_28bb849671cab958" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"팁: 캡처된 턴을 보내려면 듣기를 중지하세요."</string>
<string name="native_28d03596d24eeb4e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"건너뛰기"</string>
<string name="native_28d55ec6974d84e8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"음성 요청 실패"</string>
<string name="native_29129d736e62995a" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"이 작업은 \"%1$s\"을(를) 거부하고 Gateway에서 Skill Workshop 상태를 새로 고칩니다."</string>
<string name="native_2937013f21818106" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"update"</string>
@@ -924,7 +925,6 @@
<string name="native_9d60841e0a7891df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"확인"</string>
<string name="native_9d988c13c67e88e6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"이 휴대전화의 위치 확인"</string>
<string name="native_9db0df0afb47b562" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Skill 키"</string>
<string name="native_9dca3c2b01b40c0b" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s분 %2$s초 후 만료"</string>
<string name="native_9e37aa0704a3cf44" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s을(를) 설치했습니다."</string>
<string name="native_9e4635769f0aaf0d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway"</string>
<string name="native_9e9bef85bb320b81" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"전달 모드"</string>
@@ -1221,7 +1221,6 @@
<string name="native_d2ee46edf00c8eb6" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"... +%1$s개 더"</string>
<string name="native_d30f33731adb6b40" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"계획 체크리스트 펼치기"</string>
<string name="native_d31de1a5c5c8ba2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"브라우저"</string>
<string name="native_d353a99eb4556847" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"취소됨"</string>
<string name="native_d354ee936f59115e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"screen record"</string>
<string name="native_d3974fd6d3b6c81f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"실행 대기 중"</string>
<string name="native_d3f75e24411053c4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"기기에서 사용하는 개인 AI"</string>
@@ -1344,7 +1343,6 @@
<string name="native_e89bdf093e8a6756" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"제공업체 및 모델"</string>
<string name="native_e93372533f323b2f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"노드"</string>
<string name="native_e98feb46bd5d7792" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s "</string>
<string name="native_e9e3a4b23345cb57" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s초 후 만료"</string>
<string name="native_ea14eb224e89a810" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"프롬프트를 사용할 수 없음"</string>
<string name="native_ea2100dc89ae9fe2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"로그"</string>
<string name="native_ea5cddf7a04d7b94" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Skill Workshop 제안을 확인하려면 Gateway를 연결하세요."</string>
@@ -247,6 +247,7 @@
<string name="native_2821f0168d6dd9df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Verander dit apparaat in een beveiligd OpenClaw-knooppunt voor chat, spraak, camera en apparaathulpmiddelen."</string>
<string name="native_2831546121359362" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Handmatige configuratie"</string>
<string name="native_28bb849671cab958" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tip: stop met luisteren om de vastgelegde beurt te verzenden."</string>
<string name="native_28d03596d24eeb4e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Overslaan"</string>
<string name="native_28d55ec6974d84e8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Spraakverzoek mislukt"</string>
<string name="native_29129d736e62995a" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Hiermee wordt \"%1$s\" afgewezen en wordt de status van Skill Workshop vernieuwd vanuit de gateway."</string>
<string name="native_2937013f21818106" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"update"</string>
@@ -924,7 +925,6 @@
<string name="native_9d60841e0a7891df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Controleren"</string>
<string name="native_9d988c13c67e88e6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Locatie van deze telefoon lezen"</string>
<string name="native_9db0df0afb47b562" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Skill-sleutel"</string>
<string name="native_9dca3c2b01b40c0b" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Verloopt over %1$sm %2$ss"</string>
<string name="native_9e37aa0704a3cf44" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s is geïnstalleerd."</string>
<string name="native_9e4635769f0aaf0d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateways"</string>
<string name="native_9e9bef85bb320b81" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Doorstuurmodus"</string>
@@ -1221,7 +1221,6 @@
<string name="native_d2ee46edf00c8eb6" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"... +%1$s meer"</string>
<string name="native_d30f33731adb6b40" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Checklist van plan uitklappen"</string>
<string name="native_d31de1a5c5c8ba2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Browser"</string>
<string name="native_d353a99eb4556847" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Geannuleerd"</string>
<string name="native_d354ee936f59115e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"screen record"</string>
<string name="native_d3974fd6d3b6c81f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Uitvoering in behandeling"</string>
<string name="native_d3f75e24411053c4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Persoonlijke AI op je apparaten"</string>
@@ -1344,7 +1343,6 @@
<string name="native_e89bdf093e8a6756" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Providers en modellen"</string>
<string name="native_e93372533f323b2f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Knooppunt"</string>
<string name="native_e98feb46bd5d7792" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s "</string>
<string name="native_e9e3a4b23345cb57" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Verloopt over %1$ss"</string>
<string name="native_ea14eb224e89a810" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Prompt niet beschikbaar"</string>
<string name="native_ea2100dc89ae9fe2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Logboeken"</string>
<string name="native_ea5cddf7a04d7b94" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Verbind de Gateway om voorstellen van Skill Workshop te bekijken."</string>
@@ -247,6 +247,7 @@
<string name="native_2821f0168d6dd9df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Zmień to urządzenie w bezpieczny węzeł OpenClaw do czatu, głosu, kamery i narzędzi urządzenia."</string>
<string name="native_2831546121359362" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Konfiguracja ręczna"</string>
<string name="native_28bb849671cab958" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wskazówka: zatrzymaj nasłuchiwanie, aby wysłać przechwyconą wypowiedź."</string>
<string name="native_28d03596d24eeb4e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Pomiń"</string>
<string name="native_28d55ec6974d84e8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Żądanie głosowe nie powiodło się"</string>
<string name="native_29129d736e62995a" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Spowoduje to odrzucenie propozycji „%1$s” i odświeżenie stanu Skill Workshop z gateway."</string>
<string name="native_2937013f21818106" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"update"</string>
@@ -924,7 +925,6 @@
<string name="native_9d60841e0a7891df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sprawdź"</string>
<string name="native_9d988c13c67e88e6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Odczytuj lokalizację tego telefonu"</string>
<string name="native_9db0df0afb47b562" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Klucz Skill"</string>
<string name="native_9dca3c2b01b40c0b" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wygasa za %1$sm %2$ss"</string>
<string name="native_9e37aa0704a3cf44" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Zainstalowano %1$s."</string>
<string name="native_9e4635769f0aaf0d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway"</string>
<string name="native_9e9bef85bb320b81" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tryb przekazywania"</string>
@@ -1221,7 +1221,6 @@
<string name="native_d2ee46edf00c8eb6" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"... +%1$s więcej"</string>
<string name="native_d30f33731adb6b40" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Rozwiń listę kontrolną planu"</string>
<string name="native_d31de1a5c5c8ba2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Przeglądarka"</string>
<string name="native_d353a99eb4556847" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Anulowano"</string>
<string name="native_d354ee936f59115e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"screen record"</string>
<string name="native_d3974fd6d3b6c81f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Oczekujące uruchomienie"</string>
<string name="native_d3f75e24411053c4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Osobista AI na Twoich urządzeniach"</string>
@@ -1344,7 +1343,6 @@
<string name="native_e89bdf093e8a6756" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dostawcy i modele"</string>
<string name="native_e93372533f323b2f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Węzeł"</string>
<string name="native_e98feb46bd5d7792" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s "</string>
<string name="native_e9e3a4b23345cb57" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wygasa za %1$ss"</string>
<string name="native_ea14eb224e89a810" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Polecenie niedostępne"</string>
<string name="native_ea2100dc89ae9fe2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dzienniki"</string>
<string name="native_ea5cddf7a04d7b94" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Połącz Gateway, aby sprawdzić propozycje Skill Workshop."</string>
@@ -247,6 +247,7 @@
<string name="native_2821f0168d6dd9df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Transforme este dispositivo em um nó OpenClaw seguro para chat, voz, câmera e ferramentas do dispositivo."</string>
<string name="native_2831546121359362" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Configuração manual"</string>
<string name="native_28bb849671cab958" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dica: pare de ouvir para enviar o turno capturado."</string>
<string name="native_28d03596d24eeb4e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Pular"</string>
<string name="native_28d55ec6974d84e8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Falha na solicitação de voz"</string>
<string name="native_29129d736e62995a" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Isso rejeitará \"%1$s\" e atualizará o estado do Skill Workshop com base no gateway."</string>
<string name="native_2937013f21818106" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"update"</string>
@@ -924,7 +925,6 @@
<string name="native_9d60841e0a7891df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Verificar"</string>
<string name="native_9d988c13c67e88e6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ler a localização deste telefone"</string>
<string name="native_9db0df0afb47b562" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Chave da Skill"</string>
<string name="native_9dca3c2b01b40c0b" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Expira em %1$sm %2$ss"</string>
<string name="native_9e37aa0704a3cf44" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s foi instalada."</string>
<string name="native_9e4635769f0aaf0d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateways"</string>
<string name="native_9e9bef85bb320b81" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Modo de encaminhamento"</string>
@@ -1221,7 +1221,6 @@
<string name="native_d2ee46edf00c8eb6" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"... +%1$s mais"</string>
<string name="native_d30f33731adb6b40" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Expandir lista de verificação do plano"</string>
<string name="native_d31de1a5c5c8ba2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Navegador"</string>
<string name="native_d353a99eb4556847" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Cancelado"</string>
<string name="native_d354ee936f59115e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"screen record"</string>
<string name="native_d3974fd6d3b6c81f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Execução pendente"</string>
<string name="native_d3f75e24411053c4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"IA pessoal nos seus dispositivos"</string>
@@ -1344,7 +1343,6 @@
<string name="native_e89bdf093e8a6756" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Provedores e modelos"</string>
<string name="native_e93372533f323b2f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nó"</string>
<string name="native_e98feb46bd5d7792" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s "</string>
<string name="native_e9e3a4b23345cb57" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Expira em %1$ss"</string>
<string name="native_ea14eb224e89a810" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Prompt indisponível"</string>
<string name="native_ea2100dc89ae9fe2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Logs"</string>
<string name="native_ea5cddf7a04d7b94" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Conecte o Gateway para inspecionar as propostas do Skill Workshop."</string>
@@ -247,6 +247,7 @@
<string name="native_2821f0168d6dd9df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Превратите это устройство в безопасный узел OpenClaw для чата, голоса, камеры и инструментов устройства."</string>
<string name="native_2831546121359362" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ручная настройка"</string>
<string name="native_28bb849671cab958" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Совет: остановите прослушивание, чтобы отправить записанную реплику."</string>
<string name="native_28d03596d24eeb4e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Пропустить"</string>
<string name="native_28d55ec6974d84e8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Не удалось выполнить голосовой запрос"</string>
<string name="native_29129d736e62995a" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Предложение \"%1$s\" будет отклонено, а состояние Skill Workshop будет обновлено из Gateway."</string>
<string name="native_2937013f21818106" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"update"</string>
@@ -924,7 +925,6 @@
<string name="native_9d60841e0a7891df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Проверить"</string>
<string name="native_9d988c13c67e88e6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Получать местоположение этого телефона"</string>
<string name="native_9db0df0afb47b562" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ключ Skill"</string>
<string name="native_9dca3c2b01b40c0b" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Истекает через %1$s мин %2$s с"</string>
<string name="native_9e37aa0704a3cf44" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Установлен %1$s."</string>
<string name="native_9e4635769f0aaf0d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway"</string>
<string name="native_9e9bef85bb320b81" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Режим пересылки"</string>
@@ -1221,7 +1221,6 @@
<string name="native_d2ee46edf00c8eb6" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"... +%1$s еще"</string>
<string name="native_d30f33731adb6b40" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Развернуть контрольный список плана"</string>
<string name="native_d31de1a5c5c8ba2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Браузер"</string>
<string name="native_d353a99eb4556847" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Отменено"</string>
<string name="native_d354ee936f59115e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"screen record"</string>
<string name="native_d3974fd6d3b6c81f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Запуск ожидается"</string>
<string name="native_d3f75e24411053c4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Персональный ИИ на ваших устройствах"</string>
@@ -1344,7 +1343,6 @@
<string name="native_e89bdf093e8a6756" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Провайдеры и модели"</string>
<string name="native_e93372533f323b2f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Узел"</string>
<string name="native_e98feb46bd5d7792" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s "</string>
<string name="native_e9e3a4b23345cb57" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Истекает через %1$s с"</string>
<string name="native_ea14eb224e89a810" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Запрос недоступен"</string>
<string name="native_ea2100dc89ae9fe2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Журналы"</string>
<string name="native_ea5cddf7a04d7b94" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Подключите Gateway, чтобы просмотреть предложения Skill Workshop."</string>
@@ -247,6 +247,7 @@
<string name="native_2821f0168d6dd9df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gör den här enheten till en säker OpenClaw-nod för chatt, röst, kamera och enhetsverktyg."</string>
<string name="native_2831546121359362" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Manuell konfiguration"</string>
<string name="native_28bb849671cab958" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tips: sluta lyssna för att skicka den fångade turen."</string>
<string name="native_28d03596d24eeb4e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Hoppa över"</string>
<string name="native_28d55ec6974d84e8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Röstbegäran misslyckades"</string>
<string name="native_29129d736e62995a" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Detta avvisar \"%1$s\" och uppdaterar statusen för Skill Workshop från Gateway."</string>
<string name="native_2937013f21818106" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"update"</string>
@@ -924,7 +925,6 @@
<string name="native_9d60841e0a7891df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kontrollera"</string>
<string name="native_9d988c13c67e88e6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Läs av den här telefonens plats"</string>
<string name="native_9db0df0afb47b562" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Skill-nyckel"</string>
<string name="native_9dca3c2b01b40c0b" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Upphör om %1$sm %2$ss"</string>
<string name="native_9e37aa0704a3cf44" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Installerade %1$s."</string>
<string name="native_9e4635769f0aaf0d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateways"</string>
<string name="native_9e9bef85bb320b81" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Vidarebefordringsläge"</string>
@@ -1165,7 +1165,7 @@
<string name="native_c9aa0d6611abe5f0" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ta bort automatisering"</string>
<string name="native_c9e4f8f40d07909e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Standardassistent"</string>
<string name="native_c9e9b24ded599d1b" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Välj en %1$s-leverantör som stöds på Gateway"</string>
<string name="native_ca184496974204a0" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Otillgänglig"</string>
<string name="native_ca184496974204a0" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Inte tillgänglig"</string>
<string name="native_ca1fd4548524cb2e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tom mapp"</string>
<string name="native_ca7981b46ecf2c17" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Av"</string>
<string name="native_cab94aba84f97f7f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Typografi"</string>
@@ -1221,7 +1221,6 @@
<string name="native_d2ee46edf00c8eb6" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"... +%1$s till"</string>
<string name="native_d30f33731adb6b40" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Visa planens checklista"</string>
<string name="native_d31de1a5c5c8ba2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Webbläsare"</string>
<string name="native_d353a99eb4556847" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Avbruten"</string>
<string name="native_d354ee936f59115e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"screen record"</string>
<string name="native_d3974fd6d3b6c81f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Körning väntar"</string>
<string name="native_d3f75e24411053c4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Personlig AI på dina enheter"</string>
@@ -1344,7 +1343,6 @@
<string name="native_e89bdf093e8a6756" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Leverantörer och modeller"</string>
<string name="native_e93372533f323b2f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nod"</string>
<string name="native_e98feb46bd5d7792" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s "</string>
<string name="native_e9e3a4b23345cb57" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Upphör om %1$ss"</string>
<string name="native_ea14eb224e89a810" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Prompten är inte tillgänglig"</string>
<string name="native_ea2100dc89ae9fe2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Loggar"</string>
<string name="native_ea5cddf7a04d7b94" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Anslut Gateway för att granska förslag från Skill Workshop."</string>
@@ -247,6 +247,7 @@
<string name="native_2821f0168d6dd9df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"เปลี่ยนอุปกรณ์นี้ให้เป็นโหนด OpenClaw ที่ปลอดภัยสำหรับแชท, เสียง, กล้อง และเครื่องมืออุปกรณ์"</string>
<string name="native_2831546121359362" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ตั้งค่าด้วยตนเอง"</string>
<string name="native_28bb849671cab958" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"เคล็ดลับ: หยุดฟังเพื่อส่งช่วงที่บันทึกไว้"</string>
<string name="native_28d03596d24eeb4e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ข้าม"</string>
<string name="native_28d55ec6974d84e8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"คำขอด้วยเสียงล้มเหลว"</string>
<string name="native_29129d736e62995a" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"การดำเนินการนี้จะปฏิเสธ \"%1$s\" และรีเฟรชสถานะ Skill Workshop จาก Gateway"</string>
<string name="native_2937013f21818106" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"update"</string>
@@ -924,7 +925,6 @@
<string name="native_9d60841e0a7891df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ตรวจสอบ"</string>
<string name="native_9d988c13c67e88e6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"อ่านตำแหน่งของโทรศัพท์เครื่องนี้"</string>
<string name="native_9db0df0afb47b562" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"คีย์ของ Skill"</string>
<string name="native_9dca3c2b01b40c0b" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"หมดอายุใน %1$s นาที %2$s วินาที"</string>
<string name="native_9e37aa0704a3cf44" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ติดตั้ง %1$s แล้ว"</string>
<string name="native_9e4635769f0aaf0d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway"</string>
<string name="native_9e9bef85bb320b81" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"โหมดการส่งต่อ"</string>
@@ -1221,7 +1221,6 @@
<string name="native_d2ee46edf00c8eb6" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"... +%1$s เพิ่มเติม"</string>
<string name="native_d30f33731adb6b40" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ขยายรายการตรวจสอบแผน"</string>
<string name="native_d31de1a5c5c8ba2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"เบราว์เซอร์"</string>
<string name="native_d353a99eb4556847" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ยกเลิกแล้ว"</string>
<string name="native_d354ee936f59115e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"screen record"</string>
<string name="native_d3974fd6d3b6c81f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"รอการเรียกใช้"</string>
<string name="native_d3f75e24411053c4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"AI ส่วนตัวบนอุปกรณ์ของคุณ"</string>
@@ -1344,7 +1343,6 @@
<string name="native_e89bdf093e8a6756" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ผู้ให้บริการและโมเดล"</string>
<string name="native_e93372533f323b2f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"โหนด"</string>
<string name="native_e98feb46bd5d7792" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s "</string>
<string name="native_e9e3a4b23345cb57" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"หมดอายุใน %1$s วินาที"</string>
<string name="native_ea14eb224e89a810" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ไม่มีพรอมต์"</string>
<string name="native_ea2100dc89ae9fe2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"บันทึก"</string>
<string name="native_ea5cddf7a04d7b94" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"เชื่อมต่อ Gateway เพื่อตรวจสอบข้อเสนอ Skill Workshop"</string>
@@ -247,6 +247,7 @@
<string name="native_2821f0168d6dd9df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bu cihazı sohbet, ses, kamera ve cihaz araçları için güvenli bir OpenClaw düğümüne dönüştürün."</string>
<string name="native_2831546121359362" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Manuel kurulum"</string>
<string name="native_28bb849671cab958" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"İpucu: yakalanan sırayı göndermek için dinlemeyi durdurun."</string>
<string name="native_28d03596d24eeb4e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Atla"</string>
<string name="native_28d55ec6974d84e8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sesli istek başarısız oldu"</string>
<string name="native_29129d736e62995a" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bu işlem \"%1$s\" teklifini reddedecek ve Skill Workshop durumunu Gateway\'den yenileyecek."</string>
<string name="native_2937013f21818106" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"update"</string>
@@ -924,7 +925,6 @@
<string name="native_9d60841e0a7891df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kontrol et"</string>
<string name="native_9d988c13c67e88e6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bu telefonun konumunu okuyun"</string>
<string name="native_9db0df0afb47b562" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Skill Anahtarı"</string>
<string name="native_9dca3c2b01b40c0b" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sona ermesine %1$s dk %2$s sn"</string>
<string name="native_9e37aa0704a3cf44" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s yüklendi."</string>
<string name="native_9e4635769f0aaf0d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway\'ler"</string>
<string name="native_9e9bef85bb320b81" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Yönlendirme Modu"</string>
@@ -1221,7 +1221,6 @@
<string name="native_d2ee46edf00c8eb6" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"... +%1$s daha"</string>
<string name="native_d30f33731adb6b40" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Plan kontrol listesini genişlet"</string>
<string name="native_d31de1a5c5c8ba2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tarayıcı"</string>
<string name="native_d353a99eb4556847" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"İptal edildi"</string>
<string name="native_d354ee936f59115e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"screen record"</string>
<string name="native_d3974fd6d3b6c81f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Çalıştırma Bekliyor"</string>
<string name="native_d3f75e24411053c4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Cihazlarınızda kişisel AI"</string>
@@ -1344,7 +1343,6 @@
<string name="native_e89bdf093e8a6756" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sağlayıcılar ve Modeller"</string>
<string name="native_e93372533f323b2f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Düğüm"</string>
<string name="native_e98feb46bd5d7792" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s "</string>
<string name="native_e9e3a4b23345cb57" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sona ermesine %1$s sn"</string>
<string name="native_ea14eb224e89a810" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"İstem kullanılamıyor"</string>
<string name="native_ea2100dc89ae9fe2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Günlükler"</string>
<string name="native_ea5cddf7a04d7b94" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Skill Workshop önerilerini incelemek için Gateway\'e bağlanın."</string>
@@ -247,6 +247,7 @@
<string name="native_2821f0168d6dd9df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Перетворіть цей пристрій на захищений вузол OpenClaw для чату, голосу, камери та інструментів пристрою."</string>
<string name="native_2831546121359362" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ручне налаштування"</string>
<string name="native_28bb849671cab958" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Порада: зупиніть прослуховування, щоб надіслати записану репліку."</string>
<string name="native_28d03596d24eeb4e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Пропустити"</string>
<string name="native_28d55ec6974d84e8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Не вдалося виконати голосовий запит"</string>
<string name="native_29129d736e62995a" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Буде відхилено \"%1$s\", а стан Майстерні навичок оновлено з Gateway."</string>
<string name="native_2937013f21818106" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"update"</string>
@@ -924,7 +925,6 @@
<string name="native_9d60841e0a7891df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Перевірити"</string>
<string name="native_9d988c13c67e88e6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Отримувати дані про місцезнаходження цього телефону"</string>
<string name="native_9db0df0afb47b562" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ключ навички"</string>
<string name="native_9dca3c2b01b40c0b" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Залишилося %1$s хв %2$s с"</string>
<string name="native_9e37aa0704a3cf44" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Установлено %1$s."</string>
<string name="native_9e4635769f0aaf0d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Шлюзи"</string>
<string name="native_9e9bef85bb320b81" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Режим пересилання"</string>
@@ -1221,7 +1221,6 @@
<string name="native_d2ee46edf00c8eb6" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"... +%1$s ще"</string>
<string name="native_d30f33731adb6b40" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Розгорнути контрольний список плану"</string>
<string name="native_d31de1a5c5c8ba2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Браузер"</string>
<string name="native_d353a99eb4556847" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Скасовано"</string>
<string name="native_d354ee936f59115e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"screen record"</string>
<string name="native_d3974fd6d3b6c81f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Очікує запуску"</string>
<string name="native_d3f75e24411053c4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Персональний ШІ на ваших пристроях"</string>
@@ -1344,7 +1343,6 @@
<string name="native_e89bdf093e8a6756" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Постачальники й моделі"</string>
<string name="native_e93372533f323b2f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Вузол"</string>
<string name="native_e98feb46bd5d7792" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s "</string>
<string name="native_e9e3a4b23345cb57" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Залишилося %1$s с"</string>
<string name="native_ea14eb224e89a810" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Запит недоступний"</string>
<string name="native_ea2100dc89ae9fe2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Журнали"</string>
<string name="native_ea5cddf7a04d7b94" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Підключіть Gateway, щоб переглянути пропозиції Skill Workshop."</string>
@@ -247,6 +247,7 @@
<string name="native_2821f0168d6dd9df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Biến thiết bị này thành một nút OpenClaw an toàn cho trò chuyện, thoại, camera và công cụ thiết bị."</string>
<string name="native_2831546121359362" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Thiết lập thủ công"</string>
<string name="native_28bb849671cab958" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mẹo: dừng nghe để gửi lượt đã ghi lại."</string>
<string name="native_28d03596d24eeb4e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bỏ qua"</string>
<string name="native_28d55ec6974d84e8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Yêu cầu giọng nói không thành công"</string>
<string name="native_29129d736e62995a" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Thao tác này sẽ từ chối \"%1$s\" và làm mới trạng thái Skill Workshop từ Gateway."</string>
<string name="native_2937013f21818106" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"update"</string>
@@ -924,7 +925,6 @@
<string name="native_9d60841e0a7891df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kiểm tra"</string>
<string name="native_9d988c13c67e88e6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Đọc vị trí của điện thoại này"</string>
<string name="native_9db0df0afb47b562" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Khóa Skill"</string>
<string name="native_9dca3c2b01b40c0b" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Hết hạn sau %1$sm %2$ss"</string>
<string name="native_9e37aa0704a3cf44" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Đã cài đặt %1$s."</string>
<string name="native_9e4635769f0aaf0d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway"</string>
<string name="native_9e9bef85bb320b81" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Chế độ chuyển tiếp"</string>
@@ -1221,7 +1221,6 @@
<string name="native_d2ee46edf00c8eb6" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"... +%1$s nữa"</string>
<string name="native_d30f33731adb6b40" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mở rộng danh sách kiểm tra kế hoạch"</string>
<string name="native_d31de1a5c5c8ba2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Trình duyệt"</string>
<string name="native_d353a99eb4556847" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Đã hủy"</string>
<string name="native_d354ee936f59115e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"screen record"</string>
<string name="native_d3974fd6d3b6c81f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Lượt chạy đang chờ"</string>
<string name="native_d3f75e24411053c4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"AI cá nhân trên các thiết bị của bạn"</string>
@@ -1344,7 +1343,6 @@
<string name="native_e89bdf093e8a6756" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nhà cung cấp &amp; Mô hình"</string>
<string name="native_e93372533f323b2f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nút"</string>
<string name="native_e98feb46bd5d7792" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s "</string>
<string name="native_e9e3a4b23345cb57" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Hết hạn sau %1$ss"</string>
<string name="native_ea14eb224e89a810" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Câu lệnh không khả dụng"</string>
<string name="native_ea2100dc89ae9fe2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nhật ký"</string>
<string name="native_ea5cddf7a04d7b94" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kết nối Gateway để kiểm tra các đề xuất Skill Workshop."</string>
@@ -247,6 +247,7 @@
<string name="native_2821f0168d6dd9df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"将此设备变成安全的 OpenClaw 节点,用于聊天、语音、摄像头和设备工具。"</string>
<string name="native_2831546121359362" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"手动设置"</string>
<string name="native_28bb849671cab958" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"提示:停止聆听即可发送已捕获的发言。"</string>
<string name="native_28d03596d24eeb4e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"跳过"</string>
<string name="native_28d55ec6974d84e8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"语音请求失败"</string>
<string name="native_29129d736e62995a" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"这将拒绝“%1$s”,并从 Gateway 刷新 Skill Workshop 状态。"</string>
<string name="native_2937013f21818106" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"update"</string>
@@ -924,7 +925,6 @@
<string name="native_9d60841e0a7891df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"检查"</string>
<string name="native_9d988c13c67e88e6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"读取此手机的位置信息"</string>
<string name="native_9db0df0afb47b562" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Skill 键"</string>
<string name="native_9dca3c2b01b40c0b" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s分%2$s秒后过期"</string>
<string name="native_9e37aa0704a3cf44" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"已安装 %1$s。"</string>
<string name="native_9e4635769f0aaf0d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateways"</string>
<string name="native_9e9bef85bb320b81" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"转发模式"</string>
@@ -1221,7 +1221,6 @@
<string name="native_d2ee46edf00c8eb6" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"... +%1$s 个更多"</string>
<string name="native_d30f33731adb6b40" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"展开计划清单"</string>
<string name="native_d31de1a5c5c8ba2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"浏览器"</string>
<string name="native_d353a99eb4556847" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"已取消"</string>
<string name="native_d354ee936f59115e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"screen record"</string>
<string name="native_d3974fd6d3b6c81f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"运行待处理项"</string>
<string name="native_d3f75e24411053c4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"您设备上的个人 AI"</string>
@@ -1344,7 +1343,6 @@
<string name="native_e89bdf093e8a6756" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"提供商与模型"</string>
<string name="native_e93372533f323b2f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"节点"</string>
<string name="native_e98feb46bd5d7792" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s "</string>
<string name="native_e9e3a4b23345cb57" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s秒后过期"</string>
<string name="native_ea14eb224e89a810" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"提示词不可用"</string>
<string name="native_ea2100dc89ae9fe2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"日志"</string>
<string name="native_ea5cddf7a04d7b94" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"连接 Gateway 以检查 Skill Workshop 提案。"</string>
@@ -247,6 +247,7 @@
<string name="native_2821f0168d6dd9df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"將此裝置變成安全的 OpenClaw 節點,用於聊天、語音、相機與裝置工具。"</string>
<string name="native_2831546121359362" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"手動設定"</string>
<string name="native_28bb849671cab958" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"提示:停止聆聽即可傳送擷取到的發言。"</string>
<string name="native_28d03596d24eeb4e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"略過"</string>
<string name="native_28d55ec6974d84e8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"語音請求失敗"</string>
<string name="native_29129d736e62995a" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"這將拒絕「%1$s」,並從 Gateway 重新整理 Skill Workshop 狀態。"</string>
<string name="native_2937013f21818106" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"update"</string>
@@ -924,7 +925,6 @@
<string name="native_9d60841e0a7891df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"檢查"</string>
<string name="native_9d988c13c67e88e6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"讀取此手機的位置"</string>
<string name="native_9db0df0afb47b562" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Skill 金鑰"</string>
<string name="native_9dca3c2b01b40c0b" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"將於 %1$sm %2$ss 後過期"</string>
<string name="native_9e37aa0704a3cf44" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"已安裝 %1$s。"</string>
<string name="native_9e4635769f0aaf0d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateways"</string>
<string name="native_9e9bef85bb320b81" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"轉發模式"</string>
@@ -1221,7 +1221,6 @@
<string name="native_d2ee46edf00c8eb6" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"... 另有 %1$s 個"</string>
<string name="native_d30f33731adb6b40" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"展開計畫檢查清單"</string>
<string name="native_d31de1a5c5c8ba2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"瀏覽器"</string>
<string name="native_d353a99eb4556847" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"已取消"</string>
<string name="native_d354ee936f59115e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"screen record"</string>
<string name="native_d3974fd6d3b6c81f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"執行待處理項目"</string>
<string name="native_d3f75e24411053c4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"您裝置上的個人 AI"</string>
@@ -1344,7 +1343,6 @@
<string name="native_e89bdf093e8a6756" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"提供者與模型"</string>
<string name="native_e93372533f323b2f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"節點"</string>
<string name="native_e98feb46bd5d7792" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s "</string>
<string name="native_e9e3a4b23345cb57" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"將於 %1$ss 後過期"</string>
<string name="native_ea14eb224e89a810" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"提示詞無法使用"</string>
<string name="native_ea2100dc89ae9fe2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"記錄"</string>
<string name="native_ea5cddf7a04d7b94" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"請連線至 Gateway 以檢查 Skill Workshop 提案。"</string>
@@ -247,6 +247,7 @@
<string name="native_2821f0168d6dd9df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Turn this device into a secure OpenClaw node for chat, voice, camera, and device tools."</string>
<string name="native_2831546121359362" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Manual setup"</string>
<string name="native_28bb849671cab958" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tip: stop listening to send the captured turn."</string>
<string name="native_28d03596d24eeb4e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Skip"</string>
<string name="native_28d55ec6974d84e8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Voice request failed"</string>
<string name="native_29129d736e62995a" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"This will reject \"%1$s\" and refresh Skill Workshop state from the gateway."</string>
<string name="native_2937013f21818106" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"update"</string>
@@ -924,7 +925,6 @@
<string name="native_9d60841e0a7891df" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Check"</string>
<string name="native_9d988c13c67e88e6" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Read this phone\'s location"</string>
<string name="native_9db0df0afb47b562" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Skill Key"</string>
<string name="native_9dca3c2b01b40c0b" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Expires in %1$sm %2$ss"</string>
<string name="native_9e37aa0704a3cf44" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Installed %1$s."</string>
<string name="native_9e4635769f0aaf0d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateways"</string>
<string name="native_9e9bef85bb320b81" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Forwarding Mode"</string>
@@ -1221,7 +1221,6 @@
<string name="native_d2ee46edf00c8eb6" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"... +%1$s more"</string>
<string name="native_d30f33731adb6b40" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Expand plan checklist"</string>
<string name="native_d31de1a5c5c8ba2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Browser"</string>
<string name="native_d353a99eb4556847" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Cancelled"</string>
<string name="native_d354ee936f59115e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"screen record"</string>
<string name="native_d3974fd6d3b6c81f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Run Pending"</string>
<string name="native_d3f75e24411053c4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Personal AI on your devices"</string>
@@ -1344,7 +1343,6 @@
<string name="native_e89bdf093e8a6756" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Providers &amp; Models"</string>
<string name="native_e93372533f323b2f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Node"</string>
<string name="native_e98feb46bd5d7792" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s "</string>
<string name="native_e9e3a4b23345cb57" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Expires in %1$ss"</string>
<string name="native_ea14eb224e89a810" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Prompt unavailable"</string>
<string name="native_ea2100dc89ae9fe2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Logs"</string>
<string name="native_ea5cddf7a04d7b94" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Connect the gateway to inspect Skill Workshop proposals."</string>
@@ -1,9 +1,17 @@
package ai.openclaw.app.chat
import ai.openclaw.app.gateway.GatewayConnectErrorDetails
import ai.openclaw.app.gateway.GatewayRequestRejected
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.gateway.Question
import ai.openclaw.app.gateway.QuestionAnswers
import ai.openclaw.app.gateway.QuestionAnswersAnswersValue
import ai.openclaw.app.gateway.QuestionGetResult
import ai.openclaw.app.gateway.QuestionListResult
import ai.openclaw.app.gateway.QuestionOption
import ai.openclaw.app.gateway.QuestionRecord
import ai.openclaw.app.ui.chat.questionCountdown
import ai.openclaw.app.ui.chat.terminalQuestionAnswer
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceTimeBy
@@ -12,6 +20,8 @@ import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@@ -54,15 +64,42 @@ class ChatQuestionTest {
}
@Test
fun terminalPromptRetentionMatchesGatewayGrace() {
fun terminalPromptsRemainInTheTimeline() {
val prompt =
ChatQuestionPrompt(
record = record(status = "answered"),
terminalObservedAtMs = 1_000,
)
assertTrue(prompt.shouldRetainAfterList(15_999))
assertFalse(prompt.shouldRetainAfterList(16_000))
assertEquals(ChatQuestionStatus.AnsweredElsewhere, prompt.status(nowMs = Long.MAX_VALUE))
}
@Test
fun countdownMatchesWebMinuteSecondFormat() {
assertEquals("1:05", questionCountdown(expiresAtMs = 65_000, nowMs = 0))
assertEquals("0:05", questionCountdown(expiresAtMs = 4_001, nowMs = 0))
assertEquals("0:00", questionCountdown(expiresAtMs = 1_000, nowMs = 2_000))
}
@Test
fun terminalSummaryUsesAnswersAndStatusLabels() {
val answered =
ChatQuestionPrompt(
record =
record(status = "answered").copy(
answers = QuestionAnswers(mapOf("meal" to QuestionAnswersAnswersValue(listOf("Pizza", "Salad")))),
),
answeredLocally = true,
)
assertEquals("Pizza, Salad", terminalQuestionAnswer(answered, question, ChatQuestionStatus.Answered))
assertEquals("Skipped", terminalQuestionAnswer(answered, question, ChatQuestionStatus.Cancelled))
assertEquals("Expired", terminalQuestionAnswer(answered, question, ChatQuestionStatus.Expired))
assertEquals("Unavailable", terminalQuestionAnswer(answered, question, ChatQuestionStatus.Unavailable))
assertEquals(
"Answered elsewhere",
terminalQuestionAnswer(answered.copy(record = answered.record.copy(answers = null)), question, ChatQuestionStatus.AnsweredElsewhere),
)
}
@Test
@@ -162,6 +199,54 @@ class ChatQuestionTest {
advanceUntilIdle()
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun replayedPendingEventCannotReopenResolvedQuestion() =
runTest {
val json = Json { ignoreUnknownKeys = true }
val pending = record(expiresAtMs = Long.MAX_VALUE)
val controller = ChatController(scope = this, json = json, requestGateway = { _, _ -> "{}" })
controller.handleGatewayEvent("question.requested", json.encodeToString(pending))
controller.handleGatewayEvent("question.resolved", """{"id":"ask_123","status":"answered"}""")
controller.handleGatewayEvent("question.requested", json.encodeToString(pending))
assertEquals(
ChatQuestionStatus.AnsweredElsewhere,
controller.questions.value
.single()
.status(),
)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun pendingListRecordCannotReopenResolvedQuestion() =
runTest {
val json = Json { ignoreUnknownKeys = true }
val pending = record(expiresAtMs = Long.MAX_VALUE)
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { method, _ ->
if (method == "question.list") json.encodeToString(QuestionListResult(listOf(pending))) else "{}"
},
)
controller.handleGatewayEvent("question.requested", json.encodeToString(pending))
controller.handleGatewayEvent("question.resolved", """{"id":"ask_123","status":"cancelled"}""")
controller.handleGatewayEvent("health", null)
advanceUntilIdle()
assertEquals(
ChatQuestionStatus.Cancelled,
controller.questions.value
.single()
.status(),
)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun resolvedEventReconcilesAfterDiscardingOlderList() =
@@ -205,7 +290,7 @@ class ChatQuestionTest {
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun questionListRetainsRecentlyResolvedCard() =
fun questionListRetainsResolvedSummaryPermanently() =
runTest {
val json = Json { ignoreUnknownKeys = true }
val pending = record(id = "ask_done", expiresAtMs = Long.MAX_VALUE)
@@ -231,25 +316,694 @@ class ChatQuestionTest {
.status(),
)
advanceTimeBy(QUESTION_TERMINAL_RETENTION_MS)
advanceTimeBy(60_000)
runCurrent()
assertTrue(controller.questions.value.isEmpty())
assertEquals(listOf("ask_done"), controller.questions.value.map { it.record.id })
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun locallyExpiredQuestionIsEvictedAfterTerminalGrace() =
fun locallyExpiredQuestionRemainsAsSummary() =
runTest {
val json = Json { ignoreUnknownKeys = true }
val controller = ChatController(scope = this, json = json, requestGateway = { _, _ -> "{}" })
val pending = record(expiresAtMs = 1_000)
controller.handleGatewayEvent("question.requested", json.encodeToString(pending))
advanceTimeBy(QUESTION_TERMINAL_RETENTION_MS + 1_001)
advanceUntilIdle()
assertEquals(
ChatQuestionStatus.Expired,
controller.questions.value
.single()
.status(),
)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun localExpiryReconcilesMissedRemoteAnswer() =
runTest {
val json = Json { ignoreUnknownKeys = true }
val pending = record(expiresAtMs = System.currentTimeMillis() + 1_000)
val answered = pending.copy(status = "answered")
var listCalls = 0
var getCalls = 0
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { method, _ ->
when (method) {
"question.list" -> {
listCalls += 1
json.encodeToString(QuestionListResult(if (listCalls == 1) listOf(pending) else emptyList()))
}
"question.get" -> {
getCalls += 1
json.encodeToString(QuestionGetResult(answered))
}
else -> "{}"
}
},
)
controller.handleGatewayEvent("question.requested", json.encodeToString(pending))
runCurrent()
advanceTimeBy(2_000)
advanceUntilIdle()
assertEquals(2, listCalls)
assertEquals(1, getCalls)
assertEquals(
ChatQuestionStatus.AnsweredElsewhere,
controller.questions.value
.single()
.status(),
)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun missingPendingQuestionUsesPerIdGetFallback() =
runTest {
val json = Json { ignoreUnknownKeys = true }
val pending = record(expiresAtMs = Long.MAX_VALUE)
val answered =
pending.copy(
status = "answered",
answers = QuestionAnswers(mapOf("meal" to QuestionAnswersAnswersValue(listOf("Tacos")))),
)
var getParams: String? = null
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { method, params ->
when (method) {
"question.list" -> json.encodeToString(QuestionListResult(emptyList()))
"question.get" -> {
getParams = params
json.encodeToString(QuestionGetResult(answered))
}
else -> "{}"
}
},
)
controller.handleGatewayEvent("question.requested", json.encodeToString(pending))
advanceUntilIdle()
assertEquals(
"ask_123",
json
.parseToJsonElement(checkNotNull(getParams))
.jsonObject["id"]
?.jsonPrimitive
?.content,
)
assertEquals(
listOf("Tacos"),
controller.questions.value
.single()
.record.answers
?.answers
?.get("meal")
?.answers,
)
assertEquals(
ChatQuestionStatus.AnsweredElsewhere,
controller.questions.value
.single()
.status(),
)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun successfulRefreshRecordsApplyWhenAnotherFallbackFails() =
runTest {
val json = Json { ignoreUnknownKeys = true }
val listedPending = record(id = "ask_listed")
val recoveredPending = record(id = "ask_recovered")
val failingPending = record(id = "ask_failing")
val newlyMissingPending = record(id = "ask_newly_missing")
val listedAnswered =
listedPending.copy(
status = "answered",
answers = QuestionAnswers(mapOf("meal" to QuestionAnswersAnswersValue(listOf("Tacos")))),
)
val recoveredAnswered = recoveredPending.copy(status = "answered")
val failingAnswered = failingPending.copy(status = "answered")
val newlyMissingAnswered = newlyMissingPending.copy(status = "answered")
val getCalls = mutableMapOf<String, Int>()
var fallbackFailed = false
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { method, params ->
when (method) {
"question.list" -> {
val records =
if (!fallbackFailed) {
listOf(listedAnswered, newlyMissingPending)
} else {
listOf(listedAnswered)
}
json.encodeToString(QuestionListResult(records))
}
"question.get" -> {
val id =
json
.parseToJsonElement(checkNotNull(params))
.jsonObject
.getValue("id")
.jsonPrimitive
.content
getCalls[id] = getCalls.getOrDefault(id, 0) + 1
when (id) {
recoveredPending.id ->
json.encodeToString(
QuestionGetResult(
if (getCalls.getValue(id) == 1) recoveredPending else recoveredAnswered,
),
)
newlyMissingPending.id -> json.encodeToString(QuestionGetResult(newlyMissingAnswered))
failingPending.id -> {
if (getCalls.getValue(id) == 1) {
fallbackFailed = true
error("temporary question.get failure")
}
json.encodeToString(QuestionGetResult(failingAnswered))
}
else -> error("unexpected question id")
}
}
else -> "{}"
}
},
)
controller.handleGatewayEvent("question.requested", json.encodeToString(listedPending))
controller.handleGatewayEvent("question.requested", json.encodeToString(recoveredPending))
controller.handleGatewayEvent("question.requested", json.encodeToString(failingPending))
controller.handleGatewayEvent("question.requested", json.encodeToString(newlyMissingPending))
runCurrent()
assertTrue(controller.questions.value.isEmpty())
val prompts = controller.questions.value.associateBy { it.record.id }
assertEquals(ChatQuestionStatus.AnsweredElsewhere, prompts.getValue("ask_listed").status())
assertEquals(
listOf("Tacos"),
prompts
.getValue("ask_listed")
.record
.answers
?.answers
?.get("meal")
?.answers,
)
assertEquals(ChatQuestionStatus.Pending, prompts.getValue("ask_recovered").status())
assertEquals(ChatQuestionStatus.Pending, prompts.getValue("ask_failing").status())
assertEquals(ChatQuestionStatus.Pending, prompts.getValue("ask_newly_missing").status())
advanceTimeBy(1_000)
runCurrent()
assertEquals(2, getCalls["ask_recovered"])
assertEquals(2, getCalls["ask_failing"])
assertEquals(1, getCalls["ask_newly_missing"])
assertEquals(
ChatQuestionStatus.AnsweredElsewhere,
controller.questions.value
.single { it.record.id == "ask_newly_missing" }
.status(),
)
assertEquals(
ChatQuestionStatus.AnsweredElsewhere,
controller.questions.value
.single { it.record.id == "ask_recovered" }
.status(),
)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun questionGetRetryResetsExhaustedBudgetAfterAnotherQuestionChanges() =
runTest {
val json = Json { ignoreUnknownKeys = true }
val recovering = record(id = "ask_recovering")
val unrelated = record(id = "ask_unrelated")
val recovered = recovering.copy(status = "answered")
var getCalls = 0
val finalGetStarted = CompletableDeferred<Unit>()
val releaseFinalGet = CompletableDeferred<Unit>()
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { method, _ ->
when (method) {
"question.list" -> json.encodeToString(QuestionListResult(listOf(unrelated)))
"question.get" -> {
getCalls += 1
if (getCalls < 4) error("temporary question.get failure")
if (getCalls == 4) {
finalGetStarted.complete(Unit)
releaseFinalGet.await()
}
json.encodeToString(QuestionGetResult(recovered))
}
"question.resolve" -> "{}"
else -> "{}"
}
},
)
controller.handleGatewayEvent("question.requested", json.encodeToString(recovering))
controller.handleGatewayEvent("question.requested", json.encodeToString(unrelated))
runCurrent()
assertEquals(1, getCalls)
advanceTimeBy(1_000)
runCurrent()
advanceTimeBy(2_000)
runCurrent()
advanceTimeBy(4_000)
runCurrent()
finalGetStarted.await()
assertEquals(4, getCalls)
controller.skipQuestion(unrelated.id)
runCurrent()
releaseFinalGet.complete(Unit)
runCurrent()
advanceTimeBy(1_000)
runCurrent()
assertEquals(5, getCalls)
assertEquals(
ChatQuestionStatus.AnsweredElsewhere,
controller.questions.value
.single { it.record.id == recovering.id }
.status(),
)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun questionGetRetryResetsBudgetWhenRevisionChangesDuringFinalBackoff() =
runTest {
val json = Json { ignoreUnknownKeys = true }
val recovering = record(id = "ask_recovering")
val unrelated = record(id = "ask_unrelated")
val recovered = recovering.copy(status = "answered")
var getCalls = 0
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { method, _ ->
when (method) {
"question.list" -> json.encodeToString(QuestionListResult(listOf(unrelated)))
"question.get" -> {
getCalls += 1
if (getCalls < 5) error("temporary question.get failure")
json.encodeToString(QuestionGetResult(recovered))
}
"question.resolve" -> "{}"
else -> "{}"
}
},
)
controller.handleGatewayEvent("question.requested", json.encodeToString(recovering))
controller.handleGatewayEvent("question.requested", json.encodeToString(unrelated))
runCurrent()
advanceTimeBy(1_000)
runCurrent()
advanceTimeBy(2_000)
runCurrent()
assertEquals(3, getCalls)
controller.skipQuestion(unrelated.id)
runCurrent()
advanceTimeBy(4_000)
runCurrent()
assertEquals(4, getCalls)
advanceTimeBy(1_000)
runCurrent()
assertEquals(5, getCalls)
assertEquals(
ChatQuestionStatus.AnsweredElsewhere,
controller.questions.value
.single { it.record.id == recovering.id }
.status(),
)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun locallyExpiredMissingQuestionUsesPerIdGetFallback() =
runTest {
val json = Json { ignoreUnknownKeys = true }
val pending = record(expiresAtMs = 0)
val answered =
pending.copy(
status = "answered",
answers = QuestionAnswers(mapOf("meal" to QuestionAnswersAnswersValue(listOf("Tacos")))),
)
var getCalls = 0
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { method, _ ->
when (method) {
"question.list" -> json.encodeToString(QuestionListResult(emptyList()))
"question.get" -> {
getCalls += 1
json.encodeToString(QuestionGetResult(answered))
}
else -> "{}"
}
},
)
controller.handleGatewayEvent("question.requested", json.encodeToString(pending))
runCurrent()
assertEquals(1, getCalls)
assertEquals(
listOf("Tacos"),
controller.questions.value
.single()
.record.answers
?.answers
?.get("meal")
?.answers,
)
assertEquals(
ChatQuestionStatus.AnsweredElsewhere,
controller.questions.value
.single()
.status(),
)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun missingQuestionGetRetriesAfterOneTwoAndFourSeconds() =
runTest {
val json = Json { ignoreUnknownKeys = true }
val pending = record(expiresAtMs = Long.MAX_VALUE)
var getCalls = 0
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { method, _ ->
when (method) {
"question.list" -> json.encodeToString(QuestionListResult(emptyList()))
"question.get" -> {
getCalls += 1
if (getCalls < 4) error("temporary question.get failure")
json.encodeToString(QuestionGetResult(pending))
}
else -> "{}"
}
},
)
controller.handleGatewayEvent("question.requested", json.encodeToString(pending))
runCurrent()
assertEquals(1, getCalls)
advanceTimeBy(999)
runCurrent()
assertEquals(1, getCalls)
advanceTimeBy(1)
runCurrent()
assertEquals(2, getCalls)
advanceTimeBy(2_000)
runCurrent()
assertEquals(3, getCalls)
advanceTimeBy(4_000)
runCurrent()
assertEquals(4, getCalls)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun missingQuestionNotFoundHasUnknownTerminalOutcome() =
runTest {
val json = Json { ignoreUnknownKeys = true }
val pending = record(expiresAtMs = Long.MAX_VALUE)
var getCalls = 0
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { method, _ ->
when (method) {
"question.list" -> json.encodeToString(QuestionListResult(emptyList()))
"question.get" ->
run {
getCalls += 1
throw GatewayRequestRejected(
GatewaySession.ErrorShape(
code = "INVALID_REQUEST",
message = "question not found",
details =
GatewayConnectErrorDetails(
code = null,
reason = "QUESTION_NOT_FOUND",
canRetryWithDeviceToken = false,
recommendedNextStep = null,
),
),
)
}
else -> "{}"
}
},
)
controller.handleGatewayEvent("question.requested", json.encodeToString(pending))
advanceUntilIdle()
assertEquals(
ChatQuestionStatus.Unavailable,
controller.questions.value
.single()
.status(),
)
controller.handleGatewayEvent("question.requested", json.encodeToString(pending))
controller.handleGatewayEvent("health", null)
advanceUntilIdle()
assertEquals(1, getCalls)
assertEquals(
ChatQuestionStatus.Unavailable,
controller.questions.value
.single()
.status(),
)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun skipUsesCancelResolutionAndKeepsSkippedSummary() =
runTest {
val json = Json { ignoreUnknownKeys = true }
val pending = record(expiresAtMs = Long.MAX_VALUE)
var resolveParams: String? = null
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { method, params ->
when (method) {
"question.list" -> json.encodeToString(QuestionListResult(listOf(pending)))
"question.resolve" -> {
resolveParams = params
"{}"
}
else -> "{}"
}
},
)
controller.handleGatewayEvent("question.requested", json.encodeToString(pending))
controller.skipQuestion(pending.id)
advanceUntilIdle()
val params = json.parseToJsonElement(checkNotNull(resolveParams)).jsonObject
assertEquals("ask_123", params["id"]?.jsonPrimitive?.content)
assertTrue(params["cancel"]?.jsonPrimitive?.content?.toBoolean() == true)
assertFalse("answers" in params)
assertEquals(
ChatQuestionStatus.Cancelled,
controller.questions.value
.single()
.status(),
)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun skipClaimExposesSkippingProgress() =
runTest {
val json = Json { ignoreUnknownKeys = true }
val pending = record(expiresAtMs = Long.MAX_VALUE)
val resolveStarted = CompletableDeferred<Unit>()
val releaseResolve = CompletableDeferred<Unit>()
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { method, _ ->
when (method) {
"question.resolve" -> {
resolveStarted.complete(Unit)
releaseResolve.await()
"{}"
}
else -> "{}"
}
},
)
controller.handleGatewayEvent("question.requested", json.encodeToString(pending))
controller.skipQuestion(pending.id)
runCurrent()
resolveStarted.await()
val submitting = controller.questions.value.single()
assertEquals(ChatQuestionStatus.Submitting, submitting.status())
assertTrue(submitting.submitting)
assertTrue(submitting.skipping)
releaseResolve.complete(Unit)
advanceUntilIdle()
val completed = controller.questions.value.single()
assertEquals(ChatQuestionStatus.Cancelled, completed.status())
assertFalse(completed.submitting)
assertFalse(completed.skipping)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun answerClaimBlocksCompetingSkip() =
runTest {
val json = Json { ignoreUnknownKeys = true }
val pending = record(expiresAtMs = Long.MAX_VALUE)
val requestStarted = CompletableDeferred<Unit>()
val releaseRequest = CompletableDeferred<Unit>()
val resolveParams = mutableListOf<String>()
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { method, params ->
when (method) {
"question.list" -> json.encodeToString(QuestionListResult(listOf(pending)))
"question.resolve" -> {
resolveParams.add(checkNotNull(params))
requestStarted.complete(Unit)
releaseRequest.await()
"{}"
}
else -> "{}"
}
},
)
controller.handleGatewayEvent("question.requested", json.encodeToString(pending))
controller.resolveQuestion(pending.id, mapOf("meal" to listOf("Pizza")))
runCurrent()
requestStarted.await()
controller.skipQuestion(pending.id)
releaseRequest.complete(Unit)
advanceUntilIdle()
assertEquals(1, resolveParams.size)
assertFalse("cancel" in resolveParams.single())
assertEquals(
ChatQuestionStatus.Answered,
controller.questions.value
.single()
.status(),
)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun successfulAnswerOverridesUnavailableRecoveryRace() =
runTest {
val json = Json { ignoreUnknownKeys = true }
val pending = record(expiresAtMs = Long.MAX_VALUE)
val resolveStarted = CompletableDeferred<Unit>()
val releaseResolve = CompletableDeferred<Unit>()
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { method, _ ->
when (method) {
"question.list" ->
json.encodeToString(
QuestionListResult(if (resolveStarted.isCompleted) emptyList() else listOf(pending)),
)
"question.get" ->
throw GatewayRequestRejected(
GatewaySession.ErrorShape(
code = "INVALID_REQUEST",
message = "question not found",
details =
GatewayConnectErrorDetails(
code = null,
reason = "QUESTION_NOT_FOUND",
canRetryWithDeviceToken = false,
recommendedNextStep = null,
),
),
)
"question.resolve" -> {
resolveStarted.complete(Unit)
releaseResolve.await()
"{}"
}
else -> "{}"
}
},
)
controller.handleGatewayEvent("question.requested", json.encodeToString(pending))
runCurrent()
controller.resolveQuestion(pending.id, mapOf("meal" to listOf("Pizza")))
runCurrent()
resolveStarted.await()
controller.handleGatewayEvent("health", null)
runCurrent()
assertEquals(
ChatQuestionStatus.Unavailable,
controller.questions.value
.single()
.status(),
)
releaseResolve.complete(Unit)
advanceUntilIdle()
assertEquals(
ChatQuestionStatus.Answered,
controller.questions.value
.single()
.status(),
)
}
private fun record(
@@ -2,9 +2,14 @@ package ai.openclaw.app.ui.chat
import ai.openclaw.app.chat.ChatMessage
import ai.openclaw.app.chat.ChatMessageContent
import ai.openclaw.app.chat.ChatQuestionPrompt
import ai.openclaw.app.gateway.QuestionAnswers
import ai.openclaw.app.gateway.QuestionAnswersAnswersValue
import ai.openclaw.app.gateway.QuestionRecord
import androidx.compose.runtime.saveable.SaverScope
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -294,6 +299,39 @@ class ChatReaderScrollControllerTest {
assertNull(transition.scrollIndex)
}
@Test
fun questionTerminalStateAndHydratedAnswersChangeContentVersion() {
val pending =
ChatQuestionPrompt(
QuestionRecord(
id = "ask-1",
questions = emptyList(),
createdAtMs = 1_000,
expiresAtMs = Long.MAX_VALUE,
status = "pending",
),
)
val pendingTimeline = questionTimeline(pending)
val unavailableTimeline = questionTimeline(pending.copy(recoveryUnavailable = true))
val answered = pending.copy(record = pending.record.copy(status = "answered"))
val answeredWithoutValues = questionTimeline(answered)
val answeredWithValues =
questionTimeline(
answered.copy(
record =
answered.record.copy(
answers =
QuestionAnswers(
mapOf("choice" to QuestionAnswersAnswersValue(listOf("Yes"))),
),
),
),
)
assertNotEquals(pendingTimeline.latestContentVersion, unavailableTimeline.latestContentVersion)
assertNotEquals(answeredWithoutValues.latestContentVersion, answeredWithValues.latestContentVersion)
}
private fun timeline(vararg messages: ChatMessage): ChatTimeline =
buildChatTimeline(
messages = messages.toList(),
@@ -304,6 +342,15 @@ class ChatReaderScrollControllerTest {
private fun emptyTimeline(): ChatTimeline = timeline()
private fun questionTimeline(question: ChatQuestionPrompt): ChatTimeline =
buildChatTimeline(
messages = emptyList(),
pendingRunCount = 0,
pendingToolCalls = emptyList(),
streamingAssistantText = null,
questions = listOf(question),
)
private fun activeTimeline(
message: ChatMessage,
stream: String?,
+952
View File
@@ -14689,6 +14689,278 @@
}
}
},
"Answered": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Answered"
}
},
"zh-CN": {
"stringUnit": {
"state": "translated",
"value": "已回答"
}
},
"zh-TW": {
"stringUnit": {
"state": "translated",
"value": "已回答"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Respondido"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Beantwortet"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Respondida"
}
},
"ja-JP": {
"stringUnit": {
"state": "translated",
"value": "回答済み"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "응답 완료"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Répondu"
}
},
"hi": {
"stringUnit": {
"state": "translated",
"value": "उत्तर दिया गया"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "تمت الإجابة"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Risposto"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Yanıtlandı"
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Відповідь надано"
}
},
"id": {
"stringUnit": {
"state": "translated",
"value": "Sudah dijawab"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Udzielono odpowiedzi"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "ตอบแล้ว"
}
},
"vi": {
"stringUnit": {
"state": "translated",
"value": "Đã trả lời"
}
},
"nl": {
"stringUnit": {
"state": "translated",
"value": "Beantwoord"
}
},
"fa": {
"stringUnit": {
"state": "translated",
"value": "پاسخ داده شده"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Отвечено"
}
},
"sv": {
"stringUnit": {
"state": "translated",
"value": "Besvarad"
}
}
}
},
"Answered elsewhere": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Answered elsewhere"
}
},
"zh-CN": {
"stringUnit": {
"state": "translated",
"value": "已在其他地方回答"
}
},
"zh-TW": {
"stringUnit": {
"state": "translated",
"value": "已在其他地方回答"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Respondido em outro lugar"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Anderswo beantwortet"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Respondida en otro lugar"
}
},
"ja-JP": {
"stringUnit": {
"state": "translated",
"value": "他で回答済み"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "다른 곳에서 응답 완료"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Répondu ailleurs"
}
},
"hi": {
"stringUnit": {
"state": "translated",
"value": "कहीं और उत्तर दिया गया"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "تمت الإجابة في مكان آخر"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Risposto altrove"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Başka yerde yanıtlandı"
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Відповідь надано в іншому місці"
}
},
"id": {
"stringUnit": {
"state": "translated",
"value": "Dijawab di tempat lain"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Odpowiedziano gdzie indziej"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "ตอบจากที่อื่นแล้ว"
}
},
"vi": {
"stringUnit": {
"state": "translated",
"value": "Đã trả lời ở nơi khác"
}
},
"nl": {
"stringUnit": {
"state": "translated",
"value": "Elders beantwoord"
}
},
"fa": {
"stringUnit": {
"state": "translated",
"value": "در جای دیگری پاسخ داده شده"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Отвечено в другом месте"
}
},
"sv": {
"stringUnit": {
"state": "translated",
"value": "Besvarad på annat håll"
}
}
}
},
"Any binary: %@": {
"localizations": {
"en": {
@@ -64873,6 +65145,142 @@
}
}
},
"Expired": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Expired"
}
},
"zh-CN": {
"stringUnit": {
"state": "translated",
"value": "已过期"
}
},
"zh-TW": {
"stringUnit": {
"state": "translated",
"value": "已過期"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Expirado"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Abgelaufen"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Caducada"
}
},
"ja-JP": {
"stringUnit": {
"state": "translated",
"value": "期限切れ"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "만료됨"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Expiré"
}
},
"hi": {
"stringUnit": {
"state": "translated",
"value": "समय समाप्त"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "انتهت الصلاحية"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Scaduto"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Süresi doldu"
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Термін дії минув"
}
},
"id": {
"stringUnit": {
"state": "translated",
"value": "Kedaluwarsa"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Wygasło"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "หมดอายุแล้ว"
}
},
"vi": {
"stringUnit": {
"state": "translated",
"value": "Đã hết hạn"
}
},
"nl": {
"stringUnit": {
"state": "translated",
"value": "Verlopen"
}
},
"fa": {
"stringUnit": {
"state": "translated",
"value": "منقضی شده"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Срок истёк"
}
},
"sv": {
"stringUnit": {
"state": "translated",
"value": "Utgången"
}
}
}
},
"Expires": {
"localizations": {
"en": {
@@ -125801,6 +126209,142 @@
}
}
},
"Question summary": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Question summary"
}
},
"zh-CN": {
"stringUnit": {
"state": "translated",
"value": "问题摘要"
}
},
"zh-TW": {
"stringUnit": {
"state": "translated",
"value": "問題摘要"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Resumo da pergunta"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Zusammenfassung der Frage"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Resumen de la pregunta"
}
},
"ja-JP": {
"stringUnit": {
"state": "translated",
"value": "質問の概要"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "질문 요약"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Résumé de la question"
}
},
"hi": {
"stringUnit": {
"state": "translated",
"value": "प्रश्न का सारांश"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "ملخص السؤال"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Riepilogo della domanda"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Soru özeti"
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Підсумок запитання"
}
},
"id": {
"stringUnit": {
"state": "translated",
"value": "Ringkasan pertanyaan"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Podsumowanie pytania"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "สรุปคำถาม"
}
},
"vi": {
"stringUnit": {
"state": "translated",
"value": "Tóm tắt câu hỏi"
}
},
"nl": {
"stringUnit": {
"state": "translated",
"value": "Samenvatting van de vraag"
}
},
"fa": {
"stringUnit": {
"state": "translated",
"value": "خلاصه پرسش"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Краткое описание вопроса"
}
},
"sv": {
"stringUnit": {
"state": "translated",
"value": "Frågesammanfattning"
}
}
}
},
"Queue": {
"localizations": {
"en": {
@@ -159665,6 +160209,414 @@
}
}
},
"Skip": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Skip"
}
},
"zh-CN": {
"stringUnit": {
"state": "translated",
"value": "跳过"
}
},
"zh-TW": {
"stringUnit": {
"state": "translated",
"value": "略過"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Pular"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Überspringen"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Omitir"
}
},
"ja-JP": {
"stringUnit": {
"state": "translated",
"value": "スキップ"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "건너뛰기"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Ignorer"
}
},
"hi": {
"stringUnit": {
"state": "translated",
"value": "छोड़ें"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "تخطي"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Salta"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Atla"
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Пропустити"
}
},
"id": {
"stringUnit": {
"state": "translated",
"value": "Lewati"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Pomiń"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "ข้าม"
}
},
"vi": {
"stringUnit": {
"state": "translated",
"value": "Bỏ qua"
}
},
"nl": {
"stringUnit": {
"state": "translated",
"value": "Overslaan"
}
},
"fa": {
"stringUnit": {
"state": "translated",
"value": "رد کردن"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Пропустить"
}
},
"sv": {
"stringUnit": {
"state": "translated",
"value": "Hoppa över"
}
}
}
},
"Skipped": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Skipped"
}
},
"zh-CN": {
"stringUnit": {
"state": "translated",
"value": "已跳过"
}
},
"zh-TW": {
"stringUnit": {
"state": "translated",
"value": "已略過"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Pulado"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Übersprungen"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Omitido"
}
},
"ja-JP": {
"stringUnit": {
"state": "translated",
"value": "スキップ済み"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "건너뜀"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Ignoré"
}
},
"hi": {
"stringUnit": {
"state": "translated",
"value": "छोड़ा गया"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "تم التخطي"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Saltato"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Atlandı"
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Пропущено"
}
},
"id": {
"stringUnit": {
"state": "translated",
"value": "Dilewati"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Pominięto"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "ข้ามแล้ว"
}
},
"vi": {
"stringUnit": {
"state": "translated",
"value": "Đã bỏ qua"
}
},
"nl": {
"stringUnit": {
"state": "translated",
"value": "Overgeslagen"
}
},
"fa": {
"stringUnit": {
"state": "translated",
"value": "رد شد"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Пропущено"
}
},
"sv": {
"stringUnit": {
"state": "translated",
"value": "Överhoppad"
}
}
}
},
"Skipping…": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Skipping…"
}
},
"zh-CN": {
"stringUnit": {
"state": "translated",
"value": "正在跳过…"
}
},
"zh-TW": {
"stringUnit": {
"state": "translated",
"value": "正在略過…"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Pulando…"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Wird übersprungen…"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Omitiendo…"
}
},
"ja-JP": {
"stringUnit": {
"state": "translated",
"value": "スキップ中…"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "건너뛰는 중…"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Ignoré…"
}
},
"hi": {
"stringUnit": {
"state": "translated",
"value": "छोड़ा जा रहा है…"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "جارٍ التخطي…"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Ignoro…"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Atlanıyor…"
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Пропуск…"
}
},
"id": {
"stringUnit": {
"state": "translated",
"value": "Melewati…"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Pomijanie…"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "กำลังข้าม…"
}
},
"vi": {
"stringUnit": {
"state": "translated",
"value": "Đang bỏ qua…"
}
},
"nl": {
"stringUnit": {
"state": "translated",
"value": "Overslaan…"
}
},
"fa": {
"stringUnit": {
"state": "translated",
"value": "در حال رد کردن…"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Пропуск…"
}
},
"sv": {
"stringUnit": {
"state": "translated",
"value": "Hoppar över…"
}
}
}
},
"Source": {
"localizations": {
"en": {
@@ -504,10 +504,19 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
return try JSONDecoder().decode(QuestionListResult.self, from: data).questions
}
func getQuestion(id: String) async throws -> QuestionRecord {
let data = try await self.gateway.request(OpenClawChatGatewayRequests.questionGet(id: id))
return try JSONDecoder().decode(QuestionGetResult.self, from: data).question
}
func resolveQuestion(id: String, answers: [String: [String]]) async throws {
_ = try await self.gateway.request(OpenClawChatGatewayRequests.resolveQuestion(id: id, answers: answers))
}
func cancelQuestion(id: String) async throws {
_ = try await self.gateway.request(OpenClawChatGatewayRequests.cancelQuestion(id: id))
}
func events() -> AsyncStream<OpenClawChatTransportEvent> {
AsyncStream { continuation in
let task = Task {
@@ -568,11 +568,21 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
return try JSONDecoder().decode(QuestionListResult.self, from: data).questions
}
func getQuestion(id: String) async throws -> QuestionRecord {
let data = try await GatewayConnection.shared.request(OpenClawChatGatewayRequests.questionGet(id: id))
return try JSONDecoder().decode(QuestionGetResult.self, from: data).question
}
func resolveQuestion(id: String, answers: [String: [String]]) async throws {
_ = try await GatewayConnection.shared.request(
OpenClawChatGatewayRequests.resolveQuestion(id: id, answers: answers))
}
func cancelQuestion(id: String) async throws {
_ = try await GatewayConnection.shared.request(
OpenClawChatGatewayRequests.cancelQuestion(id: id))
}
func waitForRunCompletion(
runId rawRunId: String,
timeoutMs: Int) async -> OpenClawChatRunObservation
@@ -84,6 +84,13 @@ public enum OpenClawChatGatewayRequests {
OpenClawChatGatewayRequest(method: "question.list", timeoutMs: self.defaultTimeoutMs)
}
public static func questionGet(id: String) -> OpenClawChatGatewayRequest {
OpenClawChatGatewayRequest(
method: "question.get",
params: ["id": AnyCodable(id)],
timeoutMs: self.defaultTimeoutMs)
}
public static func resolveQuestion(
id: String,
answers: [String: [String]]) -> OpenClawChatGatewayRequest
@@ -98,6 +105,16 @@ public enum OpenClawChatGatewayRequests {
timeoutMs: self.mutationTimeoutMs)
}
public static func cancelQuestion(id: String) -> OpenClawChatGatewayRequest {
OpenClawChatGatewayRequest(
method: "question.resolve",
params: [
"id": AnyCodable(id),
"cancel": AnyCodable(true),
],
timeoutMs: self.mutationTimeoutMs)
}
public static func sessionsList(
limit: Int?,
search: String?,
@@ -11,38 +11,61 @@ public enum OpenClawQuestionCardStatus: Sendable, Equatable {
case answeredElsewhere
case expired
case cancelled
case unavailable
}
@MainActor
@Observable
public final class OpenClawQuestionCardModel: Identifiable {
private static let terminalRetentionSeconds: TimeInterval = 15
public let id: String
public private(set) var record: QuestionRecord
public private(set) var isSubmitting = false
public private(set) var isSkipping = false
public private(set) var wasAnsweredLocally = false
public private(set) var errorText: String?
public private(set) var selectedOptions: [String: Set<String>] = [:]
public private(set) var otherText: [String: String] = [:]
private var terminalObservedAt: Date?
public private(set) var isLocallyExpired = false
public private(set) var isRecoveryUnavailable = false
public init(record: QuestionRecord) {
self.id = record.id
self.record = record
self.terminalObservedAt = record.status == .pending ? nil : Date()
}
@discardableResult
public func apply(record: QuestionRecord, at date: Date = Date()) -> Bool {
guard record.id == self.id, !Self.recordsMatch(self.record, record) else { return false }
self.record = record
self.isSubmitting = self.isSubmitting && record.status == .pending
self.terminalObservedAt = record.status == .pending ? nil : (self.terminalObservedAt ?? date)
public func apply(record: QuestionRecord) -> Bool {
let nextRecord = self.preservingKnownAnswers(in: record)
guard record.id == self.id,
!((self.record.status != .pending || self.isRecoveryUnavailable) && record.status == .pending),
!Self.recordsMatch(self.record, nextRecord)
else { return false }
self.record = nextRecord
self.isSubmitting = self.isSubmitting && nextRecord.status == .pending
self.isSkipping = self.isSkipping && nextRecord.status == .pending
self.isLocallyExpired = false
self.isRecoveryUnavailable = false
return true
}
private func preservingKnownAnswers(in record: QuestionRecord) -> QuestionRecord {
guard record.status == .answered, record.answers == nil, let answers = self.record.answers else {
return record
}
return QuestionRecord(
id: record.id,
questions: record.questions,
agentid: record.agentid,
sessionkey: record.sessionkey,
createdatms: record.createdatms,
expiresatms: record.expiresatms,
status: record.status,
answers: answers,
resolvedby: record.resolvedby)
}
public func status(at date: Date = Date()) -> OpenClawQuestionCardStatus {
if self.isRecoveryUnavailable { return .unavailable }
switch self.record.status {
case .answered:
return self.wasAnsweredLocally ? .answered : .answeredElsewhere
@@ -51,7 +74,7 @@ public final class OpenClawQuestionCardModel: Identifiable {
case .expired:
return .expired
case .pending:
if date.timeIntervalSince1970 * 1000 >= Double(self.record.expiresatms) {
if self.isLocallyExpired || date.timeIntervalSince1970 * 1000 >= Double(self.record.expiresatms) {
return .expired
}
return self.isSubmitting ? .submitting : .pending
@@ -84,6 +107,17 @@ public final class OpenClawQuestionCardModel: Identifiable {
self.errorText = nil
}
@discardableResult
public func toggleOption(questionID: String, optionNumber: Int) -> Bool {
guard let question = self.record.questions.first(where: { $0.id == questionID }),
self.status() == .pending,
(1...4).contains(optionNumber),
question.options.indices.contains(optionNumber - 1)
else { return false }
self.toggleOption(questionID: questionID, label: question.options[optionNumber - 1].label)
return true
}
public func setOtherText(questionID: String, value: String) {
guard let question = self.record.questions.first(where: { $0.id == questionID }),
question.options.isEmpty || question.isother == true,
@@ -103,14 +137,25 @@ public final class OpenClawQuestionCardModel: Identifiable {
public func beginSubmission() -> [String: [String]]? {
guard let answers = self.answers(), self.status() == .pending else { return nil }
self.isSubmitting = true
self.isSkipping = false
self.errorText = nil
return answers
}
public func markAnsweredLocally(at date: Date = Date()) {
public func beginSkip() -> Bool {
guard self.status() == .pending else { return false }
self.isSubmitting = true
self.isSkipping = true
self.errorText = nil
return true
}
public func markAnsweredLocally(answers: [String: [String]]) {
self.wasAnsweredLocally = true
self.isSubmitting = false
self.terminalObservedAt = self.terminalObservedAt ?? date
self.isSkipping = false
self.isLocallyExpired = false
self.isRecoveryUnavailable = false
self.record = QuestionRecord(
id: self.record.id,
questions: self.record.questions,
@@ -119,18 +164,61 @@ public final class OpenClawQuestionCardModel: Identifiable {
createdatms: self.record.createdatms,
expiresatms: self.record.expiresatms,
status: .answered,
answers: self.answers().map { answers in
QuestionAnswers(answers: answers.mapValues { values in
AnyCodable(["answers": values])
})
},
answers: QuestionAnswers(answers: answers.mapValues { values in
AnyCodable(["answers": values])
}),
resolvedby: self.record.resolvedby)
}
public func apply(resolved: OpenClawQuestionResolvedEvent, at date: Date = Date()) {
public func markSkippedLocally() {
self.isSubmitting = false
self.isSkipping = false
self.isLocallyExpired = false
self.isRecoveryUnavailable = false
self.record = QuestionRecord(
id: self.record.id,
questions: self.record.questions,
agentid: self.record.agentid,
sessionkey: self.record.sessionkey,
createdatms: self.record.createdatms,
expiresatms: self.record.expiresatms,
status: .cancelled,
resolvedby: self.record.resolvedby)
}
// periphery:ignore - Public completion API for package consumers reconciling external answers.
public func markAnsweredElsewhere() {
self.isSubmitting = false
self.isSkipping = false
self.isLocallyExpired = false
self.isRecoveryUnavailable = false
self.record = QuestionRecord(
id: self.record.id,
questions: self.record.questions,
agentid: self.record.agentid,
sessionkey: self.record.sessionkey,
createdatms: self.record.createdatms,
expiresatms: self.record.expiresatms,
status: .answered,
resolvedby: self.record.resolvedby)
}
@discardableResult
public func markRecoveryUnavailable() -> Bool {
guard !self.isRecoveryUnavailable else { return false }
self.isSubmitting = false
self.isSkipping = false
self.isLocallyExpired = false
self.isRecoveryUnavailable = true
return true
}
public func apply(resolved: OpenClawQuestionResolvedEvent) {
guard resolved.id == self.id else { return }
self.isSubmitting = false
self.terminalObservedAt = self.terminalObservedAt ?? date
self.isSkipping = false
self.isLocallyExpired = false
self.isRecoveryUnavailable = false
self.record = QuestionRecord(
id: self.record.id,
questions: self.record.questions,
@@ -139,38 +227,49 @@ public final class OpenClawQuestionCardModel: Identifiable {
createdatms: self.record.createdatms,
expiresatms: self.record.expiresatms,
status: resolved.status,
answers: resolved.answers,
answers: resolved.answers ?? self.record.answers,
resolvedby: self.record.resolvedby)
}
public func failSubmission(_ message: String) {
self.isSubmitting = false
self.isSkipping = false
self.errorText = message
}
func shouldRetainAfterList(at date: Date) -> Bool {
guard let terminalObservedAt else { return false }
return date.timeIntervalSince(terminalObservedAt) < Self.terminalRetentionSeconds
}
func terminalRetentionDelay(at date: Date) -> TimeInterval? {
guard let terminalObservedAt else { return nil }
return max(0, Self.terminalRetentionSeconds - date.timeIntervalSince(terminalObservedAt))
}
func observeLocalExpiry(at date: Date) -> Bool {
guard self.record.status == .pending, self.terminalObservedAt == nil,
guard self.record.status == .pending, !self.isLocallyExpired,
date.timeIntervalSince1970 * 1000 >= Double(self.record.expiresatms)
else { return false }
self.terminalObservedAt = date
self.isLocallyExpired = true
self.isSubmitting = false
self.isSkipping = false
return true
}
func localExpiryDelay(at date: Date) -> TimeInterval? {
guard self.record.status == .pending, self.terminalObservedAt == nil else { return nil }
guard self.record.status == .pending, !self.isLocallyExpired else { return nil }
return max(0, Double(self.record.expiresatms) / 1000 - date.timeIntervalSince1970)
}
public func terminalSummaryText(for question: Question) -> String {
switch self.status() {
case .answered:
self.answerValues(questionID: question.id)?.joined(separator: ", ") ?? String(localized: "Answered")
case .answeredElsewhere:
self.answerValues(questionID: question.id)?.joined(separator: ", ")
?? String(localized: "Answered elsewhere")
case .cancelled:
String(localized: "Skipped")
case .expired:
String(localized: "Expired")
case .unavailable:
String(localized: "Unavailable")
case .pending, .submitting:
String(localized: "Pending")
}
}
private func answers() -> [String: [String]]? {
var result: [String: [String]] = [:]
for question in self.record.questions {
@@ -187,6 +286,19 @@ public final class OpenClawQuestionCardModel: Identifiable {
return result
}
private func answerValues(questionID: String) -> [String]? {
guard let answer = self.record.answers?.answers[questionID],
let data = try? JSONEncoder().encode(answer),
let decoded = try? JSONDecoder().decode(ResolvedAnswer.self, from: data),
!decoded.answers.isEmpty
else { return nil }
return decoded.answers
}
private struct ResolvedAnswer: Codable {
let answers: [String]
}
private static func recordsMatch(_ lhs: QuestionRecord, _ rhs: QuestionRecord) -> Bool {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
@@ -197,16 +309,31 @@ public final class OpenClawQuestionCardModel: Identifiable {
struct OpenClawQuestionCard: View {
@Bindable private var model: OpenClawQuestionCardModel
private let onSubmit: @MainActor @Sendable (OpenClawQuestionCardModel) async -> Void
private let onSkip: (@MainActor @Sendable (OpenClawQuestionCardModel) async -> Void)?
#if os(macOS)
@FocusState private var focusedQuestionID: String?
#endif
init(
model: OpenClawQuestionCardModel,
onSubmit: @escaping @MainActor @Sendable (OpenClawQuestionCardModel) async -> Void)
onSubmit: @escaping @MainActor @Sendable (OpenClawQuestionCardModel) async -> Void,
onSkip: @escaping @MainActor @Sendable (OpenClawQuestionCardModel) async -> Void)
{
self.model = model
self.onSubmit = onSubmit
self.onSkip = onSkip
}
var body: some View {
let status = self.model.status()
if status == .pending || status == .submitting {
self.pendingCard
} else {
self.terminalSummary
}
}
private var pendingCard: some View {
TimelineView(.periodic(from: .now, by: 1)) { context in
VStack(alignment: .leading, spacing: 14) {
ForEach(self.model.record.questions, id: \.id) { question in
@@ -220,6 +347,26 @@ struct OpenClawQuestionCard: View {
}
}
private var terminalSummary: some View {
VStack(alignment: .leading, spacing: 5) {
ForEach(self.model.record.questions, id: \.id) { question in
HStack(alignment: .firstTextBaseline, spacing: 5) {
Text(verbatim: "\(question.header):")
.font(OpenClawChatTypography.body(size: 14, weight: .semibold, relativeTo: .callout))
Text(self.model.terminalSummaryText(for: question))
.font(OpenClawChatTypography.body(size: 14, weight: .regular, relativeTo: .callout))
.foregroundStyle(.secondary)
}
.accessibilityElement(children: .combine)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 9)
.background(OpenClawChatTheme.subtleCard, in: RoundedRectangle(cornerRadius: 12))
.accessibilityElement(children: .contain)
.accessibilityLabel("Question summary")
}
private func questionSection(_ question: Question, now: Date) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(question.header.uppercased())
@@ -243,11 +390,30 @@ struct OpenClawQuestionCard: View {
.accessibilityLabel("Other answer")
}
}
#if os(macOS)
.focusable()
.focused(self.$focusedQuestionID, equals: question.id)
.onKeyPress(characters: .decimalDigits) { keyPress in
guard self.focusedQuestionID == question.id else { return .ignored }
return self.handleNumberKey(keyPress, question: question, now: now)
}
.onKeyPress(.return) {
guard self.focusedQuestionID == question.id,
self.model.status(at: now) == .pending,
self.model.canSubmit
else { return .ignored }
Task { await self.onSubmit(self.model) }
return .handled
}
#endif
}
private func optionRow(question: Question, option: QuestionOption, now: Date) -> some View {
let selected = self.model.selectedOptions[question.id]?.contains(option.label) == true
return Button {
#if os(macOS)
self.focusedQuestionID = question.id
#endif
self.model.toggleOption(questionID: question.id, label: option.label)
} label: {
HStack(alignment: .top, spacing: 10) {
@@ -282,10 +448,32 @@ struct OpenClawQuestionCard: View {
.font(OpenClawChatTypography.caption)
.foregroundStyle(.secondary)
Spacer()
Button(status == .submitting ? "Submitting…" : "Submit") {
if let onSkip = self.onSkip {
Button {
Task { await onSkip(self.model) }
} label: {
if self.model.isSkipping {
Text("Skipping…")
.font(OpenClawChatTypography.body(size: 14, weight: .semibold, relativeTo: .callout))
} else {
Text("Skip")
.font(OpenClawChatTypography.body(size: 14, weight: .semibold, relativeTo: .callout))
}
}
.buttonStyle(.bordered)
.disabled(status == .submitting)
}
Button {
Task { await self.onSubmit(self.model) }
} label: {
if status == .submitting, !self.model.isSkipping {
Text("Submitting…")
.font(OpenClawChatTypography.body(size: 14, weight: .semibold, relativeTo: .callout))
} else {
Text("Submit")
.font(OpenClawChatTypography.body(size: 14, weight: .semibold, relativeTo: .callout))
}
}
.font(OpenClawChatTypography.body(size: 14, weight: .semibold, relativeTo: .callout))
.buttonStyle(.borderedProminent)
.disabled(!self.model.canSubmit || status == .submitting)
}
@@ -294,36 +482,27 @@ struct OpenClawQuestionCard: View {
.font(OpenClawChatTypography.caption)
.foregroundStyle(OpenClawChatTheme.danger)
}
} else {
Label(self.terminalText(status), systemImage: self.terminalIcon(status))
.font(OpenClawChatTypography.captionSemiBold)
.foregroundStyle(.secondary)
}
}
private func countdownText(now: Date) -> String {
let seconds = self.model.remainingSeconds(at: now)
return seconds >= 60 ? "Expires in \(seconds / 60)m \(seconds % 60)s" : "Expires in \(seconds)s"
return String(format: "%d:%02d", seconds / 60, seconds % 60)
}
private func terminalText(_ status: OpenClawQuestionCardStatus) -> String {
switch status {
case .answered: "Answered"
case .answeredElsewhere: "Answered elsewhere"
case .expired: "Expired"
case .cancelled: "Cancelled"
case .pending, .submitting: "Pending"
}
}
private func terminalIcon(_ status: OpenClawQuestionCardStatus) -> String {
switch status {
case .answered, .answeredElsewhere: "checkmark.circle.fill"
case .expired: "clock.badge.xmark"
case .cancelled: "xmark.circle"
case .pending, .submitting: "clock"
}
#if os(macOS)
private func handleNumberKey(
_ keyPress: KeyPress,
question: Question,
now: Date) -> KeyPress.Result
{
guard self.model.status(at: now) == .pending,
let digit = keyPress.characters.first?.wholeNumberValue,
self.model.toggleOption(questionID: question.id, optionNumber: digit)
else { return .ignored }
return .handled
}
#endif
}
@MainActor
@@ -334,12 +513,25 @@ struct OpenClawQuestionCards: View {
ForEach(self.viewModel.visibleQuestionCards) { card in
OpenClawQuestionCard(model: card) { [weak viewModel = self.viewModel] model in
await viewModel?.submitQuestion(model)
} onSkip: { [weak viewModel = self.viewModel] model in
await viewModel?.skipQuestion(model)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
private enum QuestionLookupResult {
case record(QuestionRecord)
case notFound
case failed
}
private struct QuestionRefreshApplyResult {
let complete: Bool
let changed: Bool
}
extension OpenClawChatViewModel {
public var visibleQuestionCards: [OpenClawQuestionCardModel] {
self.questionCards.filter { card in
@@ -354,44 +546,132 @@ extension OpenClawChatViewModel {
func refreshQuestions() async {
self.questionRefreshGeneration &+= 1
let refreshGeneration = self.questionRefreshGeneration
self.questionRefreshRetryTask?.cancel()
self.questionRefreshRetryTask = nil
await self.refreshQuestions(generation: refreshGeneration, retryIndex: 0)
}
private func refreshQuestions(generation refreshGeneration: UInt64, retryIndex: Int) async {
guard refreshGeneration == self.questionRefreshGeneration else { return }
let stateRevision = self.questionStateRevision
do {
let records = try await self.transport.listQuestions()
guard refreshGeneration == self.questionRefreshGeneration,
stateRevision == self.questionStateRevision
guard self.questionRefreshSnapshotIsCurrent(
generation: refreshGeneration,
stateRevision: stateRevision)
else { return }
let existing = Dictionary(uniqueKeysWithValues: self.questionCards.map { ($0.id, $0) })
let listedIDs = Set(records.map(\.id))
let now = Date()
let retainedTerminal = self.questionCards.filter { model in
!listedIDs.contains(model.id) && model.shouldRetainAfterList(at: now)
let missingPending = self.questionCards.filter { model in
model.record.status == .pending &&
!model.isRecoveryUnavailable &&
!listedIDs.contains(model.id)
}
var changed = records.count + retainedTerminal.count != self.questionCards.count
self.questionCards = records.map { record in
if let model = existing[record.id] {
changed = model.apply(record: record) || changed
return model
}
changed = true
return OpenClawQuestionCardModel(record: record)
} + retainedTerminal
self.syncQuestionEvictions()
if changed {
self.questionStateRevision &+= 1
self.markTimelineChanged()
let lookups = await self.fetchMissingQuestionLookups(missingPending)
guard self.questionRefreshSnapshotIsCurrent(
generation: refreshGeneration,
stateRevision: stateRevision)
else { return }
let result = self.applyQuestionRefresh(records: records, lookups: lookups)
if result.complete {
self.questionRefreshRetryTask = nil
} else {
self.scheduleQuestionRefreshRetry(
generation: refreshGeneration,
retryIndex: result.changed ? 0 : retryIndex)
}
} catch let error as GatewayResponseError where Self.questionListIsUnavailable(error) {
guard refreshGeneration == self.questionRefreshGeneration,
stateRevision == self.questionStateRevision,
!self.questionCards.isEmpty
guard self.questionRefreshSnapshotIsCurrent(
generation: refreshGeneration,
stateRevision: stateRevision)
else { return }
self.questionCards = []
self.syncQuestionEvictions()
self.clearPendingQuestionsForUnavailableList()
} catch {
guard self.questionRefreshSnapshotIsCurrent(
generation: refreshGeneration,
stateRevision: stateRevision)
else { return }
self.scheduleQuestionRefreshRetry(
generation: refreshGeneration,
retryIndex: retryIndex)
}
}
private func fetchMissingQuestionLookups(
_ models: [OpenClawQuestionCardModel]) async
-> [(OpenClawQuestionCardModel, QuestionLookupResult)]
{
// The gateway enumerates pending questions only; terminal records remain addressable
// briefly by known ID. Cards observed by this view model persist without timed eviction,
// but session transcripts do not contain enough question data to recreate them on launch.
var lookups: [(OpenClawQuestionCardModel, QuestionLookupResult)] = []
for model in models {
do {
let record = try await self.transport.getQuestion(id: model.id)
lookups.append((model, .record(record)))
} catch let error as GatewayResponseError where Self.questionIsNotFound(error) {
lookups.append((model, .notFound))
} catch {
lookups.append((model, .failed))
}
}
return lookups
}
private func applyQuestionRefresh(
records: [QuestionRecord],
lookups: [(OpenClawQuestionCardModel, QuestionLookupResult)]) -> QuestionRefreshApplyResult
{
var changed = false
for record in records {
if let model = self.questionCards.first(where: { $0.id == record.id }) {
changed = model.apply(record: record) || changed
} else {
self.questionCards.append(OpenClawQuestionCardModel(record: record))
changed = true
}
}
var complete = true
for (model, result) in lookups {
guard self.questionCards.contains(where: { $0 === model }) else { continue }
switch result {
case let .record(record):
changed = model.apply(record: record) || changed
case .notFound:
// The terminal tombstone has aged out, so the question is no longer actionable,
// but its answered/cancelled/expired outcome cannot be reconstructed.
changed = model.markRecoveryUnavailable() || changed
case .failed:
complete = false
}
}
self.syncQuestionExpirations()
if changed {
self.questionStateRevision &+= 1
self.markTimelineChanged()
} catch {
// Question recovery is best-effort; chat bootstrap remains usable without this scope.
}
return QuestionRefreshApplyResult(complete: complete, changed: changed)
}
private func clearPendingQuestionsForUnavailableList() {
let previousCount = self.questionCards.count
self.questionCards.removeAll {
let status = $0.status()
return status == .pending || status == .submitting
}
self.syncQuestionExpirations()
if self.questionCards.count != previousCount {
self.questionStateRevision &+= 1
self.markTimelineChanged()
}
}
private func questionRefreshSnapshotIsCurrent(generation: UInt64, stateRevision: UInt64) -> Bool {
guard generation == self.questionRefreshGeneration else { return false }
guard stateRevision == self.questionStateRevision else {
self.restartQuestionRefreshAfterStateChange(generation: generation)
return false
}
return true
}
private nonisolated static func questionListIsUnavailable(_ error: GatewayResponseError) -> Bool {
@@ -400,6 +680,36 @@ extension OpenClawChatViewModel {
error.message == "missing scope: operator.questions"
}
private nonisolated static func questionIsNotFound(_ error: GatewayResponseError) -> Bool {
error.detailsReason == "QUESTION_NOT_FOUND"
}
private func restartQuestionRefreshAfterStateChange(generation: UInt64) {
// Local question mutations invalidate the whole lookup snapshot, not one transport attempt.
// Restart the bounded budget so a late mutation cannot consume the last reconciliation slot.
self.scheduleQuestionRefreshRetry(generation: generation, retryIndex: 0)
}
private func scheduleQuestionRefreshRetry(generation: UInt64, retryIndex: Int) {
guard generation == self.questionRefreshGeneration else { return }
guard self.questionRefreshRetryDelaysMs.indices.contains(retryIndex) else {
self.questionRefreshRetryTask = nil
return
}
let delayMs = self.questionRefreshRetryDelaysMs[retryIndex]
let stateRevision = self.questionStateRevision
self.questionRefreshRetryTask?.cancel()
self.questionRefreshRetryTask = Task { [weak self] in
try? await Task.sleep(for: .milliseconds(delayMs))
guard !Task.isCancelled, let self,
generation == self.questionRefreshGeneration
else { return }
// A mutation during backoff invalidates the reconciliation attempt, not the retry budget.
let nextRetryIndex = stateRevision == self.questionStateRevision ? retryIndex + 1 : 0
await self.refreshQuestions(generation: generation, retryIndex: nextRetryIndex)
}
}
func upsertQuestion(_ record: QuestionRecord) {
if let model = self.questionCards.first(where: { $0.id == record.id }) {
guard model.apply(record: record) else { return }
@@ -407,14 +717,14 @@ extension OpenClawChatViewModel {
self.questionCards.append(OpenClawQuestionCardModel(record: record))
}
self.questionStateRevision &+= 1
self.syncQuestionEvictions()
self.syncQuestionExpirations()
self.markTimelineChanged()
}
func resolveQuestionEvent(_ event: OpenClawQuestionResolvedEvent) {
self.questionCards.first(where: { $0.id == event.id })?.apply(resolved: event)
self.questionStateRevision &+= 1
self.syncQuestionEvictions()
self.syncQuestionExpirations()
self.markTimelineChanged()
}
@@ -422,6 +732,8 @@ extension OpenClawChatViewModel {
// Invalidate a list snapshot captured before this event, then fetch the
// authoritative set so other pending cards from that snapshot are not lost.
self.questionRefreshGeneration &+= 1
self.questionRefreshRetryTask?.cancel()
self.questionRefreshRetryTask = nil
Task { [weak self] in await self?.refreshQuestions() }
}
@@ -430,9 +742,9 @@ extension OpenClawChatViewModel {
self.questionStateRevision &+= 1
do {
try await self.transport.resolveQuestion(id: model.id, answers: answers)
model.markAnsweredLocally()
model.markAnsweredLocally(answers: answers)
self.questionStateRevision &+= 1
self.syncQuestionEvictions()
self.syncQuestionExpirations()
self.markTimelineChanged()
} catch {
model.failSubmission(error.localizedDescription)
@@ -440,53 +752,63 @@ extension OpenClawChatViewModel {
}
}
func evictQuestionIfTerminalGraceElapsed(
func skipQuestion(_ model: OpenClawQuestionCardModel) async {
guard model.beginSkip() else { return }
self.questionStateRevision &+= 1
do {
try await self.transport.cancelQuestion(id: model.id)
model.markSkippedLocally()
self.questionStateRevision &+= 1
self.syncQuestionExpirations()
self.markTimelineChanged()
} catch {
model.failSubmission(error.localizedDescription)
self.questionStateRevision &+= 1
}
}
func expireQuestionIfNeeded(
_ model: OpenClawQuestionCardModel,
at date: Date = Date())
{
guard self.questionCards.first(where: { $0.id == model.id }) === model else { return }
if model.observeLocalExpiry(at: date) {
self.questionStateRevision &+= 1
self.syncQuestionExpirations(at: date)
self.markTimelineChanged()
Task { [weak self] in await self?.refreshQuestions() }
} else {
self.syncQuestionExpirations(at: date)
}
guard
!model.shouldRetainAfterList(at: date),
model.status(at: date) == .expired || model.record.status != .pending
else {
self.syncQuestionEvictions(at: date)
return
}
self.questionCards.removeAll { $0 === model }
self.questionEvictionTasks.removeValue(forKey: model.id)?.cancel()
self.questionEvictionDeadlines.removeValue(forKey: model.id)
self.questionStateRevision &+= 1
self.markTimelineChanged()
}
private func syncQuestionEvictions(at date: Date = Date()) {
private func syncQuestionExpirations(at date: Date = Date()) {
let modelsByID = Dictionary(uniqueKeysWithValues: self.questionCards.map { ($0.id, $0) })
let cancelledIDs = self.questionEvictionTasks.keys.filter { modelsByID[$0] == nil }
let cancelledIDs = self.questionExpiryTasks.keys.filter { modelsByID[$0] == nil }
for id in cancelledIDs {
self.questionEvictionTasks.removeValue(forKey: id)?.cancel()
self.questionEvictionDeadlines.removeValue(forKey: id)
self.questionExpiryTasks.removeValue(forKey: id)?.cancel()
self.questionExpiryDeadlines.removeValue(forKey: id)
}
for model in self.questionCards {
guard let delay = model.terminalRetentionDelay(at: date) ?? model.localExpiryDelay(at: date)
else { continue }
guard let delay = model.localExpiryDelay(at: date) else {
self.questionExpiryTasks.removeValue(forKey: model.id)?.cancel()
self.questionExpiryDeadlines.removeValue(forKey: model.id)
continue
}
let deadline = date.addingTimeInterval(delay)
if let scheduled = self.questionEvictionDeadlines[model.id],
if let scheduled = self.questionExpiryDeadlines[model.id],
abs(scheduled.timeIntervalSince(deadline)) < 0.01
{
continue
}
self.questionEvictionTasks.removeValue(forKey: model.id)?.cancel()
self.questionEvictionDeadlines[model.id] = deadline
self.questionEvictionTasks[model.id] = Task { [weak self, weak model] in
self.questionExpiryTasks.removeValue(forKey: model.id)?.cancel()
self.questionExpiryDeadlines[model.id] = deadline
self.questionExpiryTasks[model.id] = Task { [weak self, weak model] in
try? await Task.sleep(for: .seconds(delay))
guard !Task.isCancelled, let self, let model else { return }
self.questionEvictionTasks.removeValue(forKey: model.id)
self.questionEvictionDeadlines.removeValue(forKey: model.id)
self.evictQuestionIfTerminalGraceElapsed(model)
self.questionExpiryTasks.removeValue(forKey: model.id)
self.questionExpiryDeadlines.removeValue(forKey: model.id)
self.expireQuestionIfNeeded(model)
}
}
}
@@ -451,7 +451,9 @@ public protocol OpenClawChatTransport: Sendable {
func requestHealth(timeoutMs: Int) async throws -> Bool
func listQuestions() async throws -> [QuestionRecord]
func getQuestion(id: String) async throws -> QuestionRecord
func resolveQuestion(id: String, answers: [String: [String]]) async throws
func cancelQuestion(id: String) async throws
func waitForRunCompletion(runId: String, timeoutMs: Int) async -> OpenClawChatRunObservation
func events() -> AsyncStream<OpenClawChatTransportEvent>
func resolveInlineWidgetResource(
@@ -469,6 +471,13 @@ extension OpenClawChatTransport {
[]
}
public func getQuestion(id _: String) async throws -> QuestionRecord {
throw NSError(
domain: "OpenClawChatTransport",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "question.get not supported by this transport"])
}
public func resolveQuestion(id _: String, answers _: [String: [String]]) async throws {
throw NSError(
domain: "OpenClawChatTransport",
@@ -476,6 +485,13 @@ extension OpenClawChatTransport {
userInfo: [NSLocalizedDescriptionKey: "question.resolve not supported by this transport"])
}
public func cancelQuestion(id _: String) async throws {
throw NSError(
domain: "OpenClawChatTransport",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "question.resolve cancellation not supported by this transport"])
}
public func requestFullMessage(sessionKey _: String, messageID _: String) async throws -> OpenClawChatMessage? {
nil
}
@@ -90,8 +90,10 @@ public final class OpenClawChatViewModel {
public internal(set) var questionCards: [OpenClawQuestionCardModel] = []
var questionRefreshGeneration: UInt64 = 0
var questionStateRevision: UInt64 = 0
var questionEvictionTasks: [String: Task<Void, Never>] = [:]
var questionEvictionDeadlines: [String: Date] = [:]
var questionExpiryTasks: [String: Task<Void, Never>] = [:]
var questionExpiryDeadlines: [String: Date] = [:]
var questionRefreshRetryTask: Task<Void, Never>?
var questionRefreshRetryDelaysMs: [Int64] = [1000, 2000, 4000]
var hasActiveSessionRunWithoutChatSnapshot = false
public private(set) var sessionKey: String {
@@ -498,7 +500,8 @@ public final class OpenClawChatViewModel {
self.outboxRetryTask?.cancel()
self.outboxChangesTask?.cancel()
self.activeSessionRunIndicatorTimeoutTask?.cancel()
for (_, task) in self.questionEvictionTasks {
self.questionRefreshRetryTask?.cancel()
for (_, task) in self.questionExpiryTasks {
task.cancel()
}
for (_, task) in self.pendingRunOwnerTasks {
@@ -228,6 +228,22 @@ struct ChatGatewayRequestTests {
#expect(meal["answers"] as? [String] == ["Pizza", "Salad"])
}
@Test func `question get request carries id`() {
let request = OpenClawChatGatewayRequests.questionGet(id: "ask_123")
#expect(request.method == "question.get")
#expect(request.params["id"]?.value as? String == "ask_123")
}
@Test func `question cancel request uses resolve cancel contract`() {
let request = OpenClawChatGatewayRequests.cancelQuestion(id: "ask_123")
#expect(request.method == "question.resolve")
#expect(request.params["id"]?.value as? String == "ask_123")
#expect(request.params["cancel"]?.value as? Bool == true)
#expect(request.params["answers"] == nil)
}
@Test func `long running requests share exact gateway timeout margins`() {
#expect(OpenClawChatGatewayRequests.agentWait(runID: "run-1", timeoutMs: 1).timeoutMs == 5001)
#expect(OpenClawChatGatewayRequests.agentWait(runID: "run-1", timeoutMs: 30000).timeoutMs == 35000)
@@ -9,7 +9,8 @@ private func questionRecord(
isOther: Bool = true,
createdAtMs: Int = 1_000_000,
expiresAtMs: Int = 4_000_000_000_000,
status: QuestionStatus = .pending) -> QuestionRecord
status: QuestionStatus = .pending,
answers: QuestionAnswers? = nil) -> QuestionRecord
{
QuestionRecord(
id: "ask_123",
@@ -29,7 +30,8 @@ private func questionRecord(
sessionkey: "agent:main:main",
createdatms: createdAtMs,
expiresatms: expiresAtMs,
status: status)
status: status,
answers: answers)
}
@MainActor
@@ -52,6 +54,16 @@ private func questionRecord(
#expect(model.beginSubmission() == ["meal": ["Pizza", "Tacos"]])
}
@MainActor
@Test func `question card number selection uses declared option order`() {
let model = OpenClawQuestionCardModel(record: questionRecord(multiSelect: true))
#expect(model.toggleOption(questionID: "meal", optionNumber: 2))
#expect(model.toggleOption(questionID: "meal", optionNumber: 1))
#expect(!model.toggleOption(questionID: "meal", optionNumber: 4))
#expect(model.beginSubmission() == ["meal": ["Pizza", "Tacos"]])
}
@MainActor
@Test func `question card maps expiry and answer origin`() {
let now = Date(timeIntervalSince1970: 1500)
@@ -64,7 +76,7 @@ private func questionRecord(
#expect(remote.status(at: Date(timeIntervalSince1970: 1500)) == .answeredElsewhere)
let local = OpenClawQuestionCardModel(record: questionRecord())
local.markAnsweredLocally()
local.markAnsweredLocally(answers: ["meal": ["Pizza"]])
local.apply(resolved: OpenClawQuestionResolvedEvent(id: local.id, status: .answered))
#expect(local.status(at: Date(timeIntervalSince1970: 1500)) == .answered)
}
@@ -83,32 +95,115 @@ private func questionRecord(
}
@MainActor
@Test func `question card retains terminal feedback for gateway grace`() {
let observedAt = Date(timeIntervalSince1970: 1500)
@Test func `question card ignores replayed pending record after terminal event`() {
let model = OpenClawQuestionCardModel(record: questionRecord())
model.apply(resolved: .init(id: model.id, status: .answered), at: observedAt)
model.apply(resolved: .init(id: model.id, status: .answered))
#expect(model.shouldRetainAfterList(at: observedAt.addingTimeInterval(14)))
#expect(!model.shouldRetainAfterList(at: observedAt.addingTimeInterval(15)))
#expect(!model.apply(record: questionRecord(createdAtMs: 2_000_000)))
#expect(model.status() == .answeredElsewhere)
}
@MainActor
@Test func `question card locally expired state enters terminal retention`() {
@Test func `question card preserves submitted answers across answerless refresh`() throws {
let model = OpenClawQuestionCardModel(record: questionRecord())
model.toggleOption(questionID: "meal", label: "Pizza")
let answers = try #require(model.beginSubmission())
model.markAnsweredLocally(answers: answers)
#expect(model.apply(record: questionRecord(createdAtMs: 2_000_000, status: .answered)))
#expect(model.terminalSummaryText(for: model.record.questions[0]) == "Pizza")
}
@MainActor
@Test func `question card preserves submitted answers across answerless resolved event`() throws {
let model = OpenClawQuestionCardModel(record: questionRecord())
model.toggleOption(questionID: "meal", label: "Pizza")
let answers = try #require(model.beginSubmission())
model.markAnsweredLocally(answers: answers)
model.apply(resolved: .init(id: model.id, status: .answered))
#expect(model.terminalSummaryText(for: model.record.questions[0]) == "Pizza")
}
@MainActor
@Test func `question card locally expired state remains terminal`() {
let expiresAt = Date(timeIntervalSince1970: 1500)
let model = OpenClawQuestionCardModel(record: questionRecord(expiresAtMs: 1_500_000))
#expect(model.observeLocalExpiry(at: expiresAt))
#expect(model.shouldRetainAfterList(at: expiresAt.addingTimeInterval(14)))
#expect(!model.shouldRetainAfterList(at: expiresAt.addingTimeInterval(15)))
#expect(!model.observeLocalExpiry(at: expiresAt.addingTimeInterval(15)))
#expect(model.status(at: expiresAt.addingTimeInterval(15)) == .expired)
}
@MainActor
@Test func `question card stores local answers in gateway record shape`() throws {
let model = OpenClawQuestionCardModel(record: questionRecord())
model.toggleOption(questionID: "meal", label: "Pizza")
model.markAnsweredLocally()
let answers = try #require(model.beginSubmission())
model.markAnsweredLocally(answers: answers)
let data = try JSONEncoder().encode(model.record.answers)
let json = try #require(String(data: data, encoding: .utf8))
#expect(json.contains("\"meal\":{\"answers\":[\"Pizza\"]}"))
#expect(model.terminalSummaryText(for: model.record.questions[0]) == "Pizza")
}
@MainActor
@Test func `question completions override unavailable recovery race`() throws {
let answered = OpenClawQuestionCardModel(record: questionRecord())
answered.toggleOption(questionID: "meal", label: "Pizza")
let answers = try #require(answered.beginSubmission())
answered.markRecoveryUnavailable()
answered.markAnsweredLocally(answers: answers)
#expect(answered.status() == .answered)
let skipped = OpenClawQuestionCardModel(record: questionRecord())
#expect(skipped.beginSkip())
skipped.markRecoveryUnavailable()
skipped.markSkippedLocally()
#expect(skipped.status() == .cancelled)
let answeredElsewhere = OpenClawQuestionCardModel(record: questionRecord())
answeredElsewhere.markRecoveryUnavailable()
answeredElsewhere.markAnsweredElsewhere()
#expect(answeredElsewhere.status() == .answeredElsewhere)
}
@MainActor
@Test func `question card terminal summaries prefer resolved answers`() {
let answers = QuestionAnswers(answers: [
"meal": AnyCodable(["answers": ["Pizza", "extra hot"]]),
])
let answered = OpenClawQuestionCardModel(record: questionRecord(status: .answered, answers: answers))
let question = answered.record.questions[0]
#expect(answered.terminalSummaryText(for: question) == "Pizza, extra hot")
let elsewhere = OpenClawQuestionCardModel(record: questionRecord(status: .answered))
#expect(elsewhere.terminalSummaryText(for: question) == "Answered elsewhere")
let skipped = OpenClawQuestionCardModel(record: questionRecord(status: .cancelled))
#expect(skipped.terminalSummaryText(for: question) == "Skipped")
let expired = OpenClawQuestionCardModel(record: questionRecord(status: .expired))
#expect(expired.terminalSummaryText(for: question) == "Expired")
let unavailable = OpenClawQuestionCardModel(record: questionRecord())
unavailable.markRecoveryUnavailable()
#expect(unavailable.status() == .unavailable)
#expect(unavailable.terminalSummaryText(for: question) == "Unavailable")
#expect(!unavailable.apply(record: questionRecord()))
#expect(unavailable.status() == .unavailable)
}
@MainActor
@Test func `question card skip transitions to persistent skipped summary`() {
let model = OpenClawQuestionCardModel(record: questionRecord())
#expect(model.beginSkip())
#expect(model.isSkipping)
model.markSkippedLocally()
#expect(model.status() == .cancelled)
#expect(model.terminalSummaryText(for: model.record.questions[0]) == "Skipped")
}
@@ -542,6 +542,18 @@ private actor AsyncCounter {
}
}
private actor AsyncStringRecorder {
private var values: [String] = []
func append(_ value: String) {
self.values.append(value)
}
func current() -> [String] {
self.values
}
}
private actor SessionSubscribeGate {
private var waiters: [CheckedContinuation<Void, Never>] = []
@@ -640,6 +652,8 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
(@Sendable (String, Int) async -> OpenClawChatRunObservation)?
private let acquireSessionSettingsRouteLeaseHook: (@Sendable () async -> Void)?
private let listQuestionsHook: (@Sendable () async throws -> [QuestionRecord])?
private let getQuestionHook: (@Sendable (String) async throws -> QuestionRecord)?
private let cancelQuestionHook: (@Sendable (String) async throws -> Void)?
private let healthResponses: [Bool]
private let stream: AsyncStream<OpenClawChatTransportEvent>
@@ -672,6 +686,8 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
waitForRunCompletionHook: (@Sendable (String, Int) async -> OpenClawChatRunObservation)? = nil,
acquireSessionSettingsRouteLeaseHook: (@Sendable () async -> Void)? = nil,
listQuestionsHook: (@Sendable () async throws -> [QuestionRecord])? = nil,
getQuestionHook: (@Sendable (String) async throws -> QuestionRecord)? = nil,
cancelQuestionHook: (@Sendable (String) async throws -> Void)? = nil,
healthResponses: [Bool] = [true])
{
self.historyResponses = historyResponses
@@ -698,6 +714,8 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
self.waitForRunCompletionHook = waitForRunCompletionHook
self.acquireSessionSettingsRouteLeaseHook = acquireSessionSettingsRouteLeaseHook
self.listQuestionsHook = listQuestionsHook
self.getQuestionHook = getQuestionHook
self.cancelQuestionHook = cancelQuestionHook
self.healthResponses = healthResponses
var cont: AsyncStream<OpenClawChatTransportEvent>.Continuation!
self.stream = AsyncStream { c in
@@ -989,6 +1007,20 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
try await self.listQuestionsHook?() ?? []
}
func getQuestion(id: String) async throws -> QuestionRecord {
guard let getQuestionHook else {
throw NSError(
domain: "TestChatTransport",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "missing question.get fixture"])
}
return try await getQuestionHook(id)
}
func cancelQuestion(id: String) async throws {
try await self.cancelQuestionHook?(id)
}
func waitForRunCompletion(
runId: String,
timeoutMs: Int) async -> OpenClawChatRunObservation
@@ -1285,7 +1317,9 @@ private actor QuestionListEventRace {
private func chatQuestionRecord(
id: String,
status: QuestionStatus = .pending,
expiresAtMs: Int = 4_000_000_000_000) -> QuestionRecord
expiresAtMs: Int = 4_000_000_000_000,
sessionKey: String? = "main",
answers: QuestionAnswers? = nil) -> QuestionRecord
{
QuestionRecord(
id: id,
@@ -1297,15 +1331,16 @@ private func chatQuestionRecord(
options: [QuestionOption(label: "One"), QuestionOption(label: "Two")]),
],
agentid: "main",
sessionkey: "main",
sessionkey: sessionKey,
createdatms: 1,
expiresatms: expiresAtMs,
status: status)
status: status,
answers: answers)
}
@Suite(.serialized)
struct ChatViewModelTests {
@Test @MainActor func `locally expired question is evicted after terminal grace`() {
@Test @MainActor func `locally expired question remains in transcript`() {
let viewModel = OpenClawChatViewModel(
sessionKey: "main",
transport: TestChatTransport(historyResponses: []))
@@ -1313,13 +1348,14 @@ struct ChatViewModelTests {
viewModel.upsertQuestion(chatQuestionRecord(id: "ask_local", expiresAtMs: 1_500_000))
let model = viewModel.questionCards[0]
viewModel.evictQuestionIfTerminalGraceElapsed(model, at: expiresAt)
viewModel.expireQuestionIfNeeded(model, at: expiresAt)
#expect(viewModel.questionCards.map(\.id) == ["ask_local"])
#expect(model.status(at: expiresAt) == .expired)
viewModel.evictQuestionIfTerminalGraceElapsed(
viewModel.expireQuestionIfNeeded(
model,
at: expiresAt.addingTimeInterval(15))
#expect(viewModel.questionCards.isEmpty)
#expect(viewModel.questionCards.map(\.id) == ["ask_local"])
}
@Test @MainActor func `stale question list cannot overwrite a newer event`() async throws {
@@ -1408,7 +1444,7 @@ struct ChatViewModelTests {
#expect(viewModel.questionCards.map(\.id) == ["ask_other"])
}
@Test @MainActor func `question list retains recently resolved card`() async {
@Test @MainActor func `question list retains resolved card persistently`() async {
let transport = TestChatTransport(
historyResponses: [],
listQuestionsHook: { [] })
@@ -1422,16 +1458,274 @@ struct ChatViewModelTests {
#expect(viewModel.questionCards.first?.status() == .answeredElsewhere)
}
@Test @MainActor func `question card is evicted after terminal grace`() {
let transport = TestChatTransport(historyResponses: [])
@Test @MainActor func `terminal question survives later empty list refresh`() async {
let transport = TestChatTransport(historyResponses: [], listQuestionsHook: { [] })
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
viewModel.upsertQuestion(chatQuestionRecord(id: "ask_done"))
viewModel.resolveQuestionEvent(.init(id: "ask_done", status: .answered))
let model = viewModel.questionCards[0]
viewModel.evictQuestionIfTerminalGraceElapsed(model, at: Date().addingTimeInterval(16))
await viewModel.refreshQuestions()
#expect(viewModel.questionCards.isEmpty)
#expect(viewModel.questionCards.map(\.id) == ["ask_done"])
#expect(viewModel.questionCards[0].status() == .answeredElsewhere)
}
@Test @MainActor func `missing pending question uses question get fallback`() async {
let answers = QuestionAnswers(answers: [
"choice": AnyCodable(["answers": ["Two"]]),
])
let transport = TestChatTransport(
historyResponses: [],
listQuestionsHook: { [] },
getQuestionHook: { id in
chatQuestionRecord(id: id, status: .answered, answers: answers)
})
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
viewModel.upsertQuestion(chatQuestionRecord(id: "ask_missing"))
await viewModel.refreshQuestions()
#expect(viewModel.questionCards[0].status() == .answeredElsewhere)
#expect(viewModel.questionCards[0].terminalSummaryText(
for: viewModel.questionCards[0].record.questions[0]) == "Two")
}
@Test @MainActor func `missing question tombstone has unknown terminal outcome`() async {
let getCalls = AsyncCounter()
let transport = TestChatTransport(
historyResponses: [],
listQuestionsHook: { [] },
getQuestionHook: { id in
_ = await getCalls.increment()
throw GatewayResponseError(
method: "question.get",
code: "INVALID_REQUEST",
message: "question '\(id)' was not found",
details: ["reason": AnyCodable("QUESTION_NOT_FOUND")])
})
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
viewModel.upsertQuestion(chatQuestionRecord(id: "ask_missing"))
await viewModel.refreshQuestions()
#expect(viewModel.questionCards[0].status() == .unavailable)
#expect(viewModel.questionCards[0].terminalSummaryText(
for: viewModel.questionCards[0].record.questions[0]) == "Unavailable")
await viewModel.refreshQuestions()
#expect(await getCalls.current() == 1)
}
@Test @MainActor func `question refresh retries transport failure`() async throws {
let listCalls = AsyncCounter()
let getCalls = AsyncCounter()
let transport = TestChatTransport(
historyResponses: [],
listQuestionsHook: {
let call = await listCalls.increment()
if call == 1 {
throw GatewayResponseError(
method: "question.list",
code: "UNAVAILABLE",
message: "retry",
details: nil)
}
return []
},
getQuestionHook: { id in
_ = await getCalls.increment()
return chatQuestionRecord(id: id, status: .cancelled)
})
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
viewModel.questionRefreshRetryDelaysMs = [0]
viewModel.upsertQuestion(chatQuestionRecord(id: "ask_retry"))
await viewModel.refreshQuestions()
try await waitUntil("question refresh retry") { await getCalls.current() == 1 }
#expect(await listCalls.current() == 2)
#expect(viewModel.questionCards[0].status() == .cancelled)
}
@Test @MainActor func `question refresh resets exhausted retry budget after overlapping skip`() async throws {
let listCalls = AsyncCounter()
let getStarted = AsyncCounter()
let getCalls = AsyncCounter()
let firstGetGate = AsyncGate()
let recovering = chatQuestionRecord(id: "ask_recovering")
let unrelated = chatQuestionRecord(id: "ask_unrelated")
let transport = TestChatTransport(
historyResponses: [],
listQuestionsHook: {
let call = await listCalls.increment()
if call == 1 {
throw GatewayResponseError(
method: "question.list",
code: "UNAVAILABLE",
message: "consume retry budget",
details: nil)
}
return [unrelated]
},
getQuestionHook: { id in
let call = await getCalls.increment()
if call == 1 {
_ = await getStarted.increment()
await firstGetGate.wait()
}
return chatQuestionRecord(id: id, status: .answered)
},
cancelQuestionHook: { _ in })
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
viewModel.questionRefreshRetryDelaysMs = [0]
viewModel.upsertQuestion(recovering)
viewModel.upsertQuestion(unrelated)
let refresh = Task { await viewModel.refreshQuestions() }
try await waitUntil("question get request") { await getStarted.current() == 1 }
let unrelatedModel = try #require(viewModel.questionCards.first { $0.id == unrelated.id })
await viewModel.skipQuestion(unrelatedModel)
await firstGetGate.open()
await refresh.value
try await waitUntil("question reconciliation after skip") {
await MainActor.run {
viewModel.questionCards.first { $0.id == recovering.id }?.status() == .answeredElsewhere
}
}
#expect(await getCalls.current() == 2)
#expect(await listCalls.current() == 3)
}
@Test @MainActor func `question refresh resets exhausted retry budget after partial progress`() async throws {
let listCalls = AsyncCounter()
let recoveringCalls = AsyncCounter()
let progressed = chatQuestionRecord(id: "ask_progressed")
let recovering = chatQuestionRecord(id: "ask_recovering")
let transport = TestChatTransport(
historyResponses: [],
listQuestionsHook: {
let call = await listCalls.increment()
if call == 1 {
throw GatewayResponseError(
method: "question.list",
code: "UNAVAILABLE",
message: "consume retry budget",
details: nil)
}
return []
},
getQuestionHook: { id in
if id == progressed.id {
return chatQuestionRecord(id: id, status: .answered)
}
let call = await recoveringCalls.increment()
if call == 1 {
throw GatewayResponseError(
method: "question.get",
code: "UNAVAILABLE",
message: "partial failure",
details: nil)
}
return chatQuestionRecord(id: id, status: .cancelled)
})
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
viewModel.questionRefreshRetryDelaysMs = [0]
viewModel.upsertQuestion(progressed)
viewModel.upsertQuestion(recovering)
await viewModel.refreshQuestions()
try await waitUntil("question retry after partial progress") {
await MainActor.run {
viewModel.questionCards.first { $0.id == recovering.id }?.status() == .cancelled
}
}
#expect(await listCalls.current() == 3)
#expect(await recoveringCalls.current() == 2)
#expect(viewModel.questionCards.first { $0.id == progressed.id }?.status() == .answeredElsewhere)
}
@Test @MainActor func `question refresh resets retry budget after state change during backoff`() async throws {
let listCalls = AsyncCounter()
let transport = TestChatTransport(
historyResponses: [],
listQuestionsHook: {
let call = await listCalls.increment()
if call < 3 {
throw GatewayResponseError(
method: "question.list",
code: "UNAVAILABLE",
message: "retry",
details: nil)
}
return []
})
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
viewModel.questionRefreshRetryDelaysMs = [25]
let question = chatQuestionRecord(id: "ask_backoff")
viewModel.upsertQuestion(question)
await viewModel.refreshQuestions()
viewModel.resolveQuestionEvent(.init(id: question.id, status: .cancelled))
try await waitUntil("question retry budget reset after backoff mutation") {
await listCalls.current() == 3
}
#expect(viewModel.questionCards[0].status() == .cancelled)
}
@Test @MainActor func `question refresh stops after bounded retries`() async throws {
let listCalls = AsyncCounter()
let transport = TestChatTransport(
historyResponses: [],
listQuestionsHook: {
_ = await listCalls.increment()
throw GatewayResponseError(
method: "question.list",
code: "UNAVAILABLE",
message: "retry",
details: nil)
})
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
viewModel.questionRefreshRetryDelaysMs = [0, 0, 0]
await viewModel.refreshQuestions()
try await waitUntil("bounded question refresh retries") { await listCalls.current() >= 4 }
try await Task.sleep(for: .milliseconds(25))
#expect(await listCalls.current() == 4)
#expect(viewModel.questionRefreshRetryTask == nil)
}
@Test @MainActor func `visible questions filter by current session`() {
let viewModel = OpenClawChatViewModel(
sessionKey: "main",
transport: TestChatTransport(historyResponses: []))
viewModel.upsertQuestion(chatQuestionRecord(id: "ask_main"))
viewModel.upsertQuestion(chatQuestionRecord(id: "ask_other", sessionKey: "other"))
viewModel.upsertQuestion(chatQuestionRecord(id: "ask_unscoped", sessionKey: nil))
#expect(viewModel.visibleQuestionCards.map(\.id) == ["ask_main", "ask_unscoped"])
}
@Test @MainActor func `skip sends question cancellation and retains summary`() async {
let cancelledIDs = AsyncStringRecorder()
let transport = TestChatTransport(
historyResponses: [],
cancelQuestionHook: { id in
await cancelledIDs.append(id)
})
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
viewModel.upsertQuestion(chatQuestionRecord(id: "ask_skip"))
await viewModel.skipQuestion(viewModel.questionCards[0])
#expect(await cancelledIDs.current() == ["ask_skip"])
#expect(viewModel.questionCards[0].status() == .cancelled)
#expect(viewModel.questionCards[0].terminalSummaryText(
for: viewModel.questionCards[0].record.questions[0]) == "Skipped")
}
@Test func `context usage fraction validates freshness and token bounds`() {
+1
View File
@@ -9530,6 +9530,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- Route: /tools/ask-user
- Headings:
- H2: Answer a question
- H2: Platform behavior
- H2: Timeout and no answer
- H2: Tool schema
- H2: Model guidance
+9
View File
@@ -31,6 +31,15 @@ You can answer from any supported conversation surface:
OpenClaw always enables a free-text **Other** answer. The agent must not add an
`Other` option to the authored option list.
## Platform behavior
Answers work on every supported conversation surface. The web Control UI uses a
docked stepper that replaces the composer while expanded; collapsing it restores
the full composer beneath a slim question bar. iOS, macOS, and Android show
inline cards; multiple questions stay stacked as an intentional touch-friendly
idiom. Every platform keeps the question-to-answer summary in the active chat
timeline without timed eviction, and **Skip** is available everywhere.
Prompts that cannot use native buttons, including multi-question and
multi-select prompts, degrade to readable text on channels. The Control UI
keeps the full structured stepper.
+1
View File
@@ -52,6 +52,7 @@ const schemaNames = new Map<string, string>([
["Question", "Question"],
["QuestionAnswers", "QuestionAnswers"],
["QuestionRecord", "QuestionRecord"],
["QuestionGetResult", "QuestionGetResult"],
["QuestionListResult", "QuestionListResult"],
]);
+4 -4
View File
@@ -537,7 +537,7 @@ describe("refreshPendingQuestions", () => {
});
});
it("terminalizes a missing pending question after authoritative not-found", async () => {
it("terminalizes a missing pending question with an unknown outcome after not-found", async () => {
const request = vi.fn<RequestFn>(async (method) => {
if (method === "question.list") {
return { questions: [] };
@@ -553,11 +553,11 @@ describe("refreshPendingQuestions", () => {
});
refreshPendingQuestionsWithRetry(state, client);
await waitForFast(() => expect(state.prompts.get("question-1")?.status).toBe("answered"));
await waitForFast(() => expect(state.prompts.get("question-1")?.status).toBe("unavailable"));
expect(state.prompts.get("question-1")).toMatchObject({
status: "answered",
answeredElsewhere: true,
status: "unavailable",
answeredElsewhere: false,
locallyExpired: false,
submitting: false,
error: null,
+9 -5
View File
@@ -16,6 +16,8 @@ type QuestionDraft = {
freeText: string;
};
type QuestionPromptStatus = QuestionRecord["status"] | "unavailable";
export type QuestionPrompt = {
id: string;
questions: Question[];
@@ -23,7 +25,7 @@ export type QuestionPrompt = {
sessionKey?: string;
createdAtMs: number;
expiresAtMs: number;
status: QuestionRecord["status"];
status: QuestionPromptStatus;
answers?: QuestionAnswers;
submittedAnswers?: QuestionAnswers;
answeredElsewhere: boolean;
@@ -388,10 +390,12 @@ function isQuestionNotFoundError(error: unknown): boolean {
);
}
function markResolvedElsewhere(state: QuestionPromptState, prompt: QuestionPrompt): void {
prompt.status = "answered";
function markRecoveryUnavailable(state: QuestionPromptState, prompt: QuestionPrompt): void {
// QUESTION_NOT_FOUND means the gateway tombstone aged out. It proves the prompt is
// no longer actionable, but not whether it was answered, cancelled, or expired.
prompt.status = "unavailable";
prompt.answers = undefined;
prompt.answeredElsewhere = true;
prompt.answeredElsewhere = false;
prompt.localResolutionConfirmed = false;
prompt.locallyExpired = false;
prompt.submitting = false;
@@ -474,7 +478,7 @@ async function refreshPendingQuestions(
missingResult?.status === "rejected" &&
isQuestionNotFoundError(missingResult.reason)
) {
markResolvedElsewhere(state, current);
markRecoveryUnavailable(state, current);
continue;
}
const record =
+45 -10
View File
@@ -1,4 +1,4 @@
// Control UI E2E tests cover docked Gateway questions through the mocked WebSocket.
// Control UI E2E tests cover composer-replacing Gateway questions through the mocked WebSocket.
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { chromium, type Browser, type BrowserContext, type Page } from "playwright";
@@ -67,7 +67,7 @@ function historyMessages() {
text:
index === 11
? "I have the release context ready. I only need your deployment choice."
: `Release preparation note ${index + 1}: deterministic transcript content for the docked panel proof.`,
: `Release preparation note ${index + 1}: deterministic transcript content for the question panel proof.`,
},
],
timestamp: 1_750_000_000_000 + index * 1_000,
@@ -123,8 +123,10 @@ describeControlUiE2e("Control UI Gateway question flow", () => {
await server?.close();
});
it("docks a pending question and replaces it with a compact answer summary", async () => {
it("replaces the composer, restores it on collapse, and preserves its draft through resolution", async () => {
const { gateway, page } = await openQuestionPage();
const composer = page.locator(".agent-chat__composer-combobox textarea");
await composer.fill("Keep this release note draft");
const request = questionRecord("question-deploy-target", [
{
id: "deploy_target",
@@ -152,17 +154,29 @@ describeControlUiE2e("Control UI Gateway question flow", () => {
.toBe(0);
await expect.poll(() => panel.getByText("1/1", { exact: true }).count()).toBe(1);
await expect.poll(() => panel.getByPlaceholder("Type your own answer here").count()).toBe(1);
await expect.poll(() => page.locator(".agent-chat__input").count()).toBe(0);
await expect.poll(() => page.locator(".agent-chat__composer-footer").count()).toBe(0);
await expect
.poll(() =>
panel
.locator(".chat-question-panel")
.evaluate((element) => document.activeElement === element),
)
.toBe(true);
await expect
.poll(async () => {
const panelBox = await panel.boundingBox();
const composerBox = await page.locator(".agent-chat__input").boundingBox();
if (!panelBox || !composerBox) {
const shellBox = await page.locator(".agent-chat__composer-shell").boundingBox();
if (!panelBox || !shellBox) {
return null;
}
return Math.round(composerBox.y - (panelBox.y + panelBox.height));
return {
left: Math.round(panelBox.x - shellBox.x),
width: Math.round(panelBox.width - shellBox.width),
};
})
.toBeGreaterThanOrEqual(0);
.toEqual({ left: 0, width: 0 });
await expect
.poll(async () => {
const panelHeight = (await panel.boundingBox())?.height ?? 0;
@@ -174,6 +188,22 @@ describeControlUiE2e("Control UI Gateway question flow", () => {
.toBe(true);
await screenshot(page, "01-question-pending.png");
await panel.locator(".chat-question-panel__collapse").click();
await composer.waitFor();
await expect.poll(() => composer.inputValue()).toBe("Keep this release note draft");
await expect
.poll(() => composer.evaluate((element) => document.activeElement === element))
.toBe(true);
await page.locator(".chat-question-panel__collapsed-button").click();
await expect.poll(() => page.locator(".agent-chat__input").count()).toBe(0);
await expect
.poll(() =>
panel
.locator(".chat-question-panel")
.evaluate((element) => document.activeElement === element),
)
.toBe(true);
await panel.getByRole("radio", { name: /Staging \(Recommended\)/ }).click();
await panel.getByRole("button", { name: "Submit", exact: true }).click();
const resolveRequest = await gateway.waitForRequest("question.resolve");
@@ -201,6 +231,11 @@ describeControlUiE2e("Control UI Gateway question flow", () => {
await expect
.poll(() => summary.getByText("Staging (Recommended)", { exact: true }).count())
.toBe(1);
await composer.waitFor();
await expect.poll(() => composer.inputValue()).toBe("Keep this release note draft");
await expect
.poll(() => composer.evaluate((element) => document.activeElement === element))
.toBe(true);
await screenshot(page, "02-question-answered.png");
});
@@ -304,9 +339,9 @@ describeControlUiE2e("Control UI Gateway question flow", () => {
page.locator(".chat-question-summary").filter({ hasText: "Release manager" }).count(),
)
.toBe(1);
await expect
.poll(() => page.locator(".chat-question-summary").filter({ hasText: "Expired" }).count())
.toBe(1);
const expiredSummary = page.locator(".chat-question-summary").filter({ hasText: "Expired" });
await expect.poll(() => expiredSummary.count()).toBe(1);
await expiredSummary.scrollIntoViewIfNeeded();
await screenshot(page, "04-question-terminal-states.png");
});
});
+1
View File
@@ -3772,6 +3772,7 @@ export const en: TranslationMap = {
submitFailed: "Could not submit: {error}",
answered: "Answered",
answeredElsewhere: "Answered elsewhere",
unavailable: "Unavailable",
expired: "Expired",
cancelled: "Cancelled",
},
+65
View File
@@ -313,6 +313,71 @@ describe("renderChatComposer controls", () => {
});
describe("renderChatComposer status", () => {
it("swaps the expanded question with the composer and restores its draft and focus", async () => {
const container = document.createElement("div");
document.body.append(container);
const prompt = questionPrompt("question-swap", "Choose a release target");
const composerProps = props({
paneId: "question-swap-pane",
sessionKey: "queue-test",
draft: "Keep this draft",
gatewayQuestionPrompts: [],
composerControls: html`<button type="button">Model</button>`,
onRequestUpdate: vi.fn(),
});
composerProps.onDraftChange = (next) => {
composerProps.draft = next;
};
const draw = () => render(renderChatComposer(composerProps), container);
draw();
const initialTextarea = container.querySelector<HTMLTextAreaElement>("textarea")!;
initialTextarea.focus();
expect(document.activeElement).toBe(initialTextarea);
initialTextarea.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true }));
initialTextarea.value = "Keep this draft while composing";
initialTextarea.dispatchEvent(
new InputEvent("input", { bubbles: true, inputType: "insertCompositionText" }),
);
composerProps.gatewayQuestionPrompts = [prompt];
draw();
let panel = container.querySelector("openclaw-chat-question-panel") as HTMLElement & {
updateComplete: Promise<unknown>;
};
await panel.updateComplete;
expect(container.querySelector(".agent-chat__input")).toBeNull();
expect(container.querySelector(".agent-chat__composer-footer")).toBeNull();
expect(document.activeElement).toBe(panel.querySelector(".chat-question-panel"));
expect(composerProps.draft).toBe("Keep this draft while composing");
composerProps.draft = "Host updated this draft while the question was open";
panel.querySelector<HTMLButtonElement>(".chat-question-panel__collapse")?.click();
draw();
await Promise.resolve();
let textarea = container.querySelector<HTMLTextAreaElement>("textarea")!;
expect(textarea.value).toBe("Host updated this draft while the question was open");
expect(document.activeElement).toBe(textarea);
panel = container.querySelector("openclaw-chat-question-panel") as typeof panel;
panel.querySelector<HTMLButtonElement>(".chat-question-panel__collapsed-button")?.click();
draw();
await panel.updateComplete;
expect(container.querySelector(".agent-chat__input")).toBeNull();
expect(document.activeElement).toBe(panel.querySelector(".chat-question-panel"));
prompt.status = "answered";
draw();
await Promise.resolve();
textarea = container.querySelector<HTMLTextAreaElement>("textarea")!;
expect(textarea.value).toBe("Host updated this draft while the question was open");
expect(document.activeElement).toBe(textarea);
expect(container.querySelector("openclaw-chat-question-panel")).toBeNull();
container.remove();
});
it("keeps every concurrent gateway question reachable", async () => {
const container = document.createElement("div");
const onRequestUpdate = vi.fn();
+211 -167
View File
@@ -167,6 +167,9 @@ type ChatComposerState = {
pendingClearedSubmittedDraft: PendingClearedSubmittedDraft | null;
goalExpandedId: string | null;
activeGatewayQuestionId: string | null;
gatewayQuestionCollapsed: boolean;
questionTakeoverActive: boolean;
restoreComposerFocus: boolean;
composerTextarea: HTMLTextAreaElement | null;
// Stable Lit ref: an inline arrow would change identity per render and force
// a layout re-measure of the textarea on every chat render, not just attach.
@@ -189,6 +192,9 @@ function createChatComposerState(): ChatComposerState {
pendingClearedSubmittedDraft: null,
goalExpandedId: null,
activeGatewayQuestionId: null,
gatewayQuestionCollapsed: false,
questionTakeoverActive: false,
restoreComposerFocus: false,
composerTextarea: null,
textareaRef: null,
};
@@ -1933,10 +1939,10 @@ export function renderChatComposer(props: ChatComposerProps) {
const compactBusy =
props.compactionStatus?.phase === "active" || props.compactionStatus?.phase === "retrying";
const activeSession = props.sessions?.sessions?.find((row) => row.key === props.sessionKey);
const visibleDraft = props.draft;
const draftKey = composerDraftKey(props);
const actionDraft =
state.composingDraft?.key === draftKey ? state.composingDraft.value : visibleDraft;
const visibleDraft =
state.composingDraft?.key === draftKey ? state.composingDraft.value : props.draft;
const actionDraft = visibleDraft;
let questionDock: HTMLElement | null = null;
state.textareaRef ??= (element?: Element) => {
const nextTextarea = element instanceof HTMLTextAreaElement ? element : null;
@@ -1948,6 +1954,10 @@ export function renderChatComposer(props: ChatComposerProps) {
if (nextTextarea) {
observeTextareaOverflow(nextTextarea);
scheduleTextareaHeightAdjustment(nextTextarea);
if (state.restoreComposerFocus) {
state.restoreComposerFocus = false;
queueMicrotask(() => state.composerTextarea?.focus({ preventScroll: true }));
}
}
};
// The stable ref only measures on attach, so programmatic draft swaps (send
@@ -2005,6 +2015,10 @@ export function renderChatComposer(props: ChatComposerProps) {
if (gatewayQuestionIndex < 0 && gatewayQuestionPrompts.length > 0) {
gatewayQuestionIndex = 0;
state.activeGatewayQuestionId = gatewayQuestionPrompts[0]?.id ?? null;
state.gatewayQuestionCollapsed = false;
} else if (gatewayQuestionPrompts.length === 0) {
state.activeGatewayQuestionId = null;
state.gatewayQuestionCollapsed = false;
}
const gatewayQuestionPrompt = gatewayQuestionPrompts[gatewayQuestionIndex];
const selectGatewayQuestion = (index: number) => {
@@ -2013,11 +2027,18 @@ export function renderChatComposer(props: ChatComposerProps) {
return;
}
state.activeGatewayQuestionId = prompt.id;
state.gatewayQuestionCollapsed = false;
requestUpdate();
};
const questionPanelProps = gatewayQuestionPrompt
? createGatewayQuestionPanelProps(gatewayQuestionPrompt, {
nowMs: Date.now(),
collapsed: state.gatewayQuestionCollapsed,
onCollapsedChange: (collapsed) => {
state.gatewayQuestionCollapsed = collapsed;
state.restoreComposerFocus = collapsed;
requestUpdate();
},
onChange: props.onGatewayQuestionChange,
onSubmit: props.onGatewayQuestionSubmit
? (answers) => props.onGatewayQuestionSubmit?.(gatewayQuestionPrompt.id, answers)
@@ -2038,6 +2059,21 @@ export function renderChatComposer(props: ChatComposerProps) {
selectGatewayQuestion((gatewayQuestionIndex + 1) % gatewayQuestionPrompts.length),
})
: null;
const questionTakeoverActive = Boolean(questionPanelProps && !state.gatewayQuestionCollapsed);
if (!state.questionTakeoverActive && questionTakeoverActive) {
// A question can arrive mid-IME composition before compositionend commits the host draft.
// Commit before unmounting so the detached input cannot leave a stale shadow behind.
if (state.composingDraft?.key === draftKey) {
commitComposerDraft(props, state.composingDraft.value);
state.composingDraft = null;
}
state.composerComposing = false;
}
if (state.questionTakeoverActive && !questionTakeoverActive) {
state.restoreComposerFocus = true;
}
state.questionTakeoverActive = questionTakeoverActive;
const showComposer = !questionTakeoverActive;
const placeholder =
!canCompose && props.disabledReason
@@ -2337,175 +2373,183 @@ export function renderChatComposer(props: ChatComposerProps) {
</div>
`
: nothing}
<div
class="agent-chat__input"
@click=${(event: MouseEvent) => focusComposerFromChrome(event, canCompose)}
>
${slashMenuVisible ? renderSlashMenu(requestUpdate, props, visibleDraft) : nothing}
${renderAttachmentPreview(props)}
${props.replyTarget
? html`
<div class="chat-reply-preview">
<span class="chat-reply-preview__icon">${icons.messageSquare}</span>
<span class="chat-reply-preview__label"
>Replying to ${props.replyTarget.senderLabel ?? "message"}</span
>
<span class="chat-reply-preview__text"
>${truncateUtf16Safe(props.replyTarget.text, 120)}${props.replyTarget.text
.length > 120
? "..."
: ""}</span
>
<button
type="button"
class="chat-reply-preview__dismiss"
@click=${() => props.onClearReply?.()}
aria-label=${t("chat.composer.cancelReply")}
title=${t("chat.composer.cancelReply")}
>
${icons.x}
</button>
</div>
`
: nothing}
<div class="agent-chat__composer-status-stack">
${renderChatPlanChecklist(props.planStatus, {
active: showAbortableUi,
variant: "bar",
})}
${renderFallbackIndicator(props.fallbackStatus)}
${renderCompactionIndicator(props.compactionStatus)}
${renderChatGoal(state, activeSession?.goal, {
canAct: props.connected && canCompose,
onGoalCommand: props.onGoalCommand,
onGoalEdit: (goal) => {
commitComposerDraft(props, `/goal edit ${goal.objective}`);
requestUpdate();
queueMicrotask(() => state.composerTextarea?.focus({ preventScroll: true }));
},
requestUpdate,
})}
</div>
${renderChatAttachmentInputs({ ...props, disabled: !canCompose })}
${renderChatVoiceError({
status: props.realtimeTalkCameraError ? "error" : props.realtimeTalkStatus,
detail: props.realtimeTalkDetail,
onDismissError: props.realtimeTalkCameraError
? undefined
: props.onDismissRealtimeTalkError,
})}
${props.realtimeTalkVideoStream
? html`
<div class="agent-chat__video-preview">
<video
autoplay
.muted=${true}
playsinline
aria-label=${t("chat.composer.cameraPreview")}
${ref((element) => {
if (element instanceof HTMLVideoElement) {
element.srcObject = props.realtimeTalkVideoStream ?? null;
}
})}
></video>
</div>
`
: nothing}
${props.disabledReason
? html`
<div class="agent-chat__disabled-reason">
<span>${props.disabledReason}</span>
${props.disabledActionLabel && props.onDisabledAction
? html`
<button type="button" class="btn btn--xs" @click=${props.onDisabledAction}>
${props.disabledActionLabel}
</button>
`
: nothing}
</div>
`
: nothing}
<div class="agent-chat__composer-input-row">
${renderChatAttachmentMenu({ ...props, disabled: !canCompose })}
<div class="agent-chat__composer-combobox">
<textarea
${ref(state.textareaRef)}
.value=${visibleDraft}
dir=${detectTextDirection(visibleDraft)}
?disabled=${!canCompose}
aria-autocomplete="list"
aria-controls=${ifDefined(slashMenuVisible ? slashMenuListboxId : undefined)}
aria-activedescendant=${ifDefined(activeSlashMenuOptionId ?? undefined)}
aria-describedby=${slashMenuAnnouncementId}
aria-keyshortcuts=${sendShortcut === "enter" ? "Enter" : "Control+Enter Meta+Enter"}
@keydown=${handleKeyDown}
@beforeinput=${handleBeforeInput}
@input=${handleInput}
@compositionstart=${(event: CompositionEvent) => {
state.composerComposing = true;
state.composingDraft = {
key: draftKey,
value: (event.target as HTMLTextAreaElement).value,
};
}}
@compositionend=${handleCompositionEnd}
@blur=${handleBlur}
@paste=${(event: ClipboardEvent) => {
if (canCompose) {
handleChatAttachmentPaste(event, props);
}
}}
placeholder=${placeholder}
rows="1"
></textarea>
${tokens
${showComposer
? html`<div
class="agent-chat__input"
@click=${(event: MouseEvent) => focusComposerFromChrome(event, canCompose)}
>
${slashMenuVisible ? renderSlashMenu(requestUpdate, props, visibleDraft) : nothing}
${renderAttachmentPreview(props)}
${props.replyTarget
? html`
<div class="agent-chat__token-row">
<span class="agent-chat__token-count">${tokens}</span>
<div class="chat-reply-preview">
<span class="chat-reply-preview__icon">${icons.messageSquare}</span>
<span class="chat-reply-preview__label"
>Replying to ${props.replyTarget.senderLabel ?? "message"}</span
>
<span class="chat-reply-preview__text"
>${truncateUtf16Safe(props.replyTarget.text, 120)}${props.replyTarget.text
.length > 120
? "..."
: ""}</span
>
<button
type="button"
class="chat-reply-preview__dismiss"
@click=${() => props.onClearReply?.()}
aria-label=${t("chat.composer.cancelReply")}
title=${t("chat.composer.cancelReply")}
>
${icons.x}
</button>
</div>
`
: nothing}
<span
id=${slashMenuAnnouncementId}
class="agent-chat__sr-only"
role="status"
aria-live="polite"
aria-atomic="true"
>${activeSlashMenuOptionLabel}</span
>
<span
class="agent-chat__run-status-announcement agent-chat__sr-only"
role="status"
aria-live="polite"
aria-atomic="true"
>${runStatusAnnouncement}</span
>
</div>
<div class="agent-chat__composer-actions">
${renderChatPrimaryActions(runControlsProps)}
</div>
</div>
<div class="agent-chat__composer-status-stack">
${renderChatPlanChecklist(props.planStatus, {
active: showAbortableUi,
variant: "bar",
})}
${renderFallbackIndicator(props.fallbackStatus)}
${renderCompactionIndicator(props.compactionStatus)}
${renderChatGoal(state, activeSession?.goal, {
canAct: props.connected && canCompose,
onGoalCommand: props.onGoalCommand,
onGoalEdit: (goal) => {
commitComposerDraft(props, `/goal edit ${goal.objective}`);
requestUpdate();
queueMicrotask(() => state.composerTextarea?.focus({ preventScroll: true }));
},
requestUpdate,
})}
</div>
<div class="agent-chat__composer-footer">
${composerControls !== nothing
? html`
<div class="agent-chat__composer-controls">
${composerRunStatus?.phase === "interrupted"
? html`
<div class="agent-chat__composer-run-status">
${renderChatRunStatusIndicator(composerRunStatus)}
</div>
`
: nothing}
${composerControls}
</div>
`
: nothing}
<div class="agent-chat__composer-meta">${contextNotice}</div>
</div>
</div>
${renderChatAttachmentInputs({ ...props, disabled: !canCompose })}
${renderChatVoiceError({
status: props.realtimeTalkCameraError ? "error" : props.realtimeTalkStatus,
detail: props.realtimeTalkDetail,
onDismissError: props.realtimeTalkCameraError
? undefined
: props.onDismissRealtimeTalkError,
})}
${props.realtimeTalkVideoStream
? html`
<div class="agent-chat__video-preview">
<video
autoplay
.muted=${true}
playsinline
aria-label=${t("chat.composer.cameraPreview")}
${ref((element) => {
if (element instanceof HTMLVideoElement) {
element.srcObject = props.realtimeTalkVideoStream ?? null;
}
})}
></video>
</div>
`
: nothing}
${props.disabledReason
? html`
<div class="agent-chat__disabled-reason">
<span>${props.disabledReason}</span>
${props.disabledActionLabel && props.onDisabledAction
? html`
<button
type="button"
class="btn btn--xs"
@click=${props.onDisabledAction}
>
${props.disabledActionLabel}
</button>
`
: nothing}
</div>
`
: nothing}
<div class="agent-chat__composer-input-row">
${renderChatAttachmentMenu({ ...props, disabled: !canCompose })}
<div class="agent-chat__composer-combobox">
<textarea
${ref(state.textareaRef)}
.value=${visibleDraft}
dir=${detectTextDirection(visibleDraft)}
?disabled=${!canCompose}
aria-autocomplete="list"
aria-controls=${ifDefined(slashMenuVisible ? slashMenuListboxId : undefined)}
aria-activedescendant=${ifDefined(activeSlashMenuOptionId ?? undefined)}
aria-describedby=${slashMenuAnnouncementId}
aria-keyshortcuts=${sendShortcut === "enter"
? "Enter"
: "Control+Enter Meta+Enter"}
@keydown=${handleKeyDown}
@beforeinput=${handleBeforeInput}
@input=${handleInput}
@compositionstart=${(event: CompositionEvent) => {
state.composerComposing = true;
state.composingDraft = {
key: draftKey,
value: (event.target as HTMLTextAreaElement).value,
};
}}
@compositionend=${handleCompositionEnd}
@blur=${handleBlur}
@paste=${(event: ClipboardEvent) => {
if (canCompose) {
handleChatAttachmentPaste(event, props);
}
}}
placeholder=${placeholder}
rows="1"
></textarea>
${tokens
? html`
<div class="agent-chat__token-row">
<span class="agent-chat__token-count">${tokens}</span>
</div>
`
: nothing}
<span
id=${slashMenuAnnouncementId}
class="agent-chat__sr-only"
role="status"
aria-live="polite"
aria-atomic="true"
>${activeSlashMenuOptionLabel}</span
>
<span
class="agent-chat__run-status-announcement agent-chat__sr-only"
role="status"
aria-live="polite"
aria-atomic="true"
>${runStatusAnnouncement}</span
>
</div>
<div class="agent-chat__composer-actions">
${renderChatPrimaryActions(runControlsProps)}
</div>
</div>
<div class="agent-chat__composer-footer">
${composerControls !== nothing
? html`
<div class="agent-chat__composer-controls">
${composerRunStatus?.phase === "interrupted"
? html`
<div class="agent-chat__composer-run-status">
${renderChatRunStatusIndicator(composerRunStatus)}
</div>
`
: nothing}
${composerControls}
</div>
`
: nothing}
<div class="agent-chat__composer-meta">${contextNotice}</div>
</div>
</div>`
: nothing}
</div>
`;
}
@@ -64,11 +64,17 @@ describe("shared question panel", () => {
onSkip?: () => void | Promise<void>;
} = {},
) {
let collapsed = false;
const redraw = () => {
render(
html`<openclaw-chat-question-panel
.props=${createGatewayQuestionPanelProps(prompt, {
nowMs: 2_000,
collapsed,
onCollapsedChange: (nextCollapsed) => {
collapsed = nextCollapsed;
redraw();
},
onChange: redraw,
onSubmit: callbacks.onSubmit ?? vi.fn(),
onSkip: callbacks.onSkip ?? vi.fn(),
@@ -235,6 +241,7 @@ describe("shared question panel", () => {
container.querySelector<HTMLButtonElement>(".chat-question-panel__collapsed-button")?.click();
await panel.updateComplete;
expect(document.activeElement).toBe(container.querySelector(".chat-question-panel"));
container.querySelector<HTMLButtonElement>(".chat-question-panel__skip")?.click();
expect(onSkip).toHaveBeenCalledOnce();
});
@@ -254,6 +261,24 @@ describe("shared question panel", () => {
expect(container.querySelector(".chat-question-panel__skip")).toBeNull();
});
it("manages collapse state when no controlled callback is supplied", async () => {
render(
html`<openclaw-chat-question-panel
.props=${createGatewayQuestionPanelProps(gatewayPrompt(), { nowMs: 2_000 })}
></openclaw-chat-question-panel>`,
container,
);
const panel = await panelIn(container);
container.querySelector<HTMLButtonElement>(".chat-question-panel__collapse")?.click();
await panel.updateComplete;
expect(container.querySelector(".chat-question-panel--collapsed")).not.toBeNull();
container.querySelector<HTMLButtonElement>(".chat-question-panel__collapsed-button")?.click();
await panel.updateComplete;
expect(container.querySelector(".chat-question-panel--collapsed")).toBeNull();
});
it("retains answers with submit-only wiring", async () => {
const onSubmit = vi.fn();
render(
@@ -18,6 +18,7 @@ type QuestionPanelViewModel = {
requestKey: string;
title: string;
questions: QuestionPanelQuestion[];
collapsed: boolean;
disabled: boolean;
submitting?: boolean;
countdown?: string;
@@ -32,6 +33,7 @@ type QuestionPanelProps = {
onSkip?: () => void | Promise<void>;
onAnswersChange?: (answersById: Record<string, string[]>) => void;
onDismissError?: () => void;
onCollapsedChange?: (collapsed: boolean) => void;
onPreviousRequest?: () => void;
onNextRequest?: () => void;
};
@@ -41,6 +43,8 @@ type GatewayQuestionPanelOptions = {
onChange?: () => void;
onSubmit?: (answers: Record<string, string[]>) => void | Promise<void>;
onSkip?: () => void | Promise<void>;
collapsed?: boolean;
onCollapsedChange?: (collapsed: boolean) => void;
requestPosition?: { current: number; total: number };
onPreviousRequest?: () => void;
onNextRequest?: () => void;
@@ -84,6 +88,7 @@ export function createGatewayQuestionPanelProps(
requestKey: prompt.id,
title: t("chat.questions.eyebrow"),
questions: prompt.questions,
collapsed: options.collapsed ?? false,
disabled: prompt.status !== "pending" || prompt.submitting,
submitting: prompt.submitting,
countdown:
@@ -121,6 +126,7 @@ export function createGatewayQuestionPanelProps(
options.onChange?.();
}
: undefined,
onCollapsedChange: options.onCollapsedChange,
onPreviousRequest: options.onPreviousRequest,
onNextRequest: options.onNextRequest,
};
@@ -133,6 +139,9 @@ function terminalAnswer(prompt: QuestionPrompt, question: QuestionPanelQuestion)
if (prompt.status === "expired") {
return t("chat.questions.expired");
}
if (prompt.status === "unavailable") {
return t("chat.questions.unavailable");
}
const answer = prompt.answers?.answers[question.id]?.answers.join(", ");
if (answer) {
return answer;
@@ -178,22 +187,40 @@ class ChatQuestionPanel extends LitElement {
@state() private selectedById = new Map<string, string[]>();
@state() private freeTextById = new Map<string, string>();
@state() private currentQuestionIndex = 0;
@state() private collapsed = false;
@state() private pendingAction: "submit" | "skip" | null = null;
private requestKey: string | null = null;
private collapsed = false;
private focusAfterUpdate = false;
private syncedAnswersSignature: string | null = null;
private setCollapsed(collapsed: boolean): void {
if (this.props?.onCollapsedChange) {
this.props.onCollapsedChange(collapsed);
return;
}
this.collapsed = collapsed;
this.focusAfterUpdate = !collapsed;
this.requestUpdate();
}
override willUpdate() {
const model = this.props?.model;
const nextRequestKey = model?.requestKey ?? null;
const nextCollapsed = model?.collapsed ?? false;
if (nextRequestKey !== this.requestKey) {
this.requestKey = nextRequestKey;
this.selectedById = new Map();
this.freeTextById = new Map();
this.currentQuestionIndex = 0;
this.collapsed = false;
this.pendingAction = null;
this.syncedAnswersSignature = null;
this.collapsed = nextCollapsed;
this.focusAfterUpdate = !nextCollapsed;
} else if (this.props?.onCollapsedChange) {
if (this.collapsed && !nextCollapsed) {
this.focusAfterUpdate = true;
}
this.collapsed = nextCollapsed;
}
if (!model?.answersById) {
return;
@@ -221,6 +248,14 @@ class ChatQuestionPanel extends LitElement {
this.freeTextById = freeTextById;
}
override updated(): void {
if (!this.focusAfterUpdate || this.collapsed) {
return;
}
this.focusAfterUpdate = false;
this.querySelector<HTMLElement>(".chat-question-panel")?.focus({ preventScroll: true });
}
private answerValues(question: QuestionPanelQuestion): string[] {
const selected = this.selectedById.get(question.id) ?? [];
const freeText = this.freeTextById.get(question.id)?.trim();
@@ -441,8 +476,7 @@ class ChatQuestionPanel extends LitElement {
class="chat-question-panel__collapsed-button"
type="button"
@click=${() => {
this.collapsed = false;
this.focusPanel();
this.setCollapsed(false);
}}
aria-label=${t("chat.questions.expand")}
>
@@ -504,7 +538,7 @@ class ChatQuestionPanel extends LitElement {
<button
class="chat-question-panel__collapse"
type="button"
@click=${() => (this.collapsed = true)}
@click=${() => this.setCollapsed(true)}
aria-label=${t("chat.questions.collapse")}
>
${icons.chevronDown}