Files
openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSidebarModel.swift
Peter Steinberger bb658d0a6d feat(macos): redesign chat window as native shell with sessions sidebar, toolbar pickers, slash commands, and context usage (#101103)
* feat(chat-ui): native macOS chat window shell with sessions sidebar, context usage, and slash commands

* feat(macos): host redesigned chat window shell and complete gateway chat transport

* fix(chat-ui): gate slash panel on catalog support and register window shell shortcuts outside menus

* fix(chat-ui): make sidebar selection tags optional and sync programmatic composer text into the focused NSTextView

* fix(macos): track in-window session switches for chat window reuse

* fix(chat-ui): re-bootstrap in place when deleting the active main session

* docs(macos): describe the redesigned native chat window

* fix(macos): scope bare global session RPCs to the routing default agent

* style(chat-ui): restore blank line between rebased helpers

* fix(macos): scope selected global session actions

* fix(macos): refresh chat routing identity

* fix(chat-ui): hide delete for main sessions

* fix(macos): remove duplicate session patch path

* fix(chat-ui): resolve sidebar session aliases

* fix(chat-ui): isolate sidebar alias resolution

* fix(chat-ui): reconcile active session aliases

* fix(chat-ui): prefer selected session aliases

* chore(i18n): refresh chat window inventory

* fix(chat-ui): return sidebar filter result

* refactor(chat-ui): split session actions
2026-07-07 02:06:10 +01:00

178 lines
7.1 KiB
Swift

import Foundation
/// Pure grouping/filtering for the macOS sessions sidebar. Kept UI-free so the
/// pin/search/ordering rules stay unit-testable.
enum ChatSessionSidebarModel {
struct Section: Identifiable, Equatable {
let id: String
let title: String?
let sessions: [OpenClawChatSessionEntry]
}
static func isHiddenInternalSession(_ key: String) -> Bool {
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
return trimmed == "onboarding" || trimmed.hasSuffix(":onboarding")
}
@MainActor
static func sections(
sessions: [OpenClawChatSessionEntry],
currentSessionKey: String,
mainSessionKey: String = "main",
activeAgentID: String? = nil,
query: String) -> [Section]
{
let visible = self.visibleSessions(
sessions: sessions,
currentSessionKey: currentSessionKey,
mainSessionKey: mainSessionKey,
activeAgentID: activeAgentID,
query: query)
let pinned = visible.filter { $0.pinned == true }
let recent = visible.filter { $0.pinned != true }
var result: [Section] = []
if !pinned.isEmpty {
result.append(Section(id: "pinned", title: "Pinned", sessions: pinned))
}
if !recent.isEmpty {
result.append(Section(
id: "recent",
title: pinned.isEmpty ? nil : "Recent",
sessions: recent))
}
return result
}
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)
}
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
}
@MainActor
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.
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?,
query: String) -> [OpenClawChatSessionEntry]
{
let selectedSessionKey = self.selectedSessionKey(
sessions: sessions,
currentSessionKey: currentSessionKey,
mainSessionKey: mainSessionKey,
activeAgentID: activeAgentID)
let normalizedCurrent = currentSessionKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let selectedIsResolvedAlias = (normalizedCurrent == "main" || normalizedCurrent == "global") &&
selectedSessionKey.lowercased() != normalizedCurrent
var entries = sessions.filter { entry in
if selectedIsResolvedAlias, entry.key.lowercased() == normalizedCurrent {
return false
}
return entry.key == selectedSessionKey ||
(!self.isHiddenInternalSession(entry.key) && entry.archived != true)
}
if !entries.contains(where: { $0.key == selectedSessionKey }),
!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)
}
}