| #if os(macOS) |
| import AppKit |
| import SwiftUI |
| import UniformTypeIdentifiers |
|
|
| |
| |
| |
| @MainActor |
| public struct OpenClawChatWindowShell: View { |
| public nonisolated static let assistantTraceDefaultsKey = "openclaw.webchat.showAssistantTrace" |
| public nonisolated static let assistantReasoningDefaultsKey = "openclaw.webchat.showAssistantReasoning" |
| public nonisolated static let assistantToolActivityDefaultsKey = "openclaw.webchat.showAssistantToolActivity" |
|
|
| @State private var viewModel: OpenClawChatViewModel |
| @Environment(\.colorScheme) private var colorScheme |
| |
| @AppStorage(OpenClawChatWindowShell.assistantReasoningDefaultsKey) |
| private var storedShowsReasoning: Bool? |
| @AppStorage(OpenClawChatWindowShell.assistantToolActivityDefaultsKey) |
| private var storedShowsToolActivity: Bool? |
| @State private var sessionQuery = "" |
| @State private var isConfirmingClearHistory = false |
| @State private var isPresentingSessions = false |
| @State private var isRenamingSession = false |
| @State private var isPresentingNewSessionOptions = false |
| @State private var renameSessionTarget: OpenClawChatSessionTarget? |
| @State private var renameText = "" |
| private let attentionRequests: [OpenClawChatAttentionRequest] |
| private let userAccent: Color? |
| private let displayOptions: OpenClawChatDisplayOptions |
| private let emptyAssistantIntro: String? |
| private let emptyAssistantPrompts: [OpenClawChatView.StarterPrompt] |
| private let talkControl: OpenClawChatTalkControl? |
| private let voiceNoteControl: OpenClawChatVoiceNoteControl? |
| private let speech: OpenClawChatSpeechController? |
| private let mediaPlaybackAllowed: @MainActor @Sendable () -> Bool |
|
|
| |
| public init( |
| viewModel: OpenClawChatViewModel, |
| userAccent: Color? = nil, |
| attentionRequests: [OpenClawChatAttentionRequest] = [], |
| displayOptions: OpenClawChatDisplayOptions? = nil, |
| showsAssistantTrace: Bool = false, |
| emptyAssistantIntro: String? = nil, |
| emptyAssistantPrompts: [OpenClawChatView.StarterPrompt] = [], |
| talkControl: OpenClawChatTalkControl? = nil, |
| voiceNoteControl: OpenClawChatVoiceNoteControl? = nil, |
| speech: OpenClawChatSpeechController? = nil, |
| mediaPlaybackAllowed: @escaping @MainActor @Sendable () -> Bool = { true }) |
| { |
| _viewModel = State(initialValue: viewModel) |
| self.attentionRequests = attentionRequests |
| self.userAccent = userAccent |
| self.displayOptions = displayOptions ?? .assistantTrace(showsAssistantTrace) |
| self.emptyAssistantIntro = emptyAssistantIntro |
| self.emptyAssistantPrompts = emptyAssistantPrompts |
| self.talkControl = talkControl |
| self.voiceNoteControl = voiceNoteControl |
| self.speech = speech |
| self.mediaPlaybackAllowed = mediaPlaybackAllowed |
| } |
|
|
| public var body: some View { |
| NavigationSplitView { |
| ChatSessionSidebar( |
| viewModel: self.viewModel, |
| query: self.$sessionQuery, |
| additionalAttentionRequests: self.attentionRequests) |
| .navigationSplitViewColumnWidth(min: 220, ideal: 260, max: 360) |
| } detail: { |
| OpenClawChatView( |
| viewModel: self.viewModel, |
| drawsBackground: false, |
| userAccent: self.userAccent, |
| displayOptions: self.displayOptions, |
| assistantName: self.viewModel.selectedAgent?.displayName, |
| assistantAvatarText: self.viewModel.selectedAgent?.emoji, |
| showsAssistantAvatars: false, |
| composerChrome: .clean, |
| messagePlaceholder: self.viewModel.selectedAgent.map { |
| String(format: String(localized: "Message %@…"), $0.displayName) |
| }, |
| emptyAssistantIntro: self.emptyAssistantIntro, |
| emptyAssistantPrompts: self.emptyAssistantPrompts, |
| talkControl: self.talkControl, |
| voiceNoteControl: self.voiceNoteControl, |
| speech: self.speech, |
| mediaPlaybackAllowed: self.mediaPlaybackAllowed) |
| .environment(\.openClawChatDesktopLayout, true) |
| .navigationTitle(self.activeSessionTitle) |
| .toolbar { self.detailToolbar } |
| .background(self.keyboardShortcutHandlers) |
| .background(OpenClawChatTheme.desktopCanvas(in: self.colorScheme)) |
| } |
| .task { await self.viewModel.refreshAgents() } |
| .confirmationDialog( |
| "Clear this thread'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 Thread"), isPresented: self.$isRenamingSession) { |
| TextField(String(localized: "Thread name"), text: self.$renameText) |
| Button(String(localized: "Rename")) { |
| guard let target = self.renameSessionTarget else { return } |
| self.viewModel.renameSession( |
| key: target.sessionKey, |
| label: self.renameText, |
| agentID: target.agentID) |
| self.renameSessionTarget = nil |
| } |
| Button(String(localized: "Cancel"), role: .cancel) { |
| self.renameSessionTarget = nil |
| } |
| } |
| .sheet(isPresented: self.$isPresentingSessions) { |
| ChatSessionsSheet(viewModel: self.viewModel) |
| } |
| .onChange(of: self.viewModel.pendingRunCount) { previous, current in |
| |
| |
| if previous > 0, current == 0 { |
| self.viewModel.refreshSessions(limit: 200) |
| } |
| } |
| } |
|
|
| |
| |
| |
| private var keyboardShortcutHandlers: some View { |
| Group { |
| Button { |
| Task { await self.viewModel.startNewSession() } |
| } label: { |
| Text("New Thread") |
| .font(OpenClawChatTypography.body) |
| } |
| .keyboardShortcut("n", modifiers: [.command, .shift]) |
| .focusable(false) |
|
|
| Button { |
| self.viewModel.refresh() |
| self.viewModel.refreshSessions(limit: 200) |
| } label: { |
| Text("Refresh") |
| .font(OpenClawChatTypography.body) |
| } |
| .keyboardShortcut("r", modifiers: [.command]) |
| .focusable(false) |
|
|
| Button { |
| self.exportTranscript() |
| } label: { |
| Text("Export Transcript") |
| .font(OpenClawChatTypography.body) |
| } |
| .keyboardShortcut("e", modifiers: [.command, .shift]) |
| .focusable(false) |
| .disabled(self.viewModel.messages.isEmpty) |
|
|
| Button { |
| self.isPresentingSessions = true |
| } label: { |
| Text("Threads") |
| .font(OpenClawChatTypography.body) |
| } |
| .keyboardShortcut("s", modifiers: [.command, .shift]) |
| .focusable(false) |
| } |
| .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.currentSessionEntry() |
| } |
|
|
| private var activeSessionKey: String { |
| self.activeSessionEntry?.key ?? self.viewModel.sessionKey |
| } |
|
|
| @ToolbarContentBuilder |
| private var detailToolbar: some ToolbarContent { |
| if #available(macOS 26.0, *) { |
| ToolbarItem(placement: .principal) { |
| self.conversationIdentity |
| } |
| .sharedBackgroundVisibility(.hidden) |
| } else { |
| ToolbarItem(placement: .principal) { |
| self.conversationIdentity |
| } |
| } |
| ToolbarItem(placement: .primaryAction) { |
| self.sessionActionsMenu |
| } |
| } |
|
|
| private var conversationIdentity: some View { |
| TimelineView(.periodic(from: .now, by: 30)) { context in |
| HStack(spacing: 8) { |
| if let agent = self.viewModel.selectedAgent { |
| ChatSidebarAgentAvatar(agent: agent, size: 24) |
| } |
| VStack(alignment: .leading, spacing: 2) { |
| Text(self.activeSessionTitle) |
| .font(OpenClawChatTypography.body(size: 13, weight: .semibold, relativeTo: .headline)) |
| HStack(spacing: 6) { |
| if let agent = self.viewModel.selectedAgent { |
| Text(verbatim: agent.displayName) |
| } |
| if let status = self.conversationStatus(at: context.date) { |
| Text(verbatim: "·").accessibilityHidden(true) |
| Label(status.title, systemImage: status.symbol) |
| .labelStyle(.titleAndIcon) |
| .foregroundStyle(status.tint) |
| } |
| } |
| .font(OpenClawChatTypography.caption) |
| .foregroundStyle(.secondary) |
| } |
| .lineLimit(1) |
| } |
| .padding(.horizontal, 4) |
| .padding(.vertical, 6) |
| .accessibilityElement(children: .combine) |
| .accessibilityIdentifier("chat-conversation-identity") |
| } |
| } |
|
|
| private func conversationStatus(at date: Date) -> (title: String, symbol: String, tint: Color)? { |
| if !self.viewModel.healthOK { |
| return (String(localized: "Connecting…"), "network", .secondary) |
| } |
| if self.viewModel.composerModelAvailabilityMessage != nil { |
| return (String(localized: "Sign-in needed"), "key", OpenClawChatTheme.warning) |
| } |
| if self.viewModel.visibleQuestionCards.contains(where: { $0.status(at: date) == .pending }) { |
| return (String(localized: "Needs you"), "bubble.left", OpenClawChatTheme.warning) |
| } |
| let activity = self.activeSessionEntry.flatMap { |
| ChatSessionSidebarModel.activity(for: $0, now: date.timeIntervalSince1970 * 1000) |
| } |
| if activity?.kind == .attention { |
| return (String(localized: "Needs you"), "exclamationmark.bubble", OpenClawChatTheme.warning) |
| } |
| if activity?.kind == .queued { |
| return (String(localized: "Queued"), "hourglass", .secondary) |
| } |
| if self.viewModel.hasBlockingRunActivity { |
| return (String(localized: "Working"), "circle.dotted", .secondary) |
| } |
| guard let activity else { return nil } |
| switch activity.kind { |
| case .attention: |
| return (String(localized: "Needs you"), "exclamationmark.bubble", OpenClawChatTheme.warning) |
| case .running: |
| return (String(localized: "Working"), "circle.dotted", .secondary) |
| case .queued: |
| return (String(localized: "Queued"), "hourglass", .secondary) |
| case .failed: |
| return (String(localized: "Failed"), "exclamationmark.triangle", OpenClawChatTheme.warning) |
| case .finished: |
| return (String(localized: "Finished"), "checkmark", .secondary) |
| case .idle: |
| return (String(localized: "Ready"), "circle", .secondary) |
| case .unknown: |
| return nil |
| } |
| } |
|
|
| private var sessionActionsMenu: some View { |
| Menu { |
| Button { |
| Task { await self.viewModel.startNewSession() } |
| } label: { |
| chatWindowActionLabel("New Thread", systemImage: "square.and.pencil") |
| } |
| .keyboardShortcut("n", modifiers: [.command, .shift]) |
|
|
| Button { |
| self.isPresentingNewSessionOptions = true |
| } label: { |
| chatWindowActionLabel("New Thread Options…", systemImage: "slider.horizontal.3") |
| } |
|
|
| 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("Threads…", systemImage: "rectangle.stack") |
| } |
| .keyboardShortcut("s", modifiers: [.command, .shift]) |
|
|
| Divider() |
|
|
| Button { |
| self.renameSessionTarget = self.viewModel.currentSessionTarget |
| self.renameText = self.activeSessionEntry?.label ?? self.activeSessionTitle |
| self.isRenamingSession = true |
| } label: { |
| chatWindowActionLabel( |
| LocalizedStringKey(String(localized: "Rename Thread…")), |
| systemImage: "pencil") |
| } |
|
|
| Button { |
| let target = self.viewModel.currentSessionTarget |
| Task { await self.viewModel.forkSession(key: target.sessionKey, agentID: target.agentID) } |
| } label: { |
| chatWindowActionLabel( |
| LocalizedStringKey( |
| self.activeSessionEntry?.hasActiveRun == true |
| ? String(localized: "Fork from last completed message") |
| : String(localized: "Fork")), |
| systemImage: "arrow.triangle.branch") |
| } |
|
|
| Button { |
| self.viewModel.setSessionPinned( |
| key: self.activeSessionKey, |
| pinned: self.activeSessionEntry?.pinned != true, |
| agentID: self.viewModel.currentSessionTarget.agentID) |
| } 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, |
| agentID: self.viewModel.currentSessionTarget.agentID) |
| } 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.map({ |
| ChatSessionSidebarModel.canArchiveSession( |
| $0, |
| mainSessionKey: self.viewModel.selectedAgentMainSessionKey) |
| }) == true { |
| Button { |
| if let activeSessionEntry = self.activeSessionEntry { |
| self.viewModel.setSessionArchived( |
| activeSessionEntry, |
| archived: !activeSessionEntry.isArchived) |
| } |
| } label: { |
| chatWindowActionLabel( |
| LocalizedStringKey(self.activeSessionEntry?.isArchived == true |
| ? String(localized: "Restore") |
| : String(localized: "Archive")), |
| systemImage: self.activeSessionEntry?.isArchived == true |
| ? "tray.and.arrow.up" |
| : "archivebox") |
| } |
| } |
|
|
| OpenClawSessionColorMenu(color: self.activeSessionEntry?.color) { color in |
| let target = self.viewModel.currentSessionTarget |
| Task { |
| await self.viewModel.setSessionColor( |
| key: target.sessionKey, |
| color: color, |
| agentID: target.agentID) |
| } |
| } |
|
|
| 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.displayOptions.contains(.reasoning) }, |
| set: { self.storedShowsReasoning = $0 })) |
| { |
| chatWindowActionLabel( |
| "Show Reasoning", |
| systemImage: "brain.head.profile") |
| } |
|
|
| Toggle(isOn: Binding( |
| get: { self.displayOptions.contains(.toolActivity) }, |
| set: { self.storedShowsToolActivity = $0 })) |
| { |
| chatWindowActionLabel( |
| "Show Tool Activity", |
| systemImage: "hammer") |
| } |
|
|
| Divider() |
|
|
| Button { |
| self.viewModel.requestSessionCompact() |
| } label: { |
| chatWindowActionLabel("Compact Thread", 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("Thread", systemImage: "ellipsis.circle") |
| } |
| .popover(isPresented: self.$isPresentingNewSessionOptions) { |
| ChatNewSessionOptionsPopover(viewModel: self.viewModel) { |
| self.isPresentingNewSessionOptions = false |
| } |
| } |
| .menuIndicator(.hidden) |
| .help("Thread 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) |
| } |
| } |
| } |
|
|
| 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 |
|
|