fix: polish voice overlay and webchat lint
This commit is contained in:
@@ -239,10 +239,16 @@ actor VoicePushToTalk {
|
||||
|
||||
private static func makeAttributed(committed: String, volatile: String, isFinal: Bool) -> NSAttributedString {
|
||||
let full = NSMutableAttributedString()
|
||||
let committedAttr: [NSAttributedString.Key: Any] = [.foregroundColor: NSColor.labelColor]
|
||||
let committedAttr: [NSAttributedString.Key: Any] = [
|
||||
.foregroundColor: NSColor.labelColor,
|
||||
.font: NSFont.systemFont(ofSize: 13, weight: .regular),
|
||||
]
|
||||
full.append(NSAttributedString(string: committed, attributes: committedAttr))
|
||||
let volatileColor: NSColor = isFinal ? .labelColor : .secondaryLabelColor
|
||||
let volatileAttr: [NSAttributedString.Key: Any] = [.foregroundColor: volatileColor]
|
||||
let volatileColor: NSColor = isFinal ? .labelColor : NSColor.labelColor.withAlphaComponent(0.55)
|
||||
let volatileAttr: [NSAttributedString.Key: Any] = [
|
||||
.foregroundColor: volatileColor,
|
||||
.font: NSFont.systemFont(ofSize: 13, weight: .regular),
|
||||
]
|
||||
full.append(NSAttributedString(string: volatile, attributes: volatileAttr))
|
||||
return full
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||
|
||||
private let width: CGFloat = 360
|
||||
private let padding: CGFloat = 10
|
||||
private let buttonWidth: CGFloat = 36
|
||||
private let spacing: CGFloat = 8
|
||||
private let verticalPadding: CGFloat = 8
|
||||
|
||||
func showPartial(transcript: String, attributed: NSAttributedString? = nil) {
|
||||
self.autoSendTask?.cancel()
|
||||
@@ -33,7 +36,7 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||
self.model.isFinal = false
|
||||
self.model.forwardEnabled = false
|
||||
self.model.isSending = false
|
||||
self.model.attributed = attributed ?? NSAttributedString(string: transcript)
|
||||
self.model.attributed = attributed ?? self.makeAttributed(from: transcript)
|
||||
self.present()
|
||||
self.updateWindowFrame(animate: true)
|
||||
}
|
||||
@@ -45,7 +48,7 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||
self.model.isFinal = true
|
||||
self.model.forwardEnabled = forwardConfig.enabled
|
||||
self.model.isSending = false
|
||||
self.model.attributed = attributed ?? NSAttributedString(string: transcript)
|
||||
self.model.attributed = attributed ?? self.makeAttributed(from: transcript)
|
||||
self.present()
|
||||
self.scheduleAutoSend(after: delay)
|
||||
}
|
||||
@@ -58,7 +61,7 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||
func updateText(_ text: String) {
|
||||
self.model.text = text
|
||||
self.model.isSending = false
|
||||
self.model.attributed = NSAttributedString(string: text)
|
||||
self.model.attributed = self.makeAttributed(from: text)
|
||||
self.updateWindowFrame(animate: true)
|
||||
}
|
||||
|
||||
@@ -160,13 +163,8 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||
}
|
||||
|
||||
private func targetFrame() -> NSRect {
|
||||
guard let screen = NSScreen.main, let host = self.hostingView else {
|
||||
return .zero
|
||||
}
|
||||
host.layoutSubtreeIfNeeded()
|
||||
host.invalidateIntrinsicContentSize()
|
||||
let fit = host.fittingSize
|
||||
let height = max(42, min(fit.height, 180))
|
||||
guard let screen = NSScreen.main else { return .zero }
|
||||
let height = self.measuredHeight()
|
||||
let size = NSSize(width: self.width, height: height)
|
||||
let visible = screen.visibleFrame
|
||||
let origin = CGPoint(
|
||||
@@ -189,6 +187,18 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func measuredHeight() -> CGFloat {
|
||||
let attributed = self.model.attributed.length > 0 ? self.model.attributed : self.makeAttributed(from: self.model.text)
|
||||
let maxWidth = self.width - (self.padding * 2) - self.spacing - self.buttonWidth
|
||||
let rect = attributed.boundingRect(
|
||||
with: CGSize(width: maxWidth, height: .greatestFiniteMagnitude),
|
||||
options: [.usesLineFragmentOrigin, .usesFontLeading],
|
||||
context: nil)
|
||||
let contentHeight = ceil(rect.height)
|
||||
let total = contentHeight + self.verticalPadding * 2
|
||||
return max(42, min(total, 220))
|
||||
}
|
||||
|
||||
private func dismissTargetFrame(for frame: NSRect, reason: DismissReason, outcome: SendOutcome) -> NSRect? {
|
||||
switch (reason, outcome) {
|
||||
case (.empty, _):
|
||||
@@ -212,6 +222,15 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||
self?.sendNow()
|
||||
}
|
||||
}
|
||||
|
||||
private func makeAttributed(from text: String) -> NSAttributedString {
|
||||
NSAttributedString(
|
||||
string: text,
|
||||
attributes: [
|
||||
.foregroundColor: NSColor.labelColor,
|
||||
.font: NSFont.systemFont(ofSize: 13, weight: .regular),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
private struct VoiceWakeOverlayView: View {
|
||||
@@ -289,7 +308,7 @@ private struct TranscriptTextView: NSViewRepresentable {
|
||||
let textView = TranscriptNSTextView()
|
||||
textView.delegate = context.coordinator
|
||||
textView.drawsBackground = false
|
||||
textView.isRichText = false
|
||||
textView.isRichText = true
|
||||
textView.isAutomaticQuoteSubstitutionEnabled = false
|
||||
textView.isAutomaticTextReplacementEnabled = false
|
||||
textView.font = .systemFont(ofSize: 13, weight: .regular)
|
||||
@@ -299,6 +318,8 @@ private struct TranscriptTextView: NSViewRepresentable {
|
||||
textView.textContainer?.widthTracksTextView = true
|
||||
textView.textContainer?.containerSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
|
||||
textView.string = self.text
|
||||
textView.textStorage?.setAttributedString(self.attributed)
|
||||
textView.focusRingType = .none
|
||||
textView.onSend = { [weak textView] in
|
||||
textView?.window?.makeFirstResponder(nil)
|
||||
self.onSend()
|
||||
|
||||
@@ -256,13 +256,14 @@ actor VoiceWakeRuntime {
|
||||
self.capturedTranscript = ""
|
||||
self.captureStartedAt = nil
|
||||
self.lastHeard = nil
|
||||
let heardBeyondTrigger = self.heardBeyondTrigger
|
||||
self.heardBeyondTrigger = false
|
||||
|
||||
await MainActor.run { AppStateStore.shared.stopVoiceEars() }
|
||||
|
||||
let forwardConfig = await MainActor.run { AppStateStore.shared.voiceWakeForwardConfig }
|
||||
let delay: TimeInterval = (heardBeyondTrigger && !finalTranscript.isEmpty) ? 1.0 : 3.0
|
||||
// Auto-send should fire as soon as the silence threshold is satisfied (2s after speech, 5s after trigger-only).
|
||||
// Keep the overlay visible during capture; once we finalize, we dispatch immediately.
|
||||
let delay: TimeInterval = 0.0
|
||||
let finalAttributed = Self.makeAttributed(
|
||||
committed: finalTranscript,
|
||||
volatile: "",
|
||||
@@ -339,10 +340,16 @@ actor VoiceWakeRuntime {
|
||||
|
||||
private static func makeAttributed(committed: String, volatile: String, isFinal: Bool) -> NSAttributedString {
|
||||
let full = NSMutableAttributedString()
|
||||
let committedAttr: [NSAttributedString.Key: Any] = [.foregroundColor: NSColor.labelColor]
|
||||
let committedAttr: [NSAttributedString.Key: Any] = [
|
||||
.foregroundColor: NSColor.labelColor,
|
||||
.font: NSFont.systemFont(ofSize: 13, weight: .regular),
|
||||
]
|
||||
full.append(NSAttributedString(string: committed, attributes: committedAttr))
|
||||
let volatileColor: NSColor = isFinal ? .labelColor : .secondaryLabelColor
|
||||
let volatileAttr: [NSAttributedString.Key: Any] = [.foregroundColor: volatileColor]
|
||||
let volatileColor: NSColor = isFinal ? .labelColor : NSColor.labelColor.withAlphaComponent(0.55)
|
||||
let volatileAttr: [NSAttributedString.Key: Any] = [
|
||||
.foregroundColor: volatileColor,
|
||||
.font: NSFont.systemFont(ofSize: 13, weight: .regular),
|
||||
]
|
||||
full.append(NSAttributedString(string: volatile, attributes: volatileAttr))
|
||||
return full
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import WebKit
|
||||
|
||||
private let webChatLogger = Logger(subsystem: "com.steipete.clawdis", category: "WebChat")
|
||||
|
||||
@MainActor
|
||||
final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
|
||||
private let webView: WKWebView
|
||||
private let sessionKey: String
|
||||
@@ -43,7 +44,7 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
|
||||
deinit {
|
||||
@MainActor deinit {
|
||||
self.reachabilityTask?.cancel()
|
||||
self.tunnel?.terminate()
|
||||
}
|
||||
@@ -90,7 +91,8 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
|
||||
if CommandResolver.connectionModeIsRemote() {
|
||||
return try await self.startOrRestartTunnel()
|
||||
} else {
|
||||
return URL(string: "http://127.0.0.1:\(remotePort)/")!
|
||||
return URL(string: "http://127.0.0.1:\(remotePort)/")!
|
||||
}
|
||||
}
|
||||
|
||||
private func loadWebChat(baseEndpoint: URL) {
|
||||
@@ -120,7 +122,6 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
|
||||
throw NSError(domain: "WebChat", code: 7, userInfo: [NSLocalizedDescriptionKey: "webchat unreachable: \(error.localizedDescription)"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startOrRestartTunnel() async throws -> URL {
|
||||
// Kill existing tunnel if any
|
||||
|
||||
Reference in New Issue
Block a user