mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
feat(apps): rewind and fork a session from native chat bubbles (#110886)
* feat(apps): rewind and fork a session from native chat bubbles * docs(apps): note lease-parity contract on message fork dispatch * fix(apps): native i18n inventory sync and doc-comment style for bubble actions * fix(apps): translate native bubble action strings across locales * fix(apps): refresh generated iOS localization catalog after rebase
This commit is contained in:
@@ -38211,7 +38211,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 513,
|
||||
"line": 515,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Retry Send",
|
||||
"surface": "apple",
|
||||
@@ -38219,7 +38219,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 527,
|
||||
"line": 529,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Delete",
|
||||
"surface": "apple",
|
||||
@@ -38227,7 +38227,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 561,
|
||||
"line": 565,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Stop Listening",
|
||||
"surface": "apple",
|
||||
@@ -38235,7 +38235,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 564,
|
||||
"line": 568,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Listen",
|
||||
"surface": "apple",
|
||||
@@ -38243,7 +38243,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 659,
|
||||
"line": 665,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Jump to latest reply",
|
||||
"surface": "apple",
|
||||
@@ -38251,7 +38251,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 679,
|
||||
"line": 685,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Refresh",
|
||||
"surface": "apple",
|
||||
@@ -38259,7 +38259,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 1074,
|
||||
"line": 1080,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Copy Message",
|
||||
"surface": "apple",
|
||||
@@ -38267,15 +38267,31 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 1097,
|
||||
"line": 1103,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Open Full Message",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.90bc8a8045e7017e"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 1122,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Rewind to Here",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.002bfcede229ad4b"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 1142,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Fork from Here",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.14df2e4997689fb3"
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 1118,
|
||||
"line": 1170,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Reply",
|
||||
"surface": "apple",
|
||||
@@ -38283,7 +38299,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 1128,
|
||||
"line": 1180,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "You",
|
||||
"surface": "apple",
|
||||
@@ -38291,7 +38307,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 1130,
|
||||
"line": 1182,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Assistant",
|
||||
"surface": "apple",
|
||||
@@ -38299,7 +38315,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 1196,
|
||||
"line": 1248,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Loading chat",
|
||||
"surface": "apple",
|
||||
@@ -38307,7 +38323,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 1276,
|
||||
"line": 1328,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Dismiss",
|
||||
"surface": "apple",
|
||||
|
||||
@@ -23923,6 +23923,16 @@
|
||||
"source": "Open Full Message",
|
||||
"translated": "فتح الرسالة الكاملة"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.002bfcede229ad4b",
|
||||
"source": "Rewind to Here",
|
||||
"translated": "إرجاع إلى هنا"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.14df2e4997689fb3",
|
||||
"source": "Fork from Here",
|
||||
"translated": "إنشاء تفرّع من هنا"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.b37ec7bd7dc1579a",
|
||||
"source": "Reply",
|
||||
|
||||
@@ -23923,6 +23923,16 @@
|
||||
"source": "Open Full Message",
|
||||
"translated": "Vollständige Nachricht öffnen"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.002bfcede229ad4b",
|
||||
"source": "Rewind to Here",
|
||||
"translated": "Bis hierhin zurückspulen"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.14df2e4997689fb3",
|
||||
"source": "Fork from Here",
|
||||
"translated": "Ab hier abzweigen"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.b37ec7bd7dc1579a",
|
||||
"source": "Reply",
|
||||
|
||||
@@ -23923,6 +23923,16 @@
|
||||
"source": "Open Full Message",
|
||||
"translated": "Abrir mensaje completo"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.002bfcede229ad4b",
|
||||
"source": "Rewind to Here",
|
||||
"translated": "Retroceder hasta aquí"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.14df2e4997689fb3",
|
||||
"source": "Fork from Here",
|
||||
"translated": "Bifurcar desde aquí"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.b37ec7bd7dc1579a",
|
||||
"source": "Reply",
|
||||
|
||||
@@ -23923,6 +23923,16 @@
|
||||
"source": "Open Full Message",
|
||||
"translated": "باز کردن پیام کامل"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.002bfcede229ad4b",
|
||||
"source": "Rewind to Here",
|
||||
"translated": "بازگشت به اینجا"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.14df2e4997689fb3",
|
||||
"source": "Fork from Here",
|
||||
"translated": "از اینجا منشعب شوید"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.b37ec7bd7dc1579a",
|
||||
"source": "Reply",
|
||||
|
||||
@@ -23923,6 +23923,16 @@
|
||||
"source": "Open Full Message",
|
||||
"translated": "Ouvrir le message complet"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.002bfcede229ad4b",
|
||||
"source": "Rewind to Here",
|
||||
"translated": "Revenir ici"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.14df2e4997689fb3",
|
||||
"source": "Fork from Here",
|
||||
"translated": "Créer une branche à partir d’ici"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.b37ec7bd7dc1579a",
|
||||
"source": "Reply",
|
||||
|
||||
@@ -23923,6 +23923,16 @@
|
||||
"source": "Open Full Message",
|
||||
"translated": "पूरा संदेश खोलें"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.002bfcede229ad4b",
|
||||
"source": "Rewind to Here",
|
||||
"translated": "यहाँ तक रिवाइंड करें"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.14df2e4997689fb3",
|
||||
"source": "Fork from Here",
|
||||
"translated": "यहाँ से फ़ोर्क करें"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.b37ec7bd7dc1579a",
|
||||
"source": "Reply",
|
||||
|
||||
@@ -23923,6 +23923,16 @@
|
||||
"source": "Open Full Message",
|
||||
"translated": "Buka Pesan Lengkap"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.002bfcede229ad4b",
|
||||
"source": "Rewind to Here",
|
||||
"translated": "Putar Balik ke Sini"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.14df2e4997689fb3",
|
||||
"source": "Fork from Here",
|
||||
"translated": "Buat Cabang dari Sini"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.b37ec7bd7dc1579a",
|
||||
"source": "Reply",
|
||||
|
||||
@@ -23923,6 +23923,16 @@
|
||||
"source": "Open Full Message",
|
||||
"translated": "Apri il messaggio completo"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.002bfcede229ad4b",
|
||||
"source": "Rewind to Here",
|
||||
"translated": "Riavvolgi fino a qui"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.14df2e4997689fb3",
|
||||
"source": "Fork from Here",
|
||||
"translated": "Crea una diramazione da qui"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.b37ec7bd7dc1579a",
|
||||
"source": "Reply",
|
||||
|
||||
@@ -23923,6 +23923,16 @@
|
||||
"source": "Open Full Message",
|
||||
"translated": "メッセージ全文を開く"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.002bfcede229ad4b",
|
||||
"source": "Rewind to Here",
|
||||
"translated": "ここまで巻き戻す"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.14df2e4997689fb3",
|
||||
"source": "Fork from Here",
|
||||
"translated": "ここから分岐"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.b37ec7bd7dc1579a",
|
||||
"source": "Reply",
|
||||
|
||||
@@ -23923,6 +23923,16 @@
|
||||
"source": "Open Full Message",
|
||||
"translated": "전체 메시지 열기"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.002bfcede229ad4b",
|
||||
"source": "Rewind to Here",
|
||||
"translated": "여기까지 되돌리기"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.14df2e4997689fb3",
|
||||
"source": "Fork from Here",
|
||||
"translated": "여기에서 분기하기"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.b37ec7bd7dc1579a",
|
||||
"source": "Reply",
|
||||
|
||||
@@ -23923,6 +23923,16 @@
|
||||
"source": "Open Full Message",
|
||||
"translated": "Volledig bericht openen"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.002bfcede229ad4b",
|
||||
"source": "Rewind to Here",
|
||||
"translated": "Terugspoelen naar hier"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.14df2e4997689fb3",
|
||||
"source": "Fork from Here",
|
||||
"translated": "Vanaf hier afsplitsen"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.b37ec7bd7dc1579a",
|
||||
"source": "Reply",
|
||||
|
||||
@@ -23923,6 +23923,16 @@
|
||||
"source": "Open Full Message",
|
||||
"translated": "Otwórz pełną wiadomość"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.002bfcede229ad4b",
|
||||
"source": "Rewind to Here",
|
||||
"translated": "Przewiń do tego miejsca"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.14df2e4997689fb3",
|
||||
"source": "Fork from Here",
|
||||
"translated": "Utwórz odgałęzienie od tego miejsca"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.b37ec7bd7dc1579a",
|
||||
"source": "Reply",
|
||||
|
||||
@@ -23923,6 +23923,16 @@
|
||||
"source": "Open Full Message",
|
||||
"translated": "Abrir mensagem completa"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.002bfcede229ad4b",
|
||||
"source": "Rewind to Here",
|
||||
"translated": "Rebobinar até aqui"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.14df2e4997689fb3",
|
||||
"source": "Fork from Here",
|
||||
"translated": "Criar bifurcação a partir daqui"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.b37ec7bd7dc1579a",
|
||||
"source": "Reply",
|
||||
|
||||
@@ -23923,6 +23923,16 @@
|
||||
"source": "Open Full Message",
|
||||
"translated": "Открыть полное сообщение"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.002bfcede229ad4b",
|
||||
"source": "Rewind to Here",
|
||||
"translated": "Перемотать сюда"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.14df2e4997689fb3",
|
||||
"source": "Fork from Here",
|
||||
"translated": "Создать ответвление отсюда"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.b37ec7bd7dc1579a",
|
||||
"source": "Reply",
|
||||
|
||||
@@ -23923,6 +23923,16 @@
|
||||
"source": "Open Full Message",
|
||||
"translated": "Öppna hela meddelandet"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.002bfcede229ad4b",
|
||||
"source": "Rewind to Here",
|
||||
"translated": "Spola tillbaka hit"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.14df2e4997689fb3",
|
||||
"source": "Fork from Here",
|
||||
"translated": "Förgrena härifrån"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.b37ec7bd7dc1579a",
|
||||
"source": "Reply",
|
||||
|
||||
@@ -23923,6 +23923,16 @@
|
||||
"source": "Open Full Message",
|
||||
"translated": "เปิดข้อความฉบับเต็ม"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.002bfcede229ad4b",
|
||||
"source": "Rewind to Here",
|
||||
"translated": "ย้อนกลับมาที่นี่"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.14df2e4997689fb3",
|
||||
"source": "Fork from Here",
|
||||
"translated": "แยกแขนงจากตรงนี้"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.b37ec7bd7dc1579a",
|
||||
"source": "Reply",
|
||||
|
||||
@@ -23923,6 +23923,16 @@
|
||||
"source": "Open Full Message",
|
||||
"translated": "Tam İletiyi Aç"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.002bfcede229ad4b",
|
||||
"source": "Rewind to Here",
|
||||
"translated": "Buraya Geri Sar"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.14df2e4997689fb3",
|
||||
"source": "Fork from Here",
|
||||
"translated": "Buradan Çatallandır"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.b37ec7bd7dc1579a",
|
||||
"source": "Reply",
|
||||
|
||||
@@ -23923,6 +23923,16 @@
|
||||
"source": "Open Full Message",
|
||||
"translated": "Відкрити повне повідомлення"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.002bfcede229ad4b",
|
||||
"source": "Rewind to Here",
|
||||
"translated": "Перемотати сюди"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.14df2e4997689fb3",
|
||||
"source": "Fork from Here",
|
||||
"translated": "Створити відгалуження звідси"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.b37ec7bd7dc1579a",
|
||||
"source": "Reply",
|
||||
|
||||
@@ -23923,6 +23923,16 @@
|
||||
"source": "Open Full Message",
|
||||
"translated": "Mở toàn bộ tin nhắn"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.002bfcede229ad4b",
|
||||
"source": "Rewind to Here",
|
||||
"translated": "Tua lại đến đây"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.14df2e4997689fb3",
|
||||
"source": "Fork from Here",
|
||||
"translated": "Tạo nhánh từ đây"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.b37ec7bd7dc1579a",
|
||||
"source": "Reply",
|
||||
|
||||
@@ -23923,6 +23923,16 @@
|
||||
"source": "Open Full Message",
|
||||
"translated": "打开完整消息"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.002bfcede229ad4b",
|
||||
"source": "Rewind to Here",
|
||||
"translated": "回退到此处"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.14df2e4997689fb3",
|
||||
"source": "Fork from Here",
|
||||
"translated": "从此处分叉"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.b37ec7bd7dc1579a",
|
||||
"source": "Reply",
|
||||
|
||||
@@ -23923,6 +23923,16 @@
|
||||
"source": "Open Full Message",
|
||||
"translated": "開啟完整訊息"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.002bfcede229ad4b",
|
||||
"source": "Rewind to Here",
|
||||
"translated": "倒回此處"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.14df2e4997689fb3",
|
||||
"source": "Fork from Here",
|
||||
"translated": "從這裡分支"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.b37ec7bd7dc1579a",
|
||||
"source": "Reply",
|
||||
|
||||
@@ -69905,6 +69905,142 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Fork from Here": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Fork from Here"
|
||||
}
|
||||
},
|
||||
"zh-CN": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "从此处分叉"
|
||||
}
|
||||
},
|
||||
"zh-TW": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "從這裡分支"
|
||||
}
|
||||
},
|
||||
"pt-BR": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Criar bifurcação a partir daqui"
|
||||
}
|
||||
},
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Ab hier abzweigen"
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Bifurcar desde aquí"
|
||||
}
|
||||
},
|
||||
"ja-JP": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "ここから分岐"
|
||||
}
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "여기에서 분기하기"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Créer une branche à partir d’ici"
|
||||
}
|
||||
},
|
||||
"hi": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "यहाँ से फ़ोर्क करें"
|
||||
}
|
||||
},
|
||||
"ar": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "إنشاء تفرّع من هنا"
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Crea una diramazione da qui"
|
||||
}
|
||||
},
|
||||
"tr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Buradan Çatallandır"
|
||||
}
|
||||
},
|
||||
"uk": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Створити відгалуження звідси"
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Buat Cabang dari Sini"
|
||||
}
|
||||
},
|
||||
"pl": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Utwórz odgałęzienie od tego miejsca"
|
||||
}
|
||||
},
|
||||
"th": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "แยกแขนงจากตรงนี้"
|
||||
}
|
||||
},
|
||||
"vi": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Tạo nhánh từ đây"
|
||||
}
|
||||
},
|
||||
"nl": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Vanaf hier afsplitsen"
|
||||
}
|
||||
},
|
||||
"fa": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "از اینجا منشعب شوید"
|
||||
}
|
||||
},
|
||||
"ru": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Создать ответвление отсюда"
|
||||
}
|
||||
},
|
||||
"sv": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Förgrena härifrån"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Full": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
@@ -141169,6 +141305,142 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Rewind to Here": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Rewind to Here"
|
||||
}
|
||||
},
|
||||
"zh-CN": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "回退到此处"
|
||||
}
|
||||
},
|
||||
"zh-TW": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "倒回此處"
|
||||
}
|
||||
},
|
||||
"pt-BR": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Rebobinar até aqui"
|
||||
}
|
||||
},
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Bis hierhin zurückspulen"
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Retroceder hasta aquí"
|
||||
}
|
||||
},
|
||||
"ja-JP": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "ここまで巻き戻す"
|
||||
}
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "여기까지 되돌리기"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Revenir ici"
|
||||
}
|
||||
},
|
||||
"hi": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "यहाँ तक रिवाइंड करें"
|
||||
}
|
||||
},
|
||||
"ar": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "إرجاع إلى هنا"
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Riavvolgi fino a qui"
|
||||
}
|
||||
},
|
||||
"tr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Buraya Geri Sar"
|
||||
}
|
||||
},
|
||||
"uk": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Перемотати сюди"
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Putar Balik ke Sini"
|
||||
}
|
||||
},
|
||||
"pl": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Przewiń do tego miejsca"
|
||||
}
|
||||
},
|
||||
"th": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "ย้อนกลับมาที่นี่"
|
||||
}
|
||||
},
|
||||
"vi": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Tua lại đến đây"
|
||||
}
|
||||
},
|
||||
"nl": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Terugspoelen naar hier"
|
||||
}
|
||||
},
|
||||
"fa": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "بازگشت به اینجا"
|
||||
}
|
||||
},
|
||||
"ru": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Перемотать сюда"
|
||||
}
|
||||
},
|
||||
"sv": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Spola tillbaka hit"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Roles": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
|
||||
@@ -271,6 +271,32 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
||||
return try JSONDecoder().decode(OpenClawChatCreateSessionResponse.self, from: response).key
|
||||
}
|
||||
|
||||
func rewindSession(
|
||||
sessionKey: String,
|
||||
entryId: String) async throws -> OpenClawChatRewindResponse
|
||||
{
|
||||
let target = self.sessionTarget(for: sessionKey)
|
||||
let request = OpenClawChatGatewayRequests.rewindSession(
|
||||
sessionKey: target.sessionKey,
|
||||
agentID: target.agentID,
|
||||
entryId: entryId)
|
||||
let response = try await self.requestSessionMutation(request)
|
||||
return try JSONDecoder().decode(OpenClawChatRewindResponse.self, from: response)
|
||||
}
|
||||
|
||||
func forkSessionAtMessage(
|
||||
sessionKey: String,
|
||||
entryId: String) async throws -> OpenClawChatForkAtMessageResponse
|
||||
{
|
||||
let target = self.sessionTarget(for: sessionKey)
|
||||
let request = OpenClawChatGatewayRequests.forkAtMessage(
|
||||
sessionKey: target.sessionKey,
|
||||
agentID: target.agentID,
|
||||
entryId: entryId)
|
||||
let response = try await self.requestSessionMutation(request)
|
||||
return try JSONDecoder().decode(OpenClawChatForkAtMessageResponse.self, from: response)
|
||||
}
|
||||
|
||||
func setActiveSessionKey(_ sessionKey: String) async throws {
|
||||
let target = self.sessionTarget(for: sessionKey)
|
||||
let request = OpenClawChatGatewayRequests.subscribeSessionMessages(
|
||||
|
||||
@@ -114,7 +114,7 @@ extension MacGatewayChatTransport {
|
||||
})
|
||||
}
|
||||
|
||||
func forkSession(parentKey: String) async throws -> String {
|
||||
private func requestSessionAction(_ request: OpenClawChatGatewayRequest) async throws -> Data {
|
||||
guard let serverLease = await GatewayConnection.shared.captureServerLease() else {
|
||||
throw OpenClawChatTransportSendError.notDispatched
|
||||
}
|
||||
@@ -124,15 +124,67 @@ extension MacGatewayChatTransport {
|
||||
throw OpenClawChatTransportSendError.notDispatched
|
||||
}
|
||||
}
|
||||
let target = self.sessionTarget(for: parentKey)
|
||||
let request = OpenClawChatGatewayRequests.forkSession(
|
||||
parentSessionKey: target.sessionKey,
|
||||
agentID: target.agentID)
|
||||
let data = try await GatewayConnection.shared.request(
|
||||
return try await GatewayConnection.shared.request(
|
||||
method: request.method,
|
||||
params: request.params,
|
||||
timeoutMs: request.timeoutMs,
|
||||
ifCurrentServerLease: serverLease)
|
||||
}
|
||||
|
||||
func forkSession(parentKey: String) async throws -> String {
|
||||
let target = self.sessionTarget(for: parentKey)
|
||||
let request = OpenClawChatGatewayRequests.forkSession(
|
||||
parentSessionKey: target.sessionKey,
|
||||
agentID: target.agentID)
|
||||
let data = try await self.requestSessionAction(request)
|
||||
return try JSONDecoder().decode(OpenClawChatCreateSessionResponse.self, from: data).key
|
||||
}
|
||||
|
||||
func rewindSession(
|
||||
sessionKey: String,
|
||||
entryId: String) async throws -> OpenClawChatRewindResponse
|
||||
{
|
||||
let target = self.sessionTarget(for: sessionKey)
|
||||
let request = Self.rewindSessionRequest(
|
||||
sessionKey: target.sessionKey,
|
||||
agentID: target.agentID,
|
||||
entryId: entryId)
|
||||
let data = try await self.requestSessionAction(request)
|
||||
return try JSONDecoder().decode(OpenClawChatRewindResponse.self, from: data)
|
||||
}
|
||||
|
||||
func forkSessionAtMessage(
|
||||
sessionKey: String,
|
||||
entryId: String) async throws -> OpenClawChatForkAtMessageResponse
|
||||
{
|
||||
let target = self.sessionTarget(for: sessionKey)
|
||||
let request = Self.forkSessionAtMessageRequest(
|
||||
sessionKey: target.sessionKey,
|
||||
agentID: target.agentID,
|
||||
entryId: entryId)
|
||||
let data = try await self.requestSessionAction(request)
|
||||
return try JSONDecoder().decode(OpenClawChatForkAtMessageResponse.self, from: data)
|
||||
}
|
||||
|
||||
static func rewindSessionRequest(
|
||||
sessionKey: String,
|
||||
agentID: String?,
|
||||
entryId: String) -> OpenClawChatGatewayRequest
|
||||
{
|
||||
OpenClawChatGatewayRequests.rewindSession(
|
||||
sessionKey: sessionKey,
|
||||
agentID: agentID,
|
||||
entryId: entryId)
|
||||
}
|
||||
|
||||
static func forkSessionAtMessageRequest(
|
||||
sessionKey: String,
|
||||
agentID: String?,
|
||||
entryId: String) -> OpenClawChatGatewayRequest
|
||||
{
|
||||
OpenClawChatGatewayRequests.forkAtMessage(
|
||||
sessionKey: sessionKey,
|
||||
agentID: agentID,
|
||||
entryId: entryId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,26 @@ struct MacGatewayChatTransportMappingTests {
|
||||
#expect(request.params["maxChars"]?.value as? Int == 500_000)
|
||||
}
|
||||
|
||||
@Test func `message rewind and fork requests map session targets`() {
|
||||
let rewind = MacGatewayChatTransport.rewindSessionRequest(
|
||||
sessionKey: "global",
|
||||
agentID: "reviewer",
|
||||
entryId: "msg-42")
|
||||
let fork = MacGatewayChatTransport.forkSessionAtMessageRequest(
|
||||
sessionKey: "agent:reviewer:main",
|
||||
agentID: nil,
|
||||
entryId: "msg-43")
|
||||
|
||||
#expect(rewind.method == "sessions.rewind")
|
||||
#expect(rewind.params["sessionKey"]?.value as? String == "global")
|
||||
#expect(rewind.params["agentId"]?.value as? String == "reviewer")
|
||||
#expect(rewind.params["entryId"]?.value as? String == "msg-42")
|
||||
#expect(fork.method == "sessions.fork")
|
||||
#expect(fork.params["sessionKey"]?.value as? String == "agent:reviewer:main")
|
||||
#expect(fork.params["agentId"] == nil)
|
||||
#expect(fork.params["entryId"]?.value as? String == "msg-43")
|
||||
}
|
||||
|
||||
@Test func `legacy trace preference migrates to independent defaults once`() throws {
|
||||
let suiteName = "MacGatewayChatTransportMappingTests.\(UUID().uuidString)"
|
||||
let defaults = try #require(UserDefaults(suiteName: suiteName))
|
||||
|
||||
@@ -327,6 +327,38 @@ public enum OpenClawChatGatewayRequests {
|
||||
timeoutMs: self.mutationTimeoutMs)
|
||||
}
|
||||
|
||||
public static func rewindSession(
|
||||
sessionKey: String,
|
||||
agentID: String?,
|
||||
entryId: String) -> OpenClawChatGatewayRequest
|
||||
{
|
||||
var params = self.sessionParams(
|
||||
sessionKey: sessionKey,
|
||||
agentID: agentID,
|
||||
key: "sessionKey")
|
||||
self.add(entryId, to: ¶ms, key: "entryId")
|
||||
return OpenClawChatGatewayRequest(
|
||||
method: "sessions.rewind",
|
||||
params: params,
|
||||
timeoutMs: self.mutationTimeoutMs)
|
||||
}
|
||||
|
||||
public static func forkAtMessage(
|
||||
sessionKey: String,
|
||||
agentID: String?,
|
||||
entryId: String) -> OpenClawChatGatewayRequest
|
||||
{
|
||||
var params = self.sessionParams(
|
||||
sessionKey: sessionKey,
|
||||
agentID: agentID,
|
||||
key: "sessionKey")
|
||||
self.add(entryId, to: ¶ms, key: "entryId")
|
||||
return OpenClawChatGatewayRequest(
|
||||
method: "sessions.fork",
|
||||
params: params,
|
||||
timeoutMs: self.mutationTimeoutMs)
|
||||
}
|
||||
|
||||
public static func subscribeSessionMessages(
|
||||
sessionKey: String,
|
||||
agentID: String?) -> OpenClawChatGatewayRequest
|
||||
@@ -465,9 +497,10 @@ public enum OpenClawChatGatewayRequests {
|
||||
|
||||
private static func sessionParams(
|
||||
sessionKey: String,
|
||||
agentID: String?) -> [String: AnyCodable]
|
||||
agentID: String?,
|
||||
key: String = "key") -> [String: AnyCodable]
|
||||
{
|
||||
var params = ["key": AnyCodable(sessionKey)]
|
||||
var params = [key: AnyCodable(sessionKey)]
|
||||
self.add(agentID, to: ¶ms, key: "agentId")
|
||||
return params
|
||||
}
|
||||
|
||||
@@ -516,6 +516,15 @@ public struct OpenClawChatCreateSessionResponse: Codable, Sendable {
|
||||
public let sessionId: String?
|
||||
}
|
||||
|
||||
public struct OpenClawChatRewindResponse: Codable, Sendable {
|
||||
public let editorText: String?
|
||||
}
|
||||
|
||||
public struct OpenClawChatForkAtMessageResponse: Codable, Sendable {
|
||||
public let sessionKey: String
|
||||
public let editorText: String?
|
||||
}
|
||||
|
||||
public struct OpenClawChatEventPayload: Codable, Sendable {
|
||||
public let runId: String?
|
||||
public let sessionKey: String?
|
||||
|
||||
@@ -435,6 +435,10 @@ public protocol OpenClawChatTransport: Sendable {
|
||||
func acquireSessionMutationRouteLease() async -> OpenClawChatSessionMutationRouteLease?
|
||||
func deleteSession(key: String) async throws
|
||||
func forkSession(parentKey: String) async throws -> String
|
||||
func rewindSession(sessionKey: String, entryId: String) async throws -> OpenClawChatRewindResponse
|
||||
func forkSessionAtMessage(
|
||||
sessionKey: String,
|
||||
entryId: String) async throws -> OpenClawChatForkAtMessageResponse
|
||||
func setSessionModel(sessionKey: String, model: String?) async throws
|
||||
func patchSessionModel(
|
||||
sessionKey: String,
|
||||
@@ -744,6 +748,26 @@ extension OpenClawChatTransport {
|
||||
userInfo: [NSLocalizedDescriptionKey: "sessions.create fork not supported by this transport"])
|
||||
}
|
||||
|
||||
public func rewindSession(
|
||||
sessionKey _: String,
|
||||
entryId _: String) async throws -> OpenClawChatRewindResponse
|
||||
{
|
||||
throw NSError(
|
||||
domain: "OpenClawChatTransport",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "sessions.rewind not supported by this transport"])
|
||||
}
|
||||
|
||||
public func forkSessionAtMessage(
|
||||
sessionKey _: String,
|
||||
entryId _: String) async throws -> OpenClawChatForkAtMessageResponse
|
||||
{
|
||||
throw NSError(
|
||||
domain: "OpenClawChatTransport",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "sessions.fork not supported by this transport"])
|
||||
}
|
||||
|
||||
public func listModels() async throws -> [OpenClawChatModelChoice] {
|
||||
throw NSError(
|
||||
domain: "OpenClawChatTransport",
|
||||
|
||||
@@ -505,6 +505,8 @@ public struct OpenClawChatView: View {
|
||||
self.copyMessageButton(for: msg)
|
||||
self.replyMessageButton(for: msg)
|
||||
self.openFullMessageButton(for: msg)
|
||||
self.rewindMessageButton(for: msg)
|
||||
self.forkMessageButton(for: msg)
|
||||
if outboxState.isFailed {
|
||||
Button {
|
||||
self.viewModel.retryOutboxMessage(msg.id)
|
||||
@@ -547,6 +549,8 @@ public struct OpenClawChatView: View {
|
||||
self.copyMessageButton(for: msg)
|
||||
self.replyMessageButton(for: msg)
|
||||
self.openFullMessageButton(for: msg)
|
||||
self.rewindMessageButton(for: msg)
|
||||
self.forkMessageButton(for: msg)
|
||||
Button {
|
||||
if speech.isActive(msg.id) {
|
||||
speech.stop()
|
||||
@@ -575,6 +579,8 @@ public struct OpenClawChatView: View {
|
||||
self.copyMessageButton(for: msg)
|
||||
self.replyMessageButton(for: msg)
|
||||
self.openFullMessageButton(for: msg)
|
||||
self.rewindMessageButton(for: msg)
|
||||
self.forkMessageButton(for: msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1103,6 +1109,52 @@ extension OpenClawChatView {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func rewindMessageButton(for message: OpenClawChatMessage) -> some View {
|
||||
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if role == "user",
|
||||
message.transcriptMessageID?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
{
|
||||
Button {
|
||||
Task { await self.viewModel.rewindToMessage(message) }
|
||||
} label: {
|
||||
Label {
|
||||
Text("Rewind to Here")
|
||||
.font(OpenClawChatTypography.body)
|
||||
} icon: {
|
||||
Image(systemName: "arrow.uturn.backward")
|
||||
}
|
||||
}
|
||||
.disabled(self.messageSessionActionsDisabled)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func forkMessageButton(for message: OpenClawChatMessage) -> some View {
|
||||
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if role == "user",
|
||||
message.transcriptMessageID?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
{
|
||||
Button {
|
||||
Task { await self.viewModel.forkAtMessage(message) }
|
||||
} label: {
|
||||
Label {
|
||||
Text("Fork from Here")
|
||||
.font(OpenClawChatTypography.body)
|
||||
} icon: {
|
||||
Image(systemName: "arrow.triangle.branch")
|
||||
}
|
||||
}
|
||||
.disabled(self.messageSessionActionsDisabled)
|
||||
}
|
||||
}
|
||||
|
||||
private var messageSessionActionsDisabled: Bool {
|
||||
self.viewModel.hasBlockingRunActivity ||
|
||||
self.viewModel.isSending ||
|
||||
self.viewModel.isAborting
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func replyMessageButton(for message: OpenClawChatMessage) -> some View {
|
||||
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
|
||||
@@ -371,6 +371,68 @@ extension OpenClawChatViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
public func rewindToMessage(_ message: OpenClawChatMessage) async {
|
||||
guard let entryID = Self.sessionMutationEntryID(for: message) else { return }
|
||||
guard !self.hasBlockingRunActivity, !self.isSending, !self.isAborting else { return }
|
||||
let initiatingSession = self.currentSessionSnapshot()
|
||||
do {
|
||||
let result = try await self.transport.rewindSession(
|
||||
sessionKey: initiatingSession.key,
|
||||
entryId: entryID)
|
||||
guard self.isCurrentSession(initiatingSession) else { return }
|
||||
self.replyTarget = nil
|
||||
self.runMessageScopesByRunID.removeAll()
|
||||
self.provisionalFinalMessagesByID.removeAll()
|
||||
self.input = result.editorText ?? ""
|
||||
let historyRequest = self.beginHistoryRequest(for: initiatingSession)
|
||||
_ = await self.refreshHistoryAfterRun(historyRequest: historyRequest)
|
||||
} catch {
|
||||
self.errorText = error.localizedDescription
|
||||
chatSessionActionsLogger.error(
|
||||
"sessions.rewind failed \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch parity with forkSession(key:): per-request server-lease guards in the
|
||||
/// transport plus post-RPC staleness re-checks, not the patch-flow route lease,
|
||||
/// which does not expose fork/rewind and would widen the lease API for no sibling.
|
||||
public func forkAtMessage(_ message: OpenClawChatMessage) async {
|
||||
guard let entryID = Self.sessionMutationEntryID(for: message) else { return }
|
||||
guard !self.hasBlockingRunActivity, !self.isSending, !self.isAborting else { return }
|
||||
guard self.canCreateSessionForImmediateSwitch() else { return }
|
||||
let initiatingSession = self.currentSessionSnapshot()
|
||||
do {
|
||||
let result = try await self.transport.forkSessionAtMessage(
|
||||
sessionKey: initiatingSession.key,
|
||||
entryId: entryID)
|
||||
let createdKey = result.sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !createdKey.isEmpty else { return }
|
||||
guard self.isCurrentSession(initiatingSession),
|
||||
!self.hasBlockingRunActivity,
|
||||
!self.isSending,
|
||||
!self.isAborting,
|
||||
self.canCreateSessionForImmediateSwitch()
|
||||
else {
|
||||
self.refreshSessions(limit: Self.sessionListFetchLimit)
|
||||
return
|
||||
}
|
||||
self.switchSession(to: createdKey)
|
||||
guard self.sessionKey == createdKey else { return }
|
||||
self.input = result.editorText ?? ""
|
||||
} catch {
|
||||
self.errorText = error.localizedDescription
|
||||
chatSessionActionsLogger.error(
|
||||
"sessions.fork failed \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func sessionMutationEntryID(for message: OpenClawChatMessage) -> String? {
|
||||
guard message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user"
|
||||
else { return nil }
|
||||
let entryID = message.transcriptMessageID?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return entryID?.isEmpty == false ? entryID : nil
|
||||
}
|
||||
|
||||
public func setSessionUnread(key: String, unread: Bool) {
|
||||
let identityKey = self.sessionMutationIdentity(for: key)
|
||||
let previousEntry = self.sessions.first(where: { $0.key == key })
|
||||
|
||||
@@ -43,6 +43,17 @@ extension OpenClawChatViewModel {
|
||||
Task { await self.fetchSessions(limit: 50, sessionSnapshot: context) }
|
||||
return
|
||||
}
|
||||
if change.reason == "rewind" {
|
||||
guard let sessionKey = change.sessionKey,
|
||||
self.matchesCurrentSessionKey(
|
||||
incoming: sessionKey,
|
||||
agentId: change.agentId,
|
||||
current: self.sessionKey)
|
||||
else { return }
|
||||
let context = self.beginHistoryRequest()
|
||||
Task { await self.refreshHistoryAfterRun(historyRequest: context) }
|
||||
return
|
||||
}
|
||||
guard change.reason == "patch" || change.reason == "command-metadata" else { return }
|
||||
let context = self.currentSessionSnapshot()
|
||||
Task { await self.fetchSessions(limit: 50, sessionSnapshot: context) }
|
||||
|
||||
@@ -4636,6 +4636,82 @@ public struct SessionsCompactionRestoreResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsRewindParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let agentid: String?
|
||||
public let entryid: String
|
||||
|
||||
public init(
|
||||
sessionkey: String,
|
||||
agentid: String? = nil,
|
||||
entryid: String)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.agentid = agentid
|
||||
self.entryid = entryid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case agentid = "agentId"
|
||||
case entryid = "entryId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsRewindResult: Codable, Sendable {
|
||||
public let editortext: String?
|
||||
|
||||
public init(
|
||||
editortext: String? = nil)
|
||||
{
|
||||
self.editortext = editortext
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case editortext = "editorText"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsForkParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let agentid: String?
|
||||
public let entryid: String
|
||||
|
||||
public init(
|
||||
sessionkey: String,
|
||||
agentid: String? = nil,
|
||||
entryid: String)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.agentid = agentid
|
||||
self.entryid = entryid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case agentid = "agentId"
|
||||
case entryid = "entryId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsForkResult: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let editortext: String?
|
||||
|
||||
public init(
|
||||
sessionkey: String,
|
||||
editortext: String? = nil)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.editortext = editortext
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case editortext = "editorText"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionFileBrowserEntry: Codable, Sendable {
|
||||
public let path: String
|
||||
public let name: String
|
||||
|
||||
@@ -135,6 +135,31 @@ struct ChatGatewayRequestTests {
|
||||
#expect(create.params["worktreeBaseRef"]?.value as? String == "origin/release")
|
||||
}
|
||||
|
||||
@Test func `message rewind and fork requests preserve routing identity`() {
|
||||
let rewind = OpenClawChatGatewayRequests.rewindSession(
|
||||
sessionKey: "agent:reviewer:telegram:group:1",
|
||||
agentID: " reviewer ",
|
||||
entryId: " message-42 ")
|
||||
let fork = OpenClawChatGatewayRequests.forkAtMessage(
|
||||
sessionKey: "global",
|
||||
agentID: nil,
|
||||
entryId: "message-43")
|
||||
|
||||
#expect(rewind.method == "sessions.rewind")
|
||||
#expect(rewind.timeoutMs == 15000)
|
||||
#expect(rewind.params["sessionKey"]?.value as? String == "agent:reviewer:telegram:group:1")
|
||||
#expect(rewind.params["agentId"]?.value as? String == "reviewer")
|
||||
#expect(rewind.params["entryId"]?.value as? String == "message-42")
|
||||
#expect(rewind.params["key"] == nil)
|
||||
|
||||
#expect(fork.method == "sessions.fork")
|
||||
#expect(fork.timeoutMs == 15000)
|
||||
#expect(fork.params["sessionKey"]?.value as? String == "global")
|
||||
#expect(fork.params["agentId"] == nil)
|
||||
#expect(fork.params["entryId"]?.value as? String == "message-43")
|
||||
#expect(fork.params["key"] == nil)
|
||||
}
|
||||
|
||||
@Test func `session group requests encode exact gateway contracts`() {
|
||||
let list = OpenClawChatGatewayRequests.sessionGroupsList()
|
||||
let put = OpenClawChatGatewayRequests.sessionGroupsPut(names: ["Work", "Personal"])
|
||||
|
||||
+158
-3
@@ -5,6 +5,9 @@ import Testing
|
||||
|
||||
private actor SessionActionTransportState {
|
||||
var forkedParentKeys: [String] = []
|
||||
var rewoundMessages: [(sessionKey: String, entryID: String)] = []
|
||||
var forkedMessages: [(sessionKey: String, entryID: String)] = []
|
||||
var historySessionKeys: [String] = []
|
||||
var patchedKeys: [String] = []
|
||||
var deletedKeys: [String] = []
|
||||
var groupPuts: [[String]] = []
|
||||
@@ -15,6 +18,18 @@ private actor SessionActionTransportState {
|
||||
self.forkedParentKeys.append(key)
|
||||
}
|
||||
|
||||
func recordRewind(sessionKey: String, entryID: String) {
|
||||
self.rewoundMessages.append((sessionKey, entryID))
|
||||
}
|
||||
|
||||
func recordForkAtMessage(sessionKey: String, entryID: String) {
|
||||
self.forkedMessages.append((sessionKey, entryID))
|
||||
}
|
||||
|
||||
func recordHistory(_ sessionKey: String) {
|
||||
self.historySessionKeys.append(sessionKey)
|
||||
}
|
||||
|
||||
func recordPatch(_ key: String) {
|
||||
self.patchedKeys.append(key)
|
||||
}
|
||||
@@ -69,13 +84,28 @@ private struct SessionActionForkGate: Sendable {
|
||||
private final class SessionActionTransport: @unchecked Sendable, OpenClawChatTransport {
|
||||
private let state = SessionActionTransportState()
|
||||
private let forkGate: SessionActionForkGate?
|
||||
private let forkAtMessageGate: SessionActionForkGate?
|
||||
private let rewindEditorText: String?
|
||||
private let forkAtMessageSessionKey: String
|
||||
private let forkAtMessageEditorText: String?
|
||||
|
||||
init(forkGate: SessionActionForkGate? = nil) {
|
||||
init(
|
||||
forkGate: SessionActionForkGate? = nil,
|
||||
forkAtMessageGate: SessionActionForkGate? = nil,
|
||||
rewindEditorText: String? = "rewound draft",
|
||||
forkAtMessageSessionKey: String = "forked-at-message",
|
||||
forkAtMessageEditorText: String? = "forked draft")
|
||||
{
|
||||
self.forkGate = forkGate
|
||||
self.forkAtMessageGate = forkAtMessageGate
|
||||
self.rewindEditorText = rewindEditorText
|
||||
self.forkAtMessageSessionKey = forkAtMessageSessionKey
|
||||
self.forkAtMessageEditorText = forkAtMessageEditorText
|
||||
}
|
||||
|
||||
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
|
||||
OpenClawChatHistoryPayload(
|
||||
await self.state.recordHistory(sessionKey)
|
||||
return OpenClawChatHistoryPayload(
|
||||
sessionKey: sessionKey,
|
||||
sessionId: "session-\(sessionKey)",
|
||||
messages: [],
|
||||
@@ -98,6 +128,25 @@ private final class SessionActionTransport: @unchecked Sendable, OpenClawChatTra
|
||||
return "forked"
|
||||
}
|
||||
|
||||
func rewindSession(
|
||||
sessionKey: String,
|
||||
entryId: String) async throws -> OpenClawChatRewindResponse
|
||||
{
|
||||
await self.state.recordRewind(sessionKey: sessionKey, entryID: entryId)
|
||||
return OpenClawChatRewindResponse(editorText: self.rewindEditorText)
|
||||
}
|
||||
|
||||
func forkSessionAtMessage(
|
||||
sessionKey: String,
|
||||
entryId: String) async throws -> OpenClawChatForkAtMessageResponse
|
||||
{
|
||||
await self.state.recordForkAtMessage(sessionKey: sessionKey, entryID: entryId)
|
||||
await self.forkAtMessageGate?.suspendCompletion()
|
||||
return OpenClawChatForkAtMessageResponse(
|
||||
sessionKey: self.forkAtMessageSessionKey,
|
||||
editorText: self.forkAtMessageEditorText)
|
||||
}
|
||||
|
||||
func patchSession(
|
||||
key: String,
|
||||
label _: String??,
|
||||
@@ -163,6 +212,18 @@ private final class SessionActionTransport: @unchecked Sendable, OpenClawChatTra
|
||||
await self.state.forkedParentKeys
|
||||
}
|
||||
|
||||
func rewoundMessages() async -> [(sessionKey: String, entryID: String)] {
|
||||
await self.state.rewoundMessages
|
||||
}
|
||||
|
||||
func forkedMessages() async -> [(sessionKey: String, entryID: String)] {
|
||||
await self.state.forkedMessages
|
||||
}
|
||||
|
||||
func historySessionKeys() async -> [String] {
|
||||
await self.state.historySessionKeys
|
||||
}
|
||||
|
||||
func patchedKeys() async -> [String] {
|
||||
await self.state.patchedKeys
|
||||
}
|
||||
@@ -300,7 +361,7 @@ struct ChatViewModelSessionActionTests {
|
||||
#expect(await transport.createdAgentIDs() == ["worker"])
|
||||
}
|
||||
|
||||
@Test func `unsupported create with advanced options fails without resetting`() async throws {
|
||||
@Test func `unsupported create with advanced options fails without resetting`() async {
|
||||
// SessionActionTransport relies on the protocol's default createSession,
|
||||
// which throws the canonical unsupported error; the worktree request must
|
||||
// surface it instead of taking the plain-new reset fallback.
|
||||
@@ -348,6 +409,77 @@ struct ChatViewModelSessionActionTests {
|
||||
#expect(await transport.createdParentKeys() == ["main"])
|
||||
}
|
||||
|
||||
@Test func `rewind seeds editor and refreshes history`() async {
|
||||
let transport = SessionActionTransport(rewindEditorText: "edit this turn")
|
||||
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
|
||||
viewModel.input = "old draft"
|
||||
|
||||
await viewModel.rewindToMessage(self.userMessage(entryID: "message-42"))
|
||||
|
||||
#expect(viewModel.input == "edit this turn")
|
||||
#expect(await transport.rewoundMessages().map { [$0.sessionKey, $0.entryID] } == [["main", "message-42"]])
|
||||
#expect(await transport.historySessionKeys() == ["main"])
|
||||
}
|
||||
|
||||
@Test func `rewind does not dispatch while busy`() async {
|
||||
let transport = SessionActionTransport()
|
||||
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
|
||||
viewModel.isSending = true
|
||||
|
||||
await viewModel.rewindToMessage(self.userMessage(entryID: "message-42"))
|
||||
|
||||
#expect(await transport.rewoundMessages().isEmpty)
|
||||
#expect(await transport.historySessionKeys().isEmpty)
|
||||
}
|
||||
|
||||
@Test func `fork at message switches and seeds editor`() async {
|
||||
let transport = SessionActionTransport(
|
||||
forkAtMessageSessionKey: "agent:main:forked",
|
||||
forkAtMessageEditorText: "continue here")
|
||||
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
|
||||
|
||||
await viewModel.forkAtMessage(self.userMessage(entryID: "message-42"))
|
||||
|
||||
#expect(viewModel.sessionKey == "agent:main:forked")
|
||||
#expect(viewModel.input == "continue here")
|
||||
#expect(await transport.forkedMessages().map { [$0.sessionKey, $0.entryID] } == [["main", "message-42"]])
|
||||
}
|
||||
|
||||
@Test func `fork at message completion does not override newer navigation`() async {
|
||||
let forkGate = SessionActionForkGate()
|
||||
let transport = SessionActionTransport(
|
||||
forkAtMessageGate: forkGate,
|
||||
forkAtMessageSessionKey: "agent:main:forked")
|
||||
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
|
||||
|
||||
let fork = Task { await viewModel.forkAtMessage(self.userMessage(entryID: "message-42")) }
|
||||
guard await self.waitForForkStart(forkGate) else {
|
||||
forkGate.release()
|
||||
fork.cancel()
|
||||
Issue.record("timed out waiting for fork start signal")
|
||||
return
|
||||
}
|
||||
viewModel.switchSession(to: "other")
|
||||
forkGate.release()
|
||||
await fork.value
|
||||
|
||||
#expect(viewModel.sessionKey == "other")
|
||||
#expect(viewModel.input.isEmpty)
|
||||
#expect(await transport.forkedMessages().map { [$0.sessionKey, $0.entryID] } == [["main", "message-42"]])
|
||||
}
|
||||
|
||||
@Test func `remote rewind refreshes current transcript only`() async {
|
||||
let transport = SessionActionTransport()
|
||||
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
|
||||
|
||||
viewModel.handleTransportEvent(.sessionsChanged(.init(sessionKey: "other", reason: "rewind")))
|
||||
viewModel.handleTransportEvent(.sessionsChanged(.init(sessionKey: "main", reason: "rewind")))
|
||||
|
||||
let refreshed = await self.waitForHistoryRequest(transport)
|
||||
#expect(refreshed)
|
||||
#expect(await transport.historySessionKeys() == ["main"])
|
||||
}
|
||||
|
||||
@Test func `fork does not mutate gateway while session switching is blocked`() async {
|
||||
let transport = SessionActionTransport()
|
||||
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
|
||||
@@ -404,6 +536,29 @@ struct ChatViewModelSessionActionTests {
|
||||
}
|
||||
}
|
||||
|
||||
private func waitForHistoryRequest(
|
||||
_ transport: SessionActionTransport,
|
||||
timeout: Duration = .seconds(15)) async -> Bool
|
||||
{
|
||||
let clock = ContinuousClock()
|
||||
let deadline = clock.now + timeout
|
||||
while clock.now < deadline {
|
||||
if await transport.historySessionKeys().isEmpty == false {
|
||||
return true
|
||||
}
|
||||
await Task.yield()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func userMessage(entryID: String) -> OpenClawChatMessage {
|
||||
OpenClawChatMessage(
|
||||
role: "user",
|
||||
content: [],
|
||||
timestamp: nil,
|
||||
transcriptMessageID: entryID)
|
||||
}
|
||||
|
||||
private func entry(key: String) -> OpenClawChatSessionEntry {
|
||||
OpenClawChatSessionEntry(
|
||||
key: key,
|
||||
|
||||
@@ -423,6 +423,10 @@ import {
|
||||
SessionsCompactionListResultSchema,
|
||||
SessionsCompactionRestoreParamsSchema,
|
||||
SessionsCompactionRestoreResultSchema,
|
||||
SessionsForkParamsSchema,
|
||||
SessionsForkResultSchema,
|
||||
SessionsRewindParamsSchema,
|
||||
SessionsRewindResultSchema,
|
||||
SessionFileBrowserEntrySchema,
|
||||
SessionFileBrowserResultSchema,
|
||||
SessionCompactionCheckpointSchema,
|
||||
@@ -688,6 +692,10 @@ export const ProtocolSchemas = {
|
||||
SessionsCompactionGetResult: SessionsCompactionGetResultSchema,
|
||||
SessionsCompactionBranchResult: SessionsCompactionBranchResultSchema,
|
||||
SessionsCompactionRestoreResult: SessionsCompactionRestoreResultSchema,
|
||||
SessionsRewindParams: SessionsRewindParamsSchema,
|
||||
SessionsRewindResult: SessionsRewindResultSchema,
|
||||
SessionsForkParams: SessionsForkParamsSchema,
|
||||
SessionsForkResult: SessionsForkResultSchema,
|
||||
SessionFileBrowserEntry: SessionFileBrowserEntrySchema,
|
||||
SessionFileBrowserResult: SessionFileBrowserResultSchema,
|
||||
SessionFileKind: SessionFileKindSchema,
|
||||
|
||||
Reference in New Issue
Block a user