mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-07 09:17:12 +08:00
* feat(mobile): rename, delete, and create sidebar session groups on iOS and Android Group section headers on the iOS Command Center sessions screen and the Android sessions screen gain Rename/New/Delete group actions. Bulk ops enumerate every member (active + archived, explicit high list limit since the gateway caps an absent limit at 100 rows) and patch category per session; delete keeps sessions and moves them to Ungrouped. Known custom groups persist client-side (iOS UserDefaults, Android SharedPreferences) so empty groups stay visible as move targets. * chore(i18n): refresh native string inventory after rebase onto main
369 lines
12 KiB
Swift
369 lines
12 KiB
Swift
import OpenClawChatUI
|
|
import SwiftUI
|
|
|
|
struct CommandPanel<Content: View>: View {
|
|
var tint: Color?
|
|
var isProminent = false
|
|
var padding: CGFloat = 13
|
|
@ViewBuilder var content: Content
|
|
|
|
init(
|
|
tint: Color? = nil,
|
|
isProminent: Bool = false,
|
|
padding: CGFloat = 13,
|
|
@ViewBuilder content: () -> Content)
|
|
{
|
|
self.tint = tint
|
|
self.isProminent = isProminent
|
|
self.padding = padding
|
|
self.content = content()
|
|
}
|
|
|
|
var body: some View {
|
|
ProCard(
|
|
tint: self.tint,
|
|
isProminent: self.isProminent,
|
|
padding: self.padding,
|
|
radius: OpenClawProMetric.cardRadius)
|
|
{
|
|
self.content
|
|
}
|
|
}
|
|
}
|
|
|
|
struct CommandControlBackground: View {
|
|
var body: some View {
|
|
OpenClawProBackground()
|
|
}
|
|
}
|
|
|
|
struct CommandSessionRow: View {
|
|
let item: CommandCenterTab.WorkItem
|
|
|
|
var body: some View {
|
|
HStack(alignment: .center, spacing: 12) {
|
|
Image(systemName: self.item.icon)
|
|
.font(OpenClawType.captionSemiBold)
|
|
.foregroundStyle(self.item.color)
|
|
.frame(width: 30, height: 30)
|
|
.background {
|
|
RoundedRectangle(cornerRadius: OpenClawRadius.sm, style: .continuous)
|
|
.fill(self.item.color.opacity(0.12))
|
|
}
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
|
if self.item.isUnread {
|
|
Circle()
|
|
.fill(OpenClawBrand.accent)
|
|
.frame(width: 7, height: 7)
|
|
.accessibilityHidden(true)
|
|
}
|
|
Text(self.item.title)
|
|
.font(OpenClawType.subheadSemiBold)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.82)
|
|
Spacer(minLength: 6)
|
|
if self.item.isPinned {
|
|
Image(systemName: "pin.fill")
|
|
.font(OpenClawType.caption2Medium)
|
|
.foregroundStyle(OpenClawBrand.accent)
|
|
.accessibilityHidden(true)
|
|
}
|
|
Text(self.item.trailing)
|
|
.font(OpenClawType.caption2Medium)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
HStack(spacing: 8) {
|
|
Text(self.item.detail)
|
|
.font(OpenClawType.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
Spacer(minLength: 6)
|
|
if let progress = self.item.progress {
|
|
ProProgressBar(progress: progress, color: self.item.color)
|
|
.frame(width: 68)
|
|
}
|
|
Text(self.progressLabel)
|
|
.font(OpenClawType.captionSemiBold)
|
|
.foregroundStyle(self.item.color)
|
|
.lineLimit(1)
|
|
.frame(width: 48, alignment: .trailing)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 4)
|
|
.padding(.vertical, 6)
|
|
.contentShape(Rectangle())
|
|
}
|
|
|
|
private var progressLabel: String {
|
|
guard let progress = item.progress else {
|
|
return self.item.state
|
|
}
|
|
if self.item.state == "offline" || self.item.state == "off" || self.item.state == "idle" {
|
|
return self.item.state
|
|
}
|
|
return "\(Int((progress * 100).rounded()))%"
|
|
}
|
|
}
|
|
|
|
struct CommandSessionActionsModifier: ViewModifier {
|
|
private enum Editor {
|
|
case rename
|
|
case newGroup
|
|
}
|
|
|
|
let session: OpenClawChatSessionEntry
|
|
let categories: [String]
|
|
let isArchived: Bool
|
|
let isEnabled: Bool
|
|
let onRename: (String?) -> Void
|
|
let onMoveToGroup: (String?) -> Void
|
|
let onTogglePinned: () -> Void
|
|
let onToggleUnread: () -> Void
|
|
let onFork: () -> Void
|
|
let onToggleArchived: () -> Void
|
|
let onDelete: () -> Void
|
|
|
|
@State private var editor: Editor?
|
|
@State private var draftText = ""
|
|
@State private var confirmsDelete = false
|
|
|
|
func body(content: Content) -> some View {
|
|
if self.isEnabled {
|
|
self.managedContent(content)
|
|
} else {
|
|
content
|
|
}
|
|
}
|
|
|
|
private func managedContent(_ content: Content) -> some View {
|
|
content
|
|
.contextMenu {
|
|
if self.isArchived {
|
|
self.actionButton("Unarchive", systemImage: "archivebox") {
|
|
self.onToggleArchived()
|
|
}
|
|
self.deleteButton
|
|
} else {
|
|
self.actionButton(
|
|
self.session.pinned == true ? "Unpin" : "Pin",
|
|
systemImage: self.session.pinned == true ? "pin.slash" : "pin")
|
|
{
|
|
self.onTogglePinned()
|
|
}
|
|
self.actionButton(
|
|
self.session.unread == true ? "Mark as Read" : "Mark as Unread",
|
|
systemImage: self.session.unread == true ? "envelope.open" : "envelope.badge")
|
|
{
|
|
self.onToggleUnread()
|
|
}
|
|
self.actionButton("Rename…", systemImage: "pencil") {
|
|
self.beginRename()
|
|
}
|
|
self.actionButton("Fork", systemImage: "arrow.triangle.branch") {
|
|
self.onFork()
|
|
}
|
|
self.groupMenu
|
|
self.actionButton("Archive", systemImage: "archivebox") {
|
|
self.onToggleArchived()
|
|
}
|
|
self.deleteButton
|
|
}
|
|
}
|
|
.alert(self.editorTitle, isPresented: self.editorBinding) {
|
|
TextField(self.editorPlaceholder, text: self.$draftText)
|
|
.font(OpenClawType.body)
|
|
Button {
|
|
self.commitEditor()
|
|
} label: {
|
|
Text(self.editor == .rename ? "Save" : "Create")
|
|
.font(OpenClawType.subheadSemiBold)
|
|
}
|
|
Button(role: .cancel) {
|
|
self.editor = nil
|
|
} label: {
|
|
Text("Cancel")
|
|
.font(OpenClawType.subheadSemiBold)
|
|
}
|
|
}
|
|
.confirmationDialog(
|
|
"Delete Session?",
|
|
isPresented: self.$confirmsDelete,
|
|
titleVisibility: .visible)
|
|
{
|
|
Button(role: .destructive) {
|
|
self.onDelete()
|
|
} label: {
|
|
Text("Delete Session")
|
|
.font(OpenClawType.subheadSemiBold)
|
|
}
|
|
Button(role: .cancel) {} label: {
|
|
Text("Cancel")
|
|
.font(OpenClawType.subheadSemiBold)
|
|
}
|
|
} message: {
|
|
Text("This permanently deletes the session and its transcript.")
|
|
.font(OpenClawType.caption)
|
|
}
|
|
}
|
|
|
|
private var groupMenu: some View {
|
|
Menu {
|
|
ForEach(self.categories, id: \.self) { category in
|
|
self.actionButton(category, systemImage: "folder") {
|
|
self.onMoveToGroup(category)
|
|
}
|
|
}
|
|
self.actionButton("New Group…", systemImage: "folder.badge.plus") {
|
|
self.draftText = ""
|
|
self.editor = .newGroup
|
|
}
|
|
if self.normalized(self.session.category) != nil {
|
|
self.actionButton("Remove from Group", systemImage: "folder.badge.minus") {
|
|
self.onMoveToGroup(nil)
|
|
}
|
|
}
|
|
} label: {
|
|
Label("Move to Group", systemImage: "folder")
|
|
.font(OpenClawType.subhead)
|
|
}
|
|
}
|
|
|
|
private var deleteButton: some View {
|
|
Button(role: .destructive) {
|
|
self.confirmsDelete = true
|
|
} label: {
|
|
Label("Delete…", systemImage: "trash")
|
|
.font(OpenClawType.subhead)
|
|
}
|
|
}
|
|
|
|
private var editorBinding: Binding<Bool> {
|
|
Binding(
|
|
get: { self.editor != nil },
|
|
set: { if !$0 { self.editor = nil } })
|
|
}
|
|
|
|
private var editorTitle: String {
|
|
self.editor == .newGroup ? "New Group" : "Rename Session"
|
|
}
|
|
|
|
private var editorPlaceholder: String {
|
|
self.editor == .newGroup ? "Group name" : "Session name"
|
|
}
|
|
|
|
private func actionButton(
|
|
_ title: String,
|
|
systemImage: String,
|
|
action: @escaping () -> Void) -> some View
|
|
{
|
|
Button(action: action) {
|
|
Label(title, systemImage: systemImage)
|
|
.font(OpenClawType.subhead)
|
|
}
|
|
}
|
|
|
|
private func beginRename() {
|
|
self.draftText = self.normalized(self.session.label)
|
|
?? self.normalized(self.session.displayName)
|
|
?? ""
|
|
self.editor = .rename
|
|
}
|
|
|
|
private func commitEditor() {
|
|
let value = self.normalized(self.draftText)
|
|
switch self.editor {
|
|
case .rename:
|
|
self.onRename(value)
|
|
case .newGroup:
|
|
if let value {
|
|
// Web parity: only prompt-created groups join the stored list,
|
|
// so they survive as empty sections after members leave.
|
|
SessionGroupStore.remember(value)
|
|
self.onMoveToGroup(value)
|
|
}
|
|
case nil:
|
|
break
|
|
}
|
|
self.editor = nil
|
|
}
|
|
|
|
private func normalized(_ value: String?) -> String? {
|
|
guard let value else { return nil }
|
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
func commandSessionActions(
|
|
session: OpenClawChatSessionEntry,
|
|
categories: [String],
|
|
isArchived: Bool = false,
|
|
isEnabled: Bool = true,
|
|
onRename: @escaping (String?) -> Void,
|
|
onMoveToGroup: @escaping (String?) -> Void,
|
|
onTogglePinned: @escaping () -> Void,
|
|
onToggleUnread: @escaping () -> Void,
|
|
onFork: @escaping () -> Void,
|
|
onToggleArchived: @escaping () -> Void,
|
|
onDelete: @escaping () -> Void) -> some View
|
|
{
|
|
self.modifier(CommandSessionActionsModifier(
|
|
session: session,
|
|
categories: categories,
|
|
isArchived: isArchived,
|
|
isEnabled: isEnabled,
|
|
onRename: onRename,
|
|
onMoveToGroup: onMoveToGroup,
|
|
onTogglePinned: onTogglePinned,
|
|
onToggleUnread: onToggleUnread,
|
|
onFork: onFork,
|
|
onToggleArchived: onToggleArchived,
|
|
onDelete: onDelete))
|
|
}
|
|
}
|
|
|
|
struct CommandViewMoreRow: View {
|
|
var body: some View {
|
|
Label("View More", systemImage: "chevron.right")
|
|
.font(OpenClawType.subheadBold)
|
|
.foregroundStyle(OpenClawBrand.accent)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 10)
|
|
.contentShape(Rectangle())
|
|
}
|
|
}
|
|
|
|
struct CommandEmptyStateRow: View {
|
|
let icon: String
|
|
let title: String
|
|
let detail: String
|
|
|
|
var body: some View {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: self.icon)
|
|
.font(OpenClawType.captionBold)
|
|
.foregroundStyle(OpenClawBrand.ok)
|
|
.frame(width: 30, height: 30)
|
|
.background {
|
|
RoundedRectangle(cornerRadius: OpenClawRadius.xs, style: .continuous)
|
|
.fill(OpenClawBrand.ok.opacity(0.10))
|
|
}
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(self.title)
|
|
.font(OpenClawType.subheadSemiBold)
|
|
.lineLimit(1)
|
|
Text(self.detail)
|
|
.font(OpenClawType.caption2Medium)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(.horizontal, 4)
|
|
.padding(.vertical, 6)
|
|
}
|
|
}
|