Files
openclaw-openclaw/apps/ios/Sources/Design/CommandCenterSupport.swift
Peter Steinberger 4c97aa1311 feat(mobile): rename, delete, and create session groups on iOS and Android (#101234)
* 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
2026-07-07 01:09:57 +01:00

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)
}
}