Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions desktop/macos/Desktop/Sources/Chat/ChatFollowUpTail.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,14 @@ enum ChatFollowUpTail {
if let range = text.range(of: delimiter) {
return String(text[text.startIndex..<range.lowerBound])
}
let characters = Array(text)
let delimiterCharacters = Array(delimiter)
let maxHold = min(characters.count, delimiterCharacters.count - 1)
// Only the last few characters can be a partial delimiter; copying the
// whole answer into an array to look at them ran on every streaming flush.
let tail = text.suffix(delimiter.count - 1)
let maxHold = tail.count
if maxHold >= 3 {
for length in stride(from: maxHold, through: 3, by: -1) {
if Array(characters.suffix(length)) == Array(delimiterCharacters.prefix(length)) {
return String(characters.prefix(characters.count - length))
if tail.suffix(length).elementsEqual(delimiter.prefix(length)) {
return String(text.dropLast(length))
}
}
}
Expand Down
166 changes: 148 additions & 18 deletions desktop/macos/Desktop/Sources/Chat/ChatOmiMark.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AppKit
import OmiTheme
import SwiftUI

Expand Down Expand Up @@ -109,16 +110,118 @@ struct ChatOmiMark: View {
)
}
case .animated(let motion):
TimelineView(.animation(minimumInterval: nil, paused: false)) { timeline in
Canvas { context, canvasSize in
model.advance(
to: timeline.date,
motion: motion,
reduceMotion: false
)
model.draw(into: &context, size: canvasSize, base: size, anchor: anchor)
}
}
// Drawn by AppKit on its own timer, not by a SwiftUI `TimelineView`. A timeline frame is a SwiftUI graph update, and on
// AppKit every graph update is rendered inside `NSHostingView.layout()`
// — a layout pass of the whole hosting view, an AppKit walk of its
// entire view tree and a Core Animation commit, at display refresh
// rate, for as long as an answer streams. Measured on the mounted
// transcript that alone kept the main thread fully busy with nothing
// arriving. A view that redraws its own layer costs one small
// `draw(_:)` per frame and lays nothing out.
ChatOmiMarkAnimatedFrames(model: model, motion: motion, base: size, anchor: anchor)
}
}
}

/// The animated mark as an AppKit view that repaints itself on its own timer.
struct ChatOmiMarkAnimatedFrames: NSViewRepresentable {
let model: ChatMarkModel
let motion: ChatMarkMotion
let base: CGFloat
let anchor: ChatOmiMark.Anchor

func makeNSView(context: Context) -> ChatOmiMarkFrameView {
let view = ChatOmiMarkFrameView()
view.configure(model: model, motion: motion, base: base, anchor: anchor)
return view
}

func updateNSView(_ view: ChatOmiMarkFrameView, context: Context) {
view.configure(model: model, motion: motion, base: base, anchor: anchor)
}

static func dismantleNSView(_ view: ChatOmiMarkFrameView, coordinator: ()) {
view.stop()
}
}

final class ChatOmiMarkFrameView: NSView {
/// 60 Hz on the main run loop in `.common` mode, the cadence the live-edge
/// pinner already keeps. A display link would pause with the screen, but it
/// also never fires for a window that is not on one, which is where the
/// mounted transcript harness lives; a timer animates wherever the view is
/// mounted and costs one eight-dot `draw(_:)` per tick.
private static let frameInterval: TimeInterval = 1.0 / 60.0

private var model: ChatMarkModel?
private var motion: ChatMarkMotion = .gather
private var base: CGFloat = 30
private var anchor: ChatOmiMark.Anchor = .leading
private var timer: Timer?

/// Top-left origin, matching the SwiftUI `Canvas` the resting frame draws in.
override var isFlipped: Bool { true }

override init(frame: NSRect) {
super.init(frame: frame)
wantsLayer = true
layerContentsRedrawPolicy = .onSetNeedsDisplay
}

@available(*, unavailable)
required init?(coder: NSCoder) { nil }

func configure(model: ChatMarkModel, motion: ChatMarkMotion, base: CGFloat, anchor: ChatOmiMark.Anchor) {
self.model = model
self.motion = motion
self.base = base
self.anchor = anchor
startIfNeeded()
needsDisplay = true
}

override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if window == nil { stop() } else { startIfNeeded() }
}

