diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift index 3a7dcbe5ad..471a12c5b9 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift @@ -1,3 +1,4 @@ +import Foundation import OpenBitFunMobileCore import SwiftUI import UIKit @@ -37,12 +38,30 @@ struct ChatTimelineView: View { } .simultaneousGesture( DragGesture(minimumDistance: 8).onChanged { value in - if value.translation.height < -8 { userScrolledUp = true } + if value.translation.height > 8 { userScrolledUp = true } } ) + .scrollDismissesKeyboard(.interactively) + .onChange(of: model.selectedSessionID) { _ in + userScrolledUp = false + Task { @MainActor in + await Task.yield() + proxy.scrollTo("timeline-bottom", anchor: .bottom) + } + } + .onChange(of: model.isSending) { sending in + guard sending else { return } + userScrolledUp = false + Task { @MainActor in + await Task.yield() + proxy.scrollTo("timeline-bottom", anchor: .bottom) + } + } .onChange(of: model.timelineRows) { _ in guard !userScrolledUp else { return } - withAnimation(.easeOut(duration: 0.18)) { + Task { @MainActor in + await Task.yield() + guard !userScrolledUp else { return } proxy.scrollTo("timeline-bottom", anchor: .bottom) } } @@ -73,6 +92,60 @@ struct ChatTimelineView: View { } } +struct ConversationLoadingState: View { + var body: some View { + GeometryReader { proxy in + let contentWidth = max(0, min(proxy.size.width - 44, 760)) + VStack(spacing: 18) { + assistantSkeleton(width: contentWidth * 0.72, height: 78) + userSkeleton(width: contentWidth * 0.46, height: 42) + assistantSkeleton(width: contentWidth * 0.84, height: 112) + } + .frame(width: contentWidth) + .padding(.top, 28) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + .background(OpenBitFunTheme.page) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(MobileLocalization.text("正在加载"))) + } + + private func assistantSkeleton(width: CGFloat, height: CGFloat) -> some View { + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 9) { + skeletonLine(fraction: 0.74) + skeletonLine(fraction: 0.92) + skeletonLine(fraction: 0.58) + } + .padding(14) + .frame(width: width, height: height, alignment: .leading) + .background(OpenBitFunTheme.soft) + .clipShape(RoundedRectangle(cornerRadius: 10)) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity) + } + + private func userSkeleton(width: CGFloat, height: CGFloat) -> some View { + HStack(spacing: 0) { + Spacer(minLength: 0) + RoundedRectangle(cornerRadius: 10) + .fill(OpenBitFunTheme.soft) + .frame(width: width, height: height) + } + .frame(maxWidth: .infinity) + } + + private func skeletonLine(fraction: CGFloat) -> some View { + GeometryReader { proxy in + RoundedRectangle(cornerRadius: 5) + .fill(OpenBitFunTheme.line) + .frame(width: proxy.size.width * fraction, height: 10) + } + .frame(height: 10) + } +} + private struct ConversationRowView: View { let row: MobileConversationRow @ObservedObject var model: MobileAppModel @@ -340,15 +413,11 @@ struct MarkdownMessageView: View { let text: String @ObservedObject var model: MobileAppModel - private var blocks: [MarkdownBlock] { MarkdownParser.shared.parse(text: text) } - private var references: [MessageFileReference] { - MessageFileReferenceProjector.shared.project(source: text) - } - var body: some View { + let projection = MarkdownProjectionCache.shared.projection(for: text) VStack(alignment: .leading, spacing: 9) { - ForEach(blocks, id: \.id) { MarkdownBlockView(block: $0) } - ForEach(references, id: \.id) { FileReferenceCard(reference: $0, model: model) } + ForEach(projection.blocks, id: \.id) { MarkdownBlockView(block: $0) } + ForEach(projection.references, id: \.id) { FileReferenceCard(reference: $0, model: model) } } .frame(maxWidth: .infinity, alignment: .leading) .environment(\.openURL, OpenURLAction { url in @@ -361,6 +430,43 @@ struct MarkdownMessageView: View { } } +@MainActor +private final class MarkdownProjectionCache { + struct Projection { + let blocks: [MarkdownBlock] + let references: [MessageFileReference] + } + + private final class Entry: NSObject { + let projection: Projection + + init(_ projection: Projection) { + self.projection = projection + } + } + + static let shared = MarkdownProjectionCache() + private let entries = NSCache() + + private init() { + entries.countLimit = 48 + entries.totalCostLimit = 4 * 1_024 * 1_024 + } + + func projection(for text: String) -> Projection { + let key = text as NSString + if let cached = entries.object(forKey: key) { + return cached.projection + } + let projection = Projection( + blocks: MarkdownParser.shared.parse(text: text), + references: MessageFileReferenceProjector.shared.project(source: text) + ) + entries.setObject(Entry(projection), forKey: key, cost: text.utf8.count) + return projection + } +} + private struct MarkdownBlockView: View { let block: MarkdownBlock @@ -764,10 +870,7 @@ private struct ToolStatusList: View { pending.removeAll() } for tool in tools { - let collapsible = tool.actions.isEmpty - && ["COMPLETED", "CANCELLED"].contains(tool.phase) - && ["DOCUMENT", "FOLDER", "SEARCH"].contains(tool.kind) - if collapsible { pending.append(tool) } else { flush(); result.append(.tool(tool)) } + if tool.foldIntoSummary { pending.append(tool) } else { flush(); result.append(.tool(tool)) } } flush() return result @@ -786,7 +889,7 @@ private struct CollapsedToolsRow: View { Image(systemName: "doc.on.doc").font(.system(size: 12, weight: .medium)) .frame(width: 20, height: 20).background(OpenBitFunTheme.soft) .clipShape(RoundedRectangle(cornerRadius: 6)) - Text(model.localizedFormat("已完成 %lld 项读取与搜索", Int64(tools.count))) + Text(model.localizedFormat("已完成 %lld 项操作", Int64(tools.count))) .font(MobileDesignTypography.bodySmall.font) Spacer() Image(systemName: expanded ? "chevron.up" : "chevron.down") @@ -795,7 +898,11 @@ private struct CollapsedToolsRow: View { .foregroundStyle(OpenBitFunTheme.muted).frame(minHeight: 32) } .buttonStyle(.plain) - if expanded { ForEach(tools) { ToolStatusRow(tool: $0, model: model) } } + if expanded { + VStack(alignment: .leading, spacing: 3) { + ForEach(tools) { ToolStatusRow(tool: $0, model: model) } + } + } } } } diff --git a/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/AdaptiveModalComponents.swift b/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/AdaptiveModalComponents.swift index 9e7b33cd97..9839782238 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/AdaptiveModalComponents.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/AdaptiveModalComponents.swift @@ -202,6 +202,10 @@ private struct OpenBitFunAdaptiveModalModifier: ViewModifier private var isSide: Bool { placement.mode == .side } + private var compactDetent: PresentationDetent { + placement.height > 0 ? .height(CGFloat(placement.height)) : .large + } + private var compactPresented: Binding { Binding( get: { isPresented && !isSide }, @@ -229,7 +233,7 @@ private struct OpenBitFunAdaptiveModalModifier: ViewModifier @ViewBuilder private var compactSheet: some View { let surface = modalContent() - .presentationDetents([.large]) + .presentationDetents([compactDetent]) .presentationDragIndicator(.hidden) if #available(iOS 16.4, *) { surface.presentationCornerRadius(MobileDesignGeometry.sheetTopRadius) diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Shell/MobileShellView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Shell/MobileShellView.swift index fcf75483e4..6ec1a8c540 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Shell/MobileShellView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Shell/MobileShellView.swift @@ -354,7 +354,12 @@ struct MobileShellView: View { LocalHomeView() ComposerBar(model: model) } else { - ChatTimelineView(model: model) + ZStack { + ChatTimelineView(model: model) + if model.surface == .remote && model.remoteConversationLoading { + ConversationLoadingState() + } + } ComposerBar(model: model) } } diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Shell/SessionActionComponents.swift b/src/apps/mobile/ios/OpenBitFun/Features/Shell/SessionActionComponents.swift index 57f0f43c3b..502d87c383 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Shell/SessionActionComponents.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Shell/SessionActionComponents.swift @@ -26,13 +26,6 @@ struct SessionActionSurface: View { var body: some View { VStack(spacing: 0) { - if presentation == .bottomSheet { - Capsule() - .fill(OpenBitFunTheme.line) - .frame(width: 36, height: 4) - .padding(.bottom, 10) - } - HStack(spacing: 12) { VStack(alignment: .leading, spacing: 3) { Text(model.localized("会话操作")) @@ -69,11 +62,17 @@ struct SessionActionSurface: View { .frame(width: presentation == .popover ? 300 : nil) .frame(maxWidth: presentation == .bottomSheet ? .infinity : nil) .background(OpenBitFunTheme.card) - .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.popoverRadius)) - .overlay( - RoundedRectangle(cornerRadius: MobileDesignGeometry.popoverRadius) - .stroke(OpenBitFunTheme.line, lineWidth: 1) + .clipShape( + RoundedRectangle( + cornerRadius: presentation == .popover ? MobileDesignGeometry.popoverRadius : 0 + ) ) + .overlay { + if presentation == .popover { + RoundedRectangle(cornerRadius: MobileDesignGeometry.popoverRadius) + .stroke(OpenBitFunTheme.line, lineWidth: 1) + } + } .shadow( color: presentation == .popover ? OpenBitFunTheme.line : OpenBitFunTheme.transparent, radius: presentation == .popover ? 20 : 0, diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarView.swift index e24f3ae02b..76bee10eaa 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarView.swift @@ -37,7 +37,7 @@ struct SidebarView: View { @State private var searchVisible = false @State private var visibleRecentCount = 6 @State private var expandedWorkspacePaths: Set = [] - @State private var expandedDeviceWorkspaceLists: Set = [] + @State private var visibleDeviceWorkspaceCounts: [String: Int] = [:] @State private var compactActionSession: ChatSession? @State private var workspacePickerDevice: MobileDeviceDirectoryEntry? @State private var workspaceCreatePath: String? @@ -61,7 +61,8 @@ struct SidebarView: View { private var directoryEntries: [MobileDeviceDirectoryEntry] { var entries = model.deviceDirectory - if let direct = model.directPairingDirectoryEntry, + if model.remoteExpectedDeviceKey == "pairing", + let direct = model.directPairingDirectoryEntry, !entries.contains(where: { $0.id == direct.id }) { entries.insert(direct, at: 0) } @@ -69,15 +70,7 @@ struct SidebarView: View { } private var selectedDirectoryEntry: MobileDeviceDirectoryEntry? { - if model.directPairingConnected, - let direct = directoryEntries.first(where: { $0.id == model.directPairingSidebarDeviceID }) { - return direct - } - if let selectedID = model.accountSelectedDeviceID, - let selected = directoryEntries.first(where: { $0.id == selectedID }) { - return selected - } - return directoryEntries.first(where: \.online) ?? directoryEntries.first + directoryEntries.first(where: \.expanded) } var body: some View { @@ -107,6 +100,7 @@ struct SidebarView: View { .background(OpenBitFunTheme.page) } .sheet(item: $compactActionSession) { session in + let detentHeight: CGFloat = model.surface == .local ? 330 : 230 let surface = SessionActionSurface( model: model, session: session, @@ -124,15 +118,17 @@ struct SidebarView: View { }, onClose: { compactActionSession = nil } ) - .presentationDetents([.height(380)]) - .presentationDragIndicator(.hidden) + .frame(maxHeight: .infinity, alignment: .top) + .presentationDetents([.height(detentHeight)]) + .presentationDragIndicator(.visible) if #available(iOS 16.4, *) { surface.presentationCornerRadius(MobileDesignGeometry.popoverRadius) } else { surface } } - .sheet(item: $workspacePickerDevice) { device in + .sheet(item: $workspacePickerDevice) { requestedDevice in + let device = directoryEntries.first(where: { $0.id == requestedDevice.id }) ?? requestedDevice SidebarWorkspacePickerSheet( device: device, onClose: { workspacePickerDevice = nil }, @@ -378,7 +374,10 @@ struct SidebarView: View { .font(.system(size: 14, weight: .medium)) .foregroundStyle(OpenBitFunTheme.muted) Spacer(minLength: 0) - Button { workspacePickerDevice = selectedDirectoryEntry } label: { + Button { + workspacePickerDevice = selectedDirectoryEntry + model.refreshDirectoryWorkspacesForPicker(selectedDirectoryEntry) + } label: { Image(systemName: "plus") .font(.system(size: 18, weight: .regular)) .foregroundStyle(selectedDirectoryEntry.online ? OpenBitFunTheme.ink : OpenBitFunTheme.muted) @@ -426,7 +425,7 @@ struct SidebarView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .disabled(!device.online || selected) + .disabled(!device.online) .opacity(device.online ? 1 : 0.58) .accessibilityIdentifier("sidebar.device.\(device.id)") .accessibilityLabel(Text(device.name)) @@ -434,10 +433,18 @@ struct SidebarView: View { } private func selectDirectoryDevice(_ device: MobileDeviceDirectoryEntry) { - guard device.online, selectedDirectoryEntry?.id != device.id else { return } + guard device.online else { return } + if device.expanded { + model.toggleDeviceDirectory(device) + return + } + if let selected = selectedDirectoryEntry, selected.id != device.id { + model.toggleDeviceDirectory(selected) + } + model.toggleDeviceDirectory(device) guard device.id != model.directPairingSidebarDeviceID, let accountDevice = model.accountDevices.first(where: { $0.id == device.id }) else { return } - model.selectRemoteDevice(accountDevice) + model.selectRemoteDevice(accountDevice, preserveDrawer: true) } @ViewBuilder @@ -458,7 +465,8 @@ struct SidebarView: View { .frame(maxWidth: .infinity, minHeight: 42, alignment: .leading) .accessibilityIdentifier("sidebar.emptyWorkspaces") } - ForEach((expandedDeviceWorkspaceLists.contains(device.id) ? device.workspaces : Array(device.workspaces.prefix(3)))) { workspace in + let visibleWorkspaceCount = visibleDeviceWorkspaceCounts[device.id] ?? 3 + ForEach(device.workspaces.prefix(visibleWorkspaceCount)) { workspace in let scopedWorkspace = MobileWorkspaceGroup( path: workspace.path, name: workspace.name, @@ -468,14 +476,22 @@ struct SidebarView: View { scopedSession.deviceKey = device.id return scopedSession }, - deviceKey: device.id + deviceKey: device.id, + directoryExpanded: workspace.directoryExpanded, + directoryStatus: workspace.directoryStatus ) SidebarWorkspaceRow( workspace: scopedWorkspace, - expanded: expandedWorkspacePaths.contains(workspace.id), + expanded: workspace.directoryExpanded, selectedSessionID: model.surface == .remote ? model.selectedSessionID : nil, metadata: { _ in nil }, - onToggle: { if expandedWorkspacePaths.contains(workspace.id) { expandedWorkspacePaths.remove(workspace.id) } else { expandedWorkspacePaths.insert(workspace.id) } }, + onToggle: { + model.setDirectoryWorkspaceExpanded( + device: device, + workspace: scopedWorkspace, + expanded: !workspace.directoryExpanded + ) + }, onToggleCreate: { model.openDirectoryRemoteDraft(device: device, workspace: scopedWorkspace) }, @@ -483,18 +499,26 @@ struct SidebarView: View { onOpenSession: { model.selectDirectorySession($0) }, onActions: { session in if permanent { onPermanentActions?(session) } else { compactActionSession = session } }, - sessionLimit: expandedWorkspacePaths.contains(workspace.id) ? workspace.sessions.count : 3, selectedDeviceKey: model.accountSelectedDeviceID, selectedWorkspacePath: model.workspaceCatalog.first(where: { $0.selected })?.path, - onShowMore: { expandedWorkspacePaths.insert(workspace.id) } + directoryLoadStatus: workspace.directoryStatus, + onRetryDirectoryLoad: { + model.retryDirectoryWorkspace(device: device, workspace: scopedWorkspace) + } ) .padding(.leading, 20) } - if device.workspaces.count > 3 { + if device.workspaces.count > visibleWorkspaceCount { Button { - expandedDeviceWorkspaceLists.insert(device.id) + visibleDeviceWorkspaceCounts[device.id] = min( + device.workspaces.count, + visibleWorkspaceCount + 3 + ) } label: { - Text(model.localizedFormat("还有 %lld 个工作区", Int64(device.workspaces.count - 3))) + Text(model.localizedFormat( + "还有 %lld 个工作区", + Int64(device.workspaces.count - visibleWorkspaceCount) + )) .font(.system(size: 13)).foregroundStyle(OpenBitFunTheme.muted).padding(.leading, 42).frame(height: 36, alignment: .leading) }.buttonStyle(.plain) } @@ -933,10 +957,11 @@ private struct SidebarWorkspaceRow: View { let onOpenWorkspace: () -> Void let onOpenSession: (ChatSession) -> Void let onActions: (ChatSession) -> Void - var sessionLimit: Int = 3 var selectedDeviceKey: String? = nil var selectedWorkspacePath: String? = nil - var onShowMore: (() -> Void)? = nil + var directoryLoadStatus = "READY" + var onRetryDirectoryLoad: (() -> Void)? = nil + @State private var visibleSessionCount = 3 private func isSelected(_ session: ChatSession) -> Bool { guard selectedSessionID == session.id, @@ -1002,14 +1027,38 @@ private struct SidebarWorkspaceRow: View { .clipShape(RoundedRectangle(cornerRadius: 10)) if expanded { - if workspace.sessions.isEmpty { + if directoryLoadStatus == "LOADING" { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text(MobileLocalization.text("正在加载")) + .font(.system(size: 13)) + .foregroundStyle(OpenBitFunTheme.muted) + } + .padding(.leading, 42) + .frame(height: 40, alignment: .leading) + } else if directoryLoadStatus == "FAILED" { + Button(action: { onRetryDirectoryLoad?() }) { + HStack(spacing: 8) { + Text(MobileLocalization.text("这台电脑暂时无法读取")) + .foregroundStyle(OpenBitFunTheme.muted) + Spacer(minLength: 0) + Text(MobileLocalization.text("重试")) + .foregroundStyle(OpenBitFunTheme.ink) + } + .font(.system(size: 13)) + .padding(.leading, 42) + .padding(.trailing, 10) + .frame(height: 40) + } + .buttonStyle(.plain) + } else if workspace.sessions.isEmpty && directoryLoadStatus == "READY" { Text(MobileLocalization.text("此工作区暂无会话")) .font(.system(size: 13)) .foregroundStyle(OpenBitFunTheme.muted) .padding(.leading, 42) .frame(height: 38, alignment: .leading) } - ForEach(workspace.sessions.prefix(sessionLimit)) { session in + ForEach(workspace.sessions.prefix(visibleSessionCount)) { session in HStack(spacing: 0) { Button { onOpenSession(session) } label: { HStack(spacing: 10) { @@ -1061,13 +1110,15 @@ private struct SidebarWorkspaceRow: View { .background(isSelected(session) ? OpenBitFunTheme.soft : OpenBitFunTheme.transparent) .clipShape(RoundedRectangle(cornerRadius: 9)) } - if workspace.sessions.count > sessionLimit { - Button(action: { onShowMore?() }) { + if workspace.sessions.count > visibleSessionCount { + Button { + visibleSessionCount = min(workspace.sessions.count, visibleSessionCount + 3) + } label: { Text( MobileLocalization.format( "还有 %lld 个会话", language: MobileLocalization.restoredLanguage(), - Int64(workspace.sessions.count - sessionLimit) + Int64(workspace.sessions.count - visibleSessionCount) ) ) .font(.system(size: 13)) diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+Account.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+Account.swift index 25e098536b..99373780e7 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+Account.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+Account.swift @@ -43,17 +43,18 @@ extension MobileAppModel { remoteCreateRequestEpoch = remoteTargetEpoch remoteCreateRequestDeviceKey = nil pendingRemoteWorkspaceCreate = nil + pendingRemoteSessionRefreshWorkspacePath = nil pendingRemoteAssistantCreate = false remoteSessionSelected = false } - func selectRemoteDevice(_ device: MobileAccountDevice) { + func selectRemoteDevice(_ device: MobileAccountDevice, preserveDrawer: Bool = false) { guard device.online else { showToast(localized("这台桌面设备当前离线")) return } surface = .remote - drawerOpen = false + if !preserveDrawer { drawerOpen = false } let targetKey = "account:\(device.id)" guard remoteExpectedDeviceKey != targetKey else { return } invalidateTargetScopedFileTransfers() @@ -82,6 +83,7 @@ extension MobileAppModel { remoteWorkspaces = [] workspaceCatalog = [] pendingRemoteWorkspaceCreate = nil + pendingRemoteSessionRefreshWorkspacePath = nil pendingRemoteAssistantCreate = false selectedRemoteWorkspaceKind = "" messages = [] @@ -225,10 +227,12 @@ extension MobileAppModel { remoteCreateDeviceError = ready.refreshFailure != nil ? localized("设备列表加载失败,请稍后重试。") : nil accountDevices = ready.devices.map { device in - MobileAccountDevice( + let targetKey = "account:\(device.id)" + return MobileAccountDevice( id: device.id, name: device.name, - online: device.online, + online: device.online || + (targetKey == remoteExpectedDeviceKey && remoteConnected && remoteInitialSessionReady), selected: device.id == ready.selectedDeviceId ) } @@ -240,9 +244,13 @@ extension MobileAppModel { coreAdapter?.selectAccountDevice(id: target.id) return } - remoteConnected = directPairingConnected || ready.selectedDeviceId != nil + let selectedTargetKey = ready.selectedDeviceId.map { "account:\($0)" } + let retainsReachableAccountTarget = selectedTargetKey == remoteExpectedDeviceKey && remoteConnected + if !directPairingConnected && !retainsReachableAccountTarget { + remoteConnected = false + connectionPhase = ready.selectedDeviceId == nil ? .disconnected : .reconnecting + } surface = .remote - connectionPhase = .connected if ready.refreshFailure != nil { showToast(localized("设备列表刷新失败,仍显示上次结果")) } @@ -301,6 +309,23 @@ extension MobileAppModel { } } + func promoteLiveAccountTargetPresence(targetKey: String) { + let prefix = "account:" + guard targetKey.hasPrefix(prefix), remoteConnected else { return } + let deviceID = String(targetKey.dropFirst(prefix.count)) + guard let index = accountDevices.firstIndex(where: { $0.id == deviceID }), + !accountDevices[index].online else { return } + let device = accountDevices[index] + accountDevices[index] = MobileAccountDevice( + id: device.id, + name: device.name, + online: true, + selected: device.selected + ) + accountDirectoryGeneration = coreAdapter?.syncDeviceDirectory(accountDevices) ?? + (accountDirectoryGeneration &+ 1) + } + func accountErrorMessage(_ reason: String, stage: String? = nil) -> String { localized(AccountFailureCopy.localizationKey(reason: reason, stage: stage)) } diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+GeneralChat.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+GeneralChat.swift index b610d091a8..6de8af93ca 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+GeneralChat.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+GeneralChat.swift @@ -32,7 +32,12 @@ extension MobileAppModel { pendingDirectoryRemoteDraft = nil selectedSessionID = session.id if surface == .remote { + guard remoteConversationOpeningSessionID != session.id else { + drawerOpen = false + return + } remoteSessionSelected = true + beginRemoteConversationOpen(sessionID: session.id) coreAdapter?.openRemoteSession(sessionID: session.id) } else { localSessionSelected = true @@ -269,7 +274,8 @@ extension MobileAppModel { multiSelect: question.multiSelect ) }, - actions: Set(tool.actions.map(\.name)) + actions: Set(tool.actions.map(\.name)), + foldIntoSummary: tool.foldIntoSummary ) } diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift index f049f0ac5f..18b304d1ce 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift @@ -1,5 +1,11 @@ import Foundation import OpenBitFunMobileCore +import OSLog + +private let mobilePerformanceLog = Logger( + subsystem: "com.openbitfun.mobile.ios", + category: "performance" +) extension MobileAppModel { func apply(remoteTargetBound targetKey: String, epoch: UInt64, accountGeneration generation: UInt64) { @@ -47,6 +53,7 @@ extension MobileAppModel { } private func clearTargetScopedRemoteProjection(boundTargetKey targetKey: String, epoch: UInt64) { + resetRemoteConversationOpen() invalidateTargetScopedFileTransfers() remoteInitialSessionReady = false remoteInitialWorkspaceReady = false @@ -85,6 +92,7 @@ extension MobileAppModel { pendingDirectoryRemoteDraft = nil } pendingRemoteWorkspaceCreate = nil + pendingRemoteSessionRefreshWorkspacePath = nil pendingRemoteAssistantCreate = false committedRemoteCreate = nil @@ -120,13 +128,16 @@ extension MobileAppModel { ) } let workspaces = entry.workspaces.map { workspace in - MobileWorkspaceGroup( + let directory = entry.workspace(path: workspace.path) + return MobileWorkspaceGroup( path: workspace.path, name: workspace.name.isEmpty ? workspace.path : workspace.name, selected: remoteExpectedDeviceKey == deviceKey && normalizedSessionWorkspacePath(workspace.path) == normalizedSessionWorkspacePath(workspaceCatalog.first(where: { $0.selected })?.path ?? ""), sessions: sessions.filter { normalizedSessionWorkspacePath($0.workspacePath ?? "") == normalizedSessionWorkspacePath(workspace.path) }, - deviceKey: deviceKey + deviceKey: deviceKey, + directoryExpanded: directory?.expanded ?? false, + directoryStatus: directory?.status.name ?? "IDLE" ) } return MobileDeviceDirectoryEntry( @@ -164,6 +175,83 @@ extension MobileAppModel { coreAdapter?.retryDeviceDirectory(device.id) } + func refreshDirectoryWorkspacesForPicker(_ device: MobileDeviceDirectoryEntry) { + guard device.online else { return } + if device.id == directPairingSidebarDeviceID { + coreAdapter?.loadRemoteWorkspaces() + } else { + coreAdapter?.retryDeviceDirectory(device.id) + } + } + + func setDirectoryWorkspaceExpanded( + device: MobileDeviceDirectoryEntry, + workspace: MobileWorkspaceGroup, + expanded: Bool + ) { + guard device.online else { return } + if device.id == directPairingSidebarDeviceID { + let workspaces = device.workspaces.map { candidate -> MobileWorkspaceGroup in + var updated = candidate + guard normalizedSessionWorkspacePath(candidate.path) == + normalizedSessionWorkspacePath(workspace.path) else { return updated } + updated.directoryExpanded = expanded + if expanded && candidate.sessions.isEmpty && candidate.directoryStatus != "READY" { + updated.directoryStatus = "LOADING" + } else if !candidate.sessions.isEmpty { + updated.directoryStatus = "READY" + } + return updated + } + directPairingDirectoryEntry = MobileDeviceDirectoryEntry( + id: device.id, + name: device.name, + online: device.online, + expanded: device.expanded, + status: device.status, + error: device.error, + workspaces: workspaces, + sessions: device.sessions + ) + if expanded && workspace.sessions.isEmpty && + workspace.directoryStatus != "READY" && workspace.directoryStatus != "LOADING" { + coreAdapter?.loadRemoteWorkspaceSessions(path: workspace.path) + } + return + } + coreAdapter?.setDirectoryWorkspaceExpanded(device.id, path: workspace.path, expanded: expanded) + } + + func retryDirectoryWorkspace( + device: MobileDeviceDirectoryEntry, + workspace: MobileWorkspaceGroup + ) { + guard device.online else { return } + if device.id == directPairingSidebarDeviceID { + let workspaces = device.workspaces.map { candidate -> MobileWorkspaceGroup in + var updated = candidate + guard normalizedSessionWorkspacePath(candidate.path) == + normalizedSessionWorkspacePath(workspace.path) else { return updated } + updated.directoryExpanded = true + updated.directoryStatus = "LOADING" + return updated + } + directPairingDirectoryEntry = MobileDeviceDirectoryEntry( + id: device.id, + name: device.name, + online: device.online, + expanded: device.expanded, + status: device.status, + error: device.error, + workspaces: workspaces, + sessions: device.sessions + ) + coreAdapter?.retryRemoteWorkspaceSessions(path: workspace.path) + return + } + coreAdapter?.retryDirectoryWorkspace(device.id, path: workspace.path) + } + private func directoryTargetKey(forRawDeviceKey rawDeviceKey: String) -> String { rawDeviceKey == directPairingSidebarDeviceID ? "pairing" : "account:\(rawDeviceKey)" } @@ -322,10 +410,62 @@ extension MobileAppModel { surface = .remote drawerOpen = false remoteSessionSelected = true + beginRemoteConversationOpen(sessionID: pending.sessionID) selectedSessionID = pending.sessionID coreAdapter?.openRemoteSession(sessionID: pending.sessionID) } + /// Mirrors HarmonyOS's deferred conversation-loading gate. Cached transcripts + /// normally arrive inside the grace period; a relay fetch gets an explicit + /// skeleton instead of leaving the previous session visible. + func beginRemoteConversationOpen(sessionID: String) { + remoteConversationLoadTask?.cancel() + remoteConversationLoadGeneration &+= 1 + let generation = remoteConversationLoadGeneration + remoteConversationOpeningSessionID = sessionID + remoteConversationOpenStartedAt = ProcessInfo.processInfo.systemUptime + mobilePerformanceLog.info("Remote session open started generation=\(generation, privacy: .public)") + remoteConversationLoading = false + selectedSessionID = sessionID + timelineRows = [] + messages = [] + activeTurnID = nil + isSending = false + busy = true + remoteHasMoreMessages = false + remoteConversationLoadTask = Task { [weak self] in + do { + try await Task.sleep(nanoseconds: 140_000_000) + } catch { + return + } + guard let self, + self.remoteConversationLoadGeneration == generation, + self.remoteConversationOpeningSessionID == sessionID else { return } + self.remoteConversationLoading = true + } + } + + func finishRemoteConversationOpenIfReady(timelineSessionID: String) { + guard remoteConversationOpeningSessionID == timelineSessionID else { return } + if let startedAt = remoteConversationOpenStartedAt { + let elapsedMS = Int((ProcessInfo.processInfo.systemUptime - startedAt) * 1_000) + mobilePerformanceLog.info( + "Remote session timeline ready elapsed_ms=\(elapsedMS, privacy: .public) generation=\(self.remoteConversationLoadGeneration, privacy: .public)" + ) + } + resetRemoteConversationOpen() + } + + func resetRemoteConversationOpen() { + remoteConversationLoadTask?.cancel() + remoteConversationLoadTask = nil + remoteConversationLoadGeneration &+= 1 + remoteConversationOpeningSessionID = nil + remoteConversationOpenStartedAt = nil + remoteConversationLoading = false + } + private func advancePendingDirectoryRemoteDraftIfReady() { guard var pending = pendingDirectoryRemoteDraft, pending.targetKey == remoteExpectedDeviceKey, @@ -373,6 +513,7 @@ extension MobileAppModel { surface = .remote drawerOpen = false workspaceSelectionBusy = true + pendingRemoteSessionRefreshWorkspacePath = normalizedSessionWorkspacePath(workspace.path) coreAdapter?.selectRemoteWorkspace(path: workspace.path) } @@ -721,6 +862,7 @@ extension MobileAppModel { guard let ready = state as? RemoteSessionUiStateReady else { remoteInitialSessionReady = false if let failed = state as? RemoteSessionUiStateFailed { + resetRemoteConversationOpen() let detail = failed.remoteMessage ?? failed.reason.name remoteConnected = false connectionPhase = .disconnected @@ -764,10 +906,8 @@ extension MobileAppModel { revision: ready.revision, lastApplied: remoteLastAppliedAuthority ) - remoteInitialSessionReady = true - remoteConnected = true - surface = .remote - connectionPhase = .connected + remoteInitialSessionReady = remoteInitialSessionReady || !ready.busy + setPublishedIfChanged(\.surface, to: .remote) let committed = committedRemoteCreate let projectionDecision = RemoteAuthorityGate.committedProjectionDecision( readyTargetKey: targetKey, @@ -783,7 +923,7 @@ extension MobileAppModel { if !projectionDecision.retainMarker { committedRemoteCreate = nil } - remoteSessions = ready.sessions.map { session in + var projectedSessions = ready.sessions.map { session in ChatSession( id: session.id, title: session.title.isEmpty ? localized("未命名会话") : session.title, @@ -797,34 +937,63 @@ extension MobileAppModel { ) } if let committed, projectionDecision.protectCommittedRowAndSelection { - remoteSessions.removeAll { $0.id == committed.session.id } - remoteSessions.insert(committed.session, at: 0) + projectedSessions.removeAll { $0.id == committed.session.id } + projectedSessions.insert(committed.session, at: 0) } + setPublishedIfChanged(\.remoteSessions, to: projectedSessions) rebuildRemoteWorkspaceGroups() + if directPairingConnected && targetKey == "pairing" && !ready.busy { + let selectedPath = workspaceCatalog.first(where: { $0.selected })?.path + directPairingDirectoryEntry = directPairingDirectoryEntry.map { entry in + MobileDeviceDirectoryEntry( + id: entry.id, + name: entry.name, + online: entry.online, + expanded: entry.expanded, + status: entry.status, + error: entry.error, + workspaces: entry.workspaces.map { workspace in + var updated = workspace + if normalizedSessionWorkspacePath(workspace.path) == + normalizedSessionWorkspacePath(selectedPath ?? "") { + updated.directoryStatus = "READY" + } + return updated + }, + sessions: entry.sessions + ) + } + } if directPairingConnected { updateDirectPairingDirectoryEntry() } if let protected = committedRemoteCreate, protected.targetKey == targetKey, protected.epoch == epoch { - selectedSessionID = protected.session.id - remoteSessionSelected = true + setPublishedIfChanged(\.selectedSessionID, to: protected.session.id) + setPublishedIfChanged(\.remoteSessionSelected, to: true) } else { - if let selected = ready.selectedSessionId { - selectedSessionID = selected + let openingSessionIsNotReady = remoteConversationOpeningSessionID.map { opening in + ready.timeline?.sessionId != opening + } ?? false + if !openingSessionIsNotReady, let selected = ready.selectedSessionId { + setPublishedIfChanged(\.selectedSessionID, to: selected) } - remoteSessionSelected = ready.selectedSessionId != nil - } - busy = ready.busy - remoteQuery = ready.query - remoteAgentFilter = ready.agentFilter.name - remoteHasMore = ready.hasMore - remoteHasMoreMessages = ready.hasMoreMessages - remotePermissionMode = ready.permissionMode?.name ?? remotePermissionMode - remotePermissionFailure = ready.permissionModeFailure?.name + setPublishedIfChanged( + \.remoteSessionSelected, + to: openingSessionIsNotReady || ready.selectedSessionId != nil + ) + } + setPublishedIfChanged(\.busy, to: ready.busy) + setPublishedIfChanged(\.remoteQuery, to: ready.query) + setPublishedIfChanged(\.remoteAgentFilter, to: ready.agentFilter.name) + setPublishedIfChanged(\.remoteHasMore, to: ready.hasMore) + setPublishedIfChanged(\.remoteHasMoreMessages, to: ready.hasMoreMessages) + setPublishedIfChanged(\.remotePermissionMode, to: ready.permissionMode?.name ?? remotePermissionMode) + setPublishedIfChanged(\.remotePermissionFailure, to: ready.permissionModeFailure?.name) activeTurnID = ready.timeline?.activeTurn?.turnId - isSending = ready.timeline?.activeTurn != nil - modelOptions = ready.createModelOptions(fallbackLabel: localized("模型")).map { option in + setPublishedIfChanged(\.isSending, to: ready.timeline?.activeTurn != nil) + let projectedModelOptions = ready.createModelOptions(fallbackLabel: localized("模型")).map { option in ComposerModelOption( id: option.id, primaryLabel: option.primaryLabel, @@ -833,19 +1002,24 @@ extension MobileAppModel { selected: option.selected ) } + setPublishedIfChanged(\.modelOptions, to: projectedModelOptions) if let timeline = ready.timeline { - timelineRows = timeline.conversationRows().map(Self.mapConversationRow) - messages = timelineRows.compactMap { row in - guard row.kind != "EMPTY" else { return nil } - return ChatMessage( - id: UUID(uuidString: row.id) ?? UUID(), - role: row.kind == "USER" ? .user : .assistant, - text: row.text - ) + let projectedRows = timeline.conversationRows().map(Self.mapConversationRow) + if timelineRows != projectedRows { + timelineRows = projectedRows + messages = projectedRows.compactMap { row in + guard row.kind != "EMPTY" else { return nil } + return ChatMessage( + id: UUID(uuidString: row.id) ?? UUID(), + role: row.kind == "USER" ? .user : .assistant, + text: row.text + ) + } } + finishRemoteConversationOpenIfReady(timelineSessionID: timeline.sessionId) } else { - timelineRows = [] - messages = [] + setPublishedIfChanged(\.timelineRows, to: []) + setPublishedIfChanged(\.messages, to: []) } if let pending = pendingDirectoryWorkspace, remoteExpectedDeviceKey == directoryTargetKey(forRawDeviceKey: pending.deviceKey), @@ -853,12 +1027,97 @@ extension MobileAppModel { remoteConnected, remoteInitialWorkspaceReady { pendingDirectoryWorkspace = nil + pendingRemoteSessionRefreshWorkspacePath = normalizedSessionWorkspacePath(pending.path) coreAdapter?.selectRemoteWorkspace(path: pending.path) } openPendingDirectorySessionIfReady() advancePendingDirectoryRemoteDraftIfReady() } + func apply( + remoteConnectionPhase phase: OpenBitFunMobileCore.ConnectionPhase, + targetKey: String, + epoch: UInt64 + ) { + guard !localActionPreview, !accountLoginPreview, !remoteCreatePreview, + RemoteAuthorityGate.callbackMatchesAuthority( + targetKey: targetKey, + epoch: epoch, + expectedTargetKey: remoteExpectedDeviceKey, + expectedEpoch: remoteTargetEpoch + ) else { return } + switch phase.name { + case "CONNECTED": + remoteConnected = true + connectionPhase = .connected + case "CONNECTING", "RECONNECTING": + remoteConnected = true + connectionPhase = .reconnecting + default: + remoteConnected = false + connectionPhase = .disconnected + } + if phase.name == "CONNECTED" || phase.name == "RECONNECTING" { + promoteLiveAccountTargetPresence(targetKey: targetKey) + } + if directPairingConnected && targetKey == "pairing" { + updateDirectPairingDirectoryEntry() + } + } + + func apply( + remoteWorkspaceDirectory state: WorkspaceSessionDirectoryUiState, + targetKey: String, + epoch: UInt64 + ) { + guard targetKey == "pairing", directPairingConnected, + RemoteAuthorityGate.callbackMatchesAuthority( + targetKey: targetKey, + epoch: epoch, + expectedTargetKey: remoteExpectedDeviceKey, + expectedEpoch: remoteTargetEpoch + ), + let entry = directPairingDirectoryEntry else { return } + let workspaces = entry.workspaces.map { workspace -> MobileWorkspaceGroup in + guard let directory = state.workspace(path: workspace.path) else { return workspace } + var updated = workspace + updated.directoryStatus = directory.status.name + updated = MobileWorkspaceGroup( + path: updated.path, + name: updated.name, + selected: updated.selected, + sessions: directory.sessions.map { session in + ChatSession( + id: session.id, + title: session.title.isEmpty ? localized("未命名会话") : session.title, + updatedLabel: session.updatedAt, + status: session.status, + agentType: session.agentType, + workspacePath: session.workspacePath, + workspaceName: session.workspaceName, + deviceKey: directPairingSidebarDeviceID, + createdAt: session.createdAt, + messageCount: Int(session.messageCount) + ) + }, + deviceKey: updated.deviceKey, + directoryExpanded: updated.directoryExpanded, + directoryStatus: updated.directoryStatus + ) + return updated + } + directPairingDirectoryEntry = MobileDeviceDirectoryEntry( + id: entry.id, + name: entry.name, + online: entry.online, + expanded: entry.expanded, + status: entry.status, + error: entry.error, + workspaces: workspaces, + sessions: workspaces.flatMap(\.sessions) + ) + } + func apply(workspaceState state: RemoteWorkspaceUiState, targetKey: String, epoch: UInt64) { guard !localActionPreview, !accountLoginPreview, !remoteCreatePreview, RemoteAuthorityGate.callbackMatchesAuthority( @@ -867,18 +1126,20 @@ extension MobileAppModel { expectedTargetKey: remoteExpectedDeviceKey, expectedEpoch: remoteTargetEpoch ) else { return } - workspaceLoading = state is RemoteWorkspaceUiStateLoading - workspaceLoadFailed = state is RemoteWorkspaceUiStateFailed + let readyState = state as? RemoteWorkspaceUiStateReady + workspaceLoading = state is RemoteWorkspaceUiStateLoading || readyState?.busy == true + workspaceLoadFailed = state is RemoteWorkspaceUiStateFailed || readyState?.loadFailure == true workspaceSelectionBusy = (state as? RemoteWorkspaceUiStateReady)?.busy ?? false - if state is RemoteWorkspaceUiStateLoading { + if state is RemoteWorkspaceUiStateLoading || readyState?.busy == true { remoteCreateWorkspacePhase = .loading - } else if state is RemoteWorkspaceUiStateFailed { + } else if state is RemoteWorkspaceUiStateFailed || readyState?.loadFailure == true { remoteCreateWorkspacePhase = .failed } - if !(state is RemoteWorkspaceUiStateReady) { + if !(state is RemoteWorkspaceUiStateReady) || readyState?.busy == true || readyState?.loadFailure == true { remoteInitialWorkspaceReady = false } - if state is RemoteWorkspaceUiStateFailed { + if state is RemoteWorkspaceUiStateFailed || readyState?.loadFailure == true { + pendingRemoteSessionRefreshWorkspacePath = nil if pendingRemoteWorkspaceCreate != nil || pendingRemoteAssistantCreate || pendingDirectoryRemoteDraft != nil { pendingRemoteWorkspaceCreate = nil @@ -890,11 +1151,11 @@ extension MobileAppModel { } guard let ready = state as? RemoteWorkspaceUiStateReady else { return } - workspaceLoading = false - workspaceLoadFailed = false + workspaceLoading = ready.busy + workspaceLoadFailed = ready.loadFailure workspaceSelectionBusy = ready.busy - remoteCreateWorkspacePhase = .ready - remoteInitialWorkspaceReady = true + remoteCreateWorkspacePhase = ready.loadFailure ? .failed : (ready.busy ? .loading : .ready) + remoteInitialWorkspaceReady = !ready.busy && !ready.loadFailure selectedRemoteWorkspaceKind = ready.selected?.kind ?? "" var seen = Set() var catalog: [(path: String, name: String, selected: Bool)] = [] @@ -930,26 +1191,53 @@ extension MobileAppModel { pendingRemoteAssistantCreate = false createRemoteSession(agentType: "Claw", title: "", instruction: "") } + if !ready.busy, let pendingPath = pendingRemoteSessionRefreshWorkspacePath { + pendingRemoteSessionRefreshWorkspacePath = nil + if normalizedSessionWorkspacePath(ready.selected?.path ?? "") == pendingPath { + coreAdapter?.refreshRemoteSessions() + } + } advancePendingDirectoryRemoteDraftIfReady() } private func updateDirectPairingDirectoryEntry() { guard let name = directPairingDeviceName else { return } + let previous = directPairingDirectoryEntry?.workspaces ?? [] + let workspaces = remoteWorkspaces.map { workspace -> MobileWorkspaceGroup in + if let old = previous.first(where: { + normalizedSessionWorkspacePath($0.path) == normalizedSessionWorkspacePath(workspace.path) + }) { + return MobileWorkspaceGroup( + path: workspace.path, + name: workspace.name, + selected: workspace.selected, + sessions: workspace.sessions.isEmpty && old.directoryStatus == "READY" + ? old.sessions : workspace.sessions, + deviceKey: workspace.deviceKey, + directoryExpanded: old.directoryExpanded, + directoryStatus: old.directoryStatus + ) + } + var updated = workspace + if !workspace.sessions.isEmpty { updated.directoryStatus = "READY" } + return updated + } + let pairingIsActive = remoteExpectedDeviceKey == "pairing" directPairingDirectoryEntry = MobileDeviceDirectoryEntry( id: directPairingSidebarDeviceID, name: name, - online: remoteConnected, + online: pairingIsActive && remoteConnected, expanded: directPairingDirectoryEntry?.expanded ?? true, - status: remoteConnected ? "READY" : "FAILED", - error: remoteConnected ? nil : "DISCONNECTED", - workspaces: remoteWorkspaces, - sessions: remoteSessions + status: pairingIsActive && remoteConnected ? "READY" : "FAILED", + error: pairingIsActive && remoteConnected ? nil : "DISCONNECTED", + workspaces: workspaces, + sessions: workspaces.flatMap(\.sessions) ) } func rebuildRemoteWorkspaceGroups() { let selectedPath = workspaceCatalog.first(where: { $0.selected })?.path - remoteWorkspaces = workspaceCatalog.map { workspace in + let projectedWorkspaces = workspaceCatalog.map { workspace in MobileWorkspaceGroup( path: workspace.path, name: workspace.name.isEmpty ? workspace.path : workspace.name, @@ -960,6 +1248,15 @@ extension MobileAppModel { } ) } + setPublishedIfChanged(\.remoteWorkspaces, to: projectedWorkspaces) + } + + private func setPublishedIfChanged( + _ keyPath: ReferenceWritableKeyPath, + to value: Value + ) { + guard self[keyPath: keyPath] != value else { return } + self[keyPath: keyPath] = value } } diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift index 028d698699..b9ba0e06f8 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift @@ -20,6 +20,7 @@ final class MobileAppModel: ObservableObject { @Published var remoteViewSettingsOpen = false @Published var remoteHasMore = false @Published var remoteHasMoreMessages = false + @Published var remoteConversationLoading = false @Published var remotePermissionMode = "ASK" @Published var remotePermissionFailure: String? @Published var remoteAssistants: [MobileAssistantOption] = [] @@ -116,10 +117,15 @@ final class MobileAppModel: ObservableObject { var remoteLastAppliedAuthority: RemoteAuthorityScope? var workspaceCatalog: [(path: String, name: String, selected: Bool)] = [] var pendingRemoteWorkspaceCreate: (path: String, agentType: String)? + var pendingRemoteSessionRefreshWorkspacePath: String? var pendingDirectoryWorkspace: (deviceKey: String, path: String, epoch: UInt64)? var pendingDirectoryRemoteDraft: PendingDirectoryRemoteDraft? var pendingRemoteAssistantCreate = false var selectedRemoteWorkspaceKind = "" + var remoteConversationLoadTask: Task? + var remoteConversationLoadGeneration: UInt64 = 0 + var remoteConversationOpeningSessionID: String? + var remoteConversationOpenStartedAt: TimeInterval? var coreAdapter: MobileCoreAdapter? @@ -143,6 +149,12 @@ final class MobileAppModel: ObservableObject { onRemoteState: { [weak self] state, targetKey, epoch in self?.apply(remoteState: state, targetKey: targetKey, epoch: epoch) }, + onRemoteConnectionPhase: { [weak self] phase, targetKey, epoch in + self?.apply(remoteConnectionPhase: phase, targetKey: targetKey, epoch: epoch) + }, + onRemoteWorkspaceDirectory: { [weak self] state, targetKey, epoch in + self?.apply(remoteWorkspaceDirectory: state, targetKey: targetKey, epoch: epoch) + }, onWorkspaceState: { [weak self] state, targetKey, epoch in self?.apply(workspaceState: state, targetKey: targetKey, epoch: epoch) }, @@ -260,6 +272,7 @@ final class MobileAppModel: ObservableObject { } func disconnectRemote() { + resetRemoteConversationOpen() invalidateTargetScopedFileTransfers() committedRemoteCreate = nil remoteLastAppliedAuthority = nil @@ -279,6 +292,7 @@ final class MobileAppModel: ObservableObject { workspaceSelectionBusy = false remoteCreateWorkspacePhase = .unavailable pendingRemoteWorkspaceCreate = nil + pendingRemoteSessionRefreshWorkspacePath = nil pendingDirectoryRemoteDraft = nil pendingRemoteAssistantCreate = false selectedRemoteWorkspaceKind = "" @@ -374,6 +388,7 @@ final class MobileAppModel: ObservableObject { pendingDirectoryWorkspace = nil pendingDirectoryRemoteDraft = nil pendingRemoteWorkspaceCreate = nil + pendingRemoteSessionRefreshWorkspacePath = nil pendingRemoteAssistantCreate = false selectedRemoteWorkspaceKind = "" selectedSessionID = "" diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileCoreAdapter.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileCoreAdapter.swift index 54a6fd771b..95a18e3914 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileCoreAdapter.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileCoreAdapter.swift @@ -18,6 +18,8 @@ final class MobileCoreAdapter { private var remoteTargetKey: String? private var remoteTargetEpoch: UInt64 = 0 private var desiredRemoteTarget: DesiredRemoteTarget? + /** Fresh account login/restore stays directory-only until the user opens a target. */ + private var hydrateAccountTargetOnBind = false private var initialRemoteTargetSelectionOpen = true private var directoryGeneration: UInt64 = 0 private var accountGeneration: UInt64 = 0 @@ -46,6 +48,8 @@ final class MobileCoreAdapter { var onAccountState: ((AccountUiState, UInt64) -> Void)? var onRemoteTargetBound: ((String, UInt64, UInt64) -> Void)? var onRemoteState: ((RemoteSessionUiState, String, UInt64) -> Void)? + var onRemoteConnectionPhase: ((OpenBitFunMobileCore.ConnectionPhase, String, UInt64) -> Void)? + var onRemoteWorkspaceDirectory: ((WorkspaceSessionDirectoryUiState, String, UInt64) -> Void)? var onWorkspaceState: ((RemoteWorkspaceUiState, String, UInt64) -> Void)? var onDirectoryState: ((DeviceDirectoryUiState, UInt64) -> Void)? var onCreateOperation: ((CreateSessionOperationState, String) -> Void)? @@ -57,6 +61,8 @@ final class MobileCoreAdapter { onAccountState: ((AccountUiState, UInt64) -> Void)? = nil, onRemoteTargetBound: ((String, UInt64, UInt64) -> Void)? = nil, onRemoteState: ((RemoteSessionUiState, String, UInt64) -> Void)? = nil, + onRemoteConnectionPhase: ((OpenBitFunMobileCore.ConnectionPhase, String, UInt64) -> Void)? = nil, + onRemoteWorkspaceDirectory: ((WorkspaceSessionDirectoryUiState, String, UInt64) -> Void)? = nil, onWorkspaceState: ((RemoteWorkspaceUiState, String, UInt64) -> Void)? = nil, onDirectoryState: ((DeviceDirectoryUiState, UInt64) -> Void)? = nil, onCreateOperation: ((CreateSessionOperationState, String) -> Void)? = nil, @@ -91,6 +97,8 @@ final class MobileCoreAdapter { self.onAccountState = onAccountState self.onRemoteTargetBound = onRemoteTargetBound self.onRemoteState = onRemoteState + self.onRemoteConnectionPhase = onRemoteConnectionPhase + self.onRemoteWorkspaceDirectory = onRemoteWorkspaceDirectory self.onWorkspaceState = onWorkspaceState self.onDirectoryState = onDirectoryState self.onCreateOperation = onCreateOperation @@ -304,6 +312,7 @@ final class MobileCoreAdapter { if remoteTargetKey != "pairing" { desiredRemoteTarget = .accountRestore } + hydrateAccountTargetOnBind = false initialRemoteTargetSelectionOpen = false account.dispatch(intent: AccountIntentLogin(relayUrl: relayURL, username: username, password: password)) } @@ -311,6 +320,7 @@ final class MobileCoreAdapter { func selectAccountDevice(id: String) { pendingDirectoryReconciles.removeAll() desiredRemoteTarget = .account(deviceID: id) + hydrateAccountTargetOnBind = true initialRemoteTargetSelectionOpen = false account.dispatch(intent: AccountIntentSelectDevice(deviceId: id)) let state = SkieSwiftStateFlow(account.state).value @@ -353,6 +363,21 @@ final class MobileCoreAdapter { deviceDirectory.dispatch(intent: DeviceDirectoryIntentRetry(deviceId: deviceID)) } + func setDirectoryWorkspaceExpanded(_ deviceID: String, path: String, expanded: Bool) { + deviceDirectory.dispatch(intent: DeviceDirectoryIntentSetWorkspaceExpanded( + deviceId: deviceID, + path: path, + expanded: expanded + )) + } + + func retryDirectoryWorkspace(_ deviceID: String, path: String) { + deviceDirectory.dispatch(intent: DeviceDirectoryIntentRetryWorkspace( + deviceId: deviceID, + path: path + )) + } + func refreshAccountDevices() { account.dispatch(intent: AccountIntentRefreshDevices.shared) } @@ -523,6 +548,14 @@ final class MobileCoreAdapter { remoteSession?.dispatch(intent: RemoteSessionIntentRefresh.shared) } + func loadRemoteWorkspaceSessions(path: String) { + remoteSession?.dispatch(intent: RemoteSessionIntentLoadWorkspaceSessions(path: path)) + } + + func retryRemoteWorkspaceSessions(path: String) { + remoteSession?.dispatch(intent: RemoteSessionIntentRetryWorkspaceSessions(path: path)) + } + func setRemoteAgentFilter(_ filter: SessionAgentFilter) { remoteSession?.dispatch(intent: RemoteSessionIntentSetAgentFilter(filter: filter)) } @@ -625,6 +658,7 @@ final class MobileCoreAdapter { private func startAccountRemoteSessionIfNeeded(ready: AccountUiStateReady, generation: UInt64) { guard generation == accountGeneration, + hydrateAccountTargetOnBind, let deviceID = ready.selectedDeviceId else { return } let desiredTarget = DesiredRemoteTarget.account(deviceID: deviceID) let targetKey = "account:\(deviceID)" @@ -731,7 +765,6 @@ final class MobileCoreAdapter { let sessionFlow = SkieSwiftStateFlow(sessionStore.state) onRemoteState?(sessionFlow.value, targetKey, boundEpoch) - sessionStore.dispatch(intent: RemoteSessionIntentLoad.shared) remoteObservations.append(Task { [weak self] in for await state in sessionFlow { guard !Task.isCancelled else { return } @@ -739,6 +772,28 @@ final class MobileCoreAdapter { } }) + let connectionFlow = SkieSwiftStateFlow(sessionStore.connectionPhase) + onRemoteConnectionPhase?(connectionFlow.value, targetKey, boundEpoch) + remoteObservations.append(Task { [weak self] in + for await phase in connectionFlow { + guard !Task.isCancelled else { return } + self?.onRemoteConnectionPhase?(phase, targetKey, boundEpoch) + } + }) + + let workspaceDirectoryFlow = SkieSwiftStateFlow( + sessionStore.workspaceDirectory + ) + onRemoteWorkspaceDirectory?(workspaceDirectoryFlow.value, targetKey, boundEpoch) + remoteObservations.append(Task { [weak self] in + for await directory in workspaceDirectoryFlow { + guard !Task.isCancelled else { return } + self?.onRemoteWorkspaceDirectory?(directory, targetKey, boundEpoch) + } + }) + + sessionStore.dispatch(intent: RemoteSessionIntentLoad.shared) + let createFlow = SkieSwiftStateFlow(sessionStore.createOperation) handleCreateOperation(createFlow.value, targetKey: targetKey, epoch: boundEpoch) remoteObservations.append(Task { [weak self] in diff --git a/src/apps/mobile/ios/OpenBitFun/Presentation/Models/MobilePresentationModels.swift b/src/apps/mobile/ios/OpenBitFun/Presentation/Models/MobilePresentationModels.swift index a92c6a0066..a69658e907 100644 --- a/src/apps/mobile/ios/OpenBitFun/Presentation/Models/MobilePresentationModels.swift +++ b/src/apps/mobile/ios/OpenBitFun/Presentation/Models/MobilePresentationModels.swift @@ -54,6 +54,7 @@ struct MobileTimelineTool: Identifiable, Equatable { let question: String? let questions: [MobileTimelineQuestion] let actions: Set + var foldIntoSummary: Bool = false } indirect enum MobileTimelineBlock: Identifiable, Equatable { @@ -229,6 +230,8 @@ struct MobileWorkspaceGroup: Identifiable, Equatable { let selected: Bool let sessions: [ChatSession] var deviceKey: String? = nil + var directoryExpanded = false + var directoryStatus = "IDLE" } enum MobileSessionListSectionKind: Equatable { diff --git a/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings b/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings index 8b82596190..0b46a95950 100644 --- a/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings +++ b/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings @@ -941,6 +941,16 @@ } } }, + "这台电脑暂时无法读取": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This computer cannot be read right now" + } + } + } + }, "工作区助手": { "localizations": { "en": { @@ -2551,12 +2561,12 @@ } } }, - "已完成 %lld 项读取与搜索": { + "已完成 %lld 项操作": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Completed %lld read and search operations" + "value": "Completed %lld operations" } } } diff --git a/src/apps/mobile/ios/OpenBitFunUITests/RemoteCodeSessionSendUITests.swift b/src/apps/mobile/ios/OpenBitFunUITests/RemoteCodeSessionSendUITests.swift index c60724d82e..4c4e7f9d19 100644 --- a/src/apps/mobile/ios/OpenBitFunUITests/RemoteCodeSessionSendUITests.swift +++ b/src/apps/mobile/ios/OpenBitFunUITests/RemoteCodeSessionSendUITests.swift @@ -296,3 +296,438 @@ final class ComposerFocusResponsivenessUITests: XCTestCase { add(screenshot) } } + +/// Exercises the complete account-driven remote workflow against a real relay. +/// Credentials are supplied by the invoking process and are never persisted in +/// the test bundle or emitted in diagnostics. +final class RemoteAccountWorkflowPerformanceUITests: XCTestCase { + private let app = XCUIApplication(bundleIdentifier: "com.openbitfun.mobile.ios") + private var workflowStartedAt = Date() + + override func setUpWithError() throws { + continueAfterFailure = false + workflowStartedAt = Date() + app.launchArguments = ["--simplified-chinese"] + app.launch() + } + + func testLoginDeviceWorkspaceSessionSwitchAndSend() throws { + let environment = ProcessInfo.processInfo.environment + guard let username = environment["OPENBITFUN_E2E_USERNAME"], !username.isEmpty, + let password = environment["OPENBITFUN_E2E_PASSWORD"], !password.isEmpty else { + throw XCTSkip("Set OPENBITFUN_E2E_USERNAME and OPENBITFUN_E2E_PASSWORD to run the real relay workflow.") + } + + let sidebarAlreadyOpen = try signIn(username: username, password: password) + let deviceID = try selectFirstRemoteDevice(openSidebar: !sidebarAlreadyOpen) + let sessionIDs = try exerciseWorkspaceDirectory(deviceID: deviceID) + try exerciseSessionSwitching(sessionIDs: sessionIDs) + try sendVerificationMessage() + let idleStarted = Date() + RunLoop.current.run(until: Date().addingTimeInterval(5)) + recordStep("post_workflow_idle", since: idleStarted) + recordDiagnostics(named: "RemoteAccountWorkflowSucceeded") + } + + private func signIn(username: String, password: String) throws -> Bool { + let sidebar = try requireFirst([ + app.buttons["打开侧栏"], + app.buttons["Open sidebar"], + ], timeout: 20, failure: "The sidebar action did not become available.") + sidebar.tap() + + let accountActions = [ + app.buttons["登录 OpenBitFun 账号"], + app.buttons["Sign in to OpenBitFun"], + ] + if !waitUntil(timeout: 5, condition: { + accountActions.contains(where: \.exists) || self.app.buttons.matching( + NSPredicate(format: "identifier BEGINSWITH %@", "sidebar.device.") + ).count > 0 + }) { + recordDiagnostics(named: "AccountStateUnavailable") + XCTFail("The sidebar exposed neither account login nor a restored device directory.") + throw WorkflowError.requiredElementMissing + } + if !accountActions.contains(where: \.exists) { + recordStep("account_restore_ready", since: workflowStartedAt) + return true + } + let accountAction = accountActions.first(where: \.exists)! + accountAction.tap() + + let usernameField = try requireFirst([ + app.textFields["用户名"], + app.textFields["Username"], + ], timeout: 10, failure: "The username field did not appear.") + let passwordField = try requireFirst([ + app.secureTextFields["密码"], + app.secureTextFields["Password"], + ], timeout: 10, failure: "The password field did not appear.") + usernameField.tap() + usernameField.typeText(username) + passwordField.tap() + passwordField.typeText(password) + + let login = try requireFirst([ + app.buttons["登录"], + app.buttons["Sign in"], + ], timeout: 5, failure: "The login button did not become available.") + XCTAssertTrue(login.isEnabled, "The completed login form remained disabled.") + let started = Date() + login.tap() + + _ = try requireFirst([ + app.staticTexts["个人资料"], + app.staticTexts["Profile"], + ], timeout: 45, failure: "Login did not reach the account profile.") + recordStep("login_ready", since: started) + + let close = try requireFirst([ + app.buttons["关闭"], + app.buttons["Close"], + ], timeout: 5, failure: "The account sheet could not be closed.") + close.tap() + _ = try requireFirst([ + app.buttons["打开侧栏"], + app.buttons["Open sidebar"], + ], timeout: 10, failure: "The conversation surface did not return after login.") + return false + } + + private func selectFirstRemoteDevice(openSidebar: Bool) throws -> String { + if openSidebar { + let sidebar = try requireFirst([ + app.buttons["打开侧栏"], + app.buttons["Open sidebar"], + ], timeout: 10, failure: "The sidebar action was unavailable after login.") + sidebar.tap() + } + + let devices = app.buttons.matching( + NSPredicate(format: "identifier BEGINSWITH %@", "sidebar.device.") + ) + let device = try requireUsableElement( + in: devices, + timeout: 30, + failure: "No online account device became selectable." + ) + let identifier = device.identifier + let deviceID = String(identifier.dropFirst("sidebar.device.".count)) + let started = Date() + device.tap() + + let workspaces = app.buttons.matching( + NSPredicate(format: "identifier BEGINSWITH %@", "sidebar.workspace.\(deviceID).") + ) + _ = try requireUsableElement( + in: workspaces, + timeout: 45, + failure: "The selected device did not expose a usable workspace." + ) + recordStep("device_workspaces_ready", since: started) + return deviceID + } + + private func exerciseWorkspaceDirectory(deviceID: String) throws -> [String] { + let workspacePrefix = "sidebar.workspace.\(deviceID)." + let sessionPrefix = "sidebar.session.\(deviceID)." + let workspaces = app.buttons.matching( + NSPredicate(format: "identifier BEGINSWITH %@", workspacePrefix) + ) + XCTAssertGreaterThan(workspaces.count, 1, "At least two workspaces are required to validate switching.") + + var chosenWorkspace: XCUIElement? + var sessionIDs: [String] = [] + let candidateCount = min(workspaces.count, 8) + for index in 0..= 2 { + chosenWorkspace = workspace + break + } + + if scrollToHittable(workspace) { workspace.tap() } + } + + guard let chosenWorkspace else { + recordDiagnostics(named: "NoWorkspaceWithTwoSessions") + XCTFail("No inspected workspace exposed two sessions for rapid switching.") + return [] + } + + if scrollToHittable(chosenWorkspace) { chosenWorkspace.tap() } + let secondWorkspace = workspaces.element(boundBy: 1) + guard scrollToHittable(secondWorkspace) else { + XCTFail("A second workspace exists but could not be scrolled into view.") + return [] + } + let switchStarted = Date() + secondWorkspace.tap() + waitForDirectoryLoadingToFinish(timeout: 30) + recordStep("workspace_switch_ready", since: switchStarted) + + if scrollToHittable(secondWorkspace) { secondWorkspace.tap() } + guard scrollToHittable(chosenWorkspace) else { + XCTFail("The source workspace could not be restored after switching.") + return [] + } + chosenWorkspace.tap() + waitForDirectoryLoadingToFinish(timeout: 30) + return Array(sessionIDs.prefix(3)) + } + + private func exerciseSessionSwitching(sessionIDs: [String]) throws { + guard sessionIDs.count >= 2 else { + XCTFail("Rapid switching requires at least two sessions.") + return + } + + try openSession(sessionIDs[0], step: "session_first_ready") + try reopenSidebar() + try selectSessionWithoutWaiting(sessionIDs[1], step: "rapid_switch_first_selected") + try reopenSidebar() + let finalSession = sessionIDs.count > 2 ? sessionIDs[2] : sessionIDs[0] + try openSession(finalSession, step: "rapid_switch_final_ready") + try reopenSidebar() + try openSession(sessionIDs[0], step: "cached_session_return_ready") + try reopenSidebar() + try openSession(finalSession, step: "cached_final_session_ready") + try selectIdleSession(from: sessionIDs) + } + + private func openSession(_ sessionID: String, step: String) throws { + let session = app.buttons[sessionID] + guard scrollToHittable(session) else { + recordDiagnostics(named: "SessionUnavailable") + XCTFail("The requested session was not reachable in the expanded workspace.") + return + } + let rawID = sessionID.components(separatedBy: ".").last ?? sessionID + let started = Date() + session.tap() + + let conversation = app.descendants(matching: .any)["conversation.session.\(rawID)"] + XCTAssertTrue( + waitUntil(timeout: 10) { conversation.exists }, + "The selected conversation identity did not update." + ) + waitForConversationLoadingToFinish(timeout: 45) + let composer = app.textFields["composer.input"] + XCTAssertTrue( + waitUntil(timeout: 10) { composer.exists && composer.isHittable }, + "The selected conversation composer did not become available." + ) + XCTAssertTrue( + waitUntil(timeout: 45) { + let voice = self.app.buttons["语音输入"] + let stop = self.app.buttons["停止"] + return (voice.exists && voice.isEnabled) || (stop.exists && stop.isEnabled) + }, + "The selected conversation remained busy after its content appeared." + ) + recordStep(step, since: started) + } + + private func selectSessionWithoutWaiting(_ sessionID: String, step: String) throws { + let session = app.buttons[sessionID] + guard scrollToHittable(session) else { + XCTFail("The rapid-switch source session was unreachable.") + return + } + let rawID = sessionID.components(separatedBy: ".").last ?? sessionID + let started = Date() + session.tap() + let conversation = app.descendants(matching: .any)["conversation.session.\(rawID)"] + XCTAssertTrue( + waitUntil(timeout: 10) { conversation.exists }, + "The rapid-switch source selection was not projected." + ) + recordStep(step, since: started) + } + + private func selectIdleSession(from sessionIDs: [String]) throws { + for (index, sessionID) in sessionIDs.enumerated() { + try reopenSidebar() + try openSession(sessionID, step: "send_candidate_\(index + 1)_ready") + let voice = app.buttons["语音输入"] + if voice.exists && voice.isEnabled { return } + let send = app.buttons["发送"] + XCTAssertFalse( + send.exists && send.isEnabled, + "A session with an active turn unexpectedly allowed another send." + ) + } + recordDiagnostics(named: "NoIdleSessionForSend") + XCTFail("All inspected sessions still had active turns, so the verification message was not dispatched.") + throw WorkflowError.requiredElementMissing + } + + private func reopenSidebar() throws { + let action = try requireFirst([ + app.buttons["打开侧栏"], + app.buttons["Open sidebar"], + ], timeout: 10, failure: "The sidebar could not be reopened while switching sessions.") + action.tap() + let sessions = app.buttons.matching( + NSPredicate(format: "identifier BEGINSWITH %@", "sidebar.session.") + ) + _ = try requireUsableElement(in: sessions, timeout: 10, failure: "Expanded sessions were not restored in the sidebar.") + } + + private func sendVerificationMessage() throws { + let composer = app.textFields["composer.input"] + XCTAssertTrue(waitUntil(timeout: 10) { composer.exists }, "The composer was unavailable before send.") + XCTAssertTrue(composer.isHittable, "The composer was obstructed before send.") + + let message = "iOS 模拟器端到端性能验证 \(Int(Date().timeIntervalSince1970))" + composer.tap() + composer.typeText(message) + XCTAssertEqual(composer.value as? String, message) + + let send = try requireFirst([ + app.buttons["发送"], + app.buttons["Send"], + ], timeout: 5, failure: "The Send button did not become available after typing.") + XCTAssertTrue(send.isEnabled, "The selected session did not allow message sending.") + let started = Date() + send.tap() + + XCTAssertTrue( + waitUntil(timeout: 10) { (composer.value as? String) != message }, + "The draft did not clear after dispatch." + ) + recordStep("send_draft_cleared", since: started) + + let timelineMessage = app.staticTexts[message] + XCTAssertTrue( + waitUntil(timeout: 30) { timelineMessage.exists }, + "The submitted message did not appear in the active timeline." + ) + recordStep("send_message_visible", since: started) + } + + private func waitForDirectoryLoadingToFinish(timeout: TimeInterval) { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let loading = app.staticTexts["正在加载"] + let loadingWorkspaces = app.staticTexts["正在加载工作区"] + if !loading.exists && !loadingWorkspaces.exists { return } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + } + + private func waitForConversationLoadingToFinish(timeout: TimeInterval) { + RunLoop.current.run(until: Date().addingTimeInterval(0.18)) + let loading = app.descendants(matching: .any).matching( + NSPredicate(format: "label == %@", "正在加载") + ).firstMatch + guard loading.exists else { return } + XCTAssertTrue(waitUntil(timeout: timeout) { !loading.exists }, "The conversation loading state did not finish.") + } + + private func requireFirst( + _ elements: [XCUIElement], + timeout: TimeInterval, + failure: String + ) throws -> XCUIElement { + let deadline = Date().addingTimeInterval(timeout) + repeat { + if let element = elements.first(where: \.exists) { return element } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } while Date() < deadline + if let element = elements.first(where: \.exists) { + return element + } + recordDiagnostics(named: "RequiredElementMissing") + XCTFail(failure) + throw WorkflowError.requiredElementMissing + } + + private func requireUsableElement( + in query: XCUIElementQuery, + timeout: TimeInterval, + failure: String + ) throws -> XCUIElement { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + for index in 0.. Bool { + guard waitUntil(timeout: 5, condition: { element.exists }) else { return false } + for _ in 0..<8 { + if element.isHittable { return true } + app.swipeUp() + } + for _ in 0..<8 { + if element.isHittable { return true } + app.swipeDown() + } + return element.isHittable + } + + private func waitUntil(timeout: TimeInterval, condition: () -> Bool) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + repeat { + if condition() { return true } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } while Date() < deadline + return condition() + } + + private func uniqueIdentifiers(from query: XCUIElementQuery) -> [String] { + var seen = Set() + var result: [String] = [] + for index in 0.. 0) { + knownMessageCount = if (result.hasAuthoritativeMessageCount) { result.totalMessageCount } else { cursor.knownMessageCount @@ -173,6 +176,8 @@ public class ChatSessionController internal constructor( activeTurn = activeTurn, modelCatalog = result.modelCatalog, shouldSyncAfterTurnEnded = turnEndedNow || (settling && !runningNow), + messageSnapshot = result.messageSnapshot, + historyRewritten = historyRewritten, ), ) } diff --git a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/ChatTimelineStore.kt b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/ChatTimelineStore.kt index f5cafa9679..a1bda60f17 100644 --- a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/ChatTimelineStore.kt +++ b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/ChatTimelineStore.kt @@ -215,7 +215,11 @@ public class ChatTimelineStore public constructor() { public fun applySnapshot(snapshot: ChatSessionSnapshot) { setCursor(snapshot.cursor) - if (snapshot.newMessages.isNotEmpty()) mergePersistedMessages(snapshot.newMessages) + if (snapshot.messageSnapshot != null) { + setPersistedMessages(snapshot.messageSnapshot) + } else if (snapshot.newMessages.isNotEmpty()) { + mergePersistedMessages(snapshot.newMessages) + } setActiveTurn(snapshot.activeTurn) snapshot.modelCatalog?.let { catalog -> setModelCatalog(catalog, selectedModelIdForCatalog(catalog, state.selectedModelId)) diff --git a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/ConversationModels.kt b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/ConversationModels.kt index 092894186d..2a4dc84e2a 100644 --- a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/ConversationModels.kt +++ b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/ConversationModels.kt @@ -65,6 +65,10 @@ public data class ChatSessionSnapshot public constructor( public val activeTurn: ChatMessage?, public val modelCatalog: RemoteModelCatalog?, public val shouldSyncAfterTurnEnded: Boolean, + /** Authoritative persisted history, distinct from an additive poll tail. */ + public val messageSnapshot: List?, + /** The desktop reports fewer persisted messages than the client cursor. */ + public val historyRewritten: Boolean, ) public data class PollSessionResult public constructor( @@ -76,6 +80,10 @@ public data class PollSessionResult public constructor( public val totalMessageCount: Int, public val activeTurn: ChatMessage?, public val modelCatalog: RemoteModelCatalog?, + /** Authoritative persisted history, distinct from [newMessages]. */ + public val messageSnapshot: List?, + /** Whether [totalMessageCount] came from the peer instead of a local fallback. */ + public val hasAuthoritativeMessageCount: Boolean, ) public enum class ChatTimelineItemType { diff --git a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/TranscriptIntegrityPolicy.kt b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/TranscriptIntegrityPolicy.kt new file mode 100644 index 0000000000..992ddfad51 --- /dev/null +++ b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/TranscriptIntegrityPolicy.kt @@ -0,0 +1,24 @@ +package com.openbitfun.mobile.core.domain + +import com.openbitfun.mobile.core.protocol.ChatMessageItemResponse + +/** Guards cursor resume against cached assistant rows whose display body was lost. */ +public object TranscriptIntegrityPolicy { + public fun hasDisplayableBody(message: ChatMessage): Boolean = + message.text.isNotBlank() || + !message.thinking.isNullOrBlank() || + !message.tools.isNullOrEmpty() || + hasRenderableItems(message.items) || + !message.images.isNullOrEmpty() + + public fun isHollowAssistant(message: ChatMessage): Boolean = + message.role.equals("assistant", ignoreCase = true) && !hasDisplayableBody(message) + + public fun hasHollowAssistants(messages: List): Boolean = + messages.any(::isHollowAssistant) + + private fun hasRenderableItems(items: List?): Boolean = + items.orEmpty().any { item -> + !item.content.isNullOrBlank() || item.tool != null || hasRenderableItems(item.subItems) + } +} diff --git a/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/openbitfun/mobile/core/domain/ChatSessionControllerTest.kt b/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/openbitfun/mobile/core/domain/ChatSessionControllerTest.kt index c97f22e094..78c428d298 100644 --- a/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/openbitfun/mobile/core/domain/ChatSessionControllerTest.kt +++ b/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/openbitfun/mobile/core/domain/ChatSessionControllerTest.kt @@ -160,6 +160,53 @@ class ChatSessionControllerTest { assertTrue(callbacks.snapshots[1].shouldSyncAfterTurnEnded) } + @Test + fun exposesAuthoritativeHistorySnapshotAndRewriteFence() = runTest { + val replacement = listOf(message("rewritten", "assistant", "Rewritten")) + val poller = QueuePoller( + result( + version = 8, + changed = true, + count = 1, + messageSnapshot = replacement, + ), + ) + val callbacks = RecordingCallbacks() + val controller = ChatSessionController.create(this, poller, callbacks) + + controller.start("session-1", ChatSessionCursor(7, 3, 0)) + runCurrent() + controller.stop(false) + + val snapshot = callbacks.snapshots.single() + assertEquals(replacement, snapshot.messageSnapshot) + assertEquals(1, snapshot.cursor.knownMessageCount) + assertTrue(snapshot.historyRewritten) + } + + @Test + fun authoritativeZeroCountCanReplaceANonEmptyCursor() = runTest { + val poller = QueuePoller( + result( + version = 8, + changed = true, + count = 0, + messageSnapshot = emptyList(), + ), + ) + val callbacks = RecordingCallbacks() + val controller = ChatSessionController.create(this, poller, callbacks) + + controller.start("session-1", ChatSessionCursor(7, 2, 0)) + runCurrent() + controller.stop(false) + + val snapshot = callbacks.snapshots.single() + assertEquals(0, snapshot.cursor.knownMessageCount) + assertEquals(emptyList(), snapshot.messageSnapshot) + assertTrue(snapshot.historyRewritten) + } + private class QueuePoller(vararg initial: PollSessionResult) : ChatSessionPoller { val results = ArrayDeque(initial.toList()) @@ -193,6 +240,7 @@ class ChatSessionControllerTest { messages: List = emptyList(), count: Int = 1, activeTurn: ChatMessage? = null, + messageSnapshot: List? = null, ): PollSessionResult = PollSessionResult( version = version, changed = changed, @@ -202,6 +250,8 @@ class ChatSessionControllerTest { totalMessageCount = count, activeTurn = activeTurn, modelCatalog = null, + messageSnapshot = messageSnapshot, + hasAuthoritativeMessageCount = true, ) private fun message( diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountStore.kt index 8674a13f25..ce473c0a28 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountStore.kt @@ -101,6 +101,7 @@ public class AccountStore internal constructor( backend.transport(current, target), kotlinx.coroutines.Dispatchers.Default, target, + persistence?.remoteWorkspaces, ) } @@ -130,6 +131,7 @@ public class AccountStore internal constructor( backend.transport(current, target), kotlinx.coroutines.Dispatchers.Default, target, + persistence?.remoteWorkspaces, ) } diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryStore.kt index 2e35f727e1..a48ffc31f0 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryStore.kt @@ -1,21 +1,16 @@ package com.openbitfun.mobile.core.feature.directory import com.openbitfun.mobile.core.domain.RemoteSession +import com.openbitfun.mobile.core.domain.RecentWorkspace import com.openbitfun.mobile.core.feature.account.AccountStore -import com.openbitfun.mobile.core.feature.session.RemoteSessionFailureReason -import com.openbitfun.mobile.core.feature.session.RemoteSessionIntent import com.openbitfun.mobile.core.feature.session.RemoteSessionStore -import com.openbitfun.mobile.core.feature.session.RemoteSessionUiState -import com.openbitfun.mobile.core.feature.workspace.RemoteWorkspaceIntent import com.openbitfun.mobile.core.feature.workspace.RemoteWorkspaceStore -import com.openbitfun.mobile.core.feature.workspace.RemoteWorkspaceUiState import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch /** @@ -38,6 +33,8 @@ public class DeviceDirectoryStore internal constructor( private val loads = mutableMapOf() private val generations = mutableMapOf() private val epochs = mutableMapOf() + private val workspaceLoads = mutableMapOf() + private val workspaceGenerations = mutableMapOf() public fun dispatch(intent: DeviceDirectoryIntent) { when (intent) { @@ -46,6 +43,12 @@ public class DeviceDirectoryStore internal constructor( is DeviceDirectoryIntent.Expand -> expand(intent.deviceId) is DeviceDirectoryIntent.Collapse -> collapse(intent.deviceId) is DeviceDirectoryIntent.Retry -> retry(intent.deviceId) + is DeviceDirectoryIntent.SetWorkspaceExpanded -> setWorkspaceExpanded( + intent.deviceId, + intent.path, + intent.expanded, + ) + is DeviceDirectoryIntent.RetryWorkspace -> retryWorkspace(intent.deviceId, intent.path) DeviceDirectoryIntent.Stop -> stop() } } @@ -86,12 +89,26 @@ public class DeviceDirectoryStore internal constructor( public fun stop() { for (job in loads.values) job.cancel() loads.clear() + for (job in workspaceLoads.values) job.cancel() + workspaceLoads.clear() for (id in devices.keys) { invalidate(id) invalidateEpoch(id) + invalidateDeviceWorkspaces(id) if (devices[id]?.status == DeviceDirectoryStatus.LOADING) { devices[id] = devices.getValue(id).copy(status = DeviceDirectoryStatus.IDLE, error = null) } + devices[id]?.let { entry -> + devices[id] = entry.copy( + workspaceDirectory = entry.workspaceDirectory.map { workspace -> + if (workspace.status == WorkspaceDirectoryStatus.LOADING) { + workspace.copy(status = WorkspaceDirectoryStatus.IDLE) + } else { + workspace + } + }, + ) + } } publish() for (slot in slots.values) stopSlot(slot) @@ -100,6 +117,7 @@ public class DeviceDirectoryStore internal constructor( private fun sync(incoming: List) { val ids = linkedSetOf() + val added = linkedSetOf() val updated = linkedMapOf() for (device in incoming) { val id = device.deviceId.trim() @@ -108,10 +126,12 @@ public class DeviceDirectoryStore internal constructor( val existing = devices[id] if (existing == null) { invalidateEpoch(id) + added += id } else if (existing.online && !device.online) { invalidate(id) invalidateEpoch(id) loads.remove(id)?.cancel() + cancelDeviceWorkspaceLoads(id) slots.remove(id)?.let(::stopSlot) } updated[id] = if (existing == null) { @@ -121,6 +141,8 @@ public class DeviceDirectoryStore internal constructor( deviceName = device.deviceName, online = device.online, expanded = if (device.online) existing.expanded else false, + workspaceDirectory = if (device.online) existing.workspaceDirectory else + existing.workspaceDirectory.map { it.copy(expanded = false) }, status = if (device.online) { existing.status } else if (existing.workspaces.isNotEmpty() || existing.sessions.isNotEmpty()) { @@ -135,13 +157,15 @@ public class DeviceDirectoryStore internal constructor( val removed = devices.keys - ids devices.clear() devices.putAll(updated) - publish() for (id in removed) { invalidate(id) invalidateEpoch(id) loads.remove(id)?.cancel() + cancelDeviceWorkspaceLoads(id) slots.remove(id)?.let(::stopSlot) } + for (id in added) hydrateFromCache(id) + publish() } private fun load(deviceId: String) { @@ -163,7 +187,9 @@ public class DeviceDirectoryStore internal constructor( val id = deviceId.trim() if (id.isEmpty()) return val entry = devices[id] ?: return - devices[id] = entry.copy(expanded = true) + for ((otherId, other) in devices) { + devices[otherId] = other.copy(expanded = otherId == id) + } publish() if (entry.status == DeviceDirectoryStatus.READY) return if (entry.online) load(id) @@ -194,6 +220,67 @@ public class DeviceDirectoryStore internal constructor( startLoad(id, slot, expanded) } + private fun setWorkspaceExpanded(deviceId: String, path: String, expanded: Boolean) { + val id = deviceId.trim() + val normalizedPath = normalizeWorkspacePath(path) + val entry = devices[id] ?: return + if (normalizedPath.isEmpty()) return + updateWorkspaceState(id, normalizedPath) { it.copy(expanded = expanded) } + if (!expanded || !entry.online) return + val state = devices[id]?.workspace(normalizedPath) + if (state?.status != WorkspaceDirectoryStatus.READY) loadWorkspaceSessions(id, normalizedPath, false) + } + + private fun retryWorkspace(deviceId: String, path: String) { + val id = deviceId.trim() + val normalizedPath = normalizeWorkspacePath(path) + val entry = devices[id] ?: return + if (normalizedPath.isEmpty() || !entry.online) return + updateWorkspaceState(id, normalizedPath) { it.copy(expanded = true) } + loadWorkspaceSessions(id, normalizedPath, true) + } + + private fun loadWorkspaceSessions(deviceId: String, path: String, force: Boolean) { + val key = workspaceKey(deviceId, path) + if (workspaceLoads[key]?.isActive == true) return + val entry = devices[deviceId] ?: return + if (!entry.online) return + val existing = entry.workspace(path) + if (!force && existing?.status == WorkspaceDirectoryStatus.READY) return + val slot = slotFor(deviceId) ?: run { + updateWorkspaceState(deviceId, path) { it.copy(status = WorkspaceDirectoryStatus.FAILED) } + return + } + val generation = nextWorkspaceGeneration(key) + updateWorkspaceState(deviceId, path) { it.copy(status = WorkspaceDirectoryStatus.LOADING) } + val job = scope.launch { + try { + val loaded = slot.sessionStore.sessionsForWorkspace(path) + if (!isCurrentWorkspace(key, generation) || devices[deviceId]?.online != true) return@launch + val current = devices[deviceId] ?: return@launch + val merged = replaceWorkspaceSessions(current.sessions, path, loaded) + slot.sessionStore.persistDirectorySessions(merged) + devices[deviceId] = current.copy( + sessions = merged, + workspaceDirectory = updateWorkspaceList(current.workspaceDirectory, path) { + it.copy(status = WorkspaceDirectoryStatus.READY) + }, + ) + publish() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + if (isCurrentWorkspace(key, generation)) { + updateWorkspaceState(deviceId, path) { it.copy(status = WorkspaceDirectoryStatus.FAILED) } + } + } finally { + if (workspaceLoads[key] === coroutineContext[Job]) workspaceLoads.remove(key) + } + } + workspaceLoads[key] = job + if (!job.isActive && workspaceLoads[key] === job) workspaceLoads.remove(key) + } + private fun slotFor(id: String): DeviceSlot? { slots[id]?.let { return it } val sessionStore = factory.createSessionStore(scope, id) ?: return null @@ -212,6 +299,40 @@ public class DeviceDirectoryStore internal constructor( return slot } + private fun hydrateFromCache(id: String) { + val current = devices[id] ?: return + val slot = try { + slotFor(id) + } catch (_: Throwable) { + null + } ?: return + val cachedWorkspaces = slot.workspaceStore.cachedCatalog() + val cachedSessions = slot.sessionStore.cachedSessions() + if (cachedWorkspaces.isEmpty() && cachedSessions.isEmpty()) return + val sessions = if (cachedSessions.isEmpty()) current.sessions else cachedSessions + val workspaces = when { + cachedWorkspaces.isNotEmpty() -> cachedWorkspaces + current.workspaces.isNotEmpty() -> current.workspaces + else -> workspacesFromSessions(sessions) + } + val workspaceDirectory = syncWorkspaceDirectory(current.workspaceDirectory, workspaces).map { workspace -> + if (sessions.any { session -> + normalizeWorkspacePath(session.workspacePath.orEmpty()) == normalizeWorkspacePath(workspace.path) + } + ) { + workspace.copy(status = WorkspaceDirectoryStatus.READY) + } else { + workspace + } + } + devices[id] = current.copy( + status = if (current.status == DeviceDirectoryStatus.IDLE) DeviceDirectoryStatus.CACHED else current.status, + workspaces = workspaces, + sessions = sessions, + workspaceDirectory = workspaceDirectory, + ) + } + private fun startLoad(id: String, slot: DeviceSlot, previous: DeviceDirectoryEntry) { val generation = nextGeneration(id) setEntry(id) { it.copy(status = DeviceDirectoryStatus.LOADING, error = null) } @@ -238,68 +359,135 @@ public class DeviceDirectoryStore internal constructor( private suspend fun runLoad(id: String, slot: DeviceSlot, generation: Long) { if (!isCurrent(id, generation)) return - slot.sessionStore.dispatch(RemoteSessionIntent.Load) - slot.workspaceStore.dispatch(RemoteWorkspaceIntent.Load) - val sessionState = slot.sessionStore.state.first(::sessionSettled) - if (!isCurrent(id, generation)) return - val workspaceState = slot.workspaceStore.state.first(::workspaceSettled) - if (!isCurrent(id, generation)) return - projectResult(id, generation, sessionState, workspaceState) - } - - private fun sessionSettled(state: RemoteSessionUiState): Boolean = when (state) { - is RemoteSessionUiState.Failed -> true - is RemoteSessionUiState.Ready -> !state.busy - else -> false - } - - private fun workspaceSettled(state: RemoteWorkspaceUiState): Boolean = when (state) { - is RemoteWorkspaceUiState.Ready -> true - is RemoteWorkspaceUiState.Failed -> true - else -> false - } - - private fun projectResult( - id: String, - generation: Long, - sessionState: RemoteSessionUiState, - workspaceState: RemoteWorkspaceUiState, - ) { - if (!isCurrent(id, generation)) return - val current = devices[id] ?: return - // A device that went offline while its load was in flight must not come - // back as READY with stale data; it stays in whatever offline state - // `sync` left it in and the in-flight result is dropped. - if (!current.online) return - val workspaces = (workspaceState as? RemoteWorkspaceUiState.Ready)?.workspaces.orEmpty() - val sessions = (sessionState as? RemoteSessionUiState.Ready)?.sessions.orEmpty() - val sessionFailed = sessionState as? RemoteSessionUiState.Failed - val workspaceFailed = workspaceState as? RemoteWorkspaceUiState.Failed - devices[id] = if (sessionFailed == null && workspaceFailed == null) { - current.copy( + try { + val workspaces = slot.workspaceStore.directoryCatalog() + if (!isCurrent(id, generation)) return + val current = devices[id] ?: return + // A device that went offline while its request was in flight stays + // in the offline state established by sync; the late result is stale. + if (!current.online) return + devices[id] = current.copy( status = DeviceDirectoryStatus.READY, error = null, workspaces = workspaces, - sessions = sessions, + workspaceDirectory = syncWorkspaceDirectory(current.workspaceDirectory, workspaces), ) - } else { - current.copy( + publish() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + if (!isCurrent(id, generation)) return + val current = devices[id] ?: return + if (!current.online) return + devices[id] = current.copy( status = DeviceDirectoryStatus.FAILED, - error = sessionFailed?.let { mapSessionFailure(it.reason) } ?: DeviceDirectoryFailure.LOAD_FAILED, - workspaces = workspaces, - sessions = sessions, + error = DeviceDirectoryFailure.LOAD_FAILED, + ) + publish() + } + } + + private fun replaceWorkspaceSessions( + sessions: List, + path: String, + replacement: List, + ): List { + val normalizedPath = normalizeWorkspacePath(path) + val replacementIds = replacement.mapTo(mutableSetOf()) { it.id } + val kept = sessions.filter { session -> + session.id !in replacementIds && normalizeWorkspacePath(session.workspacePath.orEmpty()) != normalizedPath + } + return replacement + kept + } + + /** Keeps the pre-workspace-cache upgrade path useful by rebuilding its minimum catalog. */ + private fun workspacesFromSessions(sessions: List): List { + val inferred = linkedMapOf() + for (session in sessions) { + val path = session.workspacePath.orEmpty().trim() + val normalized = normalizeWorkspacePath(path) + if (normalized.isEmpty() || normalized in inferred) continue + inferred[normalized] = RecentWorkspace( + path = path, + name = session.workspaceName?.takeIf(String::isNotBlank) + ?: normalized.substringAfterLast('/').ifEmpty { normalized }, + lastOpened = session.updatedAt, + kind = "", ) } + return inferred.values.toList() + } + + private fun syncWorkspaceDirectory( + current: List, + workspaces: List, + ): List = workspaces.map { workspace -> + current.firstOrNull { normalizeWorkspacePath(it.path) == normalizeWorkspacePath(workspace.path) } + ?.copy(path = workspace.path) + ?: WorkspaceDirectoryEntry(workspace.path, false, WorkspaceDirectoryStatus.IDLE) + } + + private fun updateWorkspaceList( + current: List, + path: String, + transform: (WorkspaceDirectoryEntry) -> WorkspaceDirectoryEntry, + ): List { + var found = false + val updated = current.map { entry -> + if (normalizeWorkspacePath(entry.path) == normalizeWorkspacePath(path)) { + found = true + transform(entry) + } else { + entry + } + }.toMutableList() + if (!found) updated += transform(WorkspaceDirectoryEntry(path, false, WorkspaceDirectoryStatus.IDLE)) + return updated + } + + private fun updateWorkspaceState( + deviceId: String, + path: String, + transform: (WorkspaceDirectoryEntry) -> WorkspaceDirectoryEntry, + ) { + val current = devices[deviceId] ?: return + devices[deviceId] = current.copy( + workspaceDirectory = updateWorkspaceList(current.workspaceDirectory, path, transform), + ) publish() } - private fun mapSessionFailure(reason: RemoteSessionFailureReason): DeviceDirectoryFailure = when (reason) { - RemoteSessionFailureReason.NETWORK -> DeviceDirectoryFailure.NETWORK - RemoteSessionFailureReason.TIMEOUT -> DeviceDirectoryFailure.TIMEOUT - RemoteSessionFailureReason.RATE_LIMITED -> DeviceDirectoryFailure.RATE_LIMITED - RemoteSessionFailureReason.NO_WORKSPACE -> DeviceDirectoryFailure.NO_WORKSPACE - RemoteSessionFailureReason.REMOTE_REJECTED -> DeviceDirectoryFailure.REJECTED - else -> DeviceDirectoryFailure.LOAD_FAILED + private fun workspaceKey(deviceId: String, path: String): String = + "$deviceId\u0001${normalizeWorkspacePath(path)}" + + private fun normalizeWorkspacePath(path: String): String { + val trimmed = path.trim() + val normalized = trimmed.trimEnd('/') + return normalized.ifEmpty { trimmed } + } + + private fun nextWorkspaceGeneration(key: String): Long { + val next = (workspaceGenerations[key] ?: 0L) + 1L + workspaceGenerations[key] = next + return next + } + + private fun isCurrentWorkspace(key: String, generation: Long): Boolean = + workspaceGenerations[key] == generation + + private fun invalidateDeviceWorkspaces(deviceId: String) { + val prefix = "$deviceId\u0001" + workspaceGenerations.keys.filter { it.startsWith(prefix) }.forEach { key -> + workspaceGenerations[key] = (workspaceGenerations[key] ?: 0L) + 1L + } + } + + private fun cancelDeviceWorkspaceLoads(deviceId: String) { + val prefix = "$deviceId\u0001" + workspaceLoads.keys.filter { it.startsWith(prefix) }.forEach { key -> + workspaceLoads.remove(key)?.cancel() + workspaceGenerations[key] = (workspaceGenerations[key] ?: 0L) + 1L + } } private fun nextGeneration(id: String): Long { diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryUiState.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryUiState.kt index f78a28f202..16902ebbb6 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryUiState.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryUiState.kt @@ -9,8 +9,9 @@ import com.openbitfun.mobile.core.domain.RemoteSession * [CACHED] means an offline device still has non-empty workspace or session * data retained from an earlier successful load. An offline device with no * retained data remains [IDLE]; `online = false` carries the offline fact. - * [CACHED] is therefore not a generic offline marker or a promise of disk - * hydration. Online entries transition IDLE -> LOADING -> READY/FAILED; + * [CACHED] is therefore not a generic offline marker. It may be restored from + * the device-scoped disk cache or retained from this process. Online entries + * transition IDLE -> LOADING -> READY/FAILED; * online -> offline changes READY/LOADING to CACHED only when data exists, and * offline -> online permits a new load/retry. */ @@ -22,6 +23,20 @@ public enum class DeviceDirectoryStatus { FAILED, } +public enum class WorkspaceDirectoryStatus { + IDLE, + LOADING, + READY, + FAILED, +} + +/** Disclosure and request state for one workspace inside one device row. */ +public data class WorkspaceDirectoryEntry public constructor( + public val path: String, + public val expanded: Boolean, + public val status: WorkspaceDirectoryStatus, +) + /** Why a device's directory content cannot be shown. */ public enum class DeviceDirectoryFailure { NOT_SIGNED_IN, @@ -53,8 +68,31 @@ public data class DeviceDirectoryEntry public constructor( public val error: DeviceDirectoryFailure?, public val workspaces: List, public val sessions: List, + public val workspaceDirectory: List, ) { + public constructor( + deviceId: String, + deviceName: String, + online: Boolean, + expanded: Boolean, + status: DeviceDirectoryStatus, + error: DeviceDirectoryFailure?, + workspaces: List, + sessions: List, + ) : this(deviceId, deviceName, online, expanded, status, error, workspaces, sessions, emptyList()) + + public fun workspace(path: String): WorkspaceDirectoryEntry? { + val normalized = normalizeWorkspacePath(path) + return workspaceDirectory.firstOrNull { normalizeWorkspacePath(it.path) == normalized } + } + public companion object { + private fun normalizeWorkspacePath(path: String): String { + val trimmed = path.trim() + val normalized = trimmed.trimEnd('/') + return normalized.ifEmpty { trimmed } + } + public fun empty(deviceId: String, deviceName: String, online: Boolean): DeviceDirectoryEntry = DeviceDirectoryEntry( deviceId = deviceId, @@ -65,6 +103,7 @@ public data class DeviceDirectoryEntry public constructor( error = null, workspaces = emptyList(), sessions = emptyList(), + workspaceDirectory = emptyList(), ) } } @@ -112,5 +151,16 @@ public sealed interface DeviceDirectoryIntent { public val deviceId: String, ) : DeviceDirectoryIntent + public data class SetWorkspaceExpanded public constructor( + public val deviceId: String, + public val path: String, + public val expanded: Boolean, + ) : DeviceDirectoryIntent + + public data class RetryWorkspace public constructor( + public val deviceId: String, + public val path: String, + ) : DeviceDirectoryIntent + public data object Stop : DeviceDirectoryIntent } diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/pairing/PairingStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/pairing/PairingStore.kt index c4be82c452..f5865d3e40 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/pairing/PairingStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/pairing/PairingStore.kt @@ -88,7 +88,15 @@ public class PairingStore internal constructor( /** Builds workspace and file-preview features over the same paired transport. */ public fun createWorkspaceStore(scope: CoroutineScope): RemoteWorkspaceStore? = - room?.let { RemoteWorkspaceStore.create(scope, it.transport) } + room?.let { + RemoteWorkspaceStore.create( + scope, + it.transport, + kotlinx.coroutines.Dispatchers.Default, + it.descriptor.roomId, + persistence?.remoteWorkspaces, + ) + } public fun dispatch(intent: PairingIntent) { when (intent) { diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/ConversationPresentation.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/ConversationPresentation.kt index 4354c1766e..43b5b64ed1 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/ConversationPresentation.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/ConversationPresentation.kt @@ -86,6 +86,17 @@ public data class ToolCard public constructor( public val actions: Set, public val expandable: Boolean, ) { + /** Whether a finished tool can join the compact consecutive-activity summary. */ + public val foldIntoSummary: Boolean + get() { + if (actions.isNotEmpty() || kind == ToolKind.QUESTION) return false + if (phase != ToolPhase.COMPLETED && phase != ToolPhase.CANCELLED) return false + val normalizedName = name.filterNot { it == '_' || it == '-' || it.isWhitespace() }.lowercase() + val planWrite = normalizedName in setOf("write", "writefile", "createfile") && + filePath.lowercase().endsWith(".plan.md") + return normalizedName != "createplan" && !planWrite + } + public constructor( id: String, name: String, diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt index d9eb8ef2c9..fbee193549 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt @@ -15,6 +15,7 @@ import com.openbitfun.mobile.core.persistence.PersistedRemoteSession import com.openbitfun.mobile.core.domain.RemoteSession import com.openbitfun.mobile.core.domain.SessionNaming import com.openbitfun.mobile.core.domain.SessionAgentTypes +import com.openbitfun.mobile.core.domain.TranscriptIntegrityPolicy import com.openbitfun.mobile.core.feature.connection.ConnectionPhase import com.openbitfun.mobile.core.protocol.ActiveTurnSnapshotResponse import com.openbitfun.mobile.core.protocol.ChatMessageItemResponse @@ -51,6 +52,8 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonPrimitive @@ -76,6 +79,8 @@ public class RemoteSessionStore internal constructor( private val _createOperation = MutableStateFlow(CreateSessionOperationState.Idle) /** Outcome is changed only by create operations, never by open/list selection. */ public val createOperation: StateFlow = _createOperation.asStateFlow() + private val _workspaceDirectory = MutableStateFlow(WorkspaceSessionDirectoryUiState(emptyList())) + public val workspaceDirectory: StateFlow = _workspaceDirectory.asStateFlow() private var nextCreateRequestId: Long = 0 private var nextCreateGeneration: Long = 0 private var activeCreateGeneration: Long? = null @@ -87,6 +92,8 @@ public class RemoteSessionStore internal constructor( private var modelCatalog: RemoteModelCatalog? = null private var modelCatalogFailure: ModelCatalogFailure? = null private val locallyCreatedSessions: MutableMap = mutableMapOf() + private val workspaceDirectoryJobs: MutableMap = mutableMapOf() + private val workspaceDirectoryGenerations: MutableMap = mutableMapOf() /** * The re-read of the transcript that follows a turn ending. @@ -96,6 +103,8 @@ public class RemoteSessionStore internal constructor( * repeatedly — one in flight is enough. */ private var turnEndSync: Job? = null + private var historyRewriteSync: Job? = null + private val initialTranscriptMutex = Mutex() /** * The workspace the desktop currently has open, learned from `get_workspace_info`. @@ -113,6 +122,8 @@ public class RemoteSessionStore internal constructor( load(current?.query.orEmpty(), current?.agentFilter ?: SessionAgentFilter.ALL) RemoteSessionIntent.LoadMore -> loadMore() RemoteSessionIntent.LoadOlderMessages -> loadOlderMessages() + is RemoteSessionIntent.LoadWorkspaceSessions -> loadWorkspaceSessions(intent.path, false) + is RemoteSessionIntent.RetryWorkspaceSessions -> loadWorkspaceSessions(intent.path, true) is RemoteSessionIntent.Search -> load(intent.query, current?.agentFilter ?: SessionAgentFilter.ALL) is RemoteSessionIntent.SetAgentFilter -> load(current?.query.orEmpty(), intent.filter) @@ -238,7 +249,7 @@ public class RemoteSessionStore internal constructor( cancelCreateIfActive(normalizedRequestId, generation, CreateSessionOperationFailure.CANCELLED) return@launch } - if (beforeSelection !is RemoteWorkspaceUiState.Ready) { + if (beforeSelection !is RemoteWorkspaceUiState.Ready || beforeSelection.loadFailure) { failCreate(normalizedRequestId, generation, CreateSessionOperationFailure.WORKSPACE, true, false) return@launch } @@ -254,16 +265,19 @@ public class RemoteSessionStore internal constructor( val selected = withTimeout(30_000) { workspaceStore.state.first { state -> workspaceStore.stopVersion.value != stopVersion || when (state) { - is RemoteWorkspaceUiState.Ready -> !state.busy && - state.selected?.path == normalizedPath && - state.assistants.any { it.path == normalizedPath } + is RemoteWorkspaceUiState.Ready -> state.loadFailure || + (!state.busy && + state.selected?.path == normalizedPath && + state.assistants.any { it.path == normalizedPath }) is RemoteWorkspaceUiState.Failed -> true else -> false } } } if (!isCurrentWork(operationToken) || activeCreateGeneration != generation) return@launch - if (workspaceStore.stopVersion.value != stopVersion || selected !is RemoteWorkspaceUiState.Ready) { + if (workspaceStore.stopVersion.value != stopVersion || + selected !is RemoteWorkspaceUiState.Ready || selected.loadFailure + ) { failCreate(normalizedRequestId, generation, CreateSessionOperationFailure.WORKSPACE, true, false) return@launch } @@ -299,6 +313,128 @@ public class RemoteSessionStore internal constructor( return projectConfirmedCreatedSession(session) } + /** Last device-scoped list stored on disk, used by the multi-device directory without a request. */ + internal fun cachedSessions(): List { + if (!persistenceEnabled) return emptyList() + val rows = persistedSessionSlice()?.sessions.orEmpty() + restorePendingConfirmed(rows) + return rows.map(::toRemoteSession) + } + + /** Loads one disclosed workspace without changing the desktop's active workspace. */ + internal suspend fun sessionsForWorkspace(path: String): List { + val normalizedPath = path.trim() + if (normalizedPath.isEmpty()) return emptyList() + val response = transport.send( + RemoteCommand( + cmd = "list_sessions", + workspacePath = normalizedPath, + limit = DIRECTORY_WORKSPACE_PAGE_SIZE, + offset = 0, + ), + ) + val server = response.sessions + .map(RemoteResponseMapper::session) + .filter { SessionAgentTypes.isMobileVisible(it.agentType) } + .map { session -> + if (session.workspacePath.isNullOrBlank()) session.copy(workspacePath = normalizedPath) else session + } + val serverIds = server.mapTo(mutableSetOf()) { it.id } + serverIds.forEach(locallyCreatedSessions::remove) + val pending = locallyCreatedSessions.values.filter { session -> + session.id !in serverIds && session.workspacePath.orEmpty().trim() == normalizedPath + } + return pending + server + } + + /** Persists the directory's merged cross-workspace snapshot for offline restore. */ + internal fun persistDirectorySessions(sessions: List) { + savePersistedSessions(sessions, false) + } + + private fun loadWorkspaceSessions(path: String, force: Boolean) { + val normalizedPath = normalizeWorkspacePath(path) + if (normalizedPath.isEmpty()) return + if (workspaceDirectoryJobs[normalizedPath]?.isActive == true) return + val existing = _workspaceDirectory.value.workspace(normalizedPath) + if (!force && existing?.status == WorkspaceSessionDirectoryStatus.READY) return + val generation = (workspaceDirectoryGenerations[normalizedPath] ?: 0L) + 1L + workspaceDirectoryGenerations[normalizedPath] = generation + updateWorkspaceDirectory(normalizedPath) { + it.copy(status = WorkspaceSessionDirectoryStatus.LOADING) + } + val job = scope.launch { + try { + val loaded = sessionsForWorkspace(normalizedPath) + if (workspaceDirectoryGenerations[normalizedPath] != generation) return@launch + if (persistenceEnabled) { + val cached = cachedSessions() + persistDirectorySessions(replaceWorkspaceSessions(cached, normalizedPath, loaded)) + } + updateWorkspaceDirectory(normalizedPath) { + it.copy(status = WorkspaceSessionDirectoryStatus.READY, sessions = loaded) + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + if (workspaceDirectoryGenerations[normalizedPath] == generation) { + updateWorkspaceDirectory(normalizedPath) { + it.copy(status = WorkspaceSessionDirectoryStatus.FAILED) + } + } + } finally { + if (workspaceDirectoryJobs[normalizedPath] === coroutineContext[Job]) { + workspaceDirectoryJobs.remove(normalizedPath) + } + } + } + workspaceDirectoryJobs[normalizedPath] = job + if (!job.isActive && workspaceDirectoryJobs[normalizedPath] === job) { + workspaceDirectoryJobs.remove(normalizedPath) + } + } + + private fun updateWorkspaceDirectory( + path: String, + transform: (WorkspaceSessionDirectoryEntry) -> WorkspaceSessionDirectoryEntry, + ) { + var found = false + val entries = _workspaceDirectory.value.workspaces.map { entry -> + if (normalizeWorkspacePath(entry.path) == normalizeWorkspacePath(path)) { + found = true + transform(entry) + } else { + entry + } + }.toMutableList() + if (!found) { + entries += transform( + WorkspaceSessionDirectoryEntry(path, WorkspaceSessionDirectoryStatus.IDLE, emptyList()), + ) + } + _workspaceDirectory.value = WorkspaceSessionDirectoryUiState(entries) + } + + private fun replaceWorkspaceSessions( + sessions: List, + path: String, + replacement: List, + ): List { + val normalizedPath = normalizeWorkspacePath(path) + val replacementIds = replacement.mapTo(mutableSetOf()) { it.id } + val retained = sessions.filter { session -> + session.id !in replacementIds && + normalizeWorkspacePath(session.workspacePath.orEmpty()) != normalizedPath + } + return replacement + retained + } + + private fun normalizeWorkspacePath(path: String): String { + val trimmed = path.trim() + val normalized = trimmed.trimEnd('/') + return normalized.ifEmpty { trimmed } + } + private fun projectConfirmedCreatedSession(session: RemoteSession): Boolean { val sessionId = session.id.trim() if (sessionId.isEmpty()) return false @@ -308,13 +444,12 @@ public class RemoteSessionStore internal constructor( if (current != null) { publishAuthorityReady(current.copy(sessions = mergeConfirmed(current.sessions, confirmed))) } - if (persistenceEnabled) { - val persistedRows = persistence!!.remoteSessions.load(deviceKey!!) + persistedSessionSlice()?.let { persisted -> + val persistedRows = persisted.sessions restorePendingConfirmed(persistedRows) - persistence.remoteSessions.save( - deviceKey, - mergeConfirmed(persistedRows.map(::toRemoteSession), confirmed).map(::toPersistedSession), - persistence.remoteSessions.hasMore(deviceKey), + savePersistedSessions( + mergeConfirmed(persistedRows.map(::toRemoteSession), confirmed), + persisted.hasMore, ) } return true @@ -368,8 +503,24 @@ public class RemoteSessionStore internal constructor( } beginWork() work = null + workspaceDirectoryJobs.values.forEach(Job::cancel) + workspaceDirectoryJobs.clear() + workspaceDirectoryGenerations.keys.forEach { path -> + workspaceDirectoryGenerations[path] = (workspaceDirectoryGenerations[path] ?: 0L) + 1L + } + _workspaceDirectory.value = WorkspaceSessionDirectoryUiState( + _workspaceDirectory.value.workspaces.map { entry -> + if (entry.status == WorkspaceSessionDirectoryStatus.LOADING) { + entry.copy(status = WorkspaceSessionDirectoryStatus.IDLE) + } else { + entry + } + }, + ) turnEndSync?.cancel() turnEndSync = null + historyRewriteSync?.cancel() + historyRewriteSync = null controller.stop() _connectionPhase.value = ConnectionPhase.DISCONNECTED } @@ -378,14 +529,15 @@ public class RemoteSessionStore internal constructor( if (_state.value is RemoteSessionUiState.Loading) return val current = _state.value as? RemoteSessionUiState.Ready if (current == null && persistenceEnabled) { - val cached = persistence!!.remoteSessions.load(deviceKey!!) + val cachedSlice = persistedSessionSlice() + val cached = cachedSlice?.sessions.orEmpty() restorePendingConfirmed(cached) if (cached.isNotEmpty()) { publishAuthorityReady(RemoteSessionUiState.Ready( sessions = cached.map(::toRemoteSession), selectedSessionId = null, timeline = null, busy = true, permissionMode = null, permissionModeFailure = null, query = query, agentFilter = filter, - hasMore = persistence.remoteSessions.hasMore(deviceKey), hasMoreMessages = false, + hasMore = cachedSlice?.hasMore ?: false, hasMoreMessages = false, modelCatalog = null, )) } @@ -417,7 +569,7 @@ public class RemoteSessionStore internal constructor( commitSessionPage(page) commitModelCatalog(catalog) if (persistenceEnabled && query.isEmpty() && filter == SessionAgentFilter.ALL) { - persistence!!.remoteSessions.save(deviceKey!!, page.sessions.map(::toPersistedSession), page.hasMore) + savePersistedSessions(page.sessions, page.hasMore) } if (generation != workGeneration) return@launch publishAuthorityReady(RemoteSessionUiState.Ready( @@ -460,7 +612,7 @@ public class RemoteSessionStore internal constructor( val sessions = current.sessions + page.sessions.filterNot { it.id in known } commitSessionPage(page) if (persistenceEnabled && current.query.isEmpty() && current.agentFilter == SessionAgentFilter.ALL) { - persistence!!.remoteSessions.save(deviceKey!!, sessions.map(::toPersistedSession), page.hasMore) + savePersistedSessions(sessions, page.hasMore) } publishAuthorityReady(ready.copy( sessions = sessions, @@ -592,6 +744,10 @@ public class RemoteSessionStore internal constructor( ) private fun open(sessionId: String) { + turnEndSync?.cancel() + turnEndSync = null + historyRewriteSync?.cancel() + historyRewriteSync = null val normalized = sessionId.trim() if (normalized.isEmpty()) { _state.value = RemoteSessionUiState.Failed(RemoteSessionFailureReason.SESSION_NOT_FOUND) @@ -599,18 +755,35 @@ public class RemoteSessionStore internal constructor( return } val current = _state.value as? RemoteSessionUiState.Ready - val restoredDraft = if (persistenceEnabled) persistence!!.drafts.load(draftId(normalized)).orEmpty() else "" + val restoredDraft = loadPersistedDraft(normalized) + var resumableCursor: ChatSessionCursor? = null if (persistenceEnabled) { - val cached = persistence!!.remoteTranscripts.load(deviceKey!!, normalized) + val cached = try { + persistence!!.remoteTranscripts.load(deviceKey!!, normalized) + } catch (_: Throwable) { + emptyList() + } if (cached.isNotEmpty()) { timelineStore.reset(normalized) - timelineStore.setPersistedMessages(cached.map(::toChatMessage)) - persistence.remoteTranscripts.loadCursor(deviceKey, normalized)?.let { cursor -> - timelineStore.setCursor(ChatSessionCursor( - cursor.pollVersion.toIntOrNull() ?: 0, - cursor.knownMessageCount, - cursor.knownModelCatalogVersion.toLongOrNull() ?: 0L, - )) + val restoredMessages = cached.map(::toChatMessage) + timelineStore.setPersistedMessages(restoredMessages) + val cursor = try { + persistence!!.remoteTranscripts.loadCursor(deviceKey!!, normalized) + } catch (_: Throwable) { + null + } + cursor?.let { + val restoredCursor = ChatSessionCursor( + it.pollVersion.toIntOrNull() ?: 0, + it.knownMessageCount, + it.knownModelCatalogVersion.toLongOrNull() ?: 0L, + ) + timelineStore.setCursor(restoredCursor) + if (it.knownMessageCount == restoredMessages.size && + !TranscriptIntegrityPolicy.hasHollowAssistants(restoredMessages) + ) { + resumableCursor = restoredCursor + } } _state.value = RemoteSessionUiState.Ready( sessions = current?.sessions.orEmpty(), selectedSessionId = normalized, @@ -631,7 +804,7 @@ public class RemoteSessionStore internal constructor( ?: RemoteSessionUiState.Loading work = scope.launch { try { - val opened = openSession(normalized, operationToken) ?: return@launch + val opened = openSession(normalized, operationToken, resumableCursor) ?: return@launch if (!isCurrentWork(operationToken)) return@launch _state.value = RemoteSessionUiState.Ready( sessions = current?.sessions.orEmpty(), @@ -661,10 +834,27 @@ public class RemoteSessionStore internal constructor( } /** Loads a session's history, starts polling it, and reports its permission mode. */ - private suspend fun openSession(sessionId: String, operationToken: Long): OpenedSession? { - val response = transport.send( - RemoteCommand(cmd = "get_session_messages", sessionId = sessionId, limit = 100), - ) + private suspend fun openSession( + sessionId: String, + operationToken: Long, + resumableCursor: ChatSessionCursor? = null, + ): OpenedSession? { + if (resumableCursor != null && timelineStore.snapshot().sessionId == sessionId) { + // A peer can restart while the phone retains its cursor version. Resume + // from the cached message count, but always reset the volatile version. + val restartSafeCursor = resumableCursor.copy(pollVersion = 0) + timelineStore.setCursor(restartSafeCursor) + controller.start(sessionId, restartSafeCursor) + val permission = readPermissionMode() + if (!isCurrentWork(operationToken)) return null + return OpenedSession(permission, false) + } + val response = initialTranscriptMutex.withLock { + if (!isCurrentWork(operationToken)) return@withLock null + transport.send( + RemoteCommand(cmd = "get_session_messages", sessionId = sessionId, limit = 100), + ) + } ?: return null if (!isCurrentWork(operationToken)) return null val cursor = timelineStore.snapshot().cursor.takeIf { timelineStore.snapshot().sessionId == sessionId } timelineStore.reset(sessionId) @@ -940,11 +1130,27 @@ public class RemoteSessionStore internal constructor( transport.send( RemoteCommand(cmd = "delete_session", sessionId = normalized), ) - if (!isCurrentWork(operationToken)) return@launch locallyCreatedSessions.remove(normalized) + if (persistenceEnabled) { + persistedSessionSlice()?.let { persisted -> + val persistedSessions = persisted.sessions + .filterNot { it.sessionId == normalized } + savePersistedSessionRows(persistedSessions, persisted.hasMore) + } + try { + persistence!!.drafts.delete(draftId(normalized)) + } catch (_: Throwable) { + // Each optional cache is cleaned independently so one failure does not block another. + } + try { + persistence!!.remoteTranscripts.delete(deviceKey!!, normalized) + } catch (_: Throwable) { + // Each optional cache is cleaned independently so one failure does not block another. + } + } + if (!isCurrentWork(operationToken)) return@launch val closingOpenSession = current.selectedSessionId == normalized if (closingOpenSession) { - if (persistenceEnabled) persistence!!.drafts.delete(draftId(normalized)) controller.stop() timelineStore.reset("") } @@ -981,6 +1187,14 @@ public class RemoteSessionStore internal constructor( transport.send( RemoteCommand(cmd = "update_session_title", sessionId = sessionId, title = title), ) + if (persistenceEnabled) { + persistedSessionSlice()?.let { persisted -> + val persistedSessions = persisted.sessions.map { session -> + if (session.sessionId == sessionId) session.copy(title = title) else session + } + savePersistedSessionRows(persistedSessions, persisted.hasMore) + } + } if (!isCurrentWork(operationToken)) return@launch val ready = (_state.value as? RemoteSessionUiState.Ready) ?: current publishAuthorityReady(ready.copy( @@ -1012,6 +1226,10 @@ public class RemoteSessionStore internal constructor( private fun updateTimeline(snapshot: ChatSessionSnapshot) { if (snapshot.sessionId != timelineStore.snapshot().sessionId) return + if (snapshot.historyRewritten && snapshot.messageSnapshot == null) { + reloadRewrittenTranscript(snapshot.sessionId) + return + } timelineStore.applySnapshot(snapshot) snapshot.modelCatalog?.let { catalog -> if (catalog.version > 0L || catalog.models.isNotEmpty()) modelCatalog = catalog @@ -1024,12 +1242,49 @@ public class RemoteSessionStore internal constructor( ) markConnected() } - if (snapshot.shouldSyncAfterTurnEnded) { + if (snapshot.messageSnapshot != null) { + persistTranscript(snapshot.sessionId, preserveOlder = false) + } else if (snapshot.changed) { persistTranscript(snapshot.sessionId) + } + if (snapshot.shouldSyncAfterTurnEnded && snapshot.messageSnapshot == null) { syncAfterTurnEnded(snapshot.sessionId) } } + /** Repairs a legacy poll response that reports a shorter history without a snapshot. */ + private fun reloadRewrittenTranscript(sessionId: String) { + if (sessionId.isEmpty() || historyRewriteSync?.isActive == true) return + historyRewriteSync = scope.launch { + try { + val response = transport.send( + RemoteCommand(cmd = "get_session_messages", sessionId = sessionId, limit = 100), + ) + if (timelineStore.snapshot().sessionId != sessionId) return@launch + timelineStore.setPersistedMessages(response.messages.map(RemoteResponseMapper::chatMessage)) + val cursor = ChatSessionCursor( + pollVersion = 0, + knownMessageCount = response.messages.size, + knownModelCatalogVersion = timelineStore.snapshot().cursor.knownModelCatalogVersion, + ) + timelineStore.setCursor(cursor) + controller.updateCursor(cursor) + persistTranscript(sessionId, preserveOlder = false) + val current = _state.value + if (current is RemoteSessionUiState.Ready) { + _state.value = current.copy( + timeline = timelineStore.snapshot(), + hasMoreMessages = response.hasMore, + ) + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + // Keep the cached transcript visible and let a later poll retry the fence. + } + } + } + /** * Re-reads the transcript once the agent has stopped talking, as * `syncAfterRemoteTurnEnded` does. @@ -1132,7 +1387,7 @@ public class RemoteSessionStore internal constructor( if (!isCurrentWork(operationToken)) return@launch response.turnId?.let(timelineStore::setLocalActiveTurn) controller.nudge() - if (persistenceEnabled) persistence!!.drafts.delete(draftId(sessionId)) + deletePersistedDraft(sessionId) val ready = ((_state.value as? RemoteSessionUiState.Ready) ?: current) if (ready.selectedSessionId == sessionId) _state.value = ready.copy(draft = "") setBusy((_state.value as? RemoteSessionUiState.Ready) ?: current, false) @@ -1151,13 +1406,63 @@ public class RemoteSessionStore internal constructor( private fun updateDraft(text: String) { val current = _state.value as? RemoteSessionUiState.Ready ?: return val id = current.selectedSessionId ?: return - if (persistenceEnabled) { - if (text.isEmpty()) persistence!!.drafts.delete(draftId(id)) - else persistence!!.drafts.save(draftId(id), text) - } + savePersistedDraft(id, text) _state.value = current.copy(draft = text) } + private data class PersistedSessionSlice( + val sessions: List, + val hasMore: Boolean, + ) + + private fun persistedSessionSlice(): PersistedSessionSlice? { + if (!persistenceEnabled) return null + return try { + PersistedSessionSlice( + persistence!!.remoteSessions.load(deviceKey!!), + persistence.remoteSessions.hasMore(deviceKey), + ) + } catch (_: Throwable) { + null + } + } + + private fun savePersistedSessions(sessions: List, hasMore: Boolean) { + savePersistedSessionRows(sessions.map(::toPersistedSession), hasMore) + } + + private fun savePersistedSessionRows(sessions: List, hasMore: Boolean) { + if (!persistenceEnabled) return + try { + persistence!!.remoteSessions.save(deviceKey!!, sessions, hasMore) + } catch (_: Throwable) { + // Remote state stays authoritative when its optional cache is unavailable. + } + } + + private fun loadPersistedDraft(sessionId: String): String { + if (!persistenceEnabled) return "" + return try { + persistence!!.drafts.load(draftId(sessionId)).orEmpty() + } catch (_: Throwable) { + "" + } + } + + private fun savePersistedDraft(sessionId: String, text: String) { + if (!persistenceEnabled) return + try { + if (text.isEmpty()) persistence!!.drafts.delete(draftId(sessionId)) + else persistence!!.drafts.save(draftId(sessionId), text) + } catch (_: Throwable) { + // Draft persistence is best effort; the in-memory composer remains usable. + } + } + + private fun deletePersistedDraft(sessionId: String) { + savePersistedDraft(sessionId, "") + } + private fun draftId(sessionId: String): String = "remote-composer:$deviceKey:$sessionId" private fun cancelTurn(intent: RemoteSessionIntent.CancelTurn) { @@ -1339,22 +1644,31 @@ public class RemoteSessionStore internal constructor( RemotePermissionMode.Unknown -> SessionPermissionMode.UNKNOWN } - private fun persistTranscript(sessionId: String) { + private fun persistTranscript(sessionId: String, preserveOlder: Boolean = true) { if (!persistenceEnabled || sessionId.isEmpty()) return val snapshot = timelineStore.snapshot() if (snapshot.sessionId != sessionId) return - val p = persistence!! - val window = snapshot.persistedMessages.map { toPersisted(sessionId, it) } - val windowIds = window.mapTo(mutableSetOf()) { it.messageId } - // A paginated re-read (limit 100) must not truncate pages the user already - // loaded: keep older cached rows the current window does not cover. - val older = p.remoteTranscripts.load(deviceKey!!, sessionId).filterNot { it.messageId in windowIds } - p.remoteTranscripts.replace(deviceKey, sessionId, older + window) - p.remoteTranscripts.saveCursor(deviceKey, sessionId, PersistedRemoteCursor( - pollVersion = snapshot.cursor.pollVersion.toString(), - knownMessageCount = snapshot.cursor.knownMessageCount, - knownModelCatalogVersion = snapshot.cursor.knownModelCatalogVersion.toString(), - )) + try { + val p = persistence!! + val persistedDeviceKey = deviceKey!! + val window = snapshot.persistedMessages.map { toPersisted(sessionId, it) } + val windowIds = window.mapTo(mutableSetOf()) { it.messageId } + // A paginated re-read (limit 100) must not truncate pages the user already + // loaded: keep older cached rows the current window does not cover. + val older = if (preserveOlder) { + p.remoteTranscripts.load(persistedDeviceKey, sessionId).filterNot { it.messageId in windowIds } + } else { + emptyList() + } + p.remoteTranscripts.replace(persistedDeviceKey, sessionId, older + window) + p.remoteTranscripts.saveCursor(persistedDeviceKey, sessionId, PersistedRemoteCursor( + pollVersion = snapshot.cursor.pollVersion.toString(), + knownMessageCount = snapshot.cursor.knownMessageCount, + knownModelCatalogVersion = snapshot.cursor.knownModelCatalogVersion.toString(), + )) + } catch (_: Throwable) { + // Transcript persistence is optional; live session state remains authoritative. + } } private fun restorePendingConfirmed(rows: List) { @@ -1445,7 +1759,7 @@ public class RemoteSessionStore internal constructor( ) val version = if (response.version > 0) response.version else sinceVersion return PollSessionResult( - version = response.version, + version = version, changed = response.changed, sessionState = response.sessionState.orEmpty(), title = response.title.orEmpty(), @@ -1453,6 +1767,8 @@ public class RemoteSessionStore internal constructor( totalMessageCount = response.totalMessageCount ?: knownMessageCount, activeTurn = response.activeTurn?.let { RemoteResponseMapper.activeTurn(it, version) }, modelCatalog = response.modelCatalog, + messageSnapshot = response.messageSnapshot?.map(RemoteResponseMapper::chatMessage), + hasAuthoritativeMessageCount = response.totalMessageCount != null, ) } } @@ -1466,6 +1782,7 @@ public class RemoteSessionStore internal constructor( * to 100, so asking for more only costs round trips. */ private const val FILTER_PAGE_SIZE: Int = 100 + private const val DIRECTORY_WORKSPACE_PAGE_SIZE: Int = 50 internal fun create(scope: CoroutineScope, room: PairedRoom): RemoteSessionStore = RemoteSessionStore(scope, room.transport) diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionUiState.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionUiState.kt index 8984d19d53..e47b333c57 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionUiState.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionUiState.kt @@ -149,6 +149,35 @@ public enum class RemoteSessionFailureReason { RATE_LIMITED, } +public enum class WorkspaceSessionDirectoryStatus { + IDLE, + LOADING, + READY, + FAILED, +} + +/** One independently loaded workspace branch in a live remote session store. */ +public data class WorkspaceSessionDirectoryEntry public constructor( + public val path: String, + public val status: WorkspaceSessionDirectoryStatus, + public val sessions: List, +) + +public data class WorkspaceSessionDirectoryUiState public constructor( + public val workspaces: List, +) { + public fun workspace(path: String): WorkspaceSessionDirectoryEntry? { + val normalized = normalizeWorkspaceSessionPath(path) + return workspaces.firstOrNull { normalizeWorkspaceSessionPath(it.path) == normalized } + } +} + +private fun normalizeWorkspaceSessionPath(path: String): String { + val trimmed = path.trim() + val normalized = trimmed.trimEnd('/') + return normalized.ifEmpty { trimmed } +} + public data class ComposerImage public constructor( public val id: String, public val dataUrl: String, @@ -265,6 +294,15 @@ public sealed interface RemoteSessionIntent { /** Fetch the transcript page immediately before the oldest visible message. */ public data object LoadOlderMessages : RemoteSessionIntent + /** Load one sidebar workspace branch without changing the desktop's active workspace. */ + public data class LoadWorkspaceSessions public constructor( + public val path: String, + ) : RemoteSessionIntent + + public data class RetryWorkspaceSessions public constructor( + public val path: String, + ) : RemoteSessionIntent + public data class Search public constructor( public val query: String, ) : RemoteSessionIntent diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/ToolRowPresentation.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/ToolRowPresentation.kt index 71d0508c1c..fe8aa59714 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/ToolRowPresentation.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/ToolRowPresentation.kt @@ -71,12 +71,10 @@ public sealed interface ToolRow { } /** - * Consecutive finished lookups, folded into one line. + * Consecutive finished background activities, folded into one line. * - * An agent reading six files before answering produces six rows that say - * nothing individually; the source collapses them and so does this. Only - * finished ones — anything still running, failed, or waiting on the user is - * why the user is looking at the column at all. + * Anything still running, failed, waiting on the user, or representing a + * buildable plan remains visible on its own row. */ public data class Collapsed public constructor( public val tools: List, @@ -113,7 +111,7 @@ public fun collapseToolRows(tools: List): List { } tools.forEach { tool -> - if (tool.isCollapsibleLookup()) { + if (tool.foldIntoSummary) { pending += tool } else { flush() @@ -124,12 +122,6 @@ public fun collapseToolRows(tools: List): List { return rows } -private fun ToolCard.isCollapsibleLookup(): Boolean { - if (actions.isNotEmpty()) return false - if (phase != ToolPhase.COMPLETED && phase != ToolPhase.CANCELLED) return false - return kind == ToolKind.DOCUMENT || kind == ToolKind.FOLDER || kind == ToolKind.SEARCH -} - internal fun toolKind(tool: RemoteToolStatusResponse): ToolKind = when { ToolNamePolicy.isQuestionLike(tool) -> ToolKind.QUESTION ToolNamePolicy.isTodo(tool) -> ToolKind.TODO diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceStore.kt index fc0aa6f2d4..46f95c2c1e 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceStore.kt @@ -11,6 +11,8 @@ import com.openbitfun.mobile.core.domain.FileTargetResolver import com.openbitfun.mobile.core.domain.RecentWorkspace import com.openbitfun.mobile.core.domain.SelectedWorkspace import com.openbitfun.mobile.core.domain.WorkspaceAssistant +import com.openbitfun.mobile.core.persistence.PersistedRemoteWorkspace +import com.openbitfun.mobile.core.persistence.RemoteWorkspaceListStore import com.openbitfun.mobile.core.protocol.AssistantListResponse import com.openbitfun.mobile.core.protocol.FileInfoResponse import com.openbitfun.mobile.core.protocol.ReadFileChunkResponse @@ -28,6 +30,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import kotlinx.coroutines.supervisorScope @@ -42,7 +45,9 @@ public class RemoteWorkspaceStore internal constructor( private val transport: RemoteCommandTransport, private val backgroundDispatcher: CoroutineDispatcher, public val deviceKey: String? = null, + private val persistence: RemoteWorkspaceListStore? = null, ) { + private val persistenceEnabled: Boolean get() = persistence != null && !deviceKey.isNullOrBlank() private val _state = MutableStateFlow(RemoteWorkspaceUiState.Idle) public val state: StateFlow = _state.asStateFlow() private val _stopVersion = MutableStateFlow(0L) @@ -91,11 +96,66 @@ public class RemoteWorkspaceStore internal constructor( work = null } + /** Last device-scoped catalog stored on disk, merged the same way the directory renders it. */ + internal fun cachedCatalog(): List { + if (!persistenceEnabled) return emptyList() + val rows = try { + persistence!!.load(deviceKey!!) + } catch (_: Throwable) { + emptyList() + } + val cached = cachedReady(rows) + return mergedCatalog(cached.workspaces, cached.assistants) + } + + /** Device-directory catalog request; it must not depend on the desktop's active workspace. */ + internal suspend fun directoryCatalog(): List = coroutineScope { + val recentDeferred = async { + transport.send(RemoteCommand(cmd = "list_recent_workspaces")) + } + val assistantsDeferred = async { + transport.send(RemoteCommand(cmd = "list_assistants")) + } + val recent = recentDeferred.await() + val assistants = assistantsDeferred.await() + val loadedWorkspaces = recent.workspaces.map { item -> + RecentWorkspace( + path = item.path.orEmpty(), + name = item.name?.takeIf(String::isNotBlank) ?: basename(item.path.orEmpty()), + lastOpened = item.lastOpened, + kind = item.workspaceKind.orEmpty(), + ) + }.filter { it.path.isNotEmpty() } + val loadedAssistants = assistants.assistants.map { item -> + WorkspaceAssistant(item.path, item.name, item.assistantId) + } + if (persistenceEnabled) { + try { + persistence!!.save(deviceKey!!, persistedCatalog(loadedWorkspaces, loadedAssistants)) + } catch (_: Throwable) { + // The remote catalog remains authoritative when its optional cache is unavailable. + } + } + mergedCatalog(loadedWorkspaces, loadedAssistants) + } + private fun load() { val generation = ++loadGeneration invalidatePreview() work?.cancel() - _state.value = RemoteWorkspaceUiState.Loading + if (_state.value !is RemoteWorkspaceUiState.Ready && persistenceEnabled) { + val cached = try { + persistence!!.load(deviceKey!!) + } catch (_: Throwable) { + emptyList() + } + if (cached.isNotEmpty()) { + _state.value = cachedReady(cached) + } + } + _state.value = (_state.value as? RemoteWorkspaceUiState.Ready) + ?.copy(busy = true, loadFailure = false) + ?: RemoteWorkspaceUiState.Loading work = scope.launch { try { supervisorScope { @@ -114,22 +174,35 @@ public class RemoteWorkspaceStore internal constructor( val assistants = results[1] as AssistantListResponse val info = results[2] as WorkspaceInfoResponse if (generation == loadGeneration) { - _state.value = RemoteWorkspaceUiState.Ready( - workspaces = recent.workspaces.map { item -> - RecentWorkspace( - path = item.path.orEmpty(), - name = item.name?.takeIf(String::isNotBlank) ?: basename(item.path.orEmpty()), - lastOpened = item.lastOpened, - kind = item.workspaceKind.orEmpty(), + val loadedWorkspaces = recent.workspaces.map { item -> + RecentWorkspace( + path = item.path.orEmpty(), + name = item.name?.takeIf(String::isNotBlank) ?: basename(item.path.orEmpty()), + lastOpened = item.lastOpened, + kind = item.workspaceKind.orEmpty(), + ) + }.filter { it.path.isNotEmpty() } + val loadedAssistants = assistants.assistants.map { item -> + WorkspaceAssistant(item.path, item.name, item.assistantId) + } + if (persistenceEnabled) { + try { + persistence!!.save( + deviceKey!!, + persistedCatalog(loadedWorkspaces, loadedAssistants), ) - }.filter { it.path.isNotEmpty() }, - assistants = assistants.assistants.map { item -> - WorkspaceAssistant(item.path, item.name, item.assistantId) - }, + } catch (_: Throwable) { + // Cache writes must not turn a successful remote load into a failure. + } + } + _state.value = RemoteWorkspaceUiState.Ready( + workspaces = loadedWorkspaces, + assistants = loadedAssistants, selected = info.asSelectedWorkspace(), preview = RemoteFilePreviewUiState.None, busy = false, download = RemoteFileDownloadUiState.None, + loadFailure = false, ) } } catch (cancelled: CancellationException) { @@ -141,13 +214,13 @@ public class RemoteWorkspaceStore internal constructor( recentDeferred.join() assistantsDeferred.join() infoDeferred.join() - if (generation == loadGeneration) _state.value = RemoteWorkspaceUiState.Failed(true) + if (generation == loadGeneration) failRetainingCache() } } } catch (cancelled: CancellationException) { throw cancelled } catch (_: Throwable) { - if (generation == loadGeneration) _state.value = RemoteWorkspaceUiState.Failed(true) + if (generation == loadGeneration) failRetainingCache() } } } @@ -179,11 +252,11 @@ public class RemoteWorkspaceStore internal constructor( } val info = transport.send(RemoteCommand(cmd = "get_workspace_info")) if (generation != loadGeneration) return@launch - updateReady { it.copy(selected = info.asSelectedWorkspace(), busy = false) } + updateReady { it.copy(selected = info.asSelectedWorkspace(), busy = false, loadFailure = false) } } catch (cancelled: CancellationException) { throw cancelled } catch (_: Throwable) { - if (generation == loadGeneration) _state.value = RemoteWorkspaceUiState.Failed(true) + if (generation == loadGeneration) failRetainingCache() } } } @@ -519,6 +592,58 @@ public class RemoteWorkspaceStore internal constructor( private fun basename(path: String): String = path.replace('\\', '/').substringAfterLast('/').ifEmpty { "file" } + private fun failRetainingCache() { + _state.value = (_state.value as? RemoteWorkspaceUiState.Ready) + ?.copy(busy = false, loadFailure = true) + ?: RemoteWorkspaceUiState.Failed(true) + } + + private fun cachedReady(rows: List): RemoteWorkspaceUiState.Ready { + val assistants = rows.filter { it.workspaceKind == ASSISTANT_KIND }.map { row -> + WorkspaceAssistant(row.path, row.name.ifEmpty { basename(row.path) }, null) + } + val workspaces = rows.filterNot { it.workspaceKind == ASSISTANT_KIND }.map { row -> + RecentWorkspace(row.path, row.name.ifEmpty { basename(row.path) }, row.lastOpened, row.workspaceKind) + } + return RemoteWorkspaceUiState.Ready( + workspaces = workspaces, + assistants = assistants, + selected = null, + preview = RemoteFilePreviewUiState.None, + busy = true, + download = RemoteFileDownloadUiState.None, + loadFailure = false, + ) + } + + private fun persistedCatalog( + workspaces: List, + assistants: List, + ): List { + val rows = workspaces.map { workspace -> + PersistedRemoteWorkspace(workspace.path, workspace.name, workspace.lastOpened, workspace.kind) + }.toMutableList() + assistants.forEach { assistant -> + if (rows.none { it.path == assistant.path }) { + rows += PersistedRemoteWorkspace(assistant.path, assistant.name, "", ASSISTANT_KIND) + } + } + return rows + } + + internal fun mergedCatalog( + workspaces: List, + assistants: List, + ): List { + val merged = workspaces.toMutableList() + assistants.forEach { assistant -> + if (merged.none { it.path == assistant.path }) { + merged += RecentWorkspace(assistant.path, assistant.name, "", ASSISTANT_KIND) + } + } + return merged + } + public companion object { internal fun create(scope: CoroutineScope, transport: RemoteCommandTransport): RemoteWorkspaceStore = RemoteWorkspaceStore(scope, transport, Dispatchers.Default) @@ -534,8 +659,10 @@ public class RemoteWorkspaceStore internal constructor( transport: RemoteCommandTransport, backgroundDispatcher: CoroutineDispatcher, deviceKey: String, - ): RemoteWorkspaceStore = RemoteWorkspaceStore(scope, transport, backgroundDispatcher, deviceKey) + persistence: RemoteWorkspaceListStore? = null, + ): RemoteWorkspaceStore = RemoteWorkspaceStore(scope, transport, backgroundDispatcher, deviceKey, persistence) private const val DOWNLOAD_CHUNK_BYTES = 3 * 1024 * 1024 + private const val ASSISTANT_KIND = "assistant" } } diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceUiState.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceUiState.kt index a1043facce..cc4a41a50a 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceUiState.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceUiState.kt @@ -154,7 +154,18 @@ public sealed interface RemoteWorkspaceUiState { public val preview: RemoteFilePreviewUiState, public val busy: Boolean, public val download: RemoteFileDownloadUiState, - ) : RemoteWorkspaceUiState + /** True when cached content survived the latest catalog or selection request failing. */ + public val loadFailure: Boolean, + ) : RemoteWorkspaceUiState { + public constructor( + workspaces: List, + assistants: List, + selected: SelectedWorkspace?, + preview: RemoteFilePreviewUiState, + busy: Boolean, + download: RemoteFileDownloadUiState, + ) : this(workspaces, assistants, selected, preview, busy, download, false) + } public data class Failed public constructor(public val retryable: Boolean) : RemoteWorkspaceUiState } diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryStoreTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryStoreTest.kt index 194c4c3ed3..98042f13e7 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryStoreTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryStoreTest.kt @@ -3,6 +3,15 @@ package com.openbitfun.mobile.core.feature.directory import com.openbitfun.mobile.core.domain.RemoteSession import com.openbitfun.mobile.core.feature.session.RemoteSessionStore import com.openbitfun.mobile.core.feature.workspace.RemoteWorkspaceStore +import com.openbitfun.mobile.core.persistence.ChatLocalStore +import com.openbitfun.mobile.core.persistence.DraftStore +import com.openbitfun.mobile.core.persistence.MobilePersistenceStores +import com.openbitfun.mobile.core.persistence.PersistedChatMessage +import com.openbitfun.mobile.core.persistence.PersistedChatSession +import com.openbitfun.mobile.core.persistence.PersistedRemoteSession +import com.openbitfun.mobile.core.persistence.PersistedRemoteWorkspace +import com.openbitfun.mobile.core.persistence.RemoteSessionListStore +import com.openbitfun.mobile.core.persistence.RemoteWorkspaceListStore import com.openbitfun.mobile.core.protocol.CommandStatus import com.openbitfun.mobile.core.protocol.RelayJson import com.openbitfun.mobile.core.protocol.RemoteCommand @@ -11,6 +20,7 @@ import com.openbitfun.mobile.core.transport.RelayTransportException import com.openbitfun.mobile.core.transport.RemoteCommandTransport import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent @@ -29,7 +39,7 @@ class DeviceDirectoryStoreTest { "a" to FakeDeviceTransport("a"), "b" to FakeDeviceTransport("b"), ) - transports.getValue("b").sessionFailure = RelayFailure.Timeout + transports.getValue("b").workspaceFailure = RelayFailure.Timeout val store = DeviceDirectoryStore.create(this, FakeDeviceStoreFactory(transports)) store.dispatch( @@ -47,23 +57,23 @@ class DeviceDirectoryStoreTest { val a = store.state.value.device("a")!! assertEquals(DeviceDirectoryStatus.READY, a.status) assertEquals(listOf("/repo-a"), a.workspaces.map { it.path }) - assertEquals(listOf("s-a"), a.sessions.map { it.id }) + assertTrue(a.sessions.isEmpty()) val failedB = store.state.value.device("b")!! assertEquals(DeviceDirectoryStatus.FAILED, failedB.status) - assertEquals(DeviceDirectoryFailure.TIMEOUT, failedB.error) + assertEquals(DeviceDirectoryFailure.LOAD_FAILED, failedB.error) // A retry recovers b without disturbing a's already-loaded content. - transports.getValue("b").sessionFailure = null + transports.getValue("b").workspaceFailure = null store.dispatch(DeviceDirectoryIntent.Retry("b")) advanceUntilIdle() val recoveredB = store.state.value.device("b")!! assertEquals(DeviceDirectoryStatus.READY, recoveredB.status) - assertEquals(listOf("s-b"), recoveredB.sessions.map { it.id }) + assertTrue(recoveredB.sessions.isEmpty()) val stillA = store.state.value.device("a")!! assertEquals(DeviceDirectoryStatus.READY, stillA.status) - assertEquals(listOf("s-a"), stillA.sessions.map { it.id }) + assertTrue(stillA.sessions.isEmpty()) } @Test @@ -79,7 +89,9 @@ class DeviceDirectoryStoreTest { val a = store.state.value.device("a")!! assertEquals(DeviceDirectoryStatus.READY, a.status) assertEquals(1, transport.commands.count { it.cmd == "list_recent_workspaces" }) - assertEquals(1, transport.commands.count { it.cmd == "list_sessions" }) + assertEquals(1, transport.commands.count { it.cmd == "list_assistants" }) + assertEquals(0, transport.commands.count { it.cmd == "get_workspace_info" }) + assertEquals(0, transport.commands.count { it.cmd == "list_sessions" }) } @Test @@ -94,9 +106,9 @@ class DeviceDirectoryStoreTest { val first = store.state.value.device("a")!! assertTrue(first.expanded) assertEquals(DeviceDirectoryStatus.READY, first.status) - assertEquals(listOf("s-a"), first.sessions.map { it.id }) + assertTrue(first.sessions.isEmpty()) val listSessionsBefore = transport.commands.count { it.cmd == "list_sessions" } - assertEquals(1, listSessionsBefore) + assertEquals(0, listSessionsBefore) store.dispatch(DeviceDirectoryIntent.Collapse("a")) assertFalse(store.state.value.device("a")!!.expanded) @@ -108,7 +120,7 @@ class DeviceDirectoryStoreTest { val again = store.state.value.device("a")!! assertTrue(again.expanded) assertEquals(DeviceDirectoryStatus.READY, again.status) - assertEquals(listOf("s-a"), again.sessions.map { it.id }) + assertTrue(again.sessions.isEmpty()) assertEquals(listSessionsBefore, transport.commands.count { it.cmd == "list_sessions" }) } @@ -119,7 +131,7 @@ class DeviceDirectoryStoreTest { "b" to FakeDeviceTransport("b"), ) val bGate = CompletableDeferred() - transports.getValue("b").sessionGate = bGate + transports.getValue("b").workspaceGate = bGate val store = DeviceDirectoryStore.create(this, FakeDeviceStoreFactory(transports)) store.dispatch( @@ -137,16 +149,16 @@ class DeviceDirectoryStoreTest { store.dispatch(DeviceDirectoryIntent.Load("b")) runCurrent() - // b reached its session request and is blocked, so it is still loading. + // b reached its workspace request and is blocked, so it is still loading. assertEquals(DeviceDirectoryStatus.LOADING, store.state.value.device("b")!!.status) - assertTrue(transports.getValue("b").commands.any { it.cmd == "list_sessions" }) + assertTrue(transports.getValue("b").commands.any { it.cmd == "list_recent_workspaces" }) store.dispatch(DeviceDirectoryIntent.Stop) advanceUntilIdle() // Loaded data survives; the in-flight load is cancelled, not turned into a failure. assertEquals(DeviceDirectoryStatus.READY, store.state.value.device("a")!!.status) - assertEquals(listOf("s-a"), store.state.value.device("a")!!.sessions.map { it.id }) + assertEquals(listOf("/repo-a"), store.state.value.device("a")!!.workspaces.map { it.path }) assertEquals(DeviceDirectoryStatus.IDLE, store.state.value.device("b")!!.status) assertFalse(bGate.isCompleted) } @@ -162,7 +174,7 @@ class DeviceDirectoryStoreTest { assertEquals(DeviceDirectoryStatus.READY, store.state.value.device("a")!!.status) val gate = CompletableDeferred() - transport.sessionGate = gate + transport.workspaceGate = gate store.dispatch(DeviceDirectoryIntent.Retry("a")) runCurrent() assertEquals(DeviceDirectoryStatus.LOADING, store.state.value.device("a")!!.status) @@ -171,7 +183,7 @@ class DeviceDirectoryStoreTest { assertEquals(DeviceDirectoryStatus.CACHED, store.state.value.device("a")!!.status) assertFalse(store.state.value.device("a")!!.online) assertEquals(2, transport.commands.count { it.cmd == "list_recent_workspaces" }) - transport.sessionGate = null + transport.workspaceGate = null store.dispatch(DeviceDirectoryIntent.Sync(listOf(DeviceDirectoryDevice("a", "Alpha", true)))) store.dispatch(DeviceDirectoryIntent.Load("a")) @@ -183,21 +195,113 @@ class DeviceDirectoryStoreTest { fun stopThenImmediateReloadIgnoresCancelledJobFinally() = runTest { val transport = FakeDeviceTransport("a") val gate = CompletableDeferred() - transport.sessionGate = gate + transport.workspaceGate = gate val factory = FakeDeviceStoreFactory(mutableMapOf("a" to transport)) val store = DeviceDirectoryStore.create(this, factory) store.dispatch(DeviceDirectoryIntent.Sync(listOf(DeviceDirectoryDevice("a", true)))) store.dispatch(DeviceDirectoryIntent.Load("a")) runCurrent() store.dispatch(DeviceDirectoryIntent.Stop) - transport.sessionGate = null + transport.workspaceGate = null store.dispatch(DeviceDirectoryIntent.Load("a")) advanceUntilIdle() assertEquals(DeviceDirectoryStatus.READY, store.state.value.device("a")!!.status) - assertEquals(2, transport.commands.count { it.cmd == "list_sessions" }) + assertEquals(2, transport.commands.count { it.cmd == "list_recent_workspaces" }) assertFalse(gate.isCompleted) } + @Test + fun failedRefreshRetainsTheLastWorkspaceAndSessionProjection() = runTest { + val transport = FakeDeviceTransport("a") + val store = DeviceDirectoryStore.create(this, FakeDeviceStoreFactory(mutableMapOf("a" to transport))) + store.dispatch(DeviceDirectoryIntent.Sync(listOf(DeviceDirectoryDevice("a", true)))) + store.dispatch(DeviceDirectoryIntent.Load("a")) + advanceUntilIdle() + store.dispatch(DeviceDirectoryIntent.SetWorkspaceExpanded("a", "/repo-a", true)) + advanceUntilIdle() + assertEquals(listOf("/repo-a"), store.state.value.device("a")!!.workspaces.map { it.path }) + assertEquals(listOf("s-a"), store.state.value.device("a")!!.sessions.map { it.id }) + + transport.workspaceFailure = RelayFailure.Timeout + store.dispatch(DeviceDirectoryIntent.Retry("a")) + advanceUntilIdle() + + val failed = store.state.value.device("a")!! + assertEquals(DeviceDirectoryStatus.FAILED, failed.status) + assertEquals(listOf("/repo-a"), failed.workspaces.map { it.path }) + assertEquals(listOf("s-a"), failed.sessions.map { it.id }) + } + + @Test + fun assistantCatalogEntriesAreProjectedAsDeviceWorkspaces() = runTest { + val transport = FakeDeviceTransport("a").apply { + assistantJson = """[{"path":"/assistant-a","name":"Assistant A","assistant_id":"assistant-a"}]""" + } + val store = DeviceDirectoryStore.create(this, FakeDeviceStoreFactory(mutableMapOf("a" to transport))) + store.dispatch(DeviceDirectoryIntent.Sync(listOf(DeviceDirectoryDevice("a", true)))) + store.dispatch(DeviceDirectoryIntent.Load("a")) + advanceUntilIdle() + + val workspaces = store.state.value.device("a")!!.workspaces + assertEquals(listOf("/repo-a", "/assistant-a"), workspaces.map { it.path }) + assertEquals("assistant", workspaces.last().kind) + } + + @Test + fun workspaceDisclosureLoadsOnlyThatWorkspaceAndDeduplicatesTaps() = runTest { + val transport = FakeDeviceTransport("a") + val gate = CompletableDeferred() + transport.sessionGate = gate + val store = DeviceDirectoryStore.create(this, FakeDeviceStoreFactory(mutableMapOf("a" to transport))) + store.dispatch(DeviceDirectoryIntent.Sync(listOf(DeviceDirectoryDevice("a", true)))) + store.dispatch(DeviceDirectoryIntent.Load("a")) + advanceUntilIdle() + assertEquals(0, transport.commands.count { it.cmd == "list_sessions" }) + + store.dispatch(DeviceDirectoryIntent.SetWorkspaceExpanded("a", "/repo-a", true)) + store.dispatch(DeviceDirectoryIntent.SetWorkspaceExpanded("a", "/repo-a", true)) + runCurrent() + + val loading = store.state.value.device("a")!!.workspace("/repo-a")!! + assertTrue(loading.expanded) + assertEquals(WorkspaceDirectoryStatus.LOADING, loading.status) + assertEquals(1, transport.commands.count { it.cmd == "list_sessions" }) + assertEquals("/repo-a", transport.commands.first { it.cmd == "list_sessions" }.workspacePath) + + gate.complete(Unit) + advanceUntilIdle() + val ready = store.state.value.device("a")!! + assertEquals(WorkspaceDirectoryStatus.READY, ready.workspace("/repo-a")!!.status) + assertEquals(listOf("s-a"), ready.sessions.map { it.id }) + } + + @Test + fun workspaceFailureKeepsSiblingSessionsAndCanRetry() = runTest { + val transport = FakeDeviceTransport("a") + val store = DeviceDirectoryStore.create(this, FakeDeviceStoreFactory(mutableMapOf("a" to transport))) + store.dispatch(DeviceDirectoryIntent.Sync(listOf(DeviceDirectoryDevice("a", true)))) + store.dispatch(DeviceDirectoryIntent.Load("a")) + advanceUntilIdle() + store.dispatch(DeviceDirectoryIntent.SetWorkspaceExpanded("a", "/repo-a", true)) + advanceUntilIdle() + assertEquals(listOf("s-a"), store.state.value.device("a")!!.sessions.map { it.id }) + + transport.sessionFailure = RelayFailure.Timeout + store.dispatch(DeviceDirectoryIntent.RetryWorkspace("a", "/other")) + advanceUntilIdle() + val failed = store.state.value.device("a")!! + assertEquals(WorkspaceDirectoryStatus.FAILED, failed.workspace("/other")!!.status) + assertEquals(listOf("s-a"), failed.sessions.map { it.id }) + + transport.sessionFailure = null + transport.sessionJson = """[{"id":"s-other","title":"Other","agent_type":"code"}]""" + store.dispatch(DeviceDirectoryIntent.RetryWorkspace("a", "/other")) + advanceUntilIdle() + val recovered = store.state.value.device("a")!! + assertEquals(WorkspaceDirectoryStatus.READY, recovered.workspace("/other")!!.status) + assertEquals(listOf("s-other", "s-a"), recovered.sessions.map { it.id }) + } + @Test fun partialSlotCreationStopsTheSessionStore() = runTest { val transport = FakeDeviceTransport("a") @@ -229,19 +333,19 @@ class DeviceDirectoryStoreTest { assertTrue(store.reconcileCreatedSession(key, confirmed)) assertTrue(store.reconcileCreatedSession(key, confirmed)) - assertEquals(listOf("created", "s-a"), store.state.value.device("a")!!.sessions.map { it.id }) + assertEquals(listOf("created"), store.state.value.device("a")!!.sessions.map { it.id }) assertEquals("/assistant-not-current", store.state.value.device("a")!!.sessions.first().workspacePath) - assertEquals(listOf("s-b"), store.state.value.device("b")!!.sessions.map { it.id }) + assertTrue(store.state.value.device("b")!!.sessions.isEmpty()) // The first server list is behind the confirmed create; the local row survives. - store.dispatch(DeviceDirectoryIntent.Retry("a")) + store.dispatch(DeviceDirectoryIntent.RetryWorkspace("a", "/assistant-not-current")) advanceUntilIdle() assertEquals(1, store.state.value.device("a")!!.sessions.count { it.id == "created" }) // Once the source returns the id, its newer fields replace the projection without duplication. transports.getValue("a").sessionJson = """[{"id":"created","title":"Server title","agent_type":"cowork","status":"idle","workspace_path":"/assistant-not-current","workspace_name":"Server assistant"}]""" - store.dispatch(DeviceDirectoryIntent.Retry("a")) + store.dispatch(DeviceDirectoryIntent.RetryWorkspace("a", "/assistant-not-current")) advanceUntilIdle() val calibrated = store.state.value.device("a")!!.sessions.single { it.id == "created" } assertEquals("Server title", calibrated.title) @@ -313,6 +417,70 @@ class DeviceDirectoryStoreTest { assertEquals(DeviceDirectoryStatus.FAILED, x.status) assertEquals(DeviceDirectoryFailure.NOT_SIGNED_IN, x.error) } + + @Test + fun offlineDeviceHydratesItsWorkspaceAndSessionCatalogFromDisk() = runTest { + val transport = FakeDeviceTransport("a") + val cachedSessions = MemoryDirectorySessions().apply { + byDevice["a"] = listOf( + PersistedRemoteSession( + sessionId = "cached-session", + title = "Cached", + agentType = "code", + workspacePath = "/cached/repo/", + ), + ) + } + val cachedWorkspaces = MemoryDirectoryWorkspaces().apply { + byDevice["a"] = listOf( + PersistedRemoteWorkspace(path = "/cached/repo", name = "Cached repo"), + ) + } + val factory = CachedDeviceStoreFactory( + transport = transport, + sessions = cachedSessions, + workspaces = cachedWorkspaces, + ) + val store = DeviceDirectoryStore.create(this, factory) + + store.dispatch(DeviceDirectoryIntent.Sync(listOf(DeviceDirectoryDevice("a", "Alpha", false)))) + + val cached = store.state.value.device("a")!! + assertEquals(DeviceDirectoryStatus.CACHED, cached.status) + assertEquals(listOf("/cached/repo"), cached.workspaces.map { it.path }) + assertEquals(listOf("cached-session"), cached.sessions.map { it.id }) + assertEquals(WorkspaceDirectoryStatus.READY, cached.workspace("/cached/repo/")?.status) + store.dispatch(DeviceDirectoryIntent.SetWorkspaceExpanded("a", "/cached/repo/", true)) + assertEquals(0, transport.commands.count { it.cmd == "list_sessions" }) + assertTrue(transport.commands.isEmpty()) + } + + @Test + fun legacySessionCacheInfersWorkspaceCatalogAfterUpgrade() = runTest { + val transport = FakeDeviceTransport("a") + val cachedSessions = MemoryDirectorySessions().apply { + byDevice["a"] = listOf( + PersistedRemoteSession( + sessionId = "legacy-session", + title = "Legacy", + workspacePath = "/legacy/repo/", + workspaceName = "Legacy repo", + ), + ) + } + val store = DeviceDirectoryStore.create( + this, + CachedDeviceStoreFactory(transport, cachedSessions, MemoryDirectoryWorkspaces()), + ) + + store.dispatch(DeviceDirectoryIntent.Sync(listOf(DeviceDirectoryDevice("a", false)))) + + val cached = store.state.value.device("a")!! + assertEquals(DeviceDirectoryStatus.CACHED, cached.status) + assertEquals(listOf("/legacy/repo/"), cached.workspaces.map { it.path }) + assertEquals("Legacy repo", cached.workspaces.single().name) + assertEquals(WorkspaceDirectoryStatus.READY, cached.workspace("/legacy/repo")?.status) + } } private class FakeDeviceStoreFactory( @@ -337,8 +505,10 @@ private class FakeDeviceTransport(private val deviceId: String) : RemoteCommandT var sessionFailure: RelayFailure? = null var workspaceFailure: RelayFailure? = null var sessionGate: CompletableDeferred? = null + var workspaceGate: CompletableDeferred? = null var sessionJson: String = """[{"id":"s-$deviceId","title":"Session $deviceId","agent_type":"code"}]""" + var assistantJson: String = "[]" override suspend fun send( deserializer: DeserializationStrategy, @@ -349,9 +519,10 @@ private class FakeDeviceTransport(private val deviceId: String) : RemoteCommandT val json = when (command.cmd) { "list_recent_workspaces" -> { workspaceFailure?.let { throw RelayTransportException(it) } + workspaceGate?.await() """{"resp":"ok","workspaces":[{"path":"/repo-$deviceId","name":"Repo $deviceId","last_opened":"2026-08-09","workspace_kind":"local"}]}""" } - "list_assistants" -> """{"resp":"ok","assistants":[]}""" + "list_assistants" -> """{"resp":"ok","assistants":$assistantJson}""" "get_workspace_info" -> """{"resp":"ok","has_workspace":true,"path":"$workspacePath","project_name":"Repo","git_branch":"main"}""" "list_sessions" -> { @@ -365,3 +536,56 @@ private class FakeDeviceTransport(private val deviceId: String) : RemoteCommandT return RelayJson.decodeFromString(deserializer, json) } } + +private class CachedDeviceStoreFactory( + private val transport: FakeDeviceTransport, + private val sessions: MemoryDirectorySessions, + private val workspaces: MemoryDirectoryWorkspaces, +) : DeviceStoreFactory { + private val persistence = MobilePersistenceStores( + drafts = NoOpDirectoryDrafts, + chats = NoOpDirectoryChats, + remoteSessions = sessions, + remoteWorkspaces = workspaces, + ) + + override fun createSessionStore(scope: CoroutineScope, deviceId: String): RemoteSessionStore = + RemoteSessionStore.create(scope, transport, deviceId, persistence) + + override fun createWorkspaceStore(scope: CoroutineScope, deviceId: String): RemoteWorkspaceStore = + RemoteWorkspaceStore.create(scope, transport, Dispatchers.Unconfined, deviceId, workspaces) +} + +private class MemoryDirectorySessions : RemoteSessionListStore { + val byDevice = mutableMapOf>() + override fun load(deviceKey: String): List = byDevice[deviceKey].orEmpty() + override fun save(deviceKey: String, sessions: List, hasMore: Boolean) { + byDevice[deviceKey] = sessions + } + override fun hasMore(deviceKey: String): Boolean = false +} + +private class MemoryDirectoryWorkspaces : RemoteWorkspaceListStore { + val byDevice = mutableMapOf>() + override fun load(deviceKey: String): List = byDevice[deviceKey].orEmpty() + override fun save(deviceKey: String, workspaces: List) { + byDevice[deviceKey] = workspaces + } +} + +private object NoOpDirectoryDrafts : DraftStore { + override fun load(draftId: String): String? = null + override fun save(draftId: String, text: String) = Unit + override fun delete(draftId: String) = Unit +} + +private object NoOpDirectoryChats : ChatLocalStore { + override fun listSessions(agentType: String): List = emptyList() + override fun loadSession(sessionId: String): PersistedChatSession? = null + override fun loadMessages(sessionId: String): List = emptyList() + override fun saveSession(session: PersistedChatSession) = Unit + override fun saveMessage(message: PersistedChatMessage) = Unit + override fun pinSession(agentType: String, sessionId: String, pinned: Boolean) = Unit + override fun setSessionStatus(sessionId: String, status: String) = Unit + override fun deleteSession(sessionId: String) = Unit +} diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionPersistenceTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionPersistenceTest.kt index 8a160d2979..37f339d075 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionPersistenceTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionPersistenceTest.kt @@ -30,7 +30,9 @@ import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertIs +import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class RemoteSessionPersistenceTest { @@ -47,6 +49,22 @@ class RemoteSessionPersistenceTest { store.dispatch(RemoteSessionIntent.Stop) } + @Test + fun unavailableSessionCacheDoesNotOverrideRemoteList() = runTest { + val stores = MemoryPersistence() + stores.sessions.failLoad = true + stores.sessions.failSave = true + val store = RemoteSessionStore.create(this, PersistenceTransport(), "device-a", stores.stores) + + store.dispatch(RemoteSessionIntent.Load) + advanceUntilIdle() + + val ready = assertIs(store.state.value) + assertEquals(listOf("server"), ready.sessions.map { it.id }) + assertEquals(false, ready.busy) + store.stop() + } + @Test fun confirmedCreateReconcilePersistsByDeviceAndRebuildRestoresIt() = runTest { val stores = MemoryPersistence() @@ -205,6 +223,183 @@ class RemoteSessionPersistenceTest { store.dispatch(RemoteSessionIntent.Stop) } + @Test + fun completeCachedTranscriptResumesFromCountWithoutFullFetch() = runTest { + val stores = MemoryPersistence() + stores.transcripts.rows["device-a::server"] = listOf( + PersistedRemoteMessage( + messageId = "m-1", sessionId = "server", role = "assistant", text = "cached", + payloadJson = "{}", + ), + ) + stores.transcripts.cursors["device-a::server"] = PersistedRemoteCursor("9", 1, "3") + val transport = PersistenceTransport() + val store = RemoteSessionStore.create(this, transport, "device-a", stores.stores) + + store.dispatch(RemoteSessionIntent.Open("server")) + runCurrent() + + val ready = assertIs(store.state.value) + assertEquals("cached", ready.timeline?.persistedMessages?.single()?.text) + assertFalse(ready.busy) + assertTrue(transport.commands.none { it.cmd == "get_session_messages" }) + assertEquals(listOf(0), transport.sinceVersions) + assertEquals(listOf(1), transport.knownMessageCounts) + store.stop() + } + + @Test + fun hollowCachedAssistantFallsBackToAuthoritativeFetch() = runTest { + val stores = MemoryPersistence() + stores.transcripts.rows["device-a::server"] = listOf( + PersistedRemoteMessage( + messageId = "m-1", sessionId = "server", role = "assistant", text = "", + payloadJson = "{}", + ), + ) + stores.transcripts.cursors["device-a::server"] = PersistedRemoteCursor("9", 1, "3") + val transport = PersistenceTransport().apply { + messagesJson = """[{"id":"m-1","role":"assistant","content":"restored body"}]""" + } + val store = RemoteSessionStore.create(this, transport, "device-a", stores.stores) + + store.dispatch(RemoteSessionIntent.Open("server")) + runCurrent() + + assertTrue(transport.commands.any { it.cmd == "get_session_messages" }) + assertEquals( + "restored body", + assertIs(store.state.value).timeline + ?.persistedMessages?.single()?.text, + ) + store.stop() + } + + @Test + fun pollSnapshotReplacesRewrittenCachedHistory() = runTest { + val stores = MemoryPersistence() + stores.transcripts.rows["device-a::server"] = listOf( + PersistedRemoteMessage("old-1", "server", "user", "old one", payloadJson = "{}"), + PersistedRemoteMessage("old-2", "server", "assistant", "old two", payloadJson = "{}"), + ) + stores.transcripts.cursors["device-a::server"] = PersistedRemoteCursor("7", 2, "0") + val transport = PersistenceTransport().apply { + polls = listOf( + """{"resp":"ok","version":8,"changed":true,"session_state":"idle","total_msg_count":1,"message_snapshot":[{"id":"new-1","role":"assistant","content":"replacement"}]}""", + ) + } + val store = RemoteSessionStore.create(this, transport, "device-a", stores.stores) + + store.dispatch(RemoteSessionIntent.Open("server")) + runCurrent() + + val rows = assertIs(store.state.value) + .timeline?.persistedMessages.orEmpty() + assertEquals(listOf("new-1"), rows.map { it.id }) + assertEquals(listOf("new-1"), stores.transcripts.rows.getValue("device-a::server").map { it.messageId }) + assertTrue(transport.commands.none { it.cmd == "get_session_messages" }) + store.stop() + } + + @Test + fun shorterLegacyPollWithoutSnapshotFallsBackToFullReplacement() = runTest { + val stores = MemoryPersistence() + stores.transcripts.rows["device-a::server"] = listOf( + PersistedRemoteMessage("old-1", "server", "user", "old one", payloadJson = "{}"), + PersistedRemoteMessage("old-2", "server", "assistant", "old two", payloadJson = "{}"), + ) + stores.transcripts.cursors["device-a::server"] = PersistedRemoteCursor("7", 2, "0") + val transport = PersistenceTransport().apply { + messagesJson = """[{"id":"new-1","role":"assistant","content":"replacement"}]""" + polls = listOf( + """{"resp":"ok","version":8,"changed":true,"session_state":"idle","total_msg_count":1}""", + ) + } + val store = RemoteSessionStore.create(this, transport, "device-a", stores.stores) + + store.dispatch(RemoteSessionIntent.Open("server")) + runCurrent() + + assertEquals( + listOf("new-1"), + assertIs(store.state.value) + .timeline?.persistedMessages.orEmpty().map { it.id }, + ) + assertEquals(1, transport.commands.count { it.cmd == "get_session_messages" }) + assertEquals(listOf("new-1"), stores.transcripts.rows.getValue("device-a::server").map { it.messageId }) + store.stop() + } + + @Test + fun deleteRemovesPersistedListDraftTranscriptAndCursor() = runTest { + val stores = MemoryPersistence() + stores.sessions.byDevice["device-a"] = listOf( + PersistedRemoteSession(sessionId = "server", title = "Server"), + PersistedRemoteSession(sessionId = "keep", title = "Keep"), + ) + stores.sessions.more = true + stores.drafts.values["remote-composer:device-a:server"] = "draft" + stores.transcripts.rows["device-a::server"] = listOf( + PersistedRemoteMessage(messageId = "m-1", sessionId = "server", text = "cached"), + ) + stores.transcripts.cursors["device-a::server"] = PersistedRemoteCursor("7", 1, "2") + val transport = PersistenceTransport().apply { + sessionsJson = """[{"id":"server","title":"Server"},{"id":"keep","title":"Keep"}]""" + } + val store = RemoteSessionStore.create(this, transport, "device-a", stores.stores) + store.dispatch(RemoteSessionIntent.Load) + advanceUntilIdle() + val expectedHasMore = stores.sessions.more + + store.dispatch(RemoteSessionIntent.DeleteSession("server")) + advanceUntilIdle() + + assertEquals(listOf("keep"), stores.sessions.byDevice.getValue("device-a").map { it.sessionId }) + assertEquals(expectedHasMore, stores.sessions.more) + assertEquals(null, stores.drafts.values["remote-composer:device-a:server"]) + assertEquals(null, stores.transcripts.rows["device-a::server"]) + assertEquals(null, stores.transcripts.cursors["device-a::server"]) + store.stop() + } + + @Test + fun deleteKeepsServerResultWhenSessionListCacheWriteFails() = runTest { + val stores = MemoryPersistence() + stores.drafts.values["remote-composer:device-a:server"] = "draft" + stores.transcripts.rows["device-a::server"] = listOf( + PersistedRemoteMessage(messageId = "m-1", sessionId = "server", text = "cached"), + ) + val store = RemoteSessionStore.create(this, PersistenceTransport(), "device-a", stores.stores) + store.dispatch(RemoteSessionIntent.Load) + advanceUntilIdle() + stores.sessions.failSave = true + + store.dispatch(RemoteSessionIntent.DeleteSession("server")) + advanceUntilIdle() + + val ready = assertIs(store.state.value) + assertEquals(emptyList(), ready.sessions) + assertEquals(false, ready.busy) + assertEquals(null, stores.drafts.values["remote-composer:device-a:server"]) + assertEquals(null, stores.transcripts.rows["device-a::server"]) + store.stop() + } + + @Test + fun renameUpdatesPersistedListForOfflineRestore() = runTest { + val stores = MemoryPersistence() + val transport = PersistenceTransport() + val store = RemoteSessionStore.create(this, transport, "device-a", stores.stores) + store.dispatch(RemoteSessionIntent.Load) + advanceUntilIdle() + + store.dispatch(RemoteSessionIntent.RenameSession("server", "Renamed")) + advanceUntilIdle() + + assertEquals("Renamed", stores.sessions.byDevice.getValue("device-a").single().title) + store.stop() + } + @Test fun staleLoadMoreDoesNotWriteSessionPersistence() = runTest { val stores = MemoryPersistence() @@ -271,8 +466,14 @@ private class MemorySessions : RemoteSessionListStore { val byDevice = mutableMapOf>() var more = false var saveCount = 0 - override fun load(deviceKey: String): List = byDevice[deviceKey] ?: rows + var failLoad = false + var failSave = false + override fun load(deviceKey: String): List { + if (failLoad) error("session cache read failed") + return byDevice[deviceKey] ?: rows + } override fun save(deviceKey: String, sessions: List, hasMore: Boolean) { + if (failSave) error("session cache write failed") saveCount += 1 rows = sessions byDevice[deviceKey] = sessions @@ -289,6 +490,10 @@ private class MemoryTranscripts : RemoteTranscriptStore { override fun replace(deviceKey: String, sessionId: String, messages: List) { rows["$deviceKey::$sessionId"] = messages } override fun loadCursor(deviceKey: String, sessionId: String) = cursors["$deviceKey::$sessionId"] override fun saveCursor(deviceKey: String, sessionId: String, cursor: PersistedRemoteCursor) { cursors["$deviceKey::$sessionId"] = cursor } + override fun delete(deviceKey: String, sessionId: String) { + rows.remove("$deviceKey::$sessionId") + cursors.remove("$deviceKey::$sessionId") + } } private class PersistenceTransport : RemoteCommandTransport { @@ -301,14 +506,18 @@ private class PersistenceTransport : RemoteCommandTransport { var polls: List = listOf("""{"resp":"ok","version":1,"changed":false,"session_state":"idle"}""") private var pollIndex = 0 var pollFailure: RelayFailure? = null + val commands = mutableListOf() val sinceVersions = mutableListOf() + val knownMessageCounts = mutableListOf() override suspend fun send(deserializer: DeserializationStrategy, command: RemoteCommand, timeoutMs: Long): T { + commands += command commandGates[command.cmd]?.await() if (nonCancellableCommands.remove(command.cmd)) { suspendCoroutine { continuation -> lateCommandContinuations[command.cmd] = continuation } } if (command.cmd == "poll_session") { sinceVersions += command.sinceVersion ?: 0 + knownMessageCounts += command.knownMessageCount ?: 0 pollFailure?.let { throw RelayTransportException(it) } } val json = when (command.cmd) { diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt index 4900071e3d..2d60fe745e 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt @@ -63,6 +63,31 @@ class RemoteSessionStoreTest { assertNull(ready.modelCatalogFailure) } + @Test + fun workspaceDirectoryIntentLoadsOnlyTheRequestedBranchWithoutChangingActiveState() = runTest { + val transport = FakeSessionTransport().apply { + listSessionsOverride = { _ -> + """{"resp":"ok","has_more":false,"sessions":[{"id":"branch","title":"Branch","agent_type":"code"}]}""" + } + } + val store = RemoteSessionStore.create(this, transport) + + store.dispatch(RemoteSessionIntent.LoadWorkspaceSessions("/other/repo/")) + store.dispatch(RemoteSessionIntent.LoadWorkspaceSessions("/other/repo")) + advanceUntilIdle() + + assertIs(store.state.value) + val branch = store.workspaceDirectory.value.workspace("/other/repo")!! + assertEquals(WorkspaceSessionDirectoryStatus.READY, branch.status) + assertEquals(listOf("branch"), branch.sessions.map { it.id }) + assertEquals("/other/repo", branch.sessions.single().workspacePath) + val requests = transport.commands.filter { it.cmd == "list_sessions" } + assertEquals(1, requests.size) + assertEquals("/other/repo", requests.single().workspacePath) + assertEquals(50, requests.single().limit) + assertTrue(transport.commands.none { it.cmd == "get_workspace_info" }) + } + @Test fun initialListingAndCatalogRequestsOverlapWithoutChangingReadyOrdering() = runTest { val transport = FakeSessionTransport() @@ -1104,6 +1129,30 @@ class RemoteSessionStoreTest { store.dispatch(RemoteSessionIntent.Stop) } + @Test + fun rapidCacheMissesIssueOnlyTheFirstAndLatestTranscriptRequests() = runTest { + val transport = FakeSessionTransport() + transport.nonCancellableCommands += "get_session_messages" + val store = RemoteSessionStore.create(this, transport) + + store.dispatch(RemoteSessionIntent.Open("s-code")) + runCurrent() + val firstRequest = transport.lateCommandContinuations.remove("get_session_messages")!! + store.dispatch(RemoteSessionIntent.Open("s-cowork")) + runCurrent() + store.dispatch(RemoteSessionIntent.Open("s-agentic")) + runCurrent() + + assertEquals(1, transport.commands.count { it.cmd == "get_session_messages" }) + firstRequest.resume(Unit) + runCurrent() + + val transcriptRequests = transport.commands.filter { it.cmd == "get_session_messages" } + assertEquals(2, transcriptRequests.size) + assertEquals("s-agentic", transcriptRequests.last().sessionId) + store.stop() + } + @Test fun refreshRetriesThePermissionModeAlone() = runTest { val transport = FakeSessionTransport() diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/ToolRowPresentationTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/ToolRowPresentationTest.kt new file mode 100644 index 0000000000..7a98a135b8 --- /dev/null +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/ToolRowPresentationTest.kt @@ -0,0 +1,56 @@ +package com.openbitfun.mobile.core.feature.session + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class ToolRowPresentationTest { + @Test + fun foldsConsecutiveCompletedActivitiesAcrossToolKinds() { + val rows = collapseToolRows( + listOf( + tool("read", ToolKind.DOCUMENT), + tool("shell", ToolKind.COMMAND), + tool("git", ToolKind.GIT), + ), + ) + + assertEquals(3, assertIs(rows.single()).tools.size) + } + + @Test + fun leavesAttentionAndPlanToolsVisible() { + val rows = collapseToolRows( + listOf( + tool("running", ToolKind.COMMAND, ToolPhase.RUNNING), + tool("failed", ToolKind.SEARCH, ToolPhase.FAILED), + tool("question", ToolKind.QUESTION), + tool("CreatePlan", ToolKind.CREATE), + tool("Write", ToolKind.CREATE, filePath = "/repo/work.plan.md"), + ), + ) + + assertEquals(5, rows.size) + rows.forEach { assertIs(it) } + } + + private fun tool( + id: String, + kind: ToolKind, + phase: ToolPhase = ToolPhase.COMPLETED, + filePath: String = "", + ): ToolCard = ToolCard( + id = id, + name = id, + phase = phase, + kind = kind, + operation = ToolOperation.UNKNOWN, + target = "", + filePath = filePath, + fileLabel = "", + input = "", + output = "", + question = null, + actions = emptySet(), + ) +} diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceStoreTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceStoreTest.kt index 5c741ef785..a8c6cd219a 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceStoreTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceStoreTest.kt @@ -3,6 +3,8 @@ package com.openbitfun.mobile.core.feature.workspace import com.openbitfun.mobile.core.protocol.CommandStatus import com.openbitfun.mobile.core.protocol.RelayJson import com.openbitfun.mobile.core.protocol.RemoteCommand +import com.openbitfun.mobile.core.persistence.PersistedRemoteWorkspace +import com.openbitfun.mobile.core.persistence.RemoteWorkspaceListStore import com.openbitfun.mobile.core.transport.RemoteCommandTransport import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -54,6 +56,73 @@ class RemoteWorkspaceStoreTest { assertFalse(assertIs(store.state.value).busy) } + @Test + fun cachedCatalogStaysVisibleWhenTheLiveRefreshFails() = runTest { + val cache = MemoryWorkspaceListStore().apply { + rows["device-a"] = listOf( + PersistedRemoteWorkspace("/cached", "Cached", "yesterday", "local"), + PersistedRemoteWorkspace("/assistant", "Assistant", "", "assistant"), + ) + } + val store = RemoteWorkspaceStore.create( + this, + FailingWorkspaceTransport(), + StandardTestDispatcher(testScheduler), + "device-a", + cache, + ) + + store.dispatch(RemoteWorkspaceIntent.Load) + val cached = assertIs(store.state.value) + assertTrue(cached.busy) + assertEquals(listOf("/cached"), cached.workspaces.map { it.path }) + assertEquals(listOf("/assistant"), cached.assistants.map { it.path }) + advanceUntilIdle() + + val failed = assertIs(store.state.value) + assertFalse(failed.busy) + assertTrue(failed.loadFailure) + assertEquals(listOf("/cached"), failed.workspaces.map { it.path }) + assertEquals(listOf("/assistant"), failed.assistants.map { it.path }) + } + + @Test + fun successfulCatalogRefreshReplacesAndPersistsCachedRows() = runTest { + val cache = MemoryWorkspaceListStore() + val store = RemoteWorkspaceStore.create( + this, + FakeWorkspaceTransport(), + StandardTestDispatcher(testScheduler), + "device-a", + cache, + ) + store.dispatch(RemoteWorkspaceIntent.Load) + advanceUntilIdle() + + assertEquals(listOf("/repo", "/assistant"), cache.rows.getValue("device-a").map { it.path }) + assertFalse(assertIs(store.state.value).loadFailure) + } + + @Test + fun unavailableWorkspaceCacheDoesNotOverrideRemoteCatalog() = runTest { + val store = RemoteWorkspaceStore.create( + this, + FakeWorkspaceTransport(), + StandardTestDispatcher(testScheduler), + "device-a", + FailingWorkspaceListStore(), + ) + + store.dispatch(RemoteWorkspaceIntent.Load) + advanceUntilIdle() + + val ready = assertIs(store.state.value) + assertEquals(listOf("/repo"), ready.workspaces.map { it.path }) + assertEquals(listOf("/assistant"), ready.assistants.map { it.path }) + assertFalse(ready.busy) + assertFalse(ready.loadFailure) + } + @Test fun loadsBoundedTextPreviewThroughCommandTransport() = runTest { val transport = FakeWorkspaceTransport() @@ -550,3 +619,17 @@ private class FakeWorkspaceTransport( return RelayJson.decodeFromString(deserializer, json) } } + +private class MemoryWorkspaceListStore : RemoteWorkspaceListStore { + val rows = mutableMapOf>() + override fun load(deviceKey: String): List = rows[deviceKey].orEmpty() + override fun save(deviceKey: String, workspaces: List) { + rows[deviceKey] = workspaces + } +} + +private class FailingWorkspaceListStore : RemoteWorkspaceListStore { + override fun load(deviceKey: String): List = error("workspace cache read failed") + override fun save(deviceKey: String, workspaces: List): Unit = + error("workspace cache write failed") +} diff --git a/src/apps/mobile/shared/core-persistence/src/commonMain/kotlin/com/openbitfun/mobile/core/persistence/ChatLocalStore.kt b/src/apps/mobile/shared/core-persistence/src/commonMain/kotlin/com/openbitfun/mobile/core/persistence/ChatLocalStore.kt index 10c113d0d2..b94a7b6cd3 100644 --- a/src/apps/mobile/shared/core-persistence/src/commonMain/kotlin/com/openbitfun/mobile/core/persistence/ChatLocalStore.kt +++ b/src/apps/mobile/shared/core-persistence/src/commonMain/kotlin/com/openbitfun/mobile/core/persistence/ChatLocalStore.kt @@ -174,6 +174,14 @@ public data class PersistedRemoteCursor public constructor( public val knownModelCatalogVersion: String = "", ) +@Serializable +public data class PersistedRemoteWorkspace public constructor( + public val path: String = "", + public val name: String = "", + public val lastOpened: String = "", + public val workspaceKind: String = "", +) + public interface RemoteSessionListStore { public fun load(deviceKey: String): List public fun save(deviceKey: String, sessions: List, hasMore: Boolean = false) @@ -186,6 +194,12 @@ public interface RemoteTranscriptStore { public fun replace(deviceKey: String, sessionId: String, messages: List) public fun loadCursor(deviceKey: String, sessionId: String): PersistedRemoteCursor? public fun saveCursor(deviceKey: String, sessionId: String, cursor: PersistedRemoteCursor) + public fun delete(deviceKey: String, sessionId: String) +} + +public interface RemoteWorkspaceListStore { + public fun load(deviceKey: String): List + public fun save(deviceKey: String, workspaces: List) } public class SqlDelightRemoteSessionListStore public constructor( @@ -206,8 +220,36 @@ public class SqlDelightRemoteSessionListStore public constructor( override fun save(deviceKey: String, sessions: List, hasMore: Boolean) { if (deviceKey.isBlank()) return - val kept = sessions.take(20) - val signature = "$deviceKey|${hasMore}|${kept.joinToString { it.sessionId + ":" + it.updatedAt + ":" + it.messageCount + ":" + it.pendingConfirmed }}" + val kept = sessions.take(60) + val signature = buildString { + append(deviceKey) + append('|') + append(hasMore) + kept.forEach { session -> + append('\u0002') + append(session.sessionId) + append('\u0001') + append(session.title) + append('\u0001') + append(session.agentType) + append('\u0001') + append(session.status) + append('\u0001') + append(session.updatedAt) + append('\u0001') + append(session.createdAt) + append('\u0001') + append(session.messageCount) + append('\u0001') + append(session.lastMessageId) + append('\u0001') + append(session.workspacePath.orEmpty()) + append('\u0001') + append(session.workspaceName.orEmpty()) + append('\u0001') + append(session.pendingConfirmed) + } + } if (signature == lastSignature) return queries.transaction { queries.deleteRemoteSessionsForDevice(deviceKey) @@ -279,6 +321,14 @@ public class SqlDelightRemoteTranscriptStore public constructor( cursor.knownMessageCount.toLong(), cursor.knownModelCatalogVersion) } + override fun delete(deviceKey: String, sessionId: String) { + queries.transaction { + queries.deleteRemoteMessages(deviceKey, sessionId) + queries.deleteRemoteCursor(deviceKey, sessionId) + } + resident.remove("$deviceKey::$sessionId") + } + private fun saveRow(deviceKey: String, sessionId: String, seq: Int, message: PersistedRemoteMessage) { queries.upsertRemoteMessage(deviceKey, sessionId, seq.toLong(), message.messageId, message.role, message.text, message.status, message.timestamp, message.thinking, message.payloadJson) @@ -290,6 +340,42 @@ public class SqlDelightRemoteTranscriptStore public constructor( } } +public class SqlDelightRemoteWorkspaceListStore public constructor( + driver: SqlDriver, +) : RemoteWorkspaceListStore { + private val queries = MobileDatabase(driver).mobileQueries + private var lastSignature = "" + + override fun load(deviceKey: String): List = + queries.selectRemoteWorkspaces(deviceKey).executeAsList().map { row -> + PersistedRemoteWorkspace(row.path, row.name, row.last_opened, row.workspace_kind) + } + + override fun save(deviceKey: String, workspaces: List) { + if (deviceKey.isBlank()) return + val kept = workspaces.distinctBy { it.path }.take(60) + val signature = "$deviceKey|${kept.joinToString("\u0002") { workspace -> + listOf(workspace.path, workspace.name, workspace.lastOpened, workspace.workspaceKind) + .joinToString("\u0001") + }}" + if (signature == lastSignature) return + queries.transaction { + queries.deleteRemoteWorkspacesForDevice(deviceKey) + kept.forEachIndexed { index, workspace -> + queries.upsertRemoteWorkspace( + deviceKey, + workspace.path, + workspace.name, + workspace.lastOpened, + workspace.workspaceKind, + index.toLong(), + ) + } + } + lastSignature = signature + } +} + private object EmptyRemoteSessionListStore : RemoteSessionListStore { override fun load(deviceKey: String): List = emptyList() override fun save(deviceKey: String, sessions: List, hasMore: Boolean) = Unit @@ -302,6 +388,12 @@ private object EmptyRemoteTranscriptStore : RemoteTranscriptStore { override fun replace(deviceKey: String, sessionId: String, messages: List) = Unit override fun loadCursor(deviceKey: String, sessionId: String): PersistedRemoteCursor? = null override fun saveCursor(deviceKey: String, sessionId: String, cursor: PersistedRemoteCursor) = Unit + override fun delete(deviceKey: String, sessionId: String) = Unit +} + +private object EmptyRemoteWorkspaceListStore : RemoteWorkspaceListStore { + override fun load(deviceKey: String): List = emptyList() + override fun save(deviceKey: String, workspaces: List) = Unit } public data class MobilePersistenceStores public constructor( @@ -309,10 +401,12 @@ public data class MobilePersistenceStores public constructor( public val chats: ChatLocalStore, public val remoteSessions: RemoteSessionListStore = EmptyRemoteSessionListStore, public val remoteTranscripts: RemoteTranscriptStore = EmptyRemoteTranscriptStore, + public val remoteWorkspaces: RemoteWorkspaceListStore = EmptyRemoteWorkspaceListStore, ) public fun mobilePersistenceStores(driver: SqlDriver): MobilePersistenceStores = MobilePersistenceStores( drafts = SqlDelightDraftStore(driver), chats = SqlDelightChatLocalStore(driver), remoteSessions = SqlDelightRemoteSessionListStore(driver), remoteTranscripts = SqlDelightRemoteTranscriptStore(driver), + remoteWorkspaces = SqlDelightRemoteWorkspaceListStore(driver), ) diff --git a/src/apps/mobile/shared/core-persistence/src/commonMain/sqldelight/com/openbitfun/mobile/core/persistence/db/4.sqm b/src/apps/mobile/shared/core-persistence/src/commonMain/sqldelight/com/openbitfun/mobile/core/persistence/db/4.sqm new file mode 100644 index 0000000000..25d7b033e1 --- /dev/null +++ b/src/apps/mobile/shared/core-persistence/src/commonMain/sqldelight/com/openbitfun/mobile/core/persistence/db/4.sqm @@ -0,0 +1,10 @@ +-- v4 -> v5: cache each remote device's workspace catalog for offline directory restore. +CREATE TABLE IF NOT EXISTS remote_workspace_list ( + device_key TEXT NOT NULL, + path TEXT NOT NULL, + name TEXT NOT NULL, + last_opened TEXT NOT NULL, + workspace_kind TEXT NOT NULL, + seq INTEGER NOT NULL, + PRIMARY KEY (device_key, path) +); diff --git a/src/apps/mobile/shared/core-persistence/src/commonMain/sqldelight/com/openbitfun/mobile/core/persistence/db/Mobile.sq b/src/apps/mobile/shared/core-persistence/src/commonMain/sqldelight/com/openbitfun/mobile/core/persistence/db/Mobile.sq index fe95379337..9ce53b80bd 100644 --- a/src/apps/mobile/shared/core-persistence/src/commonMain/sqldelight/com/openbitfun/mobile/core/persistence/db/Mobile.sq +++ b/src/apps/mobile/shared/core-persistence/src/commonMain/sqldelight/com/openbitfun/mobile/core/persistence/db/Mobile.sq @@ -125,6 +125,16 @@ CREATE TABLE remote_session_cursor ( PRIMARY KEY (device_key, session_id) ); +CREATE TABLE remote_workspace_list ( + device_key TEXT NOT NULL, + path TEXT NOT NULL, + name TEXT NOT NULL, + last_opened TEXT NOT NULL, + workspace_kind TEXT NOT NULL, + seq INTEGER NOT NULL, + PRIMARY KEY (device_key, path) +); + selectRemoteSessions: SELECT * FROM remote_session_list WHERE device_key = ? ORDER BY updated_at DESC, session_id ASC; @@ -154,3 +164,16 @@ VALUES (?, ?, ?, ?, ?); selectRemoteCursor: SELECT * FROM remote_session_cursor WHERE device_key = ? AND session_id = ?; + +deleteRemoteCursor: +DELETE FROM remote_session_cursor WHERE device_key = ? AND session_id = ?; + +selectRemoteWorkspaces: +SELECT * FROM remote_workspace_list WHERE device_key = ? ORDER BY seq ASC; + +upsertRemoteWorkspace: +INSERT OR REPLACE INTO remote_workspace_list(device_key, path, name, last_opened, workspace_kind, seq) +VALUES (?, ?, ?, ?, ?, ?); + +deleteRemoteWorkspacesForDevice: +DELETE FROM remote_workspace_list WHERE device_key = ?; diff --git a/src/apps/mobile/shared/core-persistence/src/jvmTest/kotlin/com/openbitfun/mobile/core/persistence/RemotePersistenceStoreTest.kt b/src/apps/mobile/shared/core-persistence/src/jvmTest/kotlin/com/openbitfun/mobile/core/persistence/RemotePersistenceStoreTest.kt index 7b93e9e222..825faf130c 100644 --- a/src/apps/mobile/shared/core-persistence/src/jvmTest/kotlin/com/openbitfun/mobile/core/persistence/RemotePersistenceStoreTest.kt +++ b/src/apps/mobile/shared/core-persistence/src/jvmTest/kotlin/com/openbitfun/mobile/core/persistence/RemotePersistenceStoreTest.kt @@ -152,8 +152,8 @@ class RemotePersistenceStoreTest { @Test fun sessionListPrunesOldestPerDevice() = runTest { val (sessions, _) = stores() - sessions.save("device-a", (20 downTo 0).map { session("s$it", "%04d".format(it)) }) - assertEquals(20, sessions.load("device-a").size) + sessions.save("device-a", (60 downTo 0).map { session("s$it", "%04d".format(it)) }) + assertEquals(60, sessions.load("device-a").size) assertTrue(sessions.load("device-a").none { it.sessionId == "s0" }) } @@ -164,6 +164,56 @@ class RemotePersistenceStoreTest { assertEquals(PersistedRemoteCursor("poll-7", 12, "models-3"), transcript.loadCursor("device-a", "s1")) } + @Test + fun sessionListRewriteObservesTitleOnlyChanges() = runTest { + val (sessions, _) = stores() + val original = session("s1", "2026-01-01") + sessions.save("device-a", listOf(original)) + sessions.save("device-a", listOf(original.copy(title = "Renamed"))) + assertEquals("Renamed", sessions.load("device-a").single().title) + } + + @Test + fun deletingTranscriptAlsoDeletesItsCursorAndResidentCopy() = runTest { + val (_, transcript) = stores() + transcript.replace("device-a", "s1", listOf(message("m0", "cached"))) + transcript.saveCursor("device-a", "s1", PersistedRemoteCursor("poll-7", 1, "models-3")) + assertEquals(1, transcript.load("device-a", "s1").size) + + transcript.delete("device-a", "s1") + + assertTrue(transcript.load("device-a", "s1").isEmpty()) + assertEquals(null, transcript.loadCursor("device-a", "s1")) + } + + @Test + fun workspaceCatalogRoundTripsInOrderAndRemainsDeviceScoped() = runTest { + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + MobileDatabase.Schema.create(driver).await() + val workspaces = SqlDelightRemoteWorkspaceListStore(driver) + workspaces.save( + "device-a", + listOf( + PersistedRemoteWorkspace("/repo", "Repo", "today", "local"), + PersistedRemoteWorkspace("/assistant", "Assistant", "", "assistant"), + ), + ) + workspaces.save("device-b", listOf(PersistedRemoteWorkspace("/other", "Other"))) + + assertEquals(listOf("/repo", "/assistant"), workspaces.load("device-a").map { it.path }) + assertEquals(listOf("/other"), workspaces.load("device-b").map { it.path }) + } + + @Test + fun migratesV4DatabaseWithAnEmptyWorkspaceCache() = runTest { + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + MobileDatabase.Schema.migrate(driver, 4, 5).await() + val workspaces = SqlDelightRemoteWorkspaceListStore(driver) + assertTrue(workspaces.load("device-a").isEmpty()) + workspaces.save("device-a", listOf(PersistedRemoteWorkspace("/repo", "Repo"))) + assertEquals("/repo", workspaces.load("device-a").single().path) + } + private fun session(id: String, updated: String) = PersistedRemoteSession( sessionId = id, title = "Title $id", agentType = "remote", status = "ready", updatedAt = updated, createdAt = updated, messageCount = 1, lastMessageId = "m0", diff --git a/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/openbitfun/mobile/core/protocol/MessageDtos.kt b/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/openbitfun/mobile/core/protocol/MessageDtos.kt index 060ccdb749..5cf1b22eed 100644 --- a/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/openbitfun/mobile/core/protocol/MessageDtos.kt +++ b/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/openbitfun/mobile/core/protocol/MessageDtos.kt @@ -103,6 +103,8 @@ public data class PollSessionResponse( @SerialName("title") val title: String? = null, @SerialName("new_messages") val newMessages: List = emptyList(), @SerialName("total_msg_count") val totalMessageCount: Int? = null, + /** Authoritative replacement used when the desktop invalidates its history tracker. */ + @SerialName("message_snapshot") val messageSnapshot: List? = null, @SerialName("active_turn") val activeTurn: ActiveTurnSnapshotResponse? = null, @SerialName("model_catalog") val modelCatalog: RemoteModelCatalog? = null, ) : CommandStatus