fix: polish voice overlay and webchat lint

This commit is contained in:
Peter Steinberger
2025-12-08 17:32:34 +01:00
parent 9625d94aa0
commit db3b3ed9eb
5 changed files with 128 additions and 47 deletions

View File

@@ -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
}

View File

@@ -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()

View File

@@ -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
}

View File

@@ -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