private func startIfNeeded() {
guard timer == nil, window != nil else { return }
let timer = Timer(timeInterval: Self.frameInterval, repeats: true) { [weak self] _ in
MainActor.assumeIsolated { self?.tick() }
}
RunLoop.main.add(timer, forMode: .common)
self.timer = timer
}

func stop() {
timer?.invalidate()
timer = nil
}

private func tick() {
#if DEBUG
ChatStreamingRenderProbe.hit(.markFrame)
#endif
model?.advance(to: Date(), motion: motion, reduceMotion: false)
needsDisplay = true
}

override func draw(_ dirtyRect: NSRect) {
guard let model, let context = NSGraphicsContext.current?.cgContext else { return }
model.draw(in: context, size: bounds.size, base: base, anchor: anchor)
}

deinit {
// An NSView's dealloc is not guaranteed to land on the main thread —
// SwiftUI can release a representable-backed view from a background
// queue — and an unconditional `MainActor.assumeIsolated` would trap
// there, turning a routine teardown into a crash. The timer's block only
// weakly captures self, so an off-main teardown costs at most one wasted
// tick before the next `tick()` no-ops; `dismantleNSView` stops the timer
// on the main thread for every SwiftUI-managed teardown.
if Thread.isMainThread {
MainActor.assumeIsolated { stop() }
}
}
}
Expand Down Expand Up @@ -197,13 +300,44 @@ final class ChatMarkModel {
}
}

/// One dot's place and opacity for the current frame.
struct DotPlacement: Equatable {
let rect: CGRect
let opacity: Double
}

func draw(
into context: inout GraphicsContext,
size: CGSize,
base: CGFloat,
anchor: ChatOmiMark.Anchor,
resting: Bool = false
) {
for dot in dotPlacements(size: size, base: base, anchor: anchor, resting: resting) {
context.fill(Path(ellipseIn: dot.rect), with: .color(Ink.primary.opacity(dot.opacity)))
}
}

/// The same frame, drawn with Core Graphics for the AppKit-hosted animation.
/// The fill is resolved through the glass's pinned appearance, not the
/// window's: on a dark-Aqua window the transcript panel is still light, and
/// a dynamic `labelColor` would resolve near-white there — dots on glass,
/// invisible. The resting `Ink.primary` Canvas reads the pinned scheme
/// through the environment; this is its AppKit twin.
func draw(in context: CGContext, size: CGSize, base: CGFloat, anchor: ChatOmiMark.Anchor) {
let fill = Ink.nsPrimaryOnGlass
for dot in dotPlacements(size: size, base: base, anchor: anchor, resting: false) {
context.setFillColor(fill.withAlphaComponent(dot.opacity).cgColor)
context.fillEllipse(in: dot.rect)
}
}

func dotPlacements(
size: CGSize,
base: CGFloat,
anchor: ChatOmiMark.Anchor,
resting: Bool
) -> [DotPlacement] {
let dotDiameter = base * Self.dotDiameterRatio
let ringRadius = base * Self.ringRadiusRatio
let centerY = size.height / 2
Expand All @@ -215,7 +349,7 @@ final class ChatMarkModel {
let lineEnd = size.width - dotDiameter / 2
let lineStep = (lineEnd - lineStart) / CGFloat(Self.count - 1)

for index in 0..<Self.count {
return (0..<Self.count).map { index in
let frame = resting ? DotFrame() : frame(dot: index)
let angle =
2 * Double.pi * Double(index) / Double(Self.count)
Expand All @@ -230,13 +364,9 @@ final class ChatMarkModel {
+ base * CGFloat(frame.offsetY)

let diameter = dotDiameter * CGFloat(frame.scale)
let rect = CGRect(
x: x - diameter / 2,
y: y - diameter / 2,
width: diameter,
height: diameter
)
context.fill(Path(ellipseIn: rect), with: .color(Ink.primary.opacity(frame.opacity)))
return DotPlacement(
rect: CGRect(x: x - diameter / 2, y: y - diameter / 2, width: diameter, height: diameter),
opacity: frame.opacity)
}
}

Expand Down
12 changes: 7 additions & 5 deletions desktop/macos/Desktop/Sources/Chat/ChatStreamingBuffer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,8 @@ final class ChatStreamingBuffer {
let seedText = message.text
var accumulator = rawAccumulators[message.id] ?? RawAccumulator(text: message.text)
accumulator.text += text
message.text = normalizeText(message, accumulator.text)
let projectedText = normalizeText(message, accumulator.text)
message.text = projectedText
// Watched against a raw stream this buffer does not own, so the check survives the buffer
// losing its own. See `ChatStreamingTailProbe`.
tailProjectionProbe?.observe(
Expand All @@ -294,10 +295,11 @@ final class ChatStreamingBuffer {
{
let rawBlock = (accumulator.blocks[blockId] ?? existing) + text
accumulator.blocks[blockId] = rawBlock
message.contentBlocks[lastBlockIndex] = .text(
id: blockId,
text: normalizeText(message, rawBlock)
)
// The common answer is one text block, whose raw text *is* the raw
// message text — the projection already computed above. Projecting it a
// second time doubled the per-flush normalization of the whole answer.
let projectedBlock = rawBlock == accumulator.text ? projectedText : normalizeText(message, rawBlock)
message.contentBlocks[lastBlockIndex] = .text(id: blockId, text: projectedBlock)
} else {
let blockId = UUID().uuidString
accumulator.blocks[blockId] = text
Expand Down
55 changes: 53 additions & 2 deletions desktop/macos/Desktop/Sources/Chat/ChatTurnLifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -129,24 +129,73 @@ struct ChatTerminalTargetRegistry<T> {
/// the session.
@MainActor
final class ChatJournalWriteCoordinator {
typealias Operation = @MainActor @Sendable () async -> Void

private var updateTasks: [String: Task<Void, Never>] = [:]
private var terminalizingMessageIDs: Set<String> = []
/// The newest coalescing write per message that has not started yet.
///
/// A streaming flush schedules a journal write of the whole row every
/// ~35 ms. Each used to queue its own task behind the one before it, so
/// whenever a kernel round trip took longer than a flush the queue grew for
/// the rest of the answer — one task and one full-message snapshot per
/// flush, every one of them written, every one of them echoed back into the
/// transcript — and drained only after the answer had already settled. A
/// snapshot of a row that is still streaming is superseded by the next one,
/// so a coalescing write replaces the write waiting here instead of queueing
/// behind it: at most one in flight and one waiting, and the waiting one
/// always carries the newest row.
private var pendingCoalescedOperations: [String: PendingCoalescedWrite] = [:]

/// The slot one queued coalescing task will drain. Later coalescing writes
/// replace what it holds until the task runs — or until a durable write is
/// queued behind it, which seals the slot so the snapshot written before
/// that durable write is the one that was current when it was scheduled.
private final class PendingCoalescedWrite {
var operation: Operation?
init(_ operation: @escaping Operation) { self.operation = operation }
}

@discardableResult
func schedule(
messageID: String,
supersededByTerminalization: Bool = true,
operation: @escaping @MainActor @Sendable () async -> Void
coalescing: Bool = false,
operation: @escaping Operation
) -> Bool {
if supersededByTerminalization, terminalizingMessageIDs.contains(messageID) { return false }
guard coalescing else {
// A durable mutation keeps its place in line, so the snapshot queued
// before it may no longer be replaced by one that arrives after it.
pendingCoalescedOperations.removeValue(forKey: messageID)
enqueue(messageID: messageID, operation)
return true
}
if let pending = pendingCoalescedOperations[messageID] {
pending.operation = operation
return true
}
let pending = PendingCoalescedWrite(operation)
pendingCoalescedOperations[messageID] = pending
enqueue(messageID: messageID) { [weak self] in
if let self, self.pendingCoalescedOperations[messageID] === pending {
self.pendingCoalescedOperations.removeValue(forKey: messageID)
}
guard let latest = pending.operation else { return }
pending.operation = nil
await latest()
}
return true
}

private func enqueue(messageID: String, _ operation: @escaping Operation) {
let previous = updateTasks[messageID]
let task = Task { @MainActor in
_ = await previous?.value
guard !Task.isCancelled else { return }
await operation()
}
updateTasks[messageID] = task
return true
}

func beginTerminalization(messageID: String) async -> Bool {
Expand All @@ -166,6 +215,8 @@ final class ChatJournalWriteCoordinator {
func cancelAll() {
for task in updateTasks.values { task.cancel() }
updateTasks.removeAll()
for pending in pendingCoalescedOperations.values { pending.operation = nil }
pendingCoalescedOperations.removeAll()
terminalizingMessageIDs.removeAll()
}
}
Expand Down
Loading