Files
openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWindowShell.swift
Peter Steinberger 75697ad3bf feat(macos): session detail parity — row actions, unread auto-clear, and subagent tree in native chat (#110019)
* feat(clients): session detail parity — row actions, unread auto-clear, and subagent tree in the native chat sidebar

* fix(clients): overlay pending explicit unread values over stale session snapshots

* chore(i18n): translate new session action strings and regenerate Apple/Android catalogs

* chore(clients): delete dead pin helper and worktree memberwise init
2026-07-17 15:54:08 +01:00

478 lines
18 KiB
Swift

#if os(macOS)
import AppKit
import SwiftUI
import UniformTypeIdentifiers
/// Native macOS chat window: sessions sidebar + transcript detail with the
/// pickers promoted into the unified window toolbar. The compact menu-bar
/// panel keeps using `OpenClawChatView` directly; this shell is the full
/// window experience.
@MainActor
public struct OpenClawChatWindowShell: View {
public static let assistantTraceDefaultsKey = "openclaw.webchat.showAssistantTrace"
@State private var viewModel: OpenClawChatViewModel
@State private var sessionQuery = ""
@State private var isConfirmingClearHistory = false
@State private var isPresentingSessions = false
@State private var isRenamingSession = false
@State private var renameSessionKey: String?
@State private var renameText = ""
private let userAccent: Color?
private let showsAssistantTrace: Bool
private let emptyAssistantIntro: String?
private let emptyAssistantPrompts: [OpenClawChatView.StarterPrompt]
private let talkControl: OpenClawChatTalkControl?
private let voiceNoteControl: OpenClawChatVoiceNoteControl?
private let speech: OpenClawChatSpeechController?
public init(
viewModel: OpenClawChatViewModel,
userAccent: Color? = nil,
showsAssistantTrace: Bool = false,
emptyAssistantIntro: String? = nil,
emptyAssistantPrompts: [OpenClawChatView.StarterPrompt] = [],
talkControl: OpenClawChatTalkControl? = nil,
voiceNoteControl: OpenClawChatVoiceNoteControl? = nil,
speech: OpenClawChatSpeechController? = nil)
{
_viewModel = State(initialValue: viewModel)
self.userAccent = userAccent
self.showsAssistantTrace = showsAssistantTrace
self.emptyAssistantIntro = emptyAssistantIntro
self.emptyAssistantPrompts = emptyAssistantPrompts
self.talkControl = talkControl
self.voiceNoteControl = voiceNoteControl
self.speech = speech
}
public var body: some View {
NavigationSplitView {
ChatSessionSidebar(
viewModel: self.viewModel,
query: self.$sessionQuery)
.navigationSplitViewColumnWidth(min: 200, ideal: 240, max: 340)
} detail: {
OpenClawChatView(
viewModel: self.viewModel,
drawsBackground: false,
userAccent: self.userAccent,
showsAssistantTrace: self.showsAssistantTrace,
composerChrome: .clean,
emptyAssistantIntro: self.emptyAssistantIntro,
emptyAssistantPrompts: self.emptyAssistantPrompts,
talkControl: self.talkControl,
voiceNoteControl: self.voiceNoteControl,
speech: self.speech)
.navigationTitle(self.activeSessionTitle)
.navigationSubtitle(self.subtitle)
.toolbar { self.detailToolbar }
.background(self.keyboardShortcutHandlers)
}
.confirmationDialog(
"Clear this session's history?",
isPresented: self.$isConfirmingClearHistory)
{
Button(role: .destructive) {
self.viewModel.requestSessionReset()
} label: {
Text("Clear History")
.font(OpenClawChatTypography.body)
}
} message: {
Text(verbatim: String(
format: String(localized: """
This resets the conversation for %@. The session key stays the same.
"""),
self.activeSessionTitle))
.font(OpenClawChatTypography.body)
}
.alert(String(localized: "Rename Session"), isPresented: self.$isRenamingSession) {
TextField(String(localized: "Session name"), text: self.$renameText)
Button(String(localized: "Rename")) {
guard let renameSessionKey else { return }
self.viewModel.renameSession(key: renameSessionKey, label: self.renameText)
self.renameSessionKey = nil
}
Button(String(localized: "Cancel"), role: .cancel) {
self.renameSessionKey = nil
}
}
.sheet(isPresented: self.$isPresentingSessions) {
ChatSessionsSheet(viewModel: self.viewModel)
}
.onChange(of: self.viewModel.pendingRunCount) { previous, current in
// Run completion changes timestamps/token totals; pull them once
// per run instead of polling.
if previous > 0, current == 0 {
self.viewModel.refreshSessions(limit: 200)
}
}
}
/// Key equivalents only fire for installed views; buttons inside a closed
/// toolbar Menu are not built yet, so the shortcuts live here and the menu
/// items carry matching labels for discoverability.
private var keyboardShortcutHandlers: some View {
Group {
Button {
Task { await self.viewModel.startNewSession() }
} label: {
Text("New Session")
.font(OpenClawChatTypography.body)
}
.keyboardShortcut("n", modifiers: [.command])
Button {
self.viewModel.refresh()
self.viewModel.refreshSessions(limit: 200)
} label: {
Text("Refresh")
.font(OpenClawChatTypography.body)
}
.keyboardShortcut("r", modifiers: [.command])
Button {
self.exportTranscript()
} label: {
Text("Export Transcript")
.font(OpenClawChatTypography.body)
}
.keyboardShortcut("e", modifiers: [.command, .shift])
.disabled(self.viewModel.messages.isEmpty)
Button {
self.isPresentingSessions = true
} label: {
Text("Sessions")
.font(OpenClawChatTypography.body)
}
.keyboardShortcut("s", modifiers: [.command, .shift])
}
.opacity(0)
.frame(width: 0, height: 0)
.accessibilityHidden(true)
}
private var activeSessionTitle: String {
if let entry = self.activeSessionEntry {
return ChatSessionSidebarModel.displayName(for: entry)
}
return ChatSessionSidebarModel.displayName(forKey: self.viewModel.sessionKey)
}
private var activeSessionEntry: OpenClawChatSessionEntry? {
self.viewModel.sessions.first { $0.key == self.viewModel.sessionKey } ??
self.viewModel.sessions.first {
self.viewModel.matchesCurrentSessionKey(
incoming: $0.key,
current: self.viewModel.sessionKey)
}
}
private var activeSessionKey: String {
self.activeSessionEntry?.key ?? self.viewModel.sessionKey
}
private var subtitle: String {
let model = self.currentModelLabel
guard let usage = self.viewModel.contextUsage, let cost = usage.totalCost else {
return model
}
let costLabel = ChatContextUsageFormatter.cost(cost)
return model.isEmpty ? costLabel : "\(model) · \(costLabel)"
}
private var currentModelLabel: String {
if self.viewModel.modelSelectionID != OpenClawChatViewModel.defaultModelSelectionID {
return self.viewModel.modelSelectionID
}
let entry = self.viewModel.sessions.first { $0.key == self.viewModel.sessionKey }
for candidate in [entry?.model, self.viewModel.sessionDefaults?.model] {
if let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty {
return trimmed
}
}
return ""
}
@ToolbarContentBuilder
private var detailToolbar: some ToolbarContent {
ToolbarItemGroup(placement: .primaryAction) {
if let usage = self.viewModel.contextUsage {
ChatContextUsageMenu(usage: usage) {
self.viewModel.requestSessionCompact()
}
}
if self.viewModel.showsThinkingPicker {
self.thinkingPicker
}
if self.viewModel.showsModelPicker {
self.modelPicker
}
self.sessionActionsMenu
}
}
private var thinkingPicker: some View {
Picker(selection: Binding(
get: { self.viewModel.thinkingLevel },
set: { self.viewModel.selectThinkingLevel($0) }))
{
ForEach(self.viewModel.thinkingLevelOptions) { option in
Text(option.label)
.font(OpenClawChatTypography.body)
.tag(option.id)
}
} label: {
Text("Thinking")
.font(OpenClawChatTypography.body)
}
.pickerStyle(.menu)
.help("Thinking level")
}
private var modelPicker: some View {
let sections = self.viewModel.modelPickerSections
return Picker(selection: Binding(
get: { self.viewModel.modelSelectionID },
set: { self.viewModel.selectModel($0) }))
{
Text(self.viewModel.defaultModelLabel)
.font(OpenClawChatTypography.body)
.tag(OpenClawChatViewModel.defaultModelSelectionID)
if sections.pinned.isEmpty, sections.recent.isEmpty {
self.modelOptions(sections.remaining)
} else {
if !sections.pinned.isEmpty {
Section("Pinned") { self.modelOptions(sections.pinned) }
}
if !sections.recent.isEmpty {
Section("Recent") { self.modelOptions(sections.recent) }
}
if !sections.remaining.isEmpty {
Section("Models") { self.modelOptions(sections.remaining) }
}
}
} label: {
Text("Model")
.font(OpenClawChatTypography.body)
}
.pickerStyle(.menu)
.help("Model")
}
private func modelOptions(_ models: [OpenClawChatModelChoice]) -> some View {
ForEach(models) { model in
Text(model.displayLabel)
.font(OpenClawChatTypography.body)
.tag(model.selectionID)
}
}
private var sessionActionsMenu: some View {
Menu {
Button {
Task { await self.viewModel.startNewSession() }
} label: {
chatWindowActionLabel("New Session", systemImage: "square.and.pencil")
}
.keyboardShortcut("n", modifiers: [.command])
Button {
self.viewModel.refresh()
self.viewModel.refreshSessions(limit: 200)
} label: {
chatWindowActionLabel("Refresh", systemImage: "arrow.clockwise")
}
.keyboardShortcut("r", modifiers: [.command])
Button {
self.isPresentingSessions = true
} label: {
chatWindowActionLabel("Sessions…", systemImage: "rectangle.stack")
}
.keyboardShortcut("s", modifiers: [.command, .shift])
Divider()
Button {
self.renameSessionKey = self.activeSessionKey
self.renameText = self.activeSessionEntry?.label ?? self.activeSessionTitle
self.isRenamingSession = true
} label: {
chatWindowActionLabel(
LocalizedStringKey(String(localized: "Rename Session…")),
systemImage: "pencil")
}
Button {
Task { await self.viewModel.forkSession(key: self.activeSessionKey) }
} label: {
chatWindowActionLabel(
LocalizedStringKey(String(localized: "Fork Session")),
systemImage: "arrow.triangle.branch")
}
Button {
self.viewModel.setSessionPinned(
key: self.activeSessionKey,
pinned: self.activeSessionEntry?.pinned != true)
} label: {
chatWindowActionLabel(
LocalizedStringKey(self.activeSessionEntry?.pinned == true
? String(localized: "Unpin")
: String(localized: "Pin")),
systemImage: self.activeSessionEntry?.pinned == true ? "pin.slash" : "pin")
}
Button {
self.viewModel.setSessionUnread(
key: self.activeSessionKey,
unread: self.activeSessionEntry?.unread != true)
} label: {
chatWindowActionLabel(
LocalizedStringKey(self.activeSessionEntry?.unread == true
? String(localized: "Mark Read")
: String(localized: "Mark Unread")),
systemImage: self.activeSessionEntry?.unread == true ? "envelope.open" : "envelope.badge")
}
if self.activeSessionEntry?.isArchived == true || self.activeSessionEntry.map({
ChatSessionSidebarModel.canArchiveSession(
$0,
mainSessionKey: self.viewModel.resolvedMainSessionKey)
}) == true {
Button {
self.viewModel.setSessionArchived(
key: self.activeSessionKey,
archived: self.activeSessionEntry?.isArchived != true)
} label: {
chatWindowActionLabel(
LocalizedStringKey(self.activeSessionEntry?.isArchived == true
? String(localized: "Restore")
: String(localized: "Archive")),
systemImage: self.activeSessionEntry?.isArchived == true
? "tray.and.arrow.up"
: "archivebox")
}
}
Divider()
Button {
self.copyToPasteboard(self.viewModel.sessionKey)
} label: {
chatWindowActionLabel("Copy Session Key", systemImage: "doc.on.doc")
}
Button {
self.exportTranscript()
} label: {
chatWindowActionLabel("Export Transcript…", systemImage: "square.and.arrow.up")
}
.keyboardShortcut("e", modifiers: [.command, .shift])
.disabled(self.viewModel.messages.isEmpty)
Toggle(isOn: Binding(
get: { self.showsAssistantTrace },
set: {
UserDefaults.standard.set(
$0,
forKey: Self.assistantTraceDefaultsKey)
})) {
chatWindowActionLabel(
"Show reasoning & tool activity",
systemImage: "brain.head.profile")
}
Divider()
Button {
self.viewModel.requestSessionCompact()
} label: {
chatWindowActionLabel("Compact Session", systemImage: "arrow.down.right.and.arrow.up.left")
}
.disabled(self.viewModel.hasBlockingRunActivity)
Button(role: .destructive) {
self.isConfirmingClearHistory = true
} label: {
chatWindowActionLabel("Clear History…", systemImage: "trash")
}
} label: {
chatWindowActionLabel("Session", systemImage: "ellipsis.circle")
}
.menuIndicator(.hidden)
.help("Session actions")
}
private func copyToPasteboard(_ string: String) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(string, forType: .string)
}
private func exportTranscript() {
let markdown = self.viewModel.exportTranscriptMarkdown()
let panel = NSSavePanel()
panel.allowedContentTypes = [UTType(filenameExtension: "md") ?? .plainText]
panel.nameFieldStringValue = ChatTranscriptExporter.filename(
sessionTitle: self.activeSessionTitle,
sessionKey: self.viewModel.sessionKey)
panel.begin { response in
guard response == .OK, let url = panel.url else { return }
try? markdown.write(to: url, atomically: true, encoding: .utf8)
}
}
}
/// Toolbar gauge + dropdown with token/cost details, mirroring the web UI's
/// context ring.
private struct ChatContextUsageMenu: View {
let usage: OpenClawChatContextUsage
let onCompact: () -> Void
var body: some View {
Menu {
Text(self.tokensLine)
.font(OpenClawChatTypography.body(size: 13, weight: .regular, relativeTo: .body))
if let cost = self.usage.totalCost {
Text(verbatim: String(
format: String(localized: "Session cost %@"),
ChatContextUsageFormatter.cost(cost)))
.font(OpenClawChatTypography.body(size: 13, weight: .regular, relativeTo: .body))
}
Divider()
Button(action: self.onCompact) {
Text("Compact Session")
.font(OpenClawChatTypography.body(size: 13, weight: .regular, relativeTo: .body))
}
} label: {
ChatContextUsageIndicator(usage: self.usage)
}
.menuIndicator(.hidden)
.help(self.tokensLine)
}
private var tokensLine: String {
let used = ChatContextUsageFormatter.tokens(self.usage.usedTokens)
guard let window = self.usage.contextWindowTokens else {
return "\(used) tokens used"
}
return "\(used) of \(ChatContextUsageFormatter.tokens(window)) tokens used"
}
}
func chatWindowActionLabel(_ title: LocalizedStringKey, systemImage: String) -> some View {
Label {
Text(title)
.font(OpenClawChatTypography.body(size: 13, weight: .regular, relativeTo: .body))
} icon: {
Image(systemName: systemImage)
}
}
#endif