mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 04:03:59 +00:00
* 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
178 lines
7.1 KiB
Swift
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)
|
|
}
|
|
}
|