Files
openclaw/apps/macos/Sources/OpenClaw/QuickChatView.swift
Peter Steinberger 5143d909b9 feat(macos): Quick Chat streamed replies and continue-recent targeting (#110631)
* feat(macos): stream Quick Chat conversations

* docs(macos): explain Quick Chat recents

* fix(macos): bind the Quick Chat reply consumer before dispatching the send

* fix(macos): pre-bind reply consumer for all send paths; gate rebind on visible route

* fix(macos): revalidate recents eligibility after the async fetch

* fix(macos): invalidate in-flight recents fetches on competing interactions

* fix(macos): single-owner recents invalidation; fresh presentations clear prepared replies

* docs(macos): name the accepted scheduler-scale pre-bind tradeoff

* chore(i18n): refresh native locale artifacts for new Quick Chat strings

* fix(macos): keep Quick Chat placeholder and recents titles localizable
2026-07-18 15:58:49 +01:00

269 lines
9.9 KiB
Swift

import AppKit
import Observation
import OpenClawChatUI
import SwiftUI
@MainActor
struct QuickChatView: View {
@Bindable var model: QuickChatModel
@Bindable var replyBinding: QuickChatReplyBinding
let onDismiss: () -> Void
let onSendAccepted: (Bool) -> Void
let onShowAgentPicker: () -> Void
let onShowRecentSessions: () -> Void
let onWindowScreenshot: () -> Void
let onContentHeightChange: (CGFloat) -> Void
let onTextViewReady: (NSTextView) -> Void
@State private var editorHeight: CGFloat = 30
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(.ultraThinMaterial)
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(Color.primary.opacity(0.08), lineWidth: 1)
VStack(spacing: 0) {
self.inputRow
if let status = self.statusLine {
HStack {
Text(status.message)
.font(.caption)
.foregroundStyle(status.isError ? Color.red : Color.orange)
Spacer()
}
.padding(.horizontal, 14)
.padding(.bottom, 9)
.transition(.opacity.combined(with: .move(edge: .top)))
}
if self.model.shouldShowPermissionStrip {
self.permissionStrip
.transition(.opacity.combined(with: .move(edge: .top)))
}
if self.replyBinding.route != nil, let viewModel = self.replyBinding.viewModel {
self.replyArea(viewModel: viewModel)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
}
.frame(width: 620)
.fixedSize(horizontal: false, vertical: true)
.animation(.spring(duration: 0.25), value: self.model.shouldShowPermissionStrip)
.animation(.easeOut(duration: 0.14), value: self.statusLine?.message)
.animation(.spring(duration: 0.28), value: self.replyBinding.route)
.onGeometryChange(for: CGFloat.self) { proxy in
proxy.size.height
} action: { height in
self.onContentHeightChange(height)
}
}
@ViewBuilder private var placeholder: some View {
if let override = self.model.targetSessionOverride {
Text("Reply in \(override.displayName)")
} else {
Text("Message \(self.model.agentDisplay.name)")
}
}
private var inputRow: some View {
HStack(spacing: 10) {
self.agentChip
ZStack(alignment: .leading) {
if self.model.text.isEmpty {
// Interpolated string literals keep the placeholder localizable
// (SwiftUI's LocalizedStringKey path). Routing through a computed
// String would select the verbatim initializer and drop translation.
self.placeholder
.font(.system(size: 13.5))
.foregroundStyle(.tertiary)
.padding(.leading, 2)
.allowsHitTesting(false)
}
QuickChatTextView(
text: self.$model.text,
onSubmit: self.submit,
onEscape: self.onDismiss,
onHeightChange: { self.editorHeight = $0 },
onTextViewReady: self.onTextViewReady)
.frame(height: self.editorHeight)
}
.frame(maxWidth: .infinity)
if self.model.sendState != .sending {
Button(action: self.onShowRecentSessions) {
Image(systemName: "clock.arrow.circlepath")
.font(.system(size: 16.5, weight: .medium))
.foregroundStyle(.secondary)
.frame(width: 22, height: 22)
}
.buttonStyle(.plain)
.disabled(!self.model.canSelectRecentSession)
.help("Continue a recent conversation")
.accessibilityLabel("Continue a recent conversation")
}
if self.model.sendState != .sending {
Button(action: self.onWindowScreenshot) {
Image(systemName: "camera.viewfinder")
.font(.system(size: 16.5, weight: .medium))
.foregroundStyle(.secondary)
.frame(width: 22, height: 22)
}
.buttonStyle(.plain)
.disabled(!self.model.canCaptureWindow)
.help("Send a window screenshot")
.accessibilityLabel("Send a window screenshot")
}
Button {
self.submit(openChat: false)
} label: {
Group {
switch self.model.sendState {
case .sending:
ProgressView()
.controlSize(.small)
case .sent:
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
case .idle, .failed:
Image(systemName: "arrow.up.circle.fill")
.foregroundStyle(self.model.canSend ? Color.accentColor : Color.secondary)
}
}
.font(.system(size: 22))
.frame(width: 24, height: 24)
}
.buttonStyle(.plain)
.disabled(!self.model.canSend)
.accessibilityLabel("Send message")
}
.padding(14)
}
@ViewBuilder
private var agentChip: some View {
if self.model.agents.count > 1 {
Button(action: self.onShowAgentPicker) {
self.agentAvatar
}
.buttonStyle(.plain)
.disabled(self.model.sendState == .sending)
.help(self.model.agentDisplay.name)
} else {
self.agentAvatar
.help(self.model.agentDisplay.name)
}
}
private var agentAvatar: some View {
ZStack {
Circle()
.fill(self.agentTint.opacity(0.18))
if case let .image(data) = self.model.agentDisplay.avatar,
let image = NSImage(data: data)
{
Image(nsImage: image)
.resizable()
.scaledToFill()
.clipShape(Circle())
} else if let emoji = self.model.agentDisplay.emoji {
Text(emoji)
.font(.system(size: 16))
} else {
Text(self.model.agentDisplay.monogram)
.font(.caption.weight(.semibold))
.foregroundStyle(self.agentTint)
}
}
.frame(width: 28, height: 28)
.accessibilityLabel(self.model.agentDisplay.name)
}
private var agentTint: Color {
Color(
hue: self.model.agentDisplay.tintHue,
saturation: 0.62,
brightness: 0.72)
}
private var permissionStrip: some View {
VStack(spacing: 0) {
Divider()
HStack(spacing: 10) {
Image(systemName: "exclamationmark.shield")
.foregroundStyle(.orange)
VStack(alignment: .leading, spacing: 2) {
Text("Needs additional permissions")
.font(.caption.weight(.semibold))
Text(self.model.missingPermissions.map(\.permissionDisplayName).joined(separator: ", "))
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
Button("Grant") {
self.model.grantMissingPermissions()
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(self.model.isGrantingPermissions)
Button("Not now") {
self.model.dismissPermissionsForSession()
}
.buttonStyle(.bordered)
.controlSize(.small)
}
.padding(12)
}
}
private func replyArea(viewModel: OpenClawChatViewModel) -> some View {
VStack(spacing: 0) {
Divider()
OpenClawChatView(
viewModel: viewModel,
drawsBackground: false,
showsSessionSwitcher: false,
displayOptions: [],
showsAssistantAvatars: false,
composerChrome: .clean,
isComposerEnabled: false,
isAttachmentInputEnabled: false)
.id(self.replyBinding.route)
.frame(height: 300)
}
}
private var statusLine: (message: String, isError: Bool)? {
if case let .failed(message) = self.model.sendState {
return (message, true)
}
if let message = self.model.connectionStatusMessage {
return (message, false)
}
return nil
}
private func submit(openChat: Bool) {
guard self.model.canSend, let presentationID = self.model.activePresentationID else { return }
Task {
guard await self.model.send() else { return }
if openChat {
// The user may have dismissed (or reopened) the bar while the ack was in
// flight; acting then would close the wrong presentation or steal focus.
guard self.model.activePresentationID == presentationID else { return }
self.onSendAccepted(true)
return
}
guard self.model.activePresentationID == presentationID else { return }
self.onSendAccepted(false)
}
}
}