feat(apps): switch session transcript branches from native chat

This commit is contained in:
Peter Steinberger
2026-07-19 21:04:49 -07:00
parent 25d6f13a1f
commit ae62572236
14 changed files with 924 additions and 12 deletions
+408
View File
@@ -2313,6 +2313,142 @@
}
}
},
"%@ messages": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "%@ messages"
}
},
"zh-CN": {
"stringUnit": {
"state": "new",
"value": "%@ messages"
}
},
"zh-TW": {
"stringUnit": {
"state": "new",
"value": "%@ messages"
}
},
"pt-BR": {
"stringUnit": {
"state": "new",
"value": "%@ messages"
}
},
"de": {
"stringUnit": {
"state": "new",
"value": "%@ messages"
}
},
"es": {
"stringUnit": {
"state": "new",
"value": "%@ messages"
}
},
"ja-JP": {
"stringUnit": {
"state": "new",
"value": "%@ messages"
}
},
"ko": {
"stringUnit": {
"state": "new",
"value": "%@ messages"
}
},
"fr": {
"stringUnit": {
"state": "new",
"value": "%@ messages"
}
},
"hi": {
"stringUnit": {
"state": "new",
"value": "%@ messages"
}
},
"ar": {
"stringUnit": {
"state": "new",
"value": "%@ messages"
}
},
"it": {
"stringUnit": {
"state": "new",
"value": "%@ messages"
}
},
"tr": {
"stringUnit": {
"state": "new",
"value": "%@ messages"
}
},
"uk": {
"stringUnit": {
"state": "new",
"value": "%@ messages"
}
},
"id": {
"stringUnit": {
"state": "new",
"value": "%@ messages"
}
},
"pl": {
"stringUnit": {
"state": "new",
"value": "%@ messages"
}
},
"th": {
"stringUnit": {
"state": "new",
"value": "%@ messages"
}
},
"vi": {
"stringUnit": {
"state": "new",
"value": "%@ messages"
}
},
"nl": {
"stringUnit": {
"state": "new",
"value": "%@ messages"
}
},
"fa": {
"stringUnit": {
"state": "new",
"value": "%@ messages"
}
},
"ru": {
"stringUnit": {
"state": "new",
"value": "%@ messages"
}
},
"sv": {
"stringUnit": {
"state": "new",
"value": "%@ messages"
}
}
}
},
"%@ min": {
"localizations": {
"en": {
@@ -24209,6 +24345,142 @@
}
}
},
"Branches": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Branches"
}
},
"zh-CN": {
"stringUnit": {
"state": "new",
"value": "Branches"
}
},
"zh-TW": {
"stringUnit": {
"state": "new",
"value": "Branches"
}
},
"pt-BR": {
"stringUnit": {
"state": "new",
"value": "Branches"
}
},
"de": {
"stringUnit": {
"state": "new",
"value": "Branches"
}
},
"es": {
"stringUnit": {
"state": "new",
"value": "Branches"
}
},
"ja-JP": {
"stringUnit": {
"state": "new",
"value": "Branches"
}
},
"ko": {
"stringUnit": {
"state": "new",
"value": "Branches"
}
},
"fr": {
"stringUnit": {
"state": "new",
"value": "Branches"
}
},
"hi": {
"stringUnit": {
"state": "new",
"value": "Branches"
}
},
"ar": {
"stringUnit": {
"state": "new",
"value": "Branches"
}
},
"it": {
"stringUnit": {
"state": "new",
"value": "Branches"
}
},
"tr": {
"stringUnit": {
"state": "new",
"value": "Branches"
}
},
"uk": {
"stringUnit": {
"state": "new",
"value": "Branches"
}
},
"id": {
"stringUnit": {
"state": "new",
"value": "Branches"
}
},
"pl": {
"stringUnit": {
"state": "new",
"value": "Branches"
}
},
"th": {
"stringUnit": {
"state": "new",
"value": "Branches"
}
},
"vi": {
"stringUnit": {
"state": "new",
"value": "Branches"
}
},
"nl": {
"stringUnit": {
"state": "new",
"value": "Branches"
}
},
"fa": {
"stringUnit": {
"state": "new",
"value": "Branches"
}
},
"ru": {
"stringUnit": {
"state": "new",
"value": "Branches"
}
},
"sv": {
"stringUnit": {
"state": "new",
"value": "Branches"
}
}
}
},
"Browse": {
"localizations": {
"en": {
@@ -185641,6 +185913,142 @@
}
}
},
"Untitled branch": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Untitled branch"
}
},
"zh-CN": {
"stringUnit": {
"state": "new",
"value": "Untitled branch"
}
},
"zh-TW": {
"stringUnit": {
"state": "new",
"value": "Untitled branch"
}
},
"pt-BR": {
"stringUnit": {
"state": "new",
"value": "Untitled branch"
}
},
"de": {
"stringUnit": {
"state": "new",
"value": "Untitled branch"
}
},
"es": {
"stringUnit": {
"state": "new",
"value": "Untitled branch"
}
},
"ja-JP": {
"stringUnit": {
"state": "new",
"value": "Untitled branch"
}
},
"ko": {
"stringUnit": {
"state": "new",
"value": "Untitled branch"
}
},
"fr": {
"stringUnit": {
"state": "new",
"value": "Untitled branch"
}
},
"hi": {
"stringUnit": {
"state": "new",
"value": "Untitled branch"
}
},
"ar": {
"stringUnit": {
"state": "new",
"value": "Untitled branch"
}
},
"it": {
"stringUnit": {
"state": "new",
"value": "Untitled branch"
}
},
"tr": {
"stringUnit": {
"state": "new",
"value": "Untitled branch"
}
},
"uk": {
"stringUnit": {
"state": "new",
"value": "Untitled branch"
}
},
"id": {
"stringUnit": {
"state": "new",
"value": "Untitled branch"
}
},
"pl": {
"stringUnit": {
"state": "new",
"value": "Untitled branch"
}
},
"th": {
"stringUnit": {
"state": "new",
"value": "Untitled branch"
}
},
"vi": {
"stringUnit": {
"state": "new",
"value": "Untitled branch"
}
},
"nl": {
"stringUnit": {
"state": "new",
"value": "Untitled branch"
}
},
"fa": {
"stringUnit": {
"state": "new",
"value": "Untitled branch"
}
},
"ru": {
"stringUnit": {
"state": "new",
"value": "Untitled branch"
}
},
"sv": {
"stringUnit": {
"state": "new",
"value": "Untitled branch"
}
}
}
},
"Update gateway token": {
"localizations": {
"en": {
@@ -439,6 +439,24 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
return try JSONDecoder().decode(OpenClawChatForkAtMessageResponse.self, from: response)
}
func listSessionBranches(sessionKey: String) async throws -> OpenClawChatSessionBranchesResponse {
let target = self.sessionTarget(for: sessionKey)
let request = OpenClawChatGatewayRequests.listSessionBranches(
sessionKey: target.sessionKey,
agentID: target.agentID)
let response = try await self.gateway.request(request)
return try JSONDecoder().decode(OpenClawChatSessionBranchesResponse.self, from: response)
}
func switchSessionBranch(sessionKey: String, leafEntryId: String) async throws {
let target = self.sessionTarget(for: sessionKey)
let request = OpenClawChatGatewayRequests.switchSessionBranch(
sessionKey: target.sessionKey,
agentID: target.agentID,
leafEntryId: leafEntryId)
_ = try await self.requestSessionMutation(request)
}
func setActiveSessionKey(_ sessionKey: String) async throws {
let target = self.sessionTarget(for: sessionKey)
let request = OpenClawChatGatewayRequests.subscribeSessionMessages(
@@ -166,6 +166,24 @@ extension MacGatewayChatTransport {
return try JSONDecoder().decode(OpenClawChatForkAtMessageResponse.self, from: data)
}
func listSessionBranches(sessionKey: String) async throws -> OpenClawChatSessionBranchesResponse {
let target = self.sessionTarget(for: sessionKey)
let request = OpenClawChatGatewayRequests.listSessionBranches(
sessionKey: target.sessionKey,
agentID: target.agentID)
let data = try await self.requestSessionAction(request)
return try JSONDecoder().decode(OpenClawChatSessionBranchesResponse.self, from: data)
}
func switchSessionBranch(sessionKey: String, leafEntryId: String) async throws {
let target = self.sessionTarget(for: sessionKey)
let request = OpenClawChatGatewayRequests.switchSessionBranch(
sessionKey: target.sessionKey,
agentID: target.agentID,
leafEntryId: leafEntryId)
_ = try await self.requestSessionAction(request)
}
static func rewindSessionRequest(
sessionKey: String,
agentID: String?,
@@ -355,6 +355,9 @@ struct OpenClawChatComposer: View {
HStack(spacing: 8) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 5) {
if self.viewModel.sessionBranches.count > 1 {
self.branchMenu
}
if self.showsSessionSwitcher {
self.sessionPicker
if self.viewModel.showsThinkingPicker {
@@ -582,6 +585,61 @@ struct OpenClawChatComposer: View {
.help("Thread")
}
private var branchMenu: some View {
Menu {
ForEach(self.viewModel.sessionBranches) { branch in
Button {
guard !branch.active else { return }
Task { await self.viewModel.switchToBranch(branch.leafEntryId) }
} label: {
HStack(spacing: 6) {
VStack(alignment: .leading, spacing: 2) {
Text(self.branchTitle(branch))
.font(OpenClawChatTypography.captionSemiBold)
Text(self.branchMetadata(branch))
.font(OpenClawChatTypography.caption)
.foregroundStyle(.secondary)
}
if branch.active {
Image(systemName: "checkmark")
}
}
}
.disabled(branch.active)
}
} label: {
Image(systemName: "arrow.triangle.branch")
}
.buttonStyle(.bordered)
.controlSize(.small)
.help("Branches")
.accessibilityLabel("Branches")
.disabled(self.messageSessionActionsDisabled)
}
private func branchTitle(_ branch: OpenClawChatSessionBranch) -> String {
let headline = branch.headline.trimmingCharacters(in: .whitespacesAndNewlines)
return headline.isEmpty ? String(localized: "Untitled branch") : headline
}
private func branchMetadata(_ branch: OpenClawChatSessionBranch) -> String {
var parts = [String(
format: String(localized: "%@ messages"),
branch.messageCount.formatted())]
if let updatedAt = branch.updatedAt,
let date = try? Date(updatedAt, strategy: .iso8601)
{
parts.append(date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated)))
}
return parts.joined(separator: " · ")
}
private var messageSessionActionsDisabled: Bool {
self.viewModel.hasBlockingRunActivity ||
self.viewModel.isSending ||
self.viewModel.isAborting
}
@ViewBuilder
private var attachmentPicker: some View {
#if os(macOS)
@@ -751,6 +809,10 @@ struct OpenClawChatComposer: View {
HStack(alignment: .center, spacing: 4) {
self.cleanAttachmentMenu
if self.viewModel.sessionBranches.count > 1 {
self.branchMenu
}
self.editorOverlay
.frame(maxWidth: .infinity, minHeight: self.cleanControlHeight, alignment: .leading)
.layoutPriority(1)
@@ -359,6 +359,36 @@ public enum OpenClawChatGatewayRequests {
timeoutMs: self.mutationTimeoutMs)
}
public static func listSessionBranches(
sessionKey: String,
agentID: String?) -> OpenClawChatGatewayRequest
{
let params = self.sessionParams(
sessionKey: sessionKey,
agentID: agentID,
key: "sessionKey")
return OpenClawChatGatewayRequest(
method: "sessions.branches.list",
params: params,
timeoutMs: self.defaultTimeoutMs)
}
public static func switchSessionBranch(
sessionKey: String,
agentID: String?,
leafEntryId: String) -> OpenClawChatGatewayRequest
{
var params = self.sessionParams(
sessionKey: sessionKey,
agentID: agentID,
key: "sessionKey")
self.add(leafEntryId, to: &params, key: "leafEntryId")
return OpenClawChatGatewayRequest(
method: "sessions.branches.switch",
params: params,
timeoutMs: self.mutationTimeoutMs)
}
public static func subscribeSessionMessages(
sessionKey: String,
agentID: String?) -> OpenClawChatGatewayRequest
@@ -571,6 +571,40 @@ public struct OpenClawChatForkAtMessageResponse: Codable, Sendable {
public let editorText: String?
}
public struct OpenClawChatSessionBranch: Codable, Sendable, Equatable, Identifiable {
public let leafEntryId: String
public let headline: String
public let messageCount: Int
public let updatedAt: String?
public let active: Bool
public var id: String {
self.leafEntryId
}
public init(
leafEntryId: String,
headline: String,
messageCount: Int,
updatedAt: String?,
active: Bool)
{
self.leafEntryId = leafEntryId
self.headline = headline
self.messageCount = messageCount
self.updatedAt = updatedAt
self.active = active
}
}
public struct OpenClawChatSessionBranchesResponse: Codable, Sendable {
public let branches: [OpenClawChatSessionBranch]
public init(branches: [OpenClawChatSessionBranch]) {
self.branches = branches
}
}
public struct OpenClawChatEventPayload: Codable, Sendable {
public let runId: String?
public let sessionKey: String?
@@ -439,6 +439,8 @@ public protocol OpenClawChatTransport: Sendable {
func forkSessionAtMessage(
sessionKey: String,
entryId: String) async throws -> OpenClawChatForkAtMessageResponse
func listSessionBranches(sessionKey: String) async throws -> OpenClawChatSessionBranchesResponse
func switchSessionBranch(sessionKey: String, leafEntryId: String) async throws
func setSessionModel(sessionKey: String, model: String?) async throws
func patchSessionModel(
sessionKey: String,
@@ -768,6 +770,20 @@ extension OpenClawChatTransport {
userInfo: [NSLocalizedDescriptionKey: "sessions.fork not supported by this transport"])
}
public func listSessionBranches(sessionKey _: String) async throws -> OpenClawChatSessionBranchesResponse {
throw NSError(
domain: "OpenClawChatTransport",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "sessions.branches.list not supported by this transport"])
}
public func switchSessionBranch(sessionKey _: String, leafEntryId _: String) async throws {
throw NSError(
domain: "OpenClawChatTransport",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "sessions.branches.switch not supported by this transport"])
}
public func listModels() async throws -> [OpenClawChatModelChoice] {
throw NSError(
domain: "OpenClawChatTransport",
@@ -386,6 +386,8 @@ extension OpenClawChatViewModel {
self.input = result.editorText ?? ""
let historyRequest = self.beginHistoryRequest(for: initiatingSession)
_ = await self.refreshHistoryAfterRun(historyRequest: historyRequest)
guard self.isCurrentSession(initiatingSession) else { return }
await self.refreshSessionBranches()
} catch {
self.errorText = error.localizedDescription
chatSessionActionsLogger.error(
@@ -393,6 +395,55 @@ extension OpenClawChatViewModel {
}
}
public func refreshSessionBranches() async {
guard !self.isLoadingSessionBranches else { return }
let session = self.currentSessionSnapshot()
self.isLoadingSessionBranches = true
defer {
if self.isCurrentSession(session) {
self.isLoadingSessionBranches = false
}
}
do {
let response = try await self.transport.listSessionBranches(sessionKey: session.key)
guard self.isCurrentSession(session) else { return }
self.sessionBranches = response.branches
} catch {
guard self.isCurrentSession(session) else { return }
self.sessionBranches = []
chatSessionActionsLogger.debug(
"sessions.branches.list failed \(error.localizedDescription, privacy: .public)")
}
}
public func switchToBranch(_ leafEntryId: String) async {
let normalizedLeafEntryID = leafEntryId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedLeafEntryID.isEmpty else { return }
guard !self.hasBlockingRunActivity, !self.isSending, !self.isAborting else { return }
guard !self.sessionBranches.contains(where: {
$0.leafEntryId == normalizedLeafEntryID && $0.active
}) else { return }
let initiatingSession = self.currentSessionSnapshot()
do {
try await self.transport.switchSessionBranch(
sessionKey: initiatingSession.key,
leafEntryId: normalizedLeafEntryID)
guard self.isCurrentSession(initiatingSession) else { return }
self.replyTarget = nil
self.runMessageScopesByRunID.removeAll()
self.provisionalFinalMessagesByID.removeAll()
let historyRequest = self.beginHistoryRequest(for: initiatingSession)
_ = await self.refreshHistoryAfterRun(historyRequest: historyRequest)
guard self.isCurrentSession(initiatingSession) else { return }
await self.refreshSessionBranches()
} catch {
guard self.isCurrentSession(initiatingSession) else { return }
self.errorText = error.localizedDescription
chatSessionActionsLogger.error(
"sessions.branches.switch 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.
@@ -43,7 +43,7 @@ extension OpenClawChatViewModel {
Task { await self.fetchSessions(limit: 50, sessionSnapshot: context) }
return
}
if change.reason == "rewind" {
if change.reason == "rewind" || change.reason == "branch-switch" {
guard let sessionKey = change.sessionKey,
self.matchesCurrentSessionKey(
incoming: sessionKey,
@@ -51,7 +51,11 @@ extension OpenClawChatViewModel {
current: self.sessionKey)
else { return }
let context = self.beginHistoryRequest()
Task { await self.refreshHistoryAfterRun(historyRequest: context) }
Task {
await self.refreshHistoryAfterRun(historyRequest: context)
guard self.isCurrentSession(context.session) else { return }
await self.refreshSessionBranches()
}
return
}
guard change.reason == "patch" || change.reason == "command-metadata" else { return }
@@ -80,6 +80,8 @@ public final class OpenClawChatViewModel {
/// refetch; catalog-only changes (e.g. creating an empty group) alter no
/// session rows and would otherwise stay stale until reconnect.
public internal(set) var sessionGroupsRevision = 0
public internal(set) var sessionBranches: [OpenClawChatSessionBranch] = []
public internal(set) var isLoadingSessionBranches = false
/// True when this view model owns a gateway-scoped durable text outbox.
public var supportsOfflineTextOutbox: Bool {
@@ -801,6 +803,7 @@ extension OpenClawChatViewModel {
historyRequest: historyRequest)
paintFromCacheIfNeeded(session: context.session)
restoreOutboxMessages(session: context.session)
Task { [weak self] in await self?.refreshSessionBranches() }
self.bootstrapTask = Task { [weak self] in
guard let self else { return }
await self.bootstrap(context: context)
@@ -1141,6 +1144,8 @@ extension OpenClawChatViewModel {
clearPlan()
self.updateActiveSessionRunWithoutChatSnapshot(false)
resetSlashCommandCatalog()
self.sessionBranches = []
self.isLoadingSessionBranches = false
clearPendingRuns(reason: nil)
}
@@ -5528,6 +5528,92 @@ public struct SessionsForkResult: Codable, Sendable {
}
}
public struct SessionBranch: Codable, Sendable {
public let leafentryid: String
public let headline: String
public let messagecount: Int
public let updatedat: String?
public let active: Bool
public init(
leafentryid: String,
headline: String,
messagecount: Int,
updatedat: String? = nil,
active: Bool)
{
self.leafentryid = leafentryid
self.headline = headline
self.messagecount = messagecount
self.updatedat = updatedat
self.active = active
}
private enum CodingKeys: String, CodingKey {
case leafentryid = "leafEntryId"
case headline
case messagecount = "messageCount"
case updatedat = "updatedAt"
case active
}
}
public struct SessionsBranchesListParams: Codable, Sendable {
public let sessionkey: String
public let agentid: String?
public init(
sessionkey: String,
agentid: String? = nil)
{
self.sessionkey = sessionkey
self.agentid = agentid
}
private enum CodingKeys: String, CodingKey {
case sessionkey = "sessionKey"
case agentid = "agentId"
}
}
public struct SessionsBranchesListResult: Codable, Sendable {
public let branches: [SessionBranch]
public init(
branches: [SessionBranch])
{
self.branches = branches
}
private enum CodingKeys: String, CodingKey {
case branches
}
}
public struct SessionsBranchesSwitchParams: Codable, Sendable {
public let sessionkey: String
public let agentid: String?
public let leafentryid: String
public init(
sessionkey: String,
agentid: String? = nil,
leafentryid: String)
{
self.sessionkey = sessionkey
self.agentid = agentid
self.leafentryid = leafentryid
}
private enum CodingKeys: String, CodingKey {
case sessionkey = "sessionKey"
case agentid = "agentId"
case leafentryid = "leafEntryId"
}
}
public struct SessionsBranchesSwitchResult: Codable, Sendable {}
public struct SessionFileBrowserEntry: Codable, Sendable {
public let path: String
public let name: String
@@ -160,6 +160,29 @@ struct ChatGatewayRequestTests {
#expect(fork.params["key"] == nil)
}
@Test func `branch list and switch requests preserve routing identity`() {
let list = OpenClawChatGatewayRequests.listSessionBranches(
sessionKey: "agent:reviewer:telegram:group:1",
agentID: " reviewer ")
let switchBranch = OpenClawChatGatewayRequests.switchSessionBranch(
sessionKey: "global",
agentID: nil,
leafEntryId: " leaf-42 ")
#expect(list.method == "sessions.branches.list")
#expect(list.timeoutMs == 15000)
#expect(list.params["sessionKey"]?.value as? String == "agent:reviewer:telegram:group:1")
#expect(list.params["agentId"]?.value as? String == "reviewer")
#expect(list.params["key"] == nil)
#expect(switchBranch.method == "sessions.branches.switch")
#expect(switchBranch.timeoutMs == 15000)
#expect(switchBranch.params["sessionKey"]?.value as? String == "global")
#expect(switchBranch.params["agentId"] == nil)
#expect(switchBranch.params["leafEntryId"]?.value as? String == "leaf-42")
#expect(switchBranch.params["key"] == nil)
}
@Test func `session group requests encode exact gateway contracts`() {
let list = OpenClawChatGatewayRequests.sessionGroupsList()
let put = OpenClawChatGatewayRequests.sessionGroupsPut(names: ["Work", "Personal"])
@@ -7,6 +7,8 @@ private actor SessionActionTransportState {
var forkedParentKeys: [String] = []
var rewoundMessages: [(sessionKey: String, entryID: String)] = []
var forkedMessages: [(sessionKey: String, entryID: String)] = []
var branchListSessionKeys: [String] = []
var switchedBranches: [(sessionKey: String, leafEntryID: String)] = []
var historySessionKeys: [String] = []
var patchedKeys: [String] = []
var deletedKeys: [String] = []
@@ -26,6 +28,14 @@ private actor SessionActionTransportState {
self.forkedMessages.append((sessionKey, entryID))
}
func recordBranchList(_ sessionKey: String) {
self.branchListSessionKeys.append(sessionKey)
}
func recordBranchSwitch(sessionKey: String, leafEntryID: String) {
self.switchedBranches.append((sessionKey, leafEntryID))
}
func recordHistory(_ sessionKey: String) {
self.historySessionKeys.append(sessionKey)
}
@@ -48,9 +58,9 @@ private actor SessionActionTransportState {
}
}
/// Signals the exact suspension point before fork completion, then holds it so
/// Signals the exact suspension point before an action completes, then holds it so
/// navigation can advance deterministically before the stale result resumes.
private struct SessionActionForkGate: Sendable {
private struct SessionActionCompletionGate: Sendable {
private let startedStream: AsyncStream<Void>
private let startedContinuation: AsyncStream<Void>.Continuation
private let releaseStream: AsyncStream<Void>
@@ -83,24 +93,30 @@ 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 forkGate: SessionActionCompletionGate?
private let forkAtMessageGate: SessionActionCompletionGate?
private let branchSwitchGate: SessionActionCompletionGate?
private let rewindEditorText: String?
private let forkAtMessageSessionKey: String
private let forkAtMessageEditorText: String?
private let branches: [OpenClawChatSessionBranch]
init(
forkGate: SessionActionForkGate? = nil,
forkAtMessageGate: SessionActionForkGate? = nil,
forkGate: SessionActionCompletionGate? = nil,
forkAtMessageGate: SessionActionCompletionGate? = nil,
branchSwitchGate: SessionActionCompletionGate? = nil,
rewindEditorText: String? = "rewound draft",
forkAtMessageSessionKey: String = "forked-at-message",
forkAtMessageEditorText: String? = "forked draft")
forkAtMessageEditorText: String? = "forked draft",
branches: [OpenClawChatSessionBranch] = [])
{
self.forkGate = forkGate
self.forkAtMessageGate = forkAtMessageGate
self.branchSwitchGate = branchSwitchGate
self.rewindEditorText = rewindEditorText
self.forkAtMessageSessionKey = forkAtMessageSessionKey
self.forkAtMessageEditorText = forkAtMessageEditorText
self.branches = branches
}
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
@@ -147,6 +163,16 @@ private final class SessionActionTransport: @unchecked Sendable, OpenClawChatTra
editorText: self.forkAtMessageEditorText)
}
func listSessionBranches(sessionKey: String) async throws -> OpenClawChatSessionBranchesResponse {
await self.state.recordBranchList(sessionKey)
return OpenClawChatSessionBranchesResponse(branches: self.branches)
}
func switchSessionBranch(sessionKey: String, leafEntryId: String) async throws {
await self.state.recordBranchSwitch(sessionKey: sessionKey, leafEntryID: leafEntryId)
await self.branchSwitchGate?.suspendCompletion()
}
func patchSession(
key: String,
label _: String??,
@@ -221,6 +247,14 @@ private final class SessionActionTransport: @unchecked Sendable, OpenClawChatTra
await self.state.forkedMessages
}
func branchListSessionKeys() async -> [String] {
await self.state.branchListSessionKeys
}
func switchedBranches() async -> [(sessionKey: String, leafEntryID: String)] {
await self.state.switchedBranches
}
func historySessionKeys() async -> [String] {
await self.state.historySessionKeys
}
@@ -433,6 +467,74 @@ struct ChatViewModelSessionActionTests {
#expect(await transport.historySessionKeys().isEmpty)
}
@Test func `branch refresh populates state`() async {
let branches = self.branches()
let transport = SessionActionTransport(branches: branches)
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
await viewModel.refreshSessionBranches()
#expect(viewModel.sessionBranches == branches)
#expect(viewModel.isLoadingSessionBranches == false)
#expect(await transport.branchListSessionKeys() == ["main"])
}
@Test func `branch switch refreshes history and branch state`() async {
let branches = self.branches()
let transport = SessionActionTransport(branches: branches)
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
viewModel.sessionBranches = branches
await viewModel.switchToBranch("leaf-new")
#expect(await transport.switchedBranches().map { [$0.sessionKey, $0.leafEntryID] } == [
["main", "leaf-new"],
])
#expect(await transport.historySessionKeys() == ["main"])
#expect(await transport.branchListSessionKeys() == ["main"])
#expect(viewModel.sessionBranches == branches)
}
@Test func `branch switch does not dispatch while busy`() async {
let branches = self.branches()
let transport = SessionActionTransport(branches: branches)
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
viewModel.sessionBranches = branches
viewModel.isSending = true
await viewModel.switchToBranch("leaf-new")
#expect(await transport.switchedBranches().isEmpty)
#expect(await transport.historySessionKeys().isEmpty)
#expect(await transport.branchListSessionKeys().isEmpty)
}
@Test func `stale branch switch completion is ignored`() async {
let gate = SessionActionCompletionGate()
let branches = self.branches()
let transport = SessionActionTransport(branchSwitchGate: gate, branches: branches)
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
viewModel.sessionBranches = branches
let branchSwitch = Task { await viewModel.switchToBranch("leaf-new") }
guard await self.waitForForkStart(gate) else {
gate.release()
branchSwitch.cancel()
Issue.record("timed out waiting for branch switch start signal")
return
}
viewModel.switchSession(to: "other")
gate.release()
await branchSwitch.value
#expect(viewModel.sessionKey == "other")
#expect(await transport.switchedBranches().map { [$0.sessionKey, $0.leafEntryID] } == [
["main", "leaf-new"],
])
#expect(await transport.historySessionKeys().contains("main") == false)
#expect(await transport.branchListSessionKeys().contains("main") == false)
}
@Test func `fork at message switches and seeds editor`() async {
let transport = SessionActionTransport(
forkAtMessageSessionKey: "agent:main:forked",
@@ -447,7 +549,7 @@ struct ChatViewModelSessionActionTests {
}
@Test func `fork at message completion does not override newer navigation`() async {
let forkGate = SessionActionForkGate()
let forkGate = SessionActionCompletionGate()
let transport = SessionActionTransport(
forkAtMessageGate: forkGate,
forkAtMessageSessionKey: "agent:main:forked")
@@ -481,6 +583,19 @@ struct ChatViewModelSessionActionTests {
#expect(await transport.historySessionKeys() == ["main"])
}
@Test func `remote branch switch refreshes current transcript and branches only`() async {
let transport = SessionActionTransport(branches: self.branches())
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
viewModel.handleTransportEvent(.sessionsChanged(.init(sessionKey: "other", reason: "branch-switch")))
viewModel.handleTransportEvent(.sessionsChanged(.init(sessionKey: "main", reason: "branch-switch")))
let refreshed = await self.waitForBranchListRequest(transport)
#expect(refreshed)
#expect(await transport.historySessionKeys() == ["main"])
#expect(await transport.branchListSessionKeys() == ["main"])
}
@Test func `fork does not mutate gateway while session switching is blocked`() async {
let transport = SessionActionTransport()
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
@@ -501,7 +616,7 @@ struct ChatViewModelSessionActionTests {
}
@Test func `fork completion does not override newer navigation`() async {
let forkGate = SessionActionForkGate()
let forkGate = SessionActionCompletionGate()
let transport = SessionActionTransport(forkGate: forkGate)
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
@@ -521,7 +636,7 @@ struct ChatViewModelSessionActionTests {
}
private func waitForForkStart(
_ gate: SessionActionForkGate,
_ gate: SessionActionCompletionGate,
timeout: Duration = .seconds(15)) async -> Bool
{
// The stream controls ordering; this deadline only bounds a broken fake or call path.
@@ -552,6 +667,21 @@ struct ChatViewModelSessionActionTests {
return false
}
private func waitForBranchListRequest(
_ transport: SessionActionTransport,
timeout: Duration = .seconds(15)) async -> Bool
{
let clock = ContinuousClock()
let deadline = clock.now + timeout
while clock.now < deadline {
if await transport.branchListSessionKeys().isEmpty == false {
return true
}
await Task.yield()
}
return false
}
private func userMessage(entryID: String) -> OpenClawChatMessage {
OpenClawChatMessage(
role: "user",
@@ -560,6 +690,23 @@ struct ChatViewModelSessionActionTests {
transcriptMessageID: entryID)
}
private func branches() -> [OpenClawChatSessionBranch] {
[
OpenClawChatSessionBranch(
leafEntryId: "leaf-active",
headline: "Current path",
messageCount: 4,
updatedAt: "2026-07-19T12:00:00Z",
active: true),
OpenClawChatSessionBranch(
leafEntryId: "leaf-new",
headline: "Alternate path",
messageCount: 2,
updatedAt: nil,
active: false),
]
}
private func entry(key: String) -> OpenClawChatSessionEntry {
OpenClawChatSessionEntry(
key: key,
@@ -469,7 +469,12 @@ import {
SessionsCatalogReadResultSchema,
} from "./sessions-catalog.js";
import {
SessionBranchSchema,
SessionsAbortParamsSchema,
SessionsBranchesListParamsSchema,
SessionsBranchesListResultSchema,
SessionsBranchesSwitchParamsSchema,
SessionsBranchesSwitchResultSchema,
SessionsCompactParamsSchema,
SessionsCompactionBranchParamsSchema,
SessionsCompactionBranchResultSchema,
@@ -791,6 +796,11 @@ export const ProtocolSchemas = {
SessionsRewindResult: SessionsRewindResultSchema,
SessionsForkParams: SessionsForkParamsSchema,
SessionsForkResult: SessionsForkResultSchema,
SessionBranch: SessionBranchSchema,
SessionsBranchesListParams: SessionsBranchesListParamsSchema,
SessionsBranchesListResult: SessionsBranchesListResultSchema,
SessionsBranchesSwitchParams: SessionsBranchesSwitchParamsSchema,
SessionsBranchesSwitchResult: SessionsBranchesSwitchResultSchema,
SessionFileBrowserEntry: SessionFileBrowserEntrySchema,
SessionFileBrowserResult: SessionFileBrowserResultSchema,
SessionFileKind: SessionFileKindSchema,