Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 68 additions & 15 deletions src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Foundation
import OpenBitFunMobileCore
import SwiftUI
import UIKit
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -340,15 +359,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
Expand All @@ -361,6 +376,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<NSString, Entry>()

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

Expand Down Expand Up @@ -764,10 +816,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
Expand All @@ -786,7 +835,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")
Expand All @@ -795,7 +844,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) }
}
}
}
}
}
Expand Down
91 changes: 70 additions & 21 deletions src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,23 +61,16 @@ 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)
}
return entries
}

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 {
Expand Down Expand Up @@ -132,7 +125,8 @@ struct SidebarView: View {
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 },
Expand Down Expand Up @@ -378,7 +372,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)
Expand Down Expand Up @@ -426,18 +423,26 @@ 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))
.accessibilityValue(Text(device.online ? model.localized("在线") : model.localized("离线")))
}

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
Expand Down Expand Up @@ -468,25 +473,43 @@ 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)
},
onOpenWorkspace: { model.selectDirectoryWorkspace(scopedWorkspace) },
onOpenSession: { model.selectDirectorySession($0) }, onActions: { session in
if permanent { onPermanentActions?(session) } else { compactActionSession = session }
},
sessionLimit: expandedWorkspacePaths.contains(workspace.id) ? workspace.sessions.count : 3,
sessionLimit: workspace.directoryExpanded ? workspace.sessions.count : 3,
selectedDeviceKey: model.accountSelectedDeviceID,
selectedWorkspacePath: model.workspaceCatalog.first(where: { $0.selected })?.path,
onShowMore: { expandedWorkspacePaths.insert(workspace.id) }
onShowMore: {
model.setDirectoryWorkspaceExpanded(
device: device,
workspace: scopedWorkspace,
expanded: true
)
},
directoryLoadStatus: workspace.directoryStatus,
onRetryDirectoryLoad: {
model.retryDirectoryWorkspace(device: device, workspace: scopedWorkspace)
}
)
.padding(.leading, 20)
}
Expand Down Expand Up @@ -937,6 +960,8 @@ private struct SidebarWorkspaceRow: View {
var selectedDeviceKey: String? = nil
var selectedWorkspacePath: String? = nil
var onShowMore: (() -> Void)? = nil
var directoryLoadStatus = "READY"
var onRetryDirectoryLoad: (() -> Void)? = nil

private func isSelected(_ session: ChatSession) -> Bool {
guard selectedSessionID == session.id,
Expand Down Expand Up @@ -1002,7 +1027,31 @@ 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -82,6 +83,7 @@ extension MobileAppModel {
remoteWorkspaces = []
workspaceCatalog = []
pendingRemoteWorkspaceCreate = nil
pendingRemoteSessionRefreshWorkspacePath = nil
pendingRemoteAssistantCreate = false
selectedRemoteWorkspaceKind = ""
messages = []
Expand Down Expand Up @@ -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
)
}
Expand All @@ -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("设备列表刷新失败,仍显示上次结果"))
}
Expand Down Expand Up @@ -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))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,8 @@ extension MobileAppModel {
multiSelect: question.multiSelect
)
},
actions: Set(tool.actions.map(\.name))
actions: Set(tool.actions.map(\.name)),
foldIntoSummary: tool.foldIntoSummary
)
}

Expand Down
Loading