From f0119e4b457ff76d0cbaefd84b5ed2a8f1fdbb79 Mon Sep 17 00:00:00 2001 From: David Choi Date: Mon, 21 Sep 2026 17:56:10 -0400 Subject: [PATCH 1/2] Use one HostUI markdown view and coalesce overlapping messaging refreshes. Chat and bubbles share the same selectable text path, and inbound or HostUI wakes no longer stack a refresh per signal. Co-authored-by: Cursor --- .../Sources/HostUI/HostUIMarkdown.swift | 41 ++++-- ui/ui/Components/SelectableLinkTextView.swift | 123 ------------------ ui/ui/Messaging/CoalescedMainActorWork.swift | 22 ++++ ui/ui/Messaging/MessagingSessionStore.swift | 10 +- ui/ui/Messaging/MessagingStore.swift | 16 ++- ui/ui/Views/MarkdownView.swift | 7 +- ui/uiTests/CoalescedMainActorWorkTests.swift | 47 +++++++ 7 files changed, 125 insertions(+), 141 deletions(-) delete mode 100644 ui/ui/Components/SelectableLinkTextView.swift create mode 100644 ui/ui/Messaging/CoalescedMainActorWork.swift create mode 100644 ui/uiTests/CoalescedMainActorWorkTests.swift diff --git a/packages/HostUI/Sources/HostUI/HostUIMarkdown.swift b/packages/HostUI/Sources/HostUI/HostUIMarkdown.swift index 73d4a1b..40a68f4 100644 --- a/packages/HostUI/Sources/HostUI/HostUIMarkdown.swift +++ b/packages/HostUI/Sources/HostUI/HostUIMarkdown.swift @@ -25,30 +25,44 @@ extension AttributedString { } } +/// How rendered markdown measures itself. Bubbles hug short copy. Documents fill the proposed width. +public enum HostUIMarkdownSizing: Sendable { + case bubble + case document(maxIdealWidth: CGFloat) +} + public struct HostUIMarkdownText: View { private let text: String private let fontSize: CGFloat - private let maxIdealWidth: CGFloat + private let sizing: HostUIMarkdownSizing public init(_ text: String, font: Font = .system(size: 13), maxIdealWidth: CGFloat = HostUIMessagingLayout.maxBubbleWidth) { self.text = text // HostUI bubbles size via AppKit measurement; keep a numeric size. self.fontSize = 13 - self.maxIdealWidth = maxIdealWidth + self.sizing = .bubble _ = font + _ = maxIdealWidth } public init(_ text: String, fontSize: CGFloat, maxIdealWidth: CGFloat = HostUIMessagingLayout.maxBubbleWidth) { self.text = text self.fontSize = fontSize - self.maxIdealWidth = maxIdealWidth + self.sizing = .bubble + _ = maxIdealWidth + } + + public init(_ text: String, fontSize: CGFloat, sizing: HostUIMarkdownSizing) { + self.text = text + self.fontSize = fontSize + self.sizing = sizing } public var body: some View { HostUIMeasuringMarkdownText( attributedString: HostUIMarkdown.attributed(text), fontSize: fontSize, - maxIdealWidth: maxIdealWidth + sizing: sizing ) .tint(Color(red: 0.176, green: 0.286, blue: 0.576)) } @@ -58,7 +72,7 @@ public struct HostUIMarkdownText: View { private struct HostUIMeasuringMarkdownText: NSViewRepresentable { let attributedString: AttributedString var fontSize: CGFloat = 13 - var maxIdealWidth: CGFloat = 420 + var sizing: HostUIMarkdownSizing = .bubble func makeCoordinator() -> Coordinator { Coordinator() @@ -102,10 +116,7 @@ private struct HostUIMeasuringMarkdownText: NSViewRepresentable { ) -> CGSize? { apply(to: nsView) let proposed = proposal.width.flatMap { $0.isFinite && $0 > 1 ? $0 : nil } - let width = HostUIMessagingLayout.cappedBubbleWidth( - ideal: nsView.idealWidth(), - containerWidth: proposed - ) + let width = measuredWidth(ideal: nsView.idealWidth(), proposed: proposed) let height = nsView.height(forWidth: width) return CGSize(width: width, height: height) } @@ -130,6 +141,18 @@ private struct HostUIMeasuringMarkdownText: NSViewRepresentable { } } + private func measuredWidth(ideal: CGFloat, proposed: CGFloat?) -> CGFloat { + switch sizing { + case .bubble: + return HostUIMessagingLayout.cappedBubbleWidth(ideal: ideal, containerWidth: proposed) + case .document(let maxIdealWidth): + if let proposed { + return proposed + } + return min(max(ideal, 1), maxIdealWidth) + } + } + final class Coordinator: NSObject, NSTextViewDelegate { func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool { let url: URL? diff --git a/ui/ui/Components/SelectableLinkTextView.swift b/ui/ui/Components/SelectableLinkTextView.swift deleted file mode 100644 index 7f71fef..0000000 --- a/ui/ui/Components/SelectableLinkTextView.swift +++ /dev/null @@ -1,123 +0,0 @@ -import AppKit -import SwiftUI - -/// Selectable markdown/attributed text that shows the pointing-hand cursor over links. -/// SwiftUI `Text` + `.textSelection(.enabled)` forces the I-beam and overrides `.pointerStyle(.link)`. -struct SelectableLinkTextView: NSViewRepresentable { - let attributedString: AttributedString - var fontSize: CGFloat = 13 - var textColor: NSColor = .labelColor - /// Cap ideal (uncompressed) width so `ViewThatFits` / trailing bubbles stay reasonable. - var maxIdealWidth: CGFloat = 420 - - func makeCoordinator() -> Coordinator { - Coordinator() - } - - func makeNSView(context: Context) -> MeasuringLinkTextView { - let textView = MeasuringLinkTextView(usingTextLayoutManager: false) - textView.delegate = context.coordinator - textView.isEditable = false - textView.isSelectable = true - textView.isRichText = true - textView.drawsBackground = false - textView.backgroundColor = .clear - textView.textContainerInset = .zero - textView.textContainer?.lineFragmentPadding = 0 - textView.textContainer?.widthTracksTextView = false - textView.isHorizontallyResizable = false - textView.isVerticallyResizable = false - textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) - textView.setContentHuggingPriority(.defaultLow, for: .horizontal) - textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - textView.linkTextAttributes = [ - .foregroundColor: NSColor.linkColor, - .underlineStyle: 0, - .cursor: NSCursor.pointingHand, - ] - apply(to: textView) - return textView - } - - func updateNSView(_ textView: MeasuringLinkTextView, context: Context) { - textView.delegate = context.coordinator - apply(to: textView) - } - - func sizeThatFits( - _ proposal: ProposedViewSize, - nsView: MeasuringLinkTextView, - context: Context - ) -> CGSize? { - apply(to: nsView) - let width: CGFloat - if let proposed = proposal.width, proposed.isFinite, proposed > 1 { - width = proposed - } else { - width = min(max(nsView.idealWidth(), 1), maxIdealWidth) - } - let height = nsView.height(forWidth: width) - return CGSize(width: width, height: height) - } - - private func apply(to textView: MeasuringLinkTextView) { - let nsFont = NSFont.systemFont(ofSize: fontSize) - var styled = attributedString - styled.font = Font(nsFont) - styled.foregroundColor = Color(nsColor: textColor) - - let next = NSMutableAttributedString(attributedString: NSAttributedString(styled)) - next.enumerateAttribute(.link, in: NSRange(location: 0, length: next.length)) { value, range, _ in - guard value != nil else { return } - next.addAttribute(.cursor, value: NSCursor.pointingHand, range: range) - next.addAttribute(.foregroundColor, value: NSColor.linkColor, range: range) - } - - if textView.textStorage?.string != next.string { - textView.textStorage?.setAttributedString(next) - } else if let storage = textView.textStorage, !storage.isEqual(to: next) { - storage.setAttributedString(next) - } - } - - final class Coordinator: NSObject, NSTextViewDelegate { - func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool { - let url: URL? - if let value = link as? URL { - url = value - } else if let value = link as? String { - url = URL(string: value) - } else { - url = nil - } - guard let url else { return false } - NSWorkspace.shared.open(url) - return true - } - } -} - -/// NSTextView that measures height for a given width without fighting SwiftUI layout. -final class MeasuringLinkTextView: NSTextView { - func height(forWidth width: CGFloat) -> CGFloat { - guard let container = textContainer, let layoutManager else { - return ceil(font?.boundingRectForFont.height ?? 16) - } - let clamped = max(width, 1) - container.containerSize = NSSize(width: clamped, height: .greatestFiniteMagnitude) - layoutManager.ensureLayout(for: container) - let used = layoutManager.usedRect(for: container) - return max(ceil(used.height), ceil(font?.boundingRectForFont.height ?? 16)) - } - - func idealWidth() -> CGFloat { - guard let container = textContainer, let layoutManager else { return 1 } - container.containerSize = NSSize( - width: CGFloat.greatestFiniteMagnitude, - height: .greatestFiniteMagnitude - ) - layoutManager.ensureLayout(for: container) - let used = layoutManager.usedRect(for: container) - return max(ceil(used.width), 1) - } -} diff --git a/ui/ui/Messaging/CoalescedMainActorWork.swift b/ui/ui/Messaging/CoalescedMainActorWork.swift new file mode 100644 index 0000000..4676cc5 --- /dev/null +++ b/ui/ui/Messaging/CoalescedMainActorWork.swift @@ -0,0 +1,22 @@ +import Foundation + +/// Runs MainActor work one pass at a time. A call during a pass schedules one more pass. +@MainActor +final class CoalescedMainActorWork { + private var task: Task? + private var generation = 0 + + func schedule(_ work: @escaping @MainActor () async -> Void) { + generation += 1 + guard task == nil else { return } + task = Task { @MainActor [weak self] in + guard let self else { return } + while !Task.isCancelled { + let seen = self.generation + await work() + if self.generation == seen { break } + } + self.task = nil + } + } +} diff --git a/ui/ui/Messaging/MessagingSessionStore.swift b/ui/ui/Messaging/MessagingSessionStore.swift index d9af66a..c50febc 100644 --- a/ui/ui/Messaging/MessagingSessionStore.swift +++ b/ui/ui/Messaging/MessagingSessionStore.swift @@ -36,6 +36,8 @@ final class MessagingSessionStore: ObservableObject { @Published var showNewMessagesPill = false @Published private(set) var lastError: String? @Published private(set) var isMessagingWorkspace = false + private let readOnActivateWork = CoalescedMainActorWork() + private let replyCloseReloadWork = CoalescedMainActorWork() /// Local outbound rows shown before Slack ack / poll. Never persisted. private var pendingOutboundIDs: Set = [] @@ -92,7 +94,9 @@ final class MessagingSessionStore: ObservableObject { let becameActive = active && !isMessagingWorkspace isMessagingWorkspace = active if becameActive { - Task { await markVisibleConversationRead() } + readOnActivateWork.schedule { [weak self] in + await self?.markVisibleConversationRead() + } } } @@ -141,7 +145,9 @@ final class MessagingSessionStore: ObservableObject { selectedReplyParentVendorMessageID = nil visibleReplyMessages = [] replyThreadWarning = nil - Task { await loadNewestWindow() } + replyCloseReloadWork.schedule { [weak self] in + await self?.loadNewestWindow() + } } func toggleMuteSelectedThread() async { diff --git a/ui/ui/Messaging/MessagingStore.swift b/ui/ui/Messaging/MessagingStore.swift index b5ad2f8..96c4246 100644 --- a/ui/ui/Messaging/MessagingStore.swift +++ b/ui/ui/Messaging/MessagingStore.swift @@ -35,6 +35,8 @@ final class MessagingStore: ObservableObject { private var hostUIPresentObserver: DerrickDarwinNotifyObserver? let inboundBannerService = MessagingInboundBannerService() private var connectorCommandGeneration = 0 + private let inboundRefreshWork = CoalescedMainActorWork() + private let hostUIRefreshWork = CoalescedMainActorWork() init() { catalog = MessagingCatalogStore() @@ -53,7 +55,9 @@ final class MessagingStore: ObservableObject { localName: DerrickMessagingInboundSignal.localNotificationName ) { [weak self] in Task { @MainActor in - await self?.refreshFromDaemonInbound() + self?.inboundRefreshWork.schedule { + await self?.refreshFromDaemonInbound() + } } } inboundObserver?.start() @@ -62,7 +66,11 @@ final class MessagingStore: ObservableObject { localName: HostUIPresentWake.localNotificationName ) { [weak self] in let pluginID = HostUIPresentWake.takePendingPluginID() - Task { await self?.refreshHostUIRoot(matching: pluginID) } + Task { @MainActor in + self?.hostUIRefreshWork.schedule { + await self?.refreshHostUIRoot(matching: pluginID) + } + } } hostUIPresentObserver?.start() NotificationCenter.default.publisher(for: PluginFactoryDeletionSignal.didDeletePluginNotification) @@ -71,7 +79,9 @@ final class MessagingStore: ObservableObject { let pluginID = note.userInfo?[PluginFactoryDeletionSignal.pluginIDKey] as? String let fullyRemoved = note.userInfo?[PluginFactoryDeletionSignal.fullyRemovedKey] as? Bool ?? false guard fullyRemoved, let pluginID else { return } - Task { await self?.refreshHostUIRoot(matching: pluginID) } + self?.hostUIRefreshWork.schedule { + await self?.refreshHostUIRoot(matching: pluginID) + } } .store(in: &cancellables) } diff --git a/ui/ui/Views/MarkdownView.swift b/ui/ui/Views/MarkdownView.swift index 1c55b87..9430648 100644 --- a/ui/ui/Views/MarkdownView.swift +++ b/ui/ui/Views/MarkdownView.swift @@ -375,11 +375,10 @@ struct MarkdownResponseView: View { } private func markdownText(_ text: String) -> some View { - SelectableLinkTextView( - attributedString: HostUIMarkdown.attributed(text), + HostUIMarkdownText( + text, fontSize: 15, - textColor: .labelColor, - maxIdealWidth: 720 + sizing: .document(maxIdealWidth: 720) ) } diff --git a/ui/uiTests/CoalescedMainActorWorkTests.swift b/ui/uiTests/CoalescedMainActorWorkTests.swift new file mode 100644 index 0000000..bc63510 --- /dev/null +++ b/ui/uiTests/CoalescedMainActorWorkTests.swift @@ -0,0 +1,47 @@ +import Testing +@testable import ui + +@MainActor +@Suite struct CoalescedMainActorWorkTests { + @Test func overlappingSchedulesCollapseToOneFollowUp() async { + let work = CoalescedMainActorWork() + let gate = WorkGate() + var runs = 0 + + work.schedule { + runs += 1 + if runs == 1 { + await gate.wait() + } + } + while !gate.started { + await Task.yield() + } + work.schedule {} + work.schedule {} + gate.open() + + while runs < 2 { + await Task.yield() + } + #expect(runs == 2) + } +} + +@MainActor +private final class WorkGate { + private var continuation: CheckedContinuation? + private(set) var started = false + + func wait() async { + await withCheckedContinuation { continuation in + self.continuation = continuation + self.started = true + } + } + + func open() { + continuation?.resume() + continuation = nil + } +} From b5e13caefe236c7f2c20a27f16696bf84a9cb429 Mon Sep 17 00:00:00 2001 From: David Choi Date: Mon, 21 Sep 2026 18:16:13 -0400 Subject: [PATCH 2/2] Color $handle and [Derrick:handle] with the same dark green. Message bubbles, the thread title, and reply previews keep that color after the text view applies its base color. Co-authored-by: Cursor --- .../Sources/HostUI/HostUIMarkdown.swift | 43 ++++++++++++++++--- .../HostUI/HostUIMessagingControls.swift | 16 ++++--- .../Sources/HostUI/HostUINodeView.swift | 2 +- .../HostUIProfileTokenColorTests.swift | 31 +++++++++++++ .../Sources/AgentRuntime/AgentProfile.swift | 5 ++- .../StructureTests/AgentProfileTests.swift | 2 +- ui/uiTests/MessagingNavigationTests.swift | 2 +- 7 files changed, 84 insertions(+), 17 deletions(-) create mode 100644 packages/HostUI/Tests/HostUITests/HostUIProfileTokenColorTests.swift diff --git a/packages/HostUI/Sources/HostUI/HostUIMarkdown.swift b/packages/HostUI/Sources/HostUI/HostUIMarkdown.swift index 40a68f4..9a77b56 100644 --- a/packages/HostUI/Sources/HostUI/HostUIMarkdown.swift +++ b/packages/HostUI/Sources/HostUI/HostUIMarkdown.swift @@ -4,17 +4,49 @@ import SwiftUI /// Shared markdown + `$profile` token coloring for HostUI message bodies. public enum HostUIMarkdown { + /// Dark green for `$handle` and `[Derrick:handle]`. Darker than system green on white bubbles. + public static let profileTokenColor = Color(red: 0.0, green: 0.42, blue: 0.18) + public static func attributed(_ text: String) -> AttributedString { var options = AttributedString.MarkdownParsingOptions() options.interpretedSyntax = .inlineOnlyPreservingWhitespace var attributed = (try? AttributedString(markdown: text, options: options)) ?? AttributedString(text) - for range in AgentProfileTokenHighlight.ranges(in: text) { - let snippet = String(text[range]) - if let match = attributed.range(of: snippet) { - attributed[match].foregroundColor = Color(red: 0.0, green: 0.42, blue: 0.18) - } + colorProfileTokens(&attributed) + return attributed + } + + /// Paints `$handle` and `[Derrick:handle]` on already-rendered text. Later copies of the same token are included. + public static func colorProfileTokens(_ attributed: inout AttributedString) { + let source = String(attributed.characters) + var search = attributed.startIndex + for range in AgentProfileTokenHighlight.ranges(in: source) { + let snippet = String(source[range]) + guard !snippet.isEmpty, let match = attributed[search...].range(of: snippet) else { continue } + attributed[match].foregroundColor = profileTokenColor + search = match.upperBound } + } +} + +/// Plain text with `$handle` and `[Derrick:handle]` in `HostUIMarkdown.profileTokenColor`. +public struct HostUIProfileTokenText: View { + private let text: String + private let base: Color + + public init(_ text: String, base: Color) { + self.text = text + self.base = base + } + + public var body: some View { + Text(Self.attributed(text, base: base)) + } + + public static func attributed(_ text: String, base: Color) -> AttributedString { + var attributed = AttributedString(text) + attributed.foregroundColor = base + HostUIMarkdown.colorProfileTokens(&attributed) return attributed } } @@ -126,6 +158,7 @@ private struct HostUIMeasuringMarkdownText: NSViewRepresentable { var styled = attributedString styled.font = Font(nsFont) styled.foregroundColor = Color(nsColor: .labelColor) + HostUIMarkdown.colorProfileTokens(&styled) let next = NSMutableAttributedString(attributedString: NSAttributedString(styled)) next.enumerateAttribute(.link, in: NSRange(location: 0, length: next.length)) { value, range, _ in diff --git a/packages/HostUI/Sources/HostUI/HostUIMessagingControls.swift b/packages/HostUI/Sources/HostUI/HostUIMessagingControls.swift index 689620d..3e632f1 100644 --- a/packages/HostUI/Sources/HostUI/HostUIMessagingControls.swift +++ b/packages/HostUI/Sources/HostUI/HostUIMessagingControls.swift @@ -157,16 +157,19 @@ public struct HostUIMessage: View { HStack(alignment: .center, spacing: 8) { Image(systemName: "bubble.left.and.bubble.right") .font(.caption.weight(.semibold)) + .foregroundStyle(row.replyCount > 0 ? HostUIMessagingLayout.navy : .primary.opacity(0.75)) VStack(alignment: .leading, spacing: 2) { Text(replyTitle) .font(.caption.weight(.semibold)) + .foregroundStyle(row.replyCount > 0 ? HostUIMessagingLayout.navy : .primary.opacity(0.75)) if row.replyCount > 0, let preview = row.replyPreview, !preview.isEmpty { - // Plain one-line preview only — never expand the full thread body here. - Text(HostUIMessagingLayout.collapsedReplyPreview(preview)) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) + HostUIProfileTokenText( + HostUIMessagingLayout.collapsedReplyPreview(preview), + base: .secondary + ) + .font(.caption2) + .lineLimit(1) + .truncationMode(.tail) } } } @@ -179,7 +182,6 @@ public struct HostUIMessage: View { ) } .buttonStyle(.plain) - .foregroundStyle(row.replyCount > 0 ? HostUIMessagingLayout.navy : .primary.opacity(0.75)) .fixedSize(horizontal: true, vertical: false) } diff --git a/packages/HostUI/Sources/HostUI/HostUINodeView.swift b/packages/HostUI/Sources/HostUI/HostUINodeView.swift index a57e8f8..bdb4a8b 100644 --- a/packages/HostUI/Sources/HostUI/HostUINodeView.swift +++ b/packages/HostUI/Sources/HostUI/HostUINodeView.swift @@ -121,7 +121,7 @@ public struct HostUINodeView: View { HostUIPaneWidthReader { VStack(spacing: 0) { HStack { - Text(bindings.replyThreadTitle) + HostUIProfileTokenText(bindings.replyThreadTitle, base: .primary) .font(.headline) .lineLimit(1) Spacer() diff --git a/packages/HostUI/Tests/HostUITests/HostUIProfileTokenColorTests.swift b/packages/HostUI/Tests/HostUITests/HostUIProfileTokenColorTests.swift new file mode 100644 index 0000000..e4360b8 --- /dev/null +++ b/packages/HostUI/Tests/HostUITests/HostUIProfileTokenColorTests.swift @@ -0,0 +1,31 @@ +import HostUI +import Testing + +@MainActor +@Suite struct HostUIProfileTokenColorTests { + @Test func dollarHandleAndSenderPrefixShareDarkGreen() { + let text = "$orchestrator then [Derrick:developer] done" + let attributed = HostUIMarkdown.attributed(text) + let snippets = attributed.runs.compactMap { run -> String? in + guard attributed[run.range].foregroundColor == HostUIMarkdown.profileTokenColor else { + return nil + } + return String(attributed[run.range].characters) + } + #expect(snippets == ["$orchestrator", "[Derrick:developer]"]) + } + + @Test func plainPreviewKeepsTheSameGreenOnTheSenderPrefix() { + let attributed = HostUIProfileTokenText.attributed( + "[Derrick:orchestrator] Searching the web…", + base: .secondary + ) + let green = attributed.runs.compactMap { run -> String? in + guard attributed[run.range].foregroundColor == HostUIMarkdown.profileTokenColor else { + return nil + } + return String(attributed[run.range].characters) + } + #expect(green == ["[Derrick:orchestrator]"]) + } +} diff --git a/packages/Structure/Sources/AgentRuntime/AgentProfile.swift b/packages/Structure/Sources/AgentRuntime/AgentProfile.swift index 0638e99..cb90be7 100644 --- a/packages/Structure/Sources/AgentRuntime/AgentProfile.swift +++ b/packages/Structure/Sources/AgentRuntime/AgentProfile.swift @@ -143,7 +143,7 @@ public enum AgentProfileTokenParser { } } -/// Ranges of `$shortName` tokens (and the profile name inside `[Derrick:handle]`) for UI highlighting. +/// Ranges of `$shortName` tokens and the full `[Derrick:handle]` sender prefix for UI highlighting. public enum AgentProfileTokenHighlight { public static func ranges( in text: String, @@ -197,7 +197,8 @@ public enum AgentProfileTokenHighlight { guard let close = text[handleStart...].firstIndex(of: "]") else { break } let handle = String(text[handleStart..