diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 42ef59e670a7..ba5ab2267a08 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -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", diff --git a/apps/.i18n/native/ar.json b/apps/.i18n/native/ar.json index 6068bd331739..f3bffb10ec30 100644 --- a/apps/.i18n/native/ar.json +++ b/apps/.i18n/native/ar.json @@ -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", diff --git a/apps/.i18n/native/de.json b/apps/.i18n/native/de.json index 403eb5212387..de105e4a1d44 100644 --- a/apps/.i18n/native/de.json +++ b/apps/.i18n/native/de.json @@ -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", diff --git a/apps/.i18n/native/es.json b/apps/.i18n/native/es.json index b4a335e7dacd..3e2f1796d9dc 100644 --- a/apps/.i18n/native/es.json +++ b/apps/.i18n/native/es.json @@ -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", diff --git a/apps/.i18n/native/fa.json b/apps/.i18n/native/fa.json index dbef2adcdd48..f35e4c85be47 100644 --- a/apps/.i18n/native/fa.json +++ b/apps/.i18n/native/fa.json @@ -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", diff --git a/apps/.i18n/native/fr.json b/apps/.i18n/native/fr.json index 88b99aa84778..931dff55e531 100644 --- a/apps/.i18n/native/fr.json +++ b/apps/.i18n/native/fr.json @@ -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", diff --git a/apps/.i18n/native/hi.json b/apps/.i18n/native/hi.json index 4823d158b080..160308639323 100644 --- a/apps/.i18n/native/hi.json +++ b/apps/.i18n/native/hi.json @@ -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", diff --git a/apps/.i18n/native/id.json b/apps/.i18n/native/id.json index 22a32d9df94f..949c96cf8d65 100644 --- a/apps/.i18n/native/id.json +++ b/apps/.i18n/native/id.json @@ -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", diff --git a/apps/.i18n/native/it.json b/apps/.i18n/native/it.json index dd2204959c8a..979e89e4d826 100644 --- a/apps/.i18n/native/it.json +++ b/apps/.i18n/native/it.json @@ -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", diff --git a/apps/.i18n/native/ja-JP.json b/apps/.i18n/native/ja-JP.json index 68779b7721b0..e7a0ec17db3a 100644 --- a/apps/.i18n/native/ja-JP.json +++ b/apps/.i18n/native/ja-JP.json @@ -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", diff --git a/apps/.i18n/native/ko.json b/apps/.i18n/native/ko.json index da628536ce91..6dfc3ef6773b 100644 --- a/apps/.i18n/native/ko.json +++ b/apps/.i18n/native/ko.json @@ -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", diff --git a/apps/.i18n/native/nl.json b/apps/.i18n/native/nl.json index 51c607df272b..60b029064829 100644 --- a/apps/.i18n/native/nl.json +++ b/apps/.i18n/native/nl.json @@ -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", diff --git a/apps/.i18n/native/pl.json b/apps/.i18n/native/pl.json index 7b33a6f20ca0..9540739cea10 100644 --- a/apps/.i18n/native/pl.json +++ b/apps/.i18n/native/pl.json @@ -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", diff --git a/apps/.i18n/native/pt-BR.json b/apps/.i18n/native/pt-BR.json index c9eefb8b0ecb..1b755511a196 100644 --- a/apps/.i18n/native/pt-BR.json +++ b/apps/.i18n/native/pt-BR.json @@ -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", diff --git a/apps/.i18n/native/ru.json b/apps/.i18n/native/ru.json index 792703472c92..35b104d76303 100644 --- a/apps/.i18n/native/ru.json +++ b/apps/.i18n/native/ru.json @@ -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", diff --git a/apps/.i18n/native/sv.json b/apps/.i18n/native/sv.json index 869cdbb8f84c..8d73e47b18d0 100644 --- a/apps/.i18n/native/sv.json +++ b/apps/.i18n/native/sv.json @@ -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", diff --git a/apps/.i18n/native/th.json b/apps/.i18n/native/th.json index 627be5d3d4db..90cad8a2be8b 100644 --- a/apps/.i18n/native/th.json +++ b/apps/.i18n/native/th.json @@ -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", diff --git a/apps/.i18n/native/tr.json b/apps/.i18n/native/tr.json index 34c635d9d912..01e931a71c20 100644 --- a/apps/.i18n/native/tr.json +++ b/apps/.i18n/native/tr.json @@ -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", diff --git a/apps/.i18n/native/uk.json b/apps/.i18n/native/uk.json index d12e3da21c16..b804b91b2539 100644 --- a/apps/.i18n/native/uk.json +++ b/apps/.i18n/native/uk.json @@ -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", diff --git a/apps/.i18n/native/vi.json b/apps/.i18n/native/vi.json index 1a9077b3f343..82265ab3c5ec 100644 --- a/apps/.i18n/native/vi.json +++ b/apps/.i18n/native/vi.json @@ -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", diff --git a/apps/.i18n/native/zh-CN.json b/apps/.i18n/native/zh-CN.json index 6ea7eff70fac..d014e7cbe574 100644 --- a/apps/.i18n/native/zh-CN.json +++ b/apps/.i18n/native/zh-CN.json @@ -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", diff --git a/apps/.i18n/native/zh-TW.json b/apps/.i18n/native/zh-TW.json index 69f67c0744c1..8356c1507454 100644 --- a/apps/.i18n/native/zh-TW.json +++ b/apps/.i18n/native/zh-TW.json @@ -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", diff --git a/apps/ios/Resources/Localizable.xcstrings b/apps/ios/Resources/Localizable.xcstrings index 77ef4f808410..de536f918fba 100644 --- a/apps/ios/Resources/Localizable.xcstrings +++ b/apps/ios/Resources/Localizable.xcstrings @@ -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": { diff --git a/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift b/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift index bb8aac4c7f91..e259547921b1 100644 --- a/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift +++ b/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift @@ -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( diff --git a/apps/macos/Sources/OpenClaw/MacGatewayChatTransport+SessionActions.swift b/apps/macos/Sources/OpenClaw/MacGatewayChatTransport+SessionActions.swift index 5bf3f440c046..0b6bfd561e7a 100644 --- a/apps/macos/Sources/OpenClaw/MacGatewayChatTransport+SessionActions.swift +++ b/apps/macos/Sources/OpenClaw/MacGatewayChatTransport+SessionActions.swift @@ -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) + } } diff --git a/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift index b97459269fa2..814583add677 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift @@ -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)) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayRequest.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayRequest.swift index a84fdbdebe0f..ce3749b05d4e 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayRequest.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayRequest.swift @@ -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 } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift index c5d038e85f6e..9994f15e7555 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift @@ -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? diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift index 710e245f57ee..b047957f0970 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift @@ -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", diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift index e8a981185429..090e5afbfa05 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift @@ -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() diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+SessionActions.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+SessionActions.swift index b736e756d3cb..4bc28df17c3c 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+SessionActions.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+SessionActions.swift @@ -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 }) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+TransportEvents.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+TransportEvents.swift index df05a6d94744..f1d07c5bd0f7 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+TransportEvents.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+TransportEvents.swift @@ -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) } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 63764a6ea1e9..1c3289ace1c0 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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 diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatGatewayRequestTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatGatewayRequestTests.swift index 07e5788a8706..6f12c5d5ccd4 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatGatewayRequestTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatGatewayRequestTests.swift @@ -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"]) diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelSessionActionTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelSessionActionTests.swift index 36ef48dc2724..aaf83d2c13ea 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelSessionActionTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelSessionActionTests.swift @@ -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, diff --git a/packages/gateway-protocol/src/schema/protocol-schemas.ts b/packages/gateway-protocol/src/schema/protocol-schemas.ts index da330149ad08..29d9dccbc4ed 100644 --- a/packages/gateway-protocol/src/schema/protocol-schemas.ts +++ b/packages/gateway-protocol/src/schema/protocol-schemas.ts @@ -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,