mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
feat(apps): switch session transcript branches from native chat
This commit is contained in:
@@ -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: ¶ms, 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"])
|
||||
|
||||
+157
-10
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user