Files
openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSidebarModel.swift
Peter Steinberger 0fff74c800 feat(ios): replace the tab bar with a black overlay sidebar (web parity + dashboard) (#111339)
* feat(ios): unify navigation on a black overlay sidebar and drop the phone tab bar

* test(ios): rework navigation tests and snapshot UITests for the sidebar shell

* chore(i18n): sync derived native catalogs for sidebar strings

* feat(ios): reveal the sidebar beneath a push-away rounded content card

* feat(ios): web-parity sidebar — agent switcher, pinned pages, sessions vocabulary, uniform content card

* chore(i18n): translate sidebar and session strings across native catalogs

* fix(ios): sidebar footer shows connection dot only when degraded, like the web

* feat(ios): sidebar toggle top-left with edge-swipe open; card surface owns the drawer chrome

* fix(ios): single sidebar refresh owner, alias-aware Home badges, RM tap-close, user pin order, drop vestigial idiom shims

* feat(ios): align sidebar with the mobile prototype — agent roster, theme-following palette, web session subtitles, row badges

* chore(i18n): translate agent roster and Recent strings across native catalogs

* feat(ios): frosted-glass agent picker with uniform drawer insets

* fix(ios): address sidebar review findings — settings toggle edge, per-agent scoping, alias-aware selection, cron paging, refresh cadence

* fix(ios): clear landing CI blockers
2026-07-19 14:50:39 -07:00

358 lines
16 KiB
Swift

import Foundation
/// Pure grouping/filtering shared by the Apple sessions sidebars. Kept UI-free
/// so pin/search/ordering rules stay unit-testable across macOS and iOS.
public enum ChatSessionSidebarModel {
public struct Badges: Equatable, Sendable {
public let runningCount: Int
public let failedCount: Int
public let hasUnread: Bool
}
public struct Node: Identifiable, Equatable, Sendable {
public let session: OpenClawChatSessionEntry
public let children: [Node]
public let badges: Badges
public var id: String {
self.session.key
}
}
public struct Section: Identifiable, Equatable, Sendable {
public let id: String
public let title: String?
public let nodes: [Node]
}
public static func isHiddenInternalSession(_ key: String) -> Bool {
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
return trimmed == "onboarding" || trimmed.hasSuffix(":onboarding")
}
/// `excludesMainSession` is for sidebars with a dedicated Home row (iOS);
/// the macOS sidebar keeps the main session inside its sections.
@MainActor
public static func sections(
sessions: [OpenClawChatSessionEntry],
currentSessionKey: String,
mainSessionKey: String = "main",
activeAgentID: String? = nil,
groups: [OpenClawChatSessionGroup] = [],
excludesMainSession: Bool = false,
query: String) -> [Section]
{
let visible = self.visibleSessions(
sessions: sessions,
currentSessionKey: currentSessionKey,
mainSessionKey: mainSessionKey,
activeAgentID: activeAgentID,
excludesMainSession: excludesMainSession,
query: query)
// Pin state owns first placement. Group sections then preserve the
// same tree builder, so grouped parent/child rosters still nest.
let pinned = self.tree(from: visible.filter { $0.pinned == true })
let unpinned = visible.filter { $0.pinned != true }
let orderedGroups = groups.sorted { lhs, rhs in
lhs.position == rhs.position ? lhs.name < rhs.name : lhs.position < rhs.position
}
let groupNames = Set(orderedGroups.map(\.name))
let recent = self.tree(from: unpinned.filter { session in
guard let category = session.category else { return true }
return !groupNames.contains(category)
})
var result: [Section] = []
if !pinned.isEmpty {
result.append(Section(id: "pinned", title: "Pinned", nodes: pinned))
}
for group in orderedGroups {
let nodes = self.tree(from: unpinned.filter { $0.category == group.name })
if !nodes.isEmpty {
result.append(Section(id: "group:\(group.name)", title: group.name, nodes: nodes))
}
}
if !recent.isEmpty {
result.append(Section(
id: "recent",
title: result.isEmpty ? nil : "Recent",
nodes: recent))
}
return result
}
static func tree(from sessions: [OpenClawChatSessionEntry]) -> [Node] {
let hierarchyPresent = sessions.contains { session in
self.normalizedKey(session.spawnedBy) != nil ||
self.normalizedKey(session.parentSessionKey) != nil ||
session.childSessions != nil
}
guard hierarchyPresent else {
return sessions.map { self.node(session: $0, children: []) }
}
var entriesByKey: [String: OpenClawChatSessionEntry] = [:]
for session in sessions where entriesByKey[session.key] == nil {
entriesByKey[session.key] = session
}
var parentByChild: [String: String] = [:]
// The gateway child roster is freshness-filtered and omitted when
// empty. Persisted parent metadata can outlive that freshness window,
// so it is display metadata only and must not recreate stale edges.
for parent in sessions {
for childKey in parent.childSessions ?? [] where childKey != parent.key {
if entriesByKey[childKey] != nil, parentByChild[childKey] == nil {
parentByChild[childKey] = parent.key
}
}
}
var orderByKey: [String: Int] = [:]
for (offset, session) in sessions.enumerated() where orderByKey[session.key] == nil {
orderByKey[session.key] = offset
}
for session in sessions {
var path: [String] = []
var indexByKey: [String: Int] = [:]
var cursor: String? = session.key
while let current = cursor, let parent = parentByChild[current] {
indexByKey[current] = path.count
path.append(current)
if let cycleStart = indexByKey[parent] {
let cycle = path[cycleStart...]
let root = cycle.min { (orderByKey[$0] ?? Int.max) < (orderByKey[$1] ?? Int.max) }
if let root {
parentByChild[root] = nil
}
break
}
cursor = parent
}
}
var childrenByParent: [String: [OpenClawChatSessionEntry]] = [:]
for session in sessions {
if let parentKey = parentByChild[session.key] {
childrenByParent[parentKey, default: []].append(session)
}
}
func build(_ session: OpenClawChatSessionEntry, ancestors: Set<String>) -> Node {
guard !ancestors.contains(session.key) else {
return Self.node(session: session, children: [])
}
var nextAncestors = ancestors
nextAncestors.insert(session.key)
let children = (childrenByParent[session.key] ?? []).map {
build($0, ancestors: nextAncestors)
}
return Self.node(session: session, children: children)
}
return sessions.compactMap { session in
guard parentByChild[session.key] == nil else { return nil }
return build(session, ancestors: [])
}
}
private static func node(session: OpenClawChatSessionEntry, children: [Node]) -> Node {
let status = session.status?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let isRunning = session.hasActiveRun == true || session.hasActiveSubagentRun == true || status == "running"
let hasFailed = status == "failed" || status == "timeout"
return Node(
session: session,
children: children,
badges: Badges(
runningCount: (isRunning ? 1 : 0) + children.reduce(0) { $0 + $1.badges.runningCount },
failedCount: (hasFailed ? 1 : 0) + children.reduce(0) { $0 + $1.badges.failedCount },
hasUnread: session.unread == true || children.contains { $0.badges.hasUnread }))
}
private static func normalizedKey(_ key: String?) -> String? {
let trimmed = key?.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed?.isEmpty == false ? trimmed : nil
}
public static func displayName(for session: OpenClawChatSessionEntry) -> String {
for candidate in [session.displayName, session.label] {
if let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines),
!trimmed.isEmpty
{
return trimmed
}
}
return self.displayName(forKey: session.key)
}
/// Compact "repo \u{2387} branch" line for worktree/work sessions; mirrors the
/// web sidebar row subtitle (ui/src/lib/session-display.ts).
public static func workSubtitle(for session: OpenClawChatSessionEntry) -> String? {
let repoRoot = session.worktree?.repoRoot?.trimmingCharacters(in: .whitespacesAndNewlines)
let branch = session.worktree?.branch?.trimmingCharacters(in: .whitespacesAndNewlines)
let repoName = repoRoot?.split(separator: "/").last.map(String.init)
let shortBranch = branch.map { $0.hasPrefix("openclaw/") ? String($0.dropFirst("openclaw/".count)) : $0 }
guard let repoName, !repoName.isEmpty else { return nil }
guard let shortBranch, !shortBranch.isEmpty else { return repoName }
return "\(repoName) \u{2387} \(shortBranch)"
}
public static func canDeleteSession(key: String, mainSessionKey: String) -> Bool {
let normalized = key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let normalizedMain = mainSessionKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return normalized != "main" && normalized != "global" && normalized != normalizedMain
}
public static func canArchiveSession(
_ session: OpenClawChatSessionEntry,
mainSessionKey: String) -> Bool
{
let status = session.status?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return self.canDeleteSession(key: session.key, mainSessionKey: mainSessionKey) &&
session.hasActiveRun != true &&
session.hasActiveSubagentRun != true &&
status != "running"
}
static func isSessionInActiveAgentScope(key: String, activeAgentID: String?) -> Bool {
let normalizedAgent = activeAgentID?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
guard !normalizedAgent.isEmpty else { return true }
let parts = key.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false)
guard parts.count == 3, parts[0].lowercased() == "agent" else { return true }
return parts[1].lowercased() == normalizedAgent
}
@MainActor
public static func selectedSessionKey(
sessions: [OpenClawChatSessionEntry],
currentSessionKey: String,
mainSessionKey: String,
activeAgentID: String?) -> String
{
let normalizedCurrent = currentSessionKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let normalizedAgent = activeAgentID?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let preferredAliasKey = if normalizedCurrent == "global",
let normalizedAgent,
!normalizedAgent.isEmpty
{
"agent:\(normalizedAgent):global"
} else if normalizedCurrent == "main" {
mainSessionKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
} else {
""
}
// The selected wrapper is the active session even when archived;
// active rows stay visible until the user leaves them.
if let preferred = sessions.first(where: { $0.key.lowercased() == preferredAliasKey }) {
return preferred.key
}
if sessions.contains(where: { $0.key == currentSessionKey }) {
return currentSessionKey
}
return sessions.first(where: {
OpenClawChatViewModel.matchesCurrentSessionKey(
incoming: $0.key,
current: currentSessionKey,
mainSessionKey: mainSessionKey,
activeAgentId: activeAgentID)
})?.key ?? currentSessionKey
}
/// Session keys read as routing ids ("agent:main:main"); show the human
/// part and keep the owning agent as a suffix only when it disambiguates.
public static func displayName(forKey key: String) -> String {
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
let parts = trimmed.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false)
guard parts.count == 3, parts[0] == "agent" else {
return trimmed.isEmpty ? key : trimmed
}
let agent = String(parts[1])
let session = String(parts[2])
if session.isEmpty { return trimmed }
return agent == "main" || agent.isEmpty ? session : "\(session) (\(agent))"
}
@MainActor
private static func visibleSessions(
sessions: [OpenClawChatSessionEntry],
currentSessionKey: String,
mainSessionKey: String,
activeAgentID: String?,
excludesMainSession: Bool,
query: String) -> [OpenClawChatSessionEntry]
{
let scopedSessions = sessions.filter {
self.isSessionInActiveAgentScope(key: $0.key, activeAgentID: activeAgentID)
}
let selectedSessionKey = self.selectedSessionKey(
sessions: scopedSessions,
currentSessionKey: currentSessionKey,
mainSessionKey: mainSessionKey,
activeAgentID: activeAgentID)
let resolvedMainSessionKey = self.selectedSessionKey(
sessions: scopedSessions,
currentSessionKey: "main",
mainSessionKey: mainSessionKey,
activeAgentID: activeAgentID)
let normalizedCurrent = currentSessionKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let selectedIsResolvedAlias = (normalizedCurrent == "main" || normalizedCurrent == "global") &&
selectedSessionKey.lowercased() != normalizedCurrent
let selectedIsMain = normalizedCurrent == "main" ||
selectedSessionKey.caseInsensitiveCompare(resolvedMainSessionKey) == .orderedSame
var entries = scopedSessions.filter { entry in
if excludesMainSession,
entry.key.caseInsensitiveCompare(resolvedMainSessionKey) == .orderedSame
{
// Home owns the main row. Removing it before tree construction
// naturally promotes any retained child rows to section roots.
return false
}
if selectedIsResolvedAlias, entry.key.lowercased() == normalizedCurrent {
return false
}
return entry.key == selectedSessionKey ||
(!self.isHiddenInternalSession(entry.key) && entry.archived != true)
}
if !(excludesMainSession && selectedIsMain),
!entries.contains(where: { $0.key == selectedSessionKey }),
self.isSessionInActiveAgentScope(key: selectedSessionKey, activeAgentID: activeAgentID),
!currentSessionKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
// Sessions can lag behind a fresh switch/new-session; keep the
// active row selectable instead of showing an empty selection.
entries.append(self.placeholder(key: currentSessionKey))
}
entries.sort { (($0.updatedAt ?? $0.lastActivityAt) ?? 0) > (($1.updatedAt ?? $1.lastActivityAt) ?? 0) }
let needle = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !needle.isEmpty else { return entries }
return entries.filter { entry in
self.displayName(for: entry).lowercased().contains(needle) ||
entry.key.lowercased().contains(needle)
}
}
private static func placeholder(key: String) -> OpenClawChatSessionEntry {
OpenClawChatSessionEntry(
key: key,
kind: nil,
displayName: nil,
surface: nil,
subject: nil,
room: nil,
space: nil,
updatedAt: nil,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
modelProvider: nil,
model: nil,
contextTokens: nil)
}
}