diff --git a/apps/ios/ADE/App/ADEAppDelegate.swift b/apps/ios/ADE/App/ADEAppDelegate.swift index 6356f5246..690afc72f 100644 --- a/apps/ios/ADE/App/ADEAppDelegate.swift +++ b/apps/ios/ADE/App/ADEAppDelegate.swift @@ -25,6 +25,18 @@ final class ADEAppDelegate: NSObject, UIApplicationDelegate { return true } + /// Transcript render caches (parsed Markdown blocks, inline attributed + /// strings, syntax highlighting) are all derived state — a long chat can + /// hold megabytes of it, and every entry can be rebuilt on demand. When the + /// system says it wants memory back, give it these first. + func applicationDidReceiveMemoryWarning(_ application: UIApplication) { + workPurgeMarkdownRenderCaches() + ADECodeRenderingCache.shared.purgeOnMemoryWarning() + MainActor.assumeIsolated { + WorkPendingUploadPreviewStore.shared.purge() + } + } + /// Register the approval-alert category so approval pushes carry inline /// Approve / Deny actions on the lock screen and in Notification Center. The /// brain stamps `aps.category = "ADE_APPROVAL"` on those alerts; the action diff --git a/apps/ios/ADE/Views/Components/ADECodeRenderingCache.swift b/apps/ios/ADE/Views/Components/ADECodeRenderingCache.swift index 3ed0510cf..0ce2af745 100644 --- a/apps/ios/ADE/Views/Components/ADECodeRenderingCache.swift +++ b/apps/ios/ADE/Views/Components/ADECodeRenderingCache.swift @@ -8,6 +8,10 @@ final class ADECodeRenderingCache { private let attributedCache = NSCache() private let regexCache = NSCache() private let regexLock = NSLock() + /// Streaming state, not a cache: one in-progress code block per language. + /// Guarded by its own lock because highlighting is reachable from any actor. + private let prefixLock = NSLock() + private var highlightPrefixes: [FilesLanguage: SyntaxHighlightPrefix] = [:] private init() { tokenCache.countLimit = 64 @@ -31,6 +35,28 @@ final class ADECodeRenderingCache { attributedCache.setObject(AttributedStringBox(value: attributed), forKey: key as NSString) } + func highlightPrefix(for language: FilesLanguage) -> SyntaxHighlightPrefix? { + prefixLock.lock() + defer { prefixLock.unlock() } + return highlightPrefixes[language] + } + + func storeHighlightPrefix(_ prefix: SyntaxHighlightPrefix, for language: FilesLanguage) { + prefixLock.lock() + defer { prefixLock.unlock() } + highlightPrefixes[language] = prefix + } + + /// Drops derived renders. Compiled regexes are cheap to hold and hot on every + /// highlight, so they stay. + func purgeOnMemoryWarning() { + tokenCache.removeAllObjects() + attributedCache.removeAllObjects() + prefixLock.lock() + highlightPrefixes.removeAll() + prefixLock.unlock() + } + func regex(for pattern: String) -> NSRegularExpression? { let key = pattern as NSString if let cached = regexCache.object(forKey: key) { diff --git a/apps/ios/ADE/Views/Components/FilesCodeSupport.swift b/apps/ios/ADE/Views/Components/FilesCodeSupport.swift index 0484248e7..fa43a784e 100644 --- a/apps/ios/ADE/Views/Components/FilesCodeSupport.swift +++ b/apps/ios/ADE/Views/Components/FilesCodeSupport.swift @@ -126,9 +126,152 @@ struct SyntaxToken: Identifiable, Equatable { let range: NSRange } +/// The already-highlighted stable prefix of the code block currently streaming +/// in a given language. It always ends just past a newline that no token spans, +/// so re-highlighting from there cannot disagree with a whole-text render. +struct SyntaxHighlightPrefix { + let text: String + let attributed: AttributedString +} + +/// The delimiters whose matches can run across a newline, per language. +/// +/// Nearly every rule here can: not only block comments and backticks, but any +/// `"(?:[^"\\]|\\.)*"` string, because `[^"\\]` matches `\n`. Which characters +/// those are is language-specific — `'` opens a string in Python but not in +/// JSON, and Go's raw backticks process no escapes — so the boundary scan reads +/// this table instead of assuming one grammar for every language. +struct SyntaxMultilineDelimiters { + /// A delimiter that is its own closer (`"`, `'`, `` ` ``). + /// + /// `escapes` mirrors whether that rule's pattern consumes `\\.`. It is per + /// delimiter, not per language: Go's `"` strings take escapes while its raw + /// backtick strings do not, and getting it wrong in either direction + /// mis-counts the delimiter and flips parity for every following line. + struct Symmetric { + let character: Character + var escapes: Bool = true + } + + var symmetric: [Symmetric] = [] + /// Open/close pairs (`/* */`, ``). Neither processes escapes. + var pairs: [(open: String, close: String)] = [] + + static let none = SyntaxMultilineDelimiters() +} + +/// UTF-16 offset just past the last newline at which every multi-line delimiter +/// is balanced, or 0 when there is no such newline. +/// +/// This asks a deliberately weaker question than "what is open here?". A state +/// machine would have to model how the rules interact, but the tokenizer runs +/// each rule independently over the whole text, so a `'` inside a `//` comment +/// really does start a string match. Balance can't be fooled that way: an +/// unbalanced delimiter anywhere since the last boundary simply refuses the +/// split. Wrong guesses only ever cost a shorter prefix — never a wrong render. +/// +/// The tokens themselves can't answer this either: while a block comment is +/// still unterminated mid-stream, no token covers it yet, and a boundary placed +/// inside it would be frozen in before the closer arrives. +/// Scanning resumes at `startOffset`, which must itself be a confirmed boundary: +/// everything before it is balanced by definition, so the counts start clean and +/// the per-tick cost is the length of the new tail rather than the whole block. +private func syntaxStableBoundaryOffset( + in text: String, + from startOffset: Int, + delimiters: SyntaxMultilineDelimiters +) -> Int { + guard !text.isEmpty, startOffset <= text.utf16.count else { return startOffset } + let start = String.Index(utf16Offset: startOffset, in: text) + + // Each symmetric delimiter is counted with its own escape rule, so one + // delimiter's escapes cannot mis-count another's. + var counts = [Int](repeating: 0, count: delimiters.symmetric.count) + var pendingEscape = [Bool](repeating: false, count: delimiters.symmetric.count) + var pairDepths = [Int](repeating: 0, count: delimiters.pairs.count) + var boundary = startOffset + var offset = startOffset + var index = start + + func isBalanced() -> Bool { + counts.allSatisfy { $0 % 2 == 0 } && pairDepths.allSatisfy { $0 == 0 } + } + + while index < text.endIndex { + let character = text[index] + let width = character.utf16.count + + if character == "\n" { + if isBalanced() { + boundary = offset + width + // Everything before here is confirmed closed, so later lines start clean. + for position in counts.indices { counts[position] = 0 } + } + for position in pendingEscape.indices { pendingEscape[position] = false } + offset += width + index = text.index(after: index) + continue + } + + var matchedPair = false + for (position, pair) in delimiters.pairs.enumerated() { + if syntaxMatches(pair.open, in: text, at: index) { + pairDepths[position] += 1 + offset += pair.open.utf16.count + index = text.index(index, offsetBy: pair.open.count) + matchedPair = true + break + } + if syntaxMatches(pair.close, in: text, at: index) { + pairDepths[position] = max(0, pairDepths[position] - 1) + offset += pair.close.utf16.count + index = text.index(index, offsetBy: pair.close.count) + matchedPair = true + break + } + } + if matchedPair { continue } + + for (position, delimiter) in delimiters.symmetric.enumerated() { + if pendingEscape[position] { + pendingEscape[position] = false + continue + } + // Only inside an open string of *this* delimiter (odd count). Outside + // one a backslash escapes nothing here, and the rules agree: the string + // patterns have no preceding-backslash check, so a `\\'` sitting in a + // comment really can open a match that runs to the next apostrophe lines + // later. Swallowing it would mark that newline stable and freeze the + // span before the closer arrives. + if character == "\\", delimiter.escapes, counts[position] % 2 == 1 { + pendingEscape[position] = true + continue + } + if character == delimiter.character { + counts[position] += 1 + } + } + + offset += width + index = text.index(after: index) + } + return boundary +} + +/// Whether `marker` occurs at `index` without running past the end of `text`. +private func syntaxMatches(_ marker: String, in text: String, at index: String.Index) -> Bool { + var cursor = index + for character in marker { + guard cursor < text.endIndex, text[cursor] == character else { return false } + cursor = text.index(after: cursor) + } + return true +} + + struct SyntaxHighlighter { static func tokenize(_ text: String, as language: FilesLanguage) -> [SyntaxToken] { - let cacheKey = "tokens|\(language.rawValue)|\(text)" + let cacheKey = "tokens|\(language.rawValue)|\(workStableDigest(text))" if let cached = ADECodeRenderingCache.shared.tokens(for: cacheKey) { return cached } @@ -156,31 +299,140 @@ struct SyntaxHighlighter { return tokens } + /// Syntax-highlights a code block, incrementally while it is still streaming. + /// + /// A streaming block grows by a few characters per delta. Highlighting the + /// whole text each time is O(n) regex work *plus* O(n) attribute application + /// per token, which made a long block the most expensive main-thread path in + /// an agent reply. This mirrors what `parseMarkdownBlocksForStreaming` does + /// for prose: everything up to the last line boundary that is provably outside + /// a multi-line construct can never be re-interpreted by text arriving later, + /// so it is highlighted once and reused; only the growing tail is re-scanned. static func highlightedAttributedString(_ text: String, as language: FilesLanguage) -> AttributedString { - let cacheKey = "highlighted|\(language.rawValue)|\(text)" + let cacheKey = "highlighted|\(language.rawValue)|\(workStableDigest(text))" if let cached = ADECodeRenderingCache.shared.highlightedString(for: cacheKey) { return cached } - var attributed = AttributedString(text) - attributed.font = .system(.body, design: .monospaced) - attributed.foregroundColor = ADEColor.textPrimary - - for token in tokenize(text, as: language) { - guard let stringRange = Range(token.range, in: text) else { continue } - let startOffset = text.distance(from: text.startIndex, to: stringRange.lowerBound) - let endOffset = text.distance(from: text.startIndex, to: stringRange.upperBound) - let lowerBound = attributed.characters.index(attributed.startIndex, offsetBy: startOffset) - let upperBound = attributed.characters.index(attributed.startIndex, offsetBy: endOffset) - let attributeRange = lowerBound.. AttributedString { + guard let delimiters = multilineDelimiters(for: language) else { + // This language has newline-crossing rules the balance scan cannot model. + return highlightedSegment(text[...], as: language) + } + + let reusable = ADECodeRenderingCache.shared.highlightPrefix(for: language) + .flatMap { prefix -> SyntaxHighlightPrefix? in + // Byte-prefix check: only a block that literally grew from this prefix + // may reuse it. A different block of the same language starts over. + guard !prefix.text.isEmpty, text.hasPrefix(prefix.text) else { return nil } + return prefix + } + + // The scan and both highlight passes start at the reused prefix, so a tick + // costs the length of the new tail rather than the whole block. + let scanOffset = reusable.map { $0.text.utf16.count } ?? 0 + let boundaryOffset = syntaxStableBoundaryOffset( + in: text, + from: scanOffset, + delimiters: delimiters + ) + + let boundary = String.Index(utf16Offset: boundaryOffset, in: text) + let scanStart = String.Index(utf16Offset: scanOffset, in: text) + + var attributed = reusable?.attributed ?? AttributedString() + if boundary > scanStart { + attributed.append(highlightedSegment(text[scanStart.. AttributedString { + let text = String(segment) + guard !text.isEmpty else { return AttributedString() } + let tokens = tokenize(text, as: language) + + // Rules match independently, so a `.type` inside a string or a keyword + // inside a comment produces overlapping ranges — including a short token + // fully contained in a long one. Assigning attributes in sorted order let + // the later token win the overlapping positions and left the rest of the + // earlier token intact; painting per position reproduces that precedence + // exactly, without needing an index into the string being built. + let utf16Count = text.utf16.count + var roles = [SyntaxTokenRole?](repeating: nil, count: utf16Count) + for token in tokens { + let lower = max(0, token.range.location) + let upper = min(utf16Count, NSMaxRange(token.range)) + guard lower < upper else { continue } + for position in lower.. [NSTextCheckingResult] { guard let regex = ADECodeRenderingCache.shared.regex(for: pattern) else { return [] @@ -188,6 +440,74 @@ struct SyntaxHighlighter { return regex.matches(in: text, options: [], range: NSRange(location: 0, length: (text as NSString).length)) } + /// Mirrors the newline-crossing constructs in `tokenRules(for:)`. Keep the two + /// in step: a delimiter missing here can let the stable prefix split inside a + /// construct, and an extra one only shortens the prefix. + /// The delimiters for a language whose newline-crossing constructs the balance + /// scan can model completely, or `nil` when it cannot. + /// + /// `nil` means "do not reuse a prefix for this language" — it highlights whole + /// text per tick, exactly as it did before incremental highlighting existed. + /// Some rules cross a newline without any delimiter at all: CSS matches a + /// selector list through `[...\s,>+~]*\s*\{`, YAML's key rule opens with + /// `^\s*`, and a Markdown link's `[^\]]+` spans lines. Modeling those would + /// mean re-implementing each regex, and a model that is *nearly* right is what + /// produced three separate boundary bugs here. Declaring the gap costs those + /// three languages the speedup and costs correctness nothing. + static func multilineDelimiters(for language: FilesLanguage) -> SyntaxMultilineDelimiters? { + typealias Symmetric = SyntaxMultilineDelimiters.Symmetric + let blockComment = [(open: "/*", close: "*/")] + let quote = Symmetric(character: "\"") + let apostrophe = Symmetric(character: "'") + switch language { + case .swift: + return SyntaxMultilineDelimiters(symmetric: [quote], pairs: blockComment) + case .typescript, .javascript: + return SyntaxMultilineDelimiters( + symmetric: [quote, apostrophe, Symmetric(character: "`")], + pairs: blockComment + ) + case .python: + return SyntaxMultilineDelimiters(symmetric: [quote, apostrophe]) + case .rust, .java: + return SyntaxMultilineDelimiters(symmetric: [quote, apostrophe], pairs: blockComment) + case .go: + // Raw strings are backtick-delimited and process no escapes, so a + // backslash before the closing backtick does not escape it. + return SyntaxMultilineDelimiters( + symmetric: [quote, Symmetric(character: "`", escapes: false)], + pairs: blockComment + ) + case .html: + return SyntaxMultilineDelimiters( + symmetric: [quote, apostrophe], + pairs: [(open: "")] + ) + case .plaintext: + return .none + case .css, .yaml, .markdown, .json: + // Each has a rule whose match depends on text the boundary cannot see: + // CSS's selector list runs through `[...\s,>+~]*\s*\{`, YAML's key rule + // opens with `^\s*`, a Markdown link's `[^\]]+` spans lines, and JSON's + // key rule only matches once its `(?=\s*:)` lookahead finds the colon — + // which may arrive after the newline. No prefix reuse for these. + return nil + } + } + + /// Fingerprint of a language's rule patterns. + /// + /// Whether a language may reuse a stable prefix is a claim about *these + /// patterns*: that nothing in them can match across a newline except the + /// delimiters `multilineDelimiters(for:)` counts. That claim cannot be + /// re-derived at runtime, and every time it has been wrong the symptom was a + /// completed code block frozen mis-highlighted in cache. A pinned test hashes + /// this, so editing a rule for an opted-in language fails loudly instead of + /// silently invalidating the boundary. + static func tokenRuleFingerprint(for language: FilesLanguage) -> String { + workStableDigest(tokenRules(for: language).map(\.pattern).joined(separator: "\u{1F}")) + } + private static func tokenRules(for language: FilesLanguage) -> [TokenRule] { let numberRule = TokenRule(role: .number, pattern: #"\b\d+(?:\.\d+)?\b"#) switch language { @@ -291,7 +611,10 @@ private struct TokenRule { let pattern: String } -private extension SyntaxTokenRole { +// Not `private`: the highlighter's equivalence tests reproduce the previous +// whole-text algorithm as their baseline, and a baseline that substitutes its +// own colors cannot prove the two renderings agree. +extension SyntaxTokenRole { var tint: Color { switch self { case .keyword: diff --git a/apps/ios/ADE/Views/Work/WorkChatAttachmentTray.swift b/apps/ios/ADE/Views/Work/WorkChatAttachmentTray.swift index 23a6851f2..bd333d701 100644 --- a/apps/ios/ADE/Views/Work/WorkChatAttachmentTray.swift +++ b/apps/ios/ADE/Views/Work/WorkChatAttachmentTray.swift @@ -24,6 +24,127 @@ private enum WorkChatRemoteImageError: Error { case responseTooLarge } +/// Placeholder scheme for an image the composer has accepted but not yet saved +/// to the host. The local echo carries these refs so the user's bubble and its +/// thumbnails paint on the tap frame; `sendMessage` swaps them for the real host +/// paths once the save round-trip returns, before the message is sent. +/// +/// A ref with this prefix never reaches the wire. +let workPendingUploadPathPrefix = "ade-pending-upload://" + +/// Roughly one message's worth of attachments (`workChatInputAttachmentLimit`). +let workPendingUploadPreviewLimit = 10 + +/// Chips render at 56-72pt, so ~256px covers 3x displays. The composer's own +/// image is the *upload* render at up to 2400px — around 23 MB decoded, which +/// ten of would be a quarter-gigabyte resident for thumbnails nobody sees at +/// that size. +private let workPendingUploadPreviewMaxPixels: CGFloat = 256 + +func workAttachmentIsPendingUpload(_ ref: AgentChatFileRef) -> Bool { + ref.path.hasPrefix(workPendingUploadPathPrefix) +} + +/// Holds the composer's already-downscaled `UIImage` for the images a send is +/// carrying, first under a placeholder path and then under the real host path. +/// +/// Keeping it past the upload is deliberate. Swapping the echo's refs replaces +/// the chip, and a fresh chip loading the host copy asynchronously would show +/// the generic placeholder in the gap — a visible flash of the image the phone +/// already has in memory. Promoting the entry to the host path also means the +/// phone never re-downloads its own upload. +/// +/// Bounded by `workPendingUploadPreviewLimit` in insertion order, so it holds +/// about one message's worth of attachments rather than growing with the chat. +@MainActor +final class WorkPendingUploadPreviewStore { + static let shared = WorkPendingUploadPreviewStore() + + private var imagesByPath: [String: UIImage] = [:] + private var insertionOrder: [String] = [] + + private init() {} + + func register(_ attachments: [WorkChatInputAttachment]) -> [AgentChatFileRef] { + attachments.map { attachment in + let ref = AgentChatFileRef( + path: "\(workPendingUploadPathPrefix)\(attachment.id.uuidString)", + type: "image" + ) + if let thumbnail = attachment.image.map(workPendingUploadThumbnail) { + store(thumbnail, forPath: ref.path) + } + return ref + } + } + + /// Drops every held thumbnail. Called on `didReceiveMemoryWarning` — these + /// exist only to smooth a handoff, and the host copy can always be refetched. + func purge() { + imagesByPath.removeAll() + insertionOrder.removeAll() + } + + /// Re-keys each placeholder's image onto the host path the save returned. + /// Positional, so it only applies when the save produced a ref for every + /// placeholder; otherwise the placeholders are simply released, because a + /// mismatched pairing would attach one image's bytes to another's path. + func promote(_ placeholders: [AgentChatFileRef], to saved: [AgentChatFileRef]) { + guard placeholders.count == saved.count else { + release(placeholders) + return + } + for (placeholder, savedRef) in zip(placeholders, saved) { + guard workAttachmentIsPendingUpload(placeholder) else { continue } + let image = imagesByPath[placeholder.path] + removeEntry(forPath: placeholder.path) + guard let image, !workAttachmentIsPendingUpload(savedRef) else { continue } + store(image, forPath: savedRef.path) + } + } + + func image(forPath path: String) -> UIImage? { + imagesByPath[path] + } + + func release(_ refs: [AgentChatFileRef]) { + for ref in refs { + removeEntry(forPath: ref.path) + } + } + + private func store(_ image: UIImage, forPath path: String) { + if imagesByPath[path] == nil { + insertionOrder.append(path) + } + imagesByPath[path] = image + while insertionOrder.count > workPendingUploadPreviewLimit { + let oldest = insertionOrder.removeFirst() + imagesByPath.removeValue(forKey: oldest) + } + } + + private func removeEntry(forPath path: String) { + guard imagesByPath.removeValue(forKey: path) != nil else { return } + insertionOrder.removeAll { $0 == path } + } +} + +/// Downscales the composer's upload-sized render to chip size. Returns the +/// original when it is already small enough. +@MainActor +private func workPendingUploadThumbnail(_ image: UIImage) -> UIImage { + let longestSide = max(image.size.width, image.size.height) + guard longestSide > workPendingUploadPreviewMaxPixels, longestSide > 0 else { return image } + let scale = workPendingUploadPreviewMaxPixels / longestSide + let target = CGSize(width: image.size.width * scale, height: image.size.height * scale) + let format = UIGraphicsImageRendererFormat.default() + format.scale = 1 + return UIGraphicsImageRenderer(size: target, format: format).image { _ in + image.draw(in: CGRect(origin: .zero, size: target)) + } +} + func workChatAttachmentIsImage(_ ref: AgentChatFileRef) -> Bool { let type = ref.type.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() return type == "image" || type == "image-url" @@ -674,6 +795,10 @@ private struct WorkChatAttachmentChip: View { @State private var previewImage: UIImage? @State private var loadFailed = false + private var isUploading: Bool { + workAttachmentIsPendingUpload(attachment) + } + var body: some View { Group { if workChatAttachmentIsImage(attachment) { @@ -703,19 +828,31 @@ private struct WorkChatAttachmentChip: View { .scaledToFill() .frame(width: size, height: size) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .opacity(isUploading ? 0.55 : 1) + .overlay { + if isUploading { + ProgressView() + .controlSize(.small) + .tint(Color.white) + } + } } else { VStack(spacing: 4) { Image(systemName: loadFailed ? "photo.badge.exclamationmark" : "photo") .font(.system(size: 18, weight: .semibold)) .foregroundStyle(Color.white.opacity(0.82)) - Text(loadFailed ? "On desktop" : "Image") + Text(loadFailed ? "On desktop" : (isUploading ? "Sending" : "Image")) .font(.system(size: 9, weight: .semibold)) .foregroundStyle(Color.white.opacity(0.72)) .lineLimit(1) } } } - .accessibilityLabel("Image attachment \(workChatAttachmentDisplayName(attachment))") + .accessibilityLabel( + isUploading + ? "Image attachment, sending" + : "Image attachment \(workChatAttachmentDisplayName(attachment))" + ) } private var fileChip: some View { @@ -742,6 +879,20 @@ private struct WorkChatAttachmentChip: View { @MainActor private func loadPreviewIfNeeded() async { guard workChatAttachmentIsImage(attachment) else { return } + // The phone already holds this image if it is the one being sent — while it + // uploads under a placeholder path, and afterwards under the host path it + // was promoted to. Resolving locally avoids both a placeholder flash across + // the swap and a re-download of our own upload. + if let local = WorkPendingUploadPreviewStore.shared.image(forPath: attachment.path) { + previewImage = local + loadFailed = false + return + } + if workAttachmentIsPendingUpload(attachment) { + previewImage = nil + loadFailed = false + return + } let maxPixelSize = max(workChatAttachmentPreviewMinimumPixels, ceil(size * displayScale)) if attachment.type == "image-url", let urlString = attachment.url, let url = URL(string: urlString), let scheme = url.scheme?.lowercased(), diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift index 0b31daab2..4b1911f3a 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift @@ -139,6 +139,23 @@ private func workSnapshotByApplyingAssistantTextTail( return nextSnapshot } +/// Whether every newly-appended echo would survive the suppression the full +/// rebuild applies (`buildWorkTimeline`). If any would be hidden there, the fast +/// path must decline so the two paths cannot disagree. +private func workAppendedEchoesRemainVisible( + _ localEchoMessages: [WorkLocalEchoMessage], + appendedFrom index: Int, + transcript: [WorkChatEnvelope] +) -> Bool { + let visibleIds = Set( + workUnrepresentedLocalEchoMessages( + localEchoMessages, + representedKeyCounts: workRepresentedEchoKeyCounts(from: transcript) + ).map(\.id) + ) + return localEchoMessages[index...].allSatisfy { visibleIds.contains($0.id) } +} + private func workSnapshotByApplyingLocalEchoTail( to snapshot: WorkChatTimelineSnapshot, cache: WorkTimelineIncrementalCache, @@ -163,8 +180,18 @@ private func workSnapshotByApplyingLocalEchoTail( else { return nil } } + let appendedEchoes = localEchoMessages[cache.localEchoCount.. Bool { + guard !localEchoMessages.isEmpty else { return false } + guard timelineSourceKey == (selectedSubagentTaskId ?? "main") else { return false } + + // A brand-new chat has no snapshot to append to; the fold is trivially + // cheap there, so build it inline rather than wait out the debounce. + if timelineSnapshot.timeline.isEmpty { + cancelScheduledTimelineSnapshotRebuild() + rebuildTimelineSnapshot() + return !timelineSnapshot.timeline.isEmpty + } + + guard let nextSnapshot = workSnapshotByApplyingLocalEchoTail( + to: timelineSnapshot, + cache: timelineIncrementalCache, + transcript: transcript, + fallbackEntries: fallbackEntries, + artifacts: artifacts, + localEchoMessages: localEchoMessages + ) else { return false } + + // A coalesced rebuild may already be inside the fold with inputs captured + // before this echo existed. Retire that generation so its result is dropped + // instead of overwriting the bubble we are about to paint. + timelineRebuildGeneration += 1 + + timelineSnapshot = nextSnapshot + timelineIncrementalCache.record( + transcript: transcript, + fallbackEntries: fallbackEntries, + artifacts: artifacts, + localEchoMessages: localEchoMessages + ) + refreshTimelinePresentation(sourceTimeline: nextSnapshot.timeline) + if isNearBottom, !timelineDragActive { + timelineLayoutPinToken &+= 1 + } + return true + } + @MainActor func scheduleTimelineSnapshotRebuild() { resetTimelineSourceIfNeeded() @@ -818,7 +905,12 @@ extension WorkChatSessionView { olderHistoryLoadError = nil let revealedBufferedEntries = hiddenTimelineCount > 0 if hiddenTimelineCount > 0 { - withAnimation(ADEMotion.quick(reduceMotion: reduceMotion)) { + // Deliberately not animated: these rows land *above* the viewport and are + // immediately offset-corrected, so animating them only produces a visible + // flash of the content sliding down and back. + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { visibleTimelineCount += workTimelinePageSize refreshTimelinePresentation() } diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift index 55c3caf96..a1ae31ff2 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift @@ -381,7 +381,7 @@ struct WorkAssistantMarkdownBlockRow: View, Equatable { } var body: some View { - WorkMarkdownBlockView(block: model.block) + WorkMarkdownBlockView(block: model.block, isStreamingTail: model.isStreamingTail) .frame(maxWidth: .infinity, alignment: .leading) .contextMenu { Button(action: onCopyMessage) { diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 4a1b0505b..bda8551e7 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -82,8 +82,75 @@ func workChatShouldContinueAutomaticOlderHistory( && (hasBufferedEntries || hasHostHistory) } +/// Scroll state a prepend has to preserve: which row led the list, where that +/// row sat, and where the reader was, at the instant rows were inserted above. +/// +/// Deliberately *not* total content height. An assistant reply streaming into +/// the tail grows the content too, and a reader scrolled back through history is +/// exactly when that happens — restoring by total growth would add the tail's +/// growth to the correction and overshoot. +struct WorkChatPrependAnchor { + let rowId: String + let rowY: CGFloat + /// The reader's offset when the prepend was armed. Not what the correction is + /// applied to — it is how the reader's own scrolling is separated from the + /// insertion, since the probed row moves by both. + let offsetY: CGFloat + /// Layout passes to wait for before giving up, so an abandoned prepend cannot + /// leave the anchor armed to fire on an unrelated later change. + var remainingAttempts: Int +} + +/// Reference box so scroll geometry can be recorded per frame without +/// invalidating the view. `distanceFromBottom` predates the prepend anchor and +/// keeps its existing meaning. final class WorkChatScrollMetrics { var distanceFromBottom: CGFloat = 0 + var offsetY: CGFloat = 0 + var scrollableHeight: CGFloat = 0 + /// Position of the row currently being probed (the list's first row, or the + /// armed row while a prepend is in flight), in the scroll coordinate space. + var probeRowId: String? + var probeRowY: CGFloat? + var prependAnchor: WorkChatPrependAnchor? +} + +/// The scroll geometry the transcript reacts to, rounded so sub-pixel jitter +/// doesn't wake the observer. +struct WorkChatScrollGeometrySample: Equatable { + let offsetY: CGFloat + /// Largest in-range content offset, used only to clamp a restore. + let scrollableHeight: CGFloat + + init(_ geometry: ScrollGeometry) { + self.offsetY = (geometry.contentOffset.y * 2).rounded() / 2 + let scrollable = geometry.contentSize.height - geometry.containerSize.height + + geometry.contentInsets.top + geometry.contentInsets.bottom + self.scrollableHeight = max(0, (scrollable * 2).rounded() / 2) + } +} + +/// Number of layout passes a prepend anchor stays armed for. +let workChatPrependAnchorAttempts = 12 + +/// Position of the single probed row, published from the row itself so a +/// prepend's displacement can be measured without a geometry reader per row. +/// +/// Carries the row id rather than letting the reader infer it: which row holds +/// the probe is decided during body evaluation, and the observer reading it back +/// runs later, so a recomputed id can describe a different row than the +/// measurement it is paired with. +struct WorkChatPrependProbeSample: Equatable { + let rowId: String + let y: CGFloat +} + +struct WorkChatPrependProbePreferenceKey: PreferenceKey { + static var defaultValue: WorkChatPrependProbeSample? { nil } + + static func reduce(value: inout WorkChatPrependProbeSample?, nextValue: () -> WorkChatPrependProbeSample?) { + value = nextValue() ?? value + } } struct WorkChatSummaryRenderContext: Equatable { @@ -213,6 +280,9 @@ struct WorkChatSessionView: View { @State var scrollViewportWidth: CGFloat = 0 @State var composerLayoutHeight: CGFloat = 150 @State var scrollMetrics = WorkChatScrollMetrics() + /// Only ever written to restore the reader's position after a prepend. Bottom + /// follow and the jump-to-latest pill keep using `ScrollViewProxy.scrollTo`. + @State var scrollPosition = ScrollPosition() @State var timelineDragActive = false @State var bottomStickinessReleasedByUser = false @State var timelineSnapshot = WorkChatTimelineSnapshot.empty @@ -635,9 +705,88 @@ struct WorkChatSessionView: View { ) } guard nextPresentation != timelinePresentation else { return } + armPrependAnchorIfRowsInsertedAbove(nextPresentation) timelinePresentation = nextPresentation } + /// The row carrying the displacement probe: normally the list's first row, and + /// the armed row while a prepend is in flight (it is no longer first once the + /// older page lands above it). + var prependProbeRowId: String? { + scrollMetrics.prependAnchor?.rowId ?? timelinePresentation.visibleEntries.first?.id + } + + /// Records where the reader is whenever the next presentation inserts rows + /// above the ones already on screen — whether that came from revealing locally + /// buffered entries or from an older page landing from the host. Without this + /// the LazyVStack grows upward, `contentOffset` stays put, and whatever the + /// user was reading slides down by the height of the inserted page. + @MainActor + private func armPrependAnchorIfRowsInsertedAbove(_ nextPresentation: WorkTimelinePresentation) { + guard scrollMetrics.prependAnchor == nil, + nextPresentation.visibleEntries.count > timelinePresentation.visibleEntries.count, + let previousFirstId = timelinePresentation.visibleEntries.first?.id, + nextPresentation.visibleEntries.first?.id != previousFirstId, + // The probe has to already be measuring the row we are about to anchor + // on, or there is no "before" position to restore to. + scrollMetrics.probeRowId == previousFirstId, + let previousFirstRowY = scrollMetrics.probeRowY + else { return } + // Only a genuine prepend: the row that used to lead the list has to still be + // in the list, just further down. + guard nextPresentation.visibleEntries.contains(where: { $0.id == previousFirstId }) else { return } + + scrollMetrics.prependAnchor = WorkChatPrependAnchor( + rowId: previousFirstId, + rowY: previousFirstRowY, + offsetY: scrollMetrics.offsetY, + remainingAttempts: workChatPrependAnchorAttempts + ) + } + + /// Re-applies the reader's position once the prepended rows have laid out. + /// + /// The anchored row moved down by exactly the height inserted above it, and + /// that displacement is measured on the row itself — so a reply streaming into + /// the tail at the same time contributes nothing to the correction. + @MainActor + func restorePrependAnchorIfNeeded(probed: WorkChatPrependProbeSample?) { + guard var anchor = scrollMetrics.prependAnchor else { return } + + // The anchored row moves by the height inserted above it *minus* whatever + // the reader scrolled in the meantime, because scrolling moves the row up + // the screen too. Adding the offset change back isolates the insertion: + // with an inserted height H and a user scroll D, the row moves H - D and the + // offset moves D, so the sum is H either way — and a pure scroll with no + // prepend sums to zero and correctly restores nothing. + let rowShift = probed?.rowId == anchor.rowId ? (probed?.y ?? anchor.rowY) - anchor.rowY : 0 + let scrolled = scrollMetrics.offsetY - anchor.offsetY + let insertedHeight = rowShift + scrolled + guard insertedHeight > 0.5 else { + anchor.remainingAttempts -= 1 + scrollMetrics.prependAnchor = anchor.remainingAttempts > 0 ? anchor : nil + return + } + + scrollMetrics.prependAnchor = nil + // Bottom-follow owns the scroll position when the reader is parked at the + // tail; a prepend there is invisible anyway. + guard !isNearBottom else { return } + + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + // Applied to the live offset so a scroll during the prepend is kept; + // only the inserted height is undone. Clamped to the scrollable range the + // way t3code's `restore(_:in:dataSource:)` bounds its `setContentOffset`: + // a measured height should never land out of range, but the retained + // last-probe path can carry a stale measurement, and a bounded restore + // fails as a slightly-wrong position instead of a blank overscroll. + let target = min(max(0, scrollMetrics.offsetY + insertedHeight), scrollMetrics.scrollableHeight) + scrollPosition.scrollTo(y: target) + } + } + var canCompose: Bool { // Typing stays available so users can draft while disconnected or while // a turn is running, except when a blocking pending-input card is open — @@ -766,6 +915,13 @@ struct WorkChatSessionView: View { } else { let streamingMessageId = streamingAssistantMessageId let userBubbleWidth = maxUserBubbleWidth + let probeRowId = prependProbeRowId + // A streaming or expanded assistant message renders as several suffixed + // block rows, so the probed *timeline* entry has no render row with a + // matching id. Resolve through `sourceEntryId` and pick its first block, + // or the probe silently never installs and the anchor never arms. + let probeRenderRowId = visibleTimelineRenderEntries + .first { $0.sourceEntryId == probeRowId }?.id ForEach(visibleTimelineRenderEntries) { entry in timelineRenderEntryView( for: entry, @@ -773,6 +929,24 @@ struct WorkChatSessionView: View { streamingAssistantMessageId: streamingMessageId, maxUserBubbleWidth: userBubbleWidth ) + .background { + // Exactly one row carries this probe. It measures how far a prepend + // pushed the reader's content down, which total content height cannot + // do while the tail is also streaming. + if let probeRowId, entry.id == probeRenderRowId { + GeometryReader { geometry in + Color.clear.preference( + key: WorkChatPrependProbePreferenceKey.self, + // Published in timeline-entry id space, which is what the anchor + // compares against. + value: WorkChatPrependProbeSample( + rowId: probeRowId, + y: geometry.frame(in: .named(workChatScrollCoordinateSpace)).minY + ) + ) + } + } + } } } } @@ -1066,6 +1240,15 @@ struct WorkChatSessionView: View { .clipped() .scrollIndicators(.hidden) .scrollDismissesKeyboard(.interactively) + .scrollPosition($scrollPosition) + .onScrollGeometryChange(for: WorkChatScrollGeometrySample.self) { geometry in + WorkChatScrollGeometrySample(geometry) + } action: { _, sample in + // Recorded into a reference box, not @State: this fires per scroll + // frame and must not invalidate the transcript. + scrollMetrics.offsetY = sample.offsetY + scrollMetrics.scrollableHeight = sample.scrollableHeight + } .coordinateSpace(name: workChatScrollCoordinateSpace) .background( GeometryReader { geometry in @@ -1146,6 +1329,19 @@ struct WorkChatSessionView: View { .onPreferenceChange(WorkChatViewportWidthPreferenceKey.self) { width in scrollViewportWidth = width } + .onPreferenceChange(WorkChatPrependProbePreferenceKey.self) { sample in + // Recorded into a reference box, not @State: this fires on every + // layout pass and must not invalidate the transcript. + // Keep the last real measurement rather than clearing on nil. The + // probed row can be recycled out of the LazyVStack while an older-page + // request is in flight, and forgetting it there means the page lands + // with no anchor to arm and pushes whatever the reader moved on to. + if let sample { + scrollMetrics.probeRowId = sample.rowId + scrollMetrics.probeRowY = sample.y + } + restorePrependAnchorIfNeeded(probed: sample) + } .onPreferenceChange(WorkChatComposerLayoutHeightPreferenceKey.self) { height in guard height > 0, abs(composerLayoutHeight - height) > 1 else { return } composerLayoutHeight = height @@ -1294,6 +1490,10 @@ struct WorkChatSessionView: View { scheduleTimelineSnapshotRebuild() } .onChange(of: localEchoMessages) { _, _ in + // The user's own message is the one timeline change that must not wait + // out the coalescing debounce — it has to be on screen by the frame + // after the tap. + guard !applyLocalEchoTailImmediatelyIfPossible() else { return } scheduleTimelineSnapshotRebuild() } .onChange(of: blockingPendingInputId) { _, newId in @@ -1931,13 +2131,15 @@ func workTimelineRenderEntries( ? parseMarkdownBlocksForStreaming(preview.text, cacheKey: "\(message.id):preview") : parseMarkdownBlocks(preview.text) rendered.reserveCapacity(rendered.count + blocks.count + (preview.isTruncated ? 1 : 0)) + let streamingTailBlockId = message.id == streamingAssistantMessageId ? blocks.last?.id : nil for block in blocks { let model = WorkAssistantMarkdownBlockRenderModel( id: "\(entry.id)-\(block.id)", messageId: message.id, turnId: message.turnId, itemId: message.itemId, - block: block + block: block, + isStreamingTail: block.id == streamingTailBlockId ) rendered.append(WorkTimelineRenderEntry( id: model.id, diff --git a/apps/ios/ADE/Views/Work/WorkMarkdownParsing.swift b/apps/ios/ADE/Views/Work/WorkMarkdownParsing.swift index 141d616ca..d099e2c52 100644 --- a/apps/ios/ADE/Views/Work/WorkMarkdownParsing.swift +++ b/apps/ios/ADE/Views/Work/WorkMarkdownParsing.swift @@ -92,7 +92,7 @@ struct WorkMarkdownBlock: Identifiable, Equatable { } func parseMarkdownBlocks(_ markdown: String) -> [WorkMarkdownBlock] { - let key = markdown as NSString + let key = workStableDigest(markdown) as NSString if let cached = workMarkdownBlocksCache.object(forKey: key) { return cached.value } @@ -400,6 +400,15 @@ private let workMarkdownBlocksCache: NSCache = { + let cache = NSCache() + cache.countLimit = 8 + return cache +}() + /// Per-message state for `parseMarkdownBlocksForStreaming`, keyed by message /// id. Immutable snapshot box (replaced wholesale on each delta) so concurrent /// readers never observe a half-updated entry. Only one message streams at a @@ -433,11 +442,33 @@ func workStableDigest(_ string: String) -> String { return String(hash, radix: 16, uppercase: false) } -func markdownAttributedString(_ text: String) -> AttributedString { - let key = text as NSString +/// Renders inline Markdown, with a separate lane for the revision that is still +/// growing. +/// +/// `intermediate` marks the tail block of a streaming message: it is re-rendered +/// several times a second with text that will never be looked up again. Those +/// revisions used to land in the shared 256-entry cache, so one long turn could +/// insert hundreds of throwaway entries and evict every completed message — +/// scrolling back after a turn then re-parsed the whole transcript on the main +/// thread. Intermediate revisions now live in their own tiny cache and never +/// displace finished work; when the turn ends the same text comes back through +/// the normal path and is promoted into the shared cache. +func markdownAttributedString(_ text: String, intermediate: Bool = false) -> AttributedString { + let key = workStableDigest(text) as NSString if let cached = workMarkdownCache.object(forKey: key) { return cached.value } + if intermediate, let cached = workStreamingInlineMarkdownCache.object(forKey: key) { + return cached.value + } + + func store(_ value: AttributedString) { + if intermediate { + workStreamingInlineMarkdownCache.setObject(WorkMarkdownCacheBox(value), forKey: key) + } else { + workMarkdownCache.setObject(WorkMarkdownCacheBox(value), forKey: key) + } + } // Preserve line breaks so multi-line paragraphs render correctly — the // default `AttributedString(markdown:)` initializer collapses them. @@ -446,7 +477,7 @@ func markdownAttributedString(_ text: String) -> AttributedString { ) guard var attributed = try? AttributedString(markdown: text, options: options) else { let fallback = AttributedString(text) - workMarkdownCache.setObject(WorkMarkdownCacheBox(fallback), forKey: key) + store(fallback) return fallback } @@ -489,6 +520,22 @@ func markdownAttributedString(_ text: String) -> AttributedString { } } - workMarkdownCache.setObject(WorkMarkdownCacheBox(attributed), forKey: key) + store(attributed) return attributed } + +/// Whether the shared inline-markdown cache is currently holding a render for +/// `text`. Exists so the "streaming tail must not evict finished messages" +/// behaviour is directly assertable. +func workMarkdownSharedCacheHolds(_ text: String) -> Bool { + workMarkdownCache.object(forKey: workStableDigest(text) as NSString) != nil +} + +/// Drops every derived-render cache. Called on `didReceiveMemoryWarning`: these +/// hold parsed copies of the transcript, all of which can be rebuilt on demand. +func workPurgeMarkdownRenderCaches() { + workMarkdownCache.removeAllObjects() + workMarkdownBlocksCache.removeAllObjects() + workStreamingMarkdownCache.removeAllObjects() + workStreamingInlineMarkdownCache.removeAllObjects() +} diff --git a/apps/ios/ADE/Views/Work/WorkMarkdownViews.swift b/apps/ios/ADE/Views/Work/WorkMarkdownViews.swift index 7ded11de6..21875aa01 100644 --- a/apps/ios/ADE/Views/Work/WorkMarkdownViews.swift +++ b/apps/ios/ADE/Views/Work/WorkMarkdownViews.swift @@ -4,9 +4,12 @@ import AVKit struct WorkInlineMarkdownText: View { let text: String + /// Set on the one block that is still growing, so its throwaway revisions + /// stay out of the shared inline-markdown cache. + var isStreamingTail = false var body: some View { - Text(markdownAttributedString(text)) + Text(markdownAttributedString(text, intermediate: isStreamingTail)) .foregroundStyle(ADEColor.textPrimary) .tint(ADEColor.accent) .frame(maxWidth: .infinity, alignment: .leading) @@ -31,9 +34,13 @@ struct WorkMarkdownRenderer: View { } var body: some View { + let blocks = self.blocks + // Only the last block of a streaming message is still growing; everything + // above it is final and belongs in the shared caches. + let streamingTailId = streamingCacheKey == nil ? nil : blocks.last?.id VStack(alignment: .leading, spacing: 10) { ForEach(blocks) { block in - WorkMarkdownBlockView(block: block) + WorkMarkdownBlockView(block: block, isStreamingTail: block.id == streamingTailId) } } } @@ -41,13 +48,14 @@ struct WorkMarkdownRenderer: View { struct WorkMarkdownBlockView: View { let block: WorkMarkdownBlock + var isStreamingTail = false var body: some View { switch block.kind { case .paragraph(let text): - WorkInlineMarkdownText(text: text) + WorkInlineMarkdownText(text: text, isStreamingTail: isStreamingTail) case .heading(let level, let text): - WorkInlineMarkdownText(text: text) + WorkInlineMarkdownText(text: text, isStreamingTail: isStreamingTail) .font(headingFont(level: level)) case .unorderedList(let items): VStack(alignment: .leading, spacing: 6) { @@ -55,7 +63,7 @@ struct WorkMarkdownBlockView: View { HStack(alignment: .top, spacing: 8) { Text("•") .foregroundStyle(ADEColor.accent) - WorkInlineMarkdownText(text: item) + WorkInlineMarkdownText(text: item, isStreamingTail: isStreamingTail) } } } @@ -65,7 +73,7 @@ struct WorkMarkdownBlockView: View { HStack(alignment: .top, spacing: 8) { Text("\(start + index).") .foregroundStyle(ADEColor.accent) - WorkInlineMarkdownText(text: item) + WorkInlineMarkdownText(text: item, isStreamingTail: isStreamingTail) } } } @@ -76,14 +84,14 @@ struct WorkMarkdownBlockView: View { .frame(width: 3) VStack(alignment: .leading, spacing: 4) { ForEach(Array(lines.enumerated()), id: \.offset) { _, line in - WorkInlineMarkdownText(text: line) + WorkInlineMarkdownText(text: line, isStreamingTail: isStreamingTail) } } } .padding(10) .background(ADEColor.surfaceBackground.opacity(0.45), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) case .table(let headers, let rows): - WorkMarkdownTable(headers: headers, rows: rows) + WorkMarkdownTable(headers: headers, rows: rows, isStreamingTail: isStreamingTail) case .code(let language, let code): WorkCodeBlockView(language: language, code: code) case .rule: @@ -103,13 +111,17 @@ struct WorkMarkdownBlockView: View { struct WorkMarkdownTable: View { let headers: [String] let rows: [[String]] + /// Cells of a still-growing table are throwaway revisions like any other + /// streaming tail; without this they land in the shared completed-message + /// cache and evict it, which is the eviction bug this branch fixes for prose. + var isStreamingTail = false var body: some View { ScrollView(.horizontal, showsIndicators: false) { VStack(spacing: 0) { HStack(spacing: 0) { ForEach(headers.indices, id: \.self) { index in - WorkInlineMarkdownText(text: headers[index]) + WorkInlineMarkdownText(text: headers[index], isStreamingTail: isStreamingTail) .font(.caption.weight(.semibold)) .padding(10) .frame(minWidth: 120, alignment: .leading) @@ -120,7 +132,7 @@ struct WorkMarkdownTable: View { Divider() HStack(spacing: 0) { ForEach(headers.indices, id: \.self) { index in - WorkInlineMarkdownText(text: index < row.count ? row[index] : "") + WorkInlineMarkdownText(text: index < row.count ? row[index] : "", isStreamingTail: isStreamingTail) .font(.caption) .padding(10) .frame(minWidth: 120, alignment: .leading) diff --git a/apps/ios/ADE/Views/Work/WorkModels.swift b/apps/ios/ADE/Views/Work/WorkModels.swift index ad3711ef3..716ac71dd 100644 --- a/apps/ios/ADE/Views/Work/WorkModels.swift +++ b/apps/ios/ADE/Views/Work/WorkModels.swift @@ -655,12 +655,16 @@ struct WorkAssistantMarkdownBlockRenderModel: Identifiable, Equatable { let turnId: String? let itemId: String? let block: WorkMarkdownBlock + /// The one block still receiving deltas. Its renders are throwaway, so they + /// are kept out of the shared inline-markdown cache. + var isStreamingTail = false static func == (lhs: WorkAssistantMarkdownBlockRenderModel, rhs: WorkAssistantMarkdownBlockRenderModel) -> Bool { lhs.id == rhs.id && lhs.messageId == rhs.messageId && lhs.turnId == rhs.turnId && lhs.itemId == rhs.itemId + && lhs.isStreamingTail == rhs.isStreamingTail && lhs.block == rhs.block } } diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift index 9f04a8115..30dfdd974 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift @@ -31,6 +31,25 @@ extension WorkSessionDestinationView { guard !text.isEmpty else { return false } guard canSendChatMessages else { return false } + // The echo goes up before the upload, not after it. Saving attachments is a + // per-image round-trip to the host; waiting for it left the tap with no + // visible result for seconds. Placeholder refs render the composer's own + // downscaled image with an uploading state, then get swapped for the real + // host paths before anything is sent. + let pendingUploadRefs = WorkPendingUploadPreviewStore.shared.register( + workChatInputReadyAttachments(inputAttachments) + ) + let initialDeliveryState = (sendWillQueueChatMessage || useSteer) ? "queued" : "sending" + let echo = WorkLocalEchoMessage( + text: text, + timestamp: workDateFormatter.string(from: Date()), + deliveryState: initialDeliveryState, + attachments: pendingUploadRefs.isEmpty ? nil : pendingUploadRefs + ) + let echoId = echo.id + localEchoMessages.append(echo) + sending = true + let attachmentRefs: [AgentChatFileRef] do { attachmentRefs = try await workChatSaveInputAttachments( @@ -39,21 +58,22 @@ extension WorkSessionDestinationView { chatSessionId: sessionId ) } catch { + sending = false + WorkPendingUploadPreviewStore.shared.release(pendingUploadRefs) + localEchoMessages.removeAll { $0.id == echoId } ADEHaptics.error() errorMessage = error.localizedDescription return false } + // Swap placeholders for host paths before the send: the echo's dedupe key + // (text + attachment refs) has to match the transcript row that comes back, + // or reconciliation would leave a duplicate bubble behind. + updateLocalEchoAttachments(echoId: echoId, attachments: attachmentRefs.isEmpty ? nil : attachmentRefs) + // Promote rather than release: the swap replaces the chip, and dropping the + // in-memory image here would flash the generic placeholder while the fresh + // chip fetched the copy we just uploaded. + WorkPendingUploadPreviewStore.shared.promote(pendingUploadRefs, to: attachmentRefs) - let initialDeliveryState = (sendWillQueueChatMessage || useSteer) ? "queued" : "sending" - let echo = WorkLocalEchoMessage( - text: text, - timestamp: workDateFormatter.string(from: Date()), - deliveryState: initialDeliveryState, - attachments: attachmentRefs.isEmpty ? nil : attachmentRefs - ) - let echoId = echo.id - localEchoMessages.append(echo) - sending = true defer { sending = false } do { let delivery: SyncChatMessageDelivery @@ -100,13 +120,12 @@ extension WorkSessionDestinationView { timestamp: echo.timestamp, attachments: attachmentRefs.isEmpty ? nil : attachmentRefs ) - await refreshChatStateAfterAction(forceRemote: true) + schedulePostSendReconciliation(reconcileLocalEchoes: false) errorMessage = "Couldn’t send immediately. The message is still queued." return true } updateLocalEchoDeliveryState(echoId: echoId, deliveryState: nil) - await refreshChatStateAfterAction(forceRemote: true) - reconcileLocalEchoMessages() + schedulePostSendReconciliation() break } updateLocalEchoDeliveryState(echoId: echoId, deliveryState: "queued") @@ -120,8 +139,7 @@ extension WorkSessionDestinationView { } case .sent: updateLocalEchoDeliveryState(echoId: echoId, deliveryState: nil) - await refreshChatStateAfterAction(forceRemote: true) - reconcileLocalEchoMessages() + schedulePostSendReconciliation() case .dropped: // The steer queue is full; the host dropped the message (and emitted its // own transcript notice). Pull the optimistic echo so it doesn't linger @@ -145,6 +163,27 @@ extension WorkSessionDestinationView { } } + /// Runs the post-send refresh cascade (transcript → artifacts → summary → + /// session) behind the composer instead of in front of it. + /// + /// The host has already accepted the message at this point; holding `sending` + /// through four serial round-trips kept the spinner up and the composer gated + /// for the whole cascade. Chained onto the previous post-send refresh so two + /// quick sends can't interleave two transcript loads. + @MainActor + func schedulePostSendReconciliation(reconcileLocalEchoes: Bool = true) { + let previous = postSendRefreshTask + postSendRefreshTask = Task { @MainActor in + await previous?.value + guard !Task.isCancelled else { return } + await refreshChatStateAfterAction(forceRemote: true) + guard !Task.isCancelled else { return } + if reconcileLocalEchoes { + reconcileLocalEchoMessages() + } + } + } + @MainActor func interruptSession(mode: AgentChatStopMode = .stopAndClear) async { do { diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift index 1003c573b..c2ecba0d5 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift @@ -595,6 +595,11 @@ struct WorkSessionDestinationView: View { @State var artifacts: [ComputerUseArtifactSummary] = [] @State var artifactsRenderSignature = 0 @State var localEchoMessages: [WorkLocalEchoMessage] = [] + /// Post-send reconciliation runs behind the composer rather than in front of + /// it, so `sending` can drop the moment the host accepts the message. Chained + /// rather than fire-and-forget: two quick sends must not interleave two + /// transcript loads. + @State var postSendRefreshTask: Task? @State var optimisticPendingSteers: [WorkPendingSteerModel] = [] @State var subagentSnapshots: [WorkSubagentSnapshot] = [] @State var remoteSubagentSnapshots: [WorkSubagentSnapshot] = [] @@ -1312,6 +1317,8 @@ struct WorkSessionDestinationView: View { self.announcedLaneId = nil } cleanupLoadedArtifactContent() + postSendRefreshTask?.cancel() + postSendRefreshTask = nil let wasCrossProject = isCrossProject let wasPersonalChat = personalChat if wasCrossProject || wasPersonalChat { @@ -2679,28 +2686,9 @@ struct WorkSessionDestinationView: View { @MainActor func reconcileLocalEchoMessages() { - guard !localEchoMessages.isEmpty else { return } - let pendingSteerKeys = Set( - derivePendingWorkSteers(from: transcript).compactMap { workLocalEchoDedupeKey(text: $0.text, attachments: $0.attachments) } - ) - localEchoMessages.removeAll { echo in - guard let echoKey = workLocalEchoDedupeKey(text: echo.text, attachments: echo.attachments) else { - return false - } - if pendingSteerKeys.contains(echoKey) { - return true - } - return transcript.contains { envelope in - guard case .userMessage(let text, let attachments, _, let steerId, let deliveryState, _) = envelope.event else { - return false - } - guard workLocalEchoDedupeKey(text: text, attachments: attachments) == echoKey else { return false } - if deliveryState == "queued", steerId != nil { - return false - } - return true - } - } + let next = workLocalEchoesRetiredByTranscript(localEchoMessages, transcript: transcript) + guard next.count != localEchoMessages.count else { return } + localEchoMessages = next } @MainActor @@ -2953,6 +2941,13 @@ struct WorkSessionDestinationView: View { localEchoMessages[index].deliveryState = deliveryState } + @MainActor + func updateLocalEchoAttachments(echoId: String, attachments: [AgentChatFileRef]?) { + guard let index = localEchoMessages.firstIndex(where: { $0.id == echoId }) else { return } + guard localEchoMessages[index].attachments != attachments else { return } + localEchoMessages[index].attachments = attachments + } + @MainActor func pollIfNeeded() async { guard isLiveAndReachable, diff --git a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift index 4ae1a766e..c206a8c3e 100644 --- a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift @@ -1493,22 +1493,25 @@ func buildWorkTimeline( } : buildWorkChatMessages(from: transcript) - let pendingSteerEchoKeys = Set( - derivePendingWorkSteers(from: transcript).compactMap { workLocalEchoDedupeKey(text: $0.text, attachments: $0.attachments) } - ) - var entries: [WorkTimelineEntry] = messages.enumerated().map { index, message in WorkTimelineEntry(id: "message-\(message.id)", timestamp: message.timestamp, rank: index, payload: .message(message)) } - let transcriptUserMessageEchoKeys = Set( - messages - .filter { $0.role.lowercased() == "user" } - .compactMap { workLocalEchoDedupeKey(text: $0.markdown, attachments: $0.attachments) } + // Counted, not set-membership: two identical echoes must not both vanish on + // one matching row. Built from `messages` rather than the transcript so the + // fallback-entry path (empty transcript) still suppresses correctly. + var representedEchoKeyCounts: [String: Int] = [:] + for steer in derivePendingWorkSteers(from: transcript) { + guard let key = workLocalEchoDedupeKey(text: steer.text, attachments: steer.attachments) else { continue } + representedEchoKeyCounts[key, default: 0] += 1 + } + for message in messages where message.role.lowercased() == "user" { + guard let key = workLocalEchoDedupeKey(text: message.markdown, attachments: message.attachments) else { continue } + representedEchoKeyCounts[key, default: 0] += 1 + } + let visibleLocalEchoMessages = workUnrepresentedLocalEchoMessages( + localEchoMessages, + representedKeyCounts: representedEchoKeyCounts ) - let visibleLocalEchoMessages = localEchoMessages.filter { echo in - guard let key = workLocalEchoDedupeKey(text: echo.text, attachments: echo.attachments) else { return true } - return !transcriptUserMessageEchoKeys.contains(key) && !pendingSteerEchoKeys.contains(key) - } entries.append(contentsOf: toolCards.enumerated().map { index, card in WorkTimelineEntry(id: "tool-\(card.id)", timestamp: card.startedAt, rank: 1_000 + index, payload: .toolCard(card)) @@ -2192,6 +2195,86 @@ func normalizedWorkLocalEchoText(_ text: String) -> String { .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) } +/// Drops the echoes the transcript already represents, **counting** matches +/// rather than testing set membership. +/// +/// Sending the same text twice ("ok", "continue") produces two echoes sharing +/// one dedupe key. A set-membership test removes both the moment a single +/// matching row lands, so the second bubble disappears until the next refresh. +/// Consuming one represented slot per echo keeps the second one on screen. +func workUnrepresentedLocalEchoMessages( + _ echoes: [WorkLocalEchoMessage], + representedKeyCounts: [String: Int] +) -> [WorkLocalEchoMessage] { + guard !representedKeyCounts.isEmpty else { return echoes } + var remaining = representedKeyCounts + return echoes.filter { echo in + guard let key = workLocalEchoDedupeKey(text: echo.text, attachments: echo.attachments), + let available = remaining[key], + available > 0 + else { return true } + remaining[key] = available - 1 + return false + } +} + +/// The echoes still worth keeping after the transcript has caught up. +/// +/// Idempotent by construction, which is the requirement: reconciliation runs +/// more than once against the same transcript (`loadTranscript` reconciles, then +/// the post-send pass reconciles again). Consuming a represented count per call +/// is not idempotent — with two identical sends and one canonical row, the first +/// call correctly retires one echo and the second applies the same count to the +/// already-pruned array and retires the survivor. +/// +/// Retiring a key only once the transcript holds at least as many rows as there +/// are echoes for it is stable under repetition, and costs nothing in between: +/// `buildWorkTimeline` filters the surplus out of the rendered timeline, and +/// that filter is a pure function of the full echo list. +func workLocalEchoesRetiredByTranscript( + _ echoes: [WorkLocalEchoMessage], + transcript: [WorkChatEnvelope] +) -> [WorkLocalEchoMessage] { + guard !echoes.isEmpty else { return echoes } + let representedCounts = workRepresentedEchoKeyCounts(from: transcript) + guard !representedCounts.isEmpty else { return echoes } + + var echoCounts: [String: Int] = [:] + for echo in echoes { + guard let key = workLocalEchoDedupeKey(text: echo.text, attachments: echo.attachments) else { continue } + echoCounts[key, default: 0] += 1 + } + + return echoes.filter { echo in + guard let key = workLocalEchoDedupeKey(text: echo.text, attachments: echo.attachments), + let represented = representedCounts[key], + let outstanding = echoCounts[key] + else { return true } + return represented < outstanding + } +} + +/// How many times each echo dedupe key is already represented in the transcript, +/// counting delivered user messages and pending steers. +func workRepresentedEchoKeyCounts(from transcript: [WorkChatEnvelope]) -> [String: Int] { + var counts: [String: Int] = [:] + for steer in derivePendingWorkSteers(from: transcript) { + guard let key = workLocalEchoDedupeKey(text: steer.text, attachments: steer.attachments) else { continue } + counts[key, default: 0] += 1 + } + for envelope in transcript { + guard case .userMessage(let text, let attachments, _, let steerId, let deliveryState, _) = envelope.event else { + continue + } + // A queued steer is already counted above; counting its transcript row too + // would consume two echo slots for one message. + if deliveryState == "queued", steerId != nil { continue } + guard let key = workLocalEchoDedupeKey(text: text, attachments: attachments) else { continue } + counts[key, default: 0] += 1 + } + return counts +} + func workLocalEchoDedupeKey(text: String, attachments: [AgentChatFileRef]?) -> String? { let normalized = normalizedWorkLocalEchoText(text) guard !normalized.isEmpty else { return nil } diff --git a/apps/ios/ADETests/WorkMarkdownStreamingParsingTests.swift b/apps/ios/ADETests/WorkMarkdownStreamingParsingTests.swift index 77d4c6ff3..ba9946cd8 100644 --- a/apps/ios/ADETests/WorkMarkdownStreamingParsingTests.swift +++ b/apps/ios/ADETests/WorkMarkdownStreamingParsingTests.swift @@ -1,3 +1,4 @@ +import SwiftUI import XCTest @testable import ADE @@ -166,3 +167,597 @@ final class WorkMarkdownStreamingParsingTests: XCTestCase { ) } } + +/// The syntax highlighter reuses an already-highlighted stable prefix while a +/// code block streams. The property that has to hold is the same one the +/// markdown parser is held to: at every snapshot, the incremental render must +/// equal a from-scratch render of the same text. +final class SyntaxHighlighterStreamingTests: XCTestCase { + override func setUp() { + super.setUp() + // Streaming prefix state is process-wide; start each case cold. + ADECodeRenderingCache.shared.purgeOnMemoryWarning() + } + + private func assertIncrementalMatchesFullHighlight( + _ fullText: String, + as language: FilesLanguage, + deltaSizes: [Int] = [1, 4, 11], + file: StaticString = #filePath, + line: UInt = #line + ) { + var snapshot = "" + var remainder = Substring(fullText) + var sizeIndex = 0 + while !remainder.isEmpty { + snapshot += remainder.prefix(deltaSizes[sizeIndex % deltaSizes.count]) + remainder = remainder.dropFirst(deltaSizes[sizeIndex % deltaSizes.count]) + sizeIndex += 1 + + let incremental = SyntaxHighlighter.highlightedAttributedString(snapshot, as: language) + let reference = SyntaxHighlighter.highlightedSegment(Substring(snapshot), as: language) + if incremental != reference { + XCTFail( + """ + Highlight mismatch at snapshot length \(snapshot.count). + Snapshot: \(snapshot.debugDescription) + First differing run: \(Self.firstRunDifference(incremental, reference) ?? "") + """, + file: file, line: line + ) + return + } + } + } + + /// Reports the first run whose text or attributes diverge, so a failure names + /// the construct that broke rather than dumping two whole documents. + private static func firstRunDifference( + _ lhs: AttributedString, + _ rhs: AttributedString + ) -> String? { + let lhsRuns = Array(lhs.runs) + let rhsRuns = Array(rhs.runs) + for index in 0.. Int { + value += amount + return value + } + } + """, + as: .swift + ) + } + + func testStreamingBlockCommentSpanningLinesMatchesFullHighlight() { + // A stable boundary must never land inside the comment: the prefix would + // then be highlighted as code and never corrected. + assertIncrementalMatchesFullHighlight( + """ + let a = 1 + /* opening + still inside + and here */ + let b = 2 + """, + as: .swift + ) + } + + func testStreamingTemplateLiteralSpanningLinesMatchesFullHighlight() { + assertIncrementalMatchesFullHighlight( + """ + const q = `select * + from t + where id = 1` + const n = 42 + """, + as: .typescript + ) + } + + func testStreamingPythonTripleQuotedStringMatchesFullHighlight() { + assertIncrementalMatchesFullHighlight( + """ + def f(): + \"\"\"Doc line one. + + Doc line two. + \"\"\" + return 1 + """, + as: .python + ) + } + + /// The incremental path must render a token-dense block exactly like the + /// previous whole-text algorithm. + /// + /// The streaming-equivalence tests above compare `highlightedAttributedString` + /// against `highlightedSegment`, which share their span/attribute logic — so + /// they cannot catch a change in that logic. This pins it against the + /// independent implementation the rewrite replaced. + func testHighlightMatchesPreviousWholeTextAlgorithm() { + let sources: [(FilesLanguage, String)] = [ + (.swift, """ + import Foundation + // A comment mentioning Counter and 42 + struct Counter { + let label = "Counter value: 42" + func bump() -> Int { return 1 } + } + """), + (.python, """ + def f(x): + # returns Value 7 + s = "Value 7" + return s + """), + (.html, """ +
+ + Text +
+ """), + ] + for (language, source) in sources { + ADECodeRenderingCache.shared.purgeOnMemoryWarning() + let rewritten = SyntaxHighlighter.highlightedSegment(Substring(source), as: language) + let legacy = Self.legacyHighlight(source, as: language) + if rewritten != legacy { + XCTFail( + """ + \(language.rawValue) highlight diverged from the previous algorithm. + First differing run: \(Self.firstRunDifference(rewritten, legacy) ?? "") + """ + ) + } + } + } + + /// Replays a long code block as a token stream and reports the cost of the + /// incremental path against the previous whole-text algorithm, which is + /// reproduced here (full tokenize + `index(offsetBy:)` walked from the start + /// for every token). + /// + /// Diagnostic only. The correctness gate is + /// `testHighlightMatchesPreviousWholeTextAlgorithm`; asserting on wall-clock + /// here would just add a flake under CI load. + func testStreamingHighlightCostIsReported() { + let line = " let value\(Int.random(in: 0...9)) = compute(from: \"input\", count: 12) // step\n" + let fullText = String(repeating: line, count: 200) + + var snapshots: [String] = [] + var snapshot = "" + var remainder = Substring(fullText) + while !remainder.isEmpty { + snapshot += remainder.prefix(24) + remainder = remainder.dropFirst(24) + snapshots.append(snapshot) + } + + ADECodeRenderingCache.shared.purgeOnMemoryWarning() + let incrementalStart = Date() + for snapshot in snapshots { + _ = SyntaxHighlighter.highlightedAttributedString(snapshot, as: .swift) + } + let incrementalSeconds = Date().timeIntervalSince(incrementalStart) + + ADECodeRenderingCache.shared.purgeOnMemoryWarning() + let wholeTextStart = Date() + for snapshot in snapshots { + _ = SyntaxHighlighter.highlightedSegment(Substring(snapshot), as: .swift) + } + let wholeTextSeconds = Date().timeIntervalSince(wholeTextStart) + + ADECodeRenderingCache.shared.purgeOnMemoryWarning() + let legacyStart = Date() + for snapshot in snapshots { + _ = Self.legacyHighlight(snapshot, as: .swift) + } + let legacySeconds = Date().timeIntervalSince(legacyStart) + print(String( + format: "whole-text with the role fill (no prefix reuse): %.3f ms per tick (%.1fx vs previous)", + wholeTextSeconds * 1000 / Double(snapshots.count), + legacySeconds / max(wholeTextSeconds, .leastNonzeroMagnitude) + )) + + let ticks = Double(snapshots.count) + print(String( + format: "streaming highlight over %d ticks (%d chars): incremental %.1f ms total / %.3f ms per tick, previous %.1f ms total / %.3f ms per tick (%.1fx)", + snapshots.count, + fullText.count, + incrementalSeconds * 1000, + incrementalSeconds * 1000 / ticks, + legacySeconds * 1000, + legacySeconds * 1000 / ticks, + legacySeconds / max(incrementalSeconds, .leastNonzeroMagnitude) + )) + } + + /// The pre-change algorithm, verbatim: tokenize the whole text, then walk from + /// `startIndex` for every token, letting later tokens overwrite the ranges + /// they overlap. Serves as both the benchmark baseline and the correctness + /// oracle, so it applies the real per-role attributes. + private static func legacyHighlight(_ text: String, as language: FilesLanguage) -> AttributedString { + var attributed = AttributedString(text) + attributed.font = .system(.body, design: .monospaced) + attributed.foregroundColor = ADEColor.textPrimary + for token in SyntaxHighlighter.tokenize(text, as: language) { + guard let stringRange = Range(token.range, in: text) else { continue } + let startOffset = text.distance(from: text.startIndex, to: stringRange.lowerBound) + let endOffset = text.distance(from: text.startIndex, to: stringRange.upperBound) + let lowerBound = attributed.characters.index(attributed.startIndex, offsetBy: startOffset) + let upperBound = attributed.characters.index(attributed.startIndex, offsetBy: endOffset) + attributed[lowerBound.. + text + + """, + as: .html + ) + } + + func testStreamingYamlQuotedValueSpanningLinesMatchesFullHighlight() { + assertIncrementalMatchesFullHighlight( + """ + key: "first + continued" + other: 2 + """, + as: .yaml + ) + } + + func testStreamingApostropheInCommentDoesNotSplitInsideAStringMatch() { + // A lone apostrophe in a comment still opens a string match for the rule + // that scans independently of the comment rule. + assertIncrementalMatchesFullHighlight( + """ + // don't do this + const x = 'ok' + const y = 2 + """, + as: .javascript + ) + } + + func testEscapedQuoteOutsideAStringStillOpensOne() { + // `\'` in a comment is not an escape — nothing is open for it to escape. + // The string rule has no preceding-backslash check either, so it opens a + // match there that runs to the apostrophe two lines later; treating the + // backslash as an escape would swallow it and mark the newline stable. + assertIncrementalMatchesFullHighlight( + #""" + // path\' here + // it's fine + const x = 1 + """#, + as: .javascript + ) + } + + func testStreamingHtmlCommentSpanningLinesMatchesFullHighlight() { + // HTML's comment rule spans lines like a `/* */` block; a stable boundary + // landing inside one would freeze mis-highlighted markup into the prefix. + assertIncrementalMatchesFullHighlight( + """ +
+ + after +
+ """, + as: .html + ) + } + + /// Languages allowed to reuse a stable prefix, each pinned to the rule + /// patterns that claim was made about. + /// + /// Reuse is only sound while nothing in a language's rules can match across a + /// newline except the delimiters the boundary counts. That is a property of + /// the patterns, not something the code can re-derive, and every time it has + /// been wrong the symptom was a completed block frozen mis-highlighted in + /// cache. If one of these fingerprints changes, re-check the new pattern + /// against `multilineDelimiters(for:)` before updating the constant. + private static let prefixReuseFingerprints: [FilesLanguage: String] = [ + .swift: "2c7b721d13b3bcc9", + .typescript: "fa894f542c775be5", + .javascript: "2f738c3408aa7929", + .python: "6de225fbadd012d8", + .rust: "563f92af2415db71", + .go: "3575d64645ceff4c", + .java: "aa8e57e8d480c530", + .html: "561765deebafcca7", + ] + + func testLanguagesWithUnmodelledMultilineRulesOptOutOfPrefixReuse() { + // Each of these has a rule whose match crosses, or depends on text past, a + // newline with no delimiter to count: CSS selector lists, YAML's `^\s*` key + // rule, Markdown links, and JSON's `(?=\s*:)` key lookahead. + for language in [FilesLanguage.css, .yaml, .markdown, .json] { + XCTAssertNil( + SyntaxHighlighter.multilineDelimiters(for: language), + "\(language.rawValue) has newline-crossing rules the balance scan cannot model" + ) + } + for language in Self.prefixReuseFingerprints.keys { + XCTAssertNotNil(SyntaxHighlighter.multilineDelimiters(for: language)) + } + } + + func testPrefixReuseLanguagesStillHaveTheRulesThatClaimWasMadeAbout() { + for (language, pinned) in Self.prefixReuseFingerprints where !pinned.isEmpty { + XCTAssertEqual( + SyntaxHighlighter.tokenRuleFingerprint(for: language), pinned, + """ + \(language.rawValue)'s token rules changed. Prefix reuse assumes no rule \ + matches across a newline except the counted delimiters — re-check the new \ + pattern against multilineDelimiters(for:), then update this fingerprint. + """ + ) + } + } + + func testStreamingJsonKeyLookaheadMatchesFullHighlight() { + // The key rule only matches once `(?=\s*:)` finds the colon, which can + // arrive after the newline — the key would otherwise freeze unhighlighted. + assertIncrementalMatchesFullHighlight("{\n \"key\"\n: 1,\n \"b\": 2\n}", as: .json) + } + + func testMultilineCssSelectorStillMatchesFullHighlight() { + assertIncrementalMatchesFullHighlight( + """ + .foo, + .bar { + color: red; + } + """, + as: .css + ) + } + + func testMultilineYamlAndMarkdownStillMatchFullHighlight() { + assertIncrementalMatchesFullHighlight("a:\n\n b: 1\nc: 2", as: .yaml) + assertIncrementalMatchesFullHighlight("see [long\nlink](https://x.test)\n\ntext", as: .markdown) + } + + func testDifferentBlockOfSameLanguageDoesNotReuseForeignPrefix() { + let first = "let alpha = 1\nlet beta = 2\n" + _ = SyntaxHighlighter.highlightedAttributedString(first, as: .swift) + let unrelated = "func gamma() {\n return\n}\n" + XCTAssertEqual( + SyntaxHighlighter.highlightedAttributedString(unrelated, as: .swift), + SyntaxHighlighter.highlightedSegment(Substring(unrelated), as: .swift) + ) + } +} + +/// The composer's downscaled image has to survive the placeholder → host-path +/// swap, or the fresh chip flashes the generic placeholder while it re-fetches +/// the image the phone just uploaded. +@MainActor +final class WorkPendingUploadPreviewStoreTests: XCTestCase { + private func makeAttachment() -> WorkChatInputAttachment { + WorkChatInputAttachment( + image: UIImage(systemName: "photo") ?? UIImage(), + uploadData: Data([0x01]), + filename: "shot.jpg", + state: .ready + ) + } + + func testPromotedImageResolvesUnderTheHostPath() { + let store = WorkPendingUploadPreviewStore.shared + let placeholders = store.register([makeAttachment()]) + XCTAssertEqual(placeholders.count, 1) + XCTAssertNotNil(store.image(forPath: placeholders[0].path)) + + let saved = [AgentChatFileRef(path: "/project/.ade/attachments/shot.jpg", type: "image")] + store.promote(placeholders, to: saved) + + XCTAssertNotNil(store.image(forPath: saved[0].path), "no image means the chip flashes a placeholder") + XCTAssertNil(store.image(forPath: placeholders[0].path), "the placeholder key must not linger") + store.release(saved) + } + + func testMismatchedSaveCountReleasesRatherThanMispairing() { + let store = WorkPendingUploadPreviewStore.shared + let placeholders = store.register([makeAttachment(), makeAttachment()]) + // One attachment failed to produce a ref: pairing positionally would attach + // the first image's bytes to a path it does not belong to. + store.promote(placeholders, to: [AgentChatFileRef(path: "/project/.ade/attachments/only.jpg", type: "image")]) + + XCTAssertNil(store.image(forPath: "/project/.ade/attachments/only.jpg")) + XCTAssertTrue(placeholders.allSatisfy { store.image(forPath: $0.path) == nil }) + } + + func testStoreIsBoundedToRoughlyOneMessageOfAttachments() { + let store = WorkPendingUploadPreviewStore.shared + let refs = store.register((0..<(workPendingUploadPreviewLimit + 4)).map { _ in makeAttachment() }) + let retained = refs.filter { store.image(forPath: $0.path) != nil } + XCTAssertEqual(retained.count, workPendingUploadPreviewLimit) + XCTAssertTrue( + retained.allSatisfy { refs.suffix(workPendingUploadPreviewLimit).contains($0) }, + "the newest entries are the ones worth keeping" + ) + store.release(refs) + } +} + +/// A streaming turn used to insert one throwaway render per delta into the +/// shared inline-markdown cache, evicting every finished message in a long +/// chat. Intermediate revisions now render without displacing finished work, +/// and the final revision is promoted. +final class WorkInlineMarkdownCacheTests: XCTestCase { + override func setUp() { + super.setUp() + workPurgeMarkdownRenderCaches() + } + + func testIntermediateRevisionsAreNotInsertedIntoTheSharedCache() { + var snapshot = "" + for word in "the agent is writing a fairly long answer here".split(separator: " ") { + snapshot += (snapshot.isEmpty ? "" : " ") + word + _ = markdownAttributedString(snapshot, intermediate: true) + XCTAssertFalse( + workMarkdownSharedCacheHolds(snapshot), + "Streaming revision \"\(snapshot)\" must not occupy the shared cache" + ) + } + } + + func testFinalRevisionIsPromotedIntoTheSharedCache() { + let finished = "A completed **message** with `code`." + _ = markdownAttributedString(finished, intermediate: true) + XCTAssertFalse(workMarkdownSharedCacheHolds(finished)) + + _ = markdownAttributedString(finished, intermediate: false) + XCTAssertTrue(workMarkdownSharedCacheHolds(finished)) + } + + func testIntermediateAndFinalRendersAreIdentical() { + let text = "Mixed *emphasis*, `inline code`, and a https://example.com link." + let intermediate = markdownAttributedString(text, intermediate: true) + workPurgeMarkdownRenderCaches() + XCTAssertEqual(intermediate, markdownAttributedString(text, intermediate: false)) + } + + /// The headline regression: a minute-long turn produces hundreds of tail + /// revisions. Before intermediate exclusion those filled the 256-entry cache + /// and evicted the finished messages above them, so scrolling back re-parsed + /// the transcript on the main thread. + func testLongStreamingTurnDoesNotEvictCompletedMessages() { + let completed = (0..<40).map { "Completed message number \($0) with some **body** text." } + for message in completed { + _ = markdownAttributedString(message) + } + + var tail = "" + for index in 0..<600 { + tail += "token\(index) " + _ = markdownAttributedString(tail, intermediate: true) + } + + for message in completed { + XCTAssertTrue( + workMarkdownSharedCacheHolds(message), + "\(message.debugDescription) was evicted by streaming tail revisions" + ) + } + } + + /// A streaming tail that parses as a table renders through `WorkMarkdownTable` + /// rather than the paragraph path, so its cells need the same intermediate + /// routing — a long table would otherwise evict the completed messages the + /// exclusion exists to protect. + func testStreamingTableCellRevisionsStayOutOfTheSharedCache() { + let completed = "A finished message worth keeping cached." + _ = markdownAttributedString(completed) + XCTAssertTrue(workMarkdownSharedCacheHolds(completed)) + + // Cells arriving token by token, the way a table streams. + var cell = "" + for token in ["Build", " status", " green", " for", " every", " shard"] { + cell += token + _ = markdownAttributedString(cell, intermediate: true) + XCTAssertFalse( + workMarkdownSharedCacheHolds(cell), + "streaming cell \(cell.debugDescription) must not occupy the shared cache" + ) + } + + XCTAssertTrue(workMarkdownSharedCacheHolds(completed), "the finished message must survive") + _ = markdownAttributedString(cell, intermediate: false) + XCTAssertTrue(workMarkdownSharedCacheHolds(cell), "the settled cell is promoted like any other block") + } + + func testMemoryWarningPurgeDropsRenders() { + let text = "Something worth caching." + _ = markdownAttributedString(text) + XCTAssertTrue(workMarkdownSharedCacheHolds(text)) + workPurgeMarkdownRenderCaches() + XCTAssertFalse(workMarkdownSharedCacheHolds(text)) + } +} + diff --git a/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift b/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift index fa586e245..3e0c1707f 100644 --- a/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift +++ b/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift @@ -778,6 +778,125 @@ final class WorkSessionCanonicalStateTests: XCTestCase { XCTAssertEqual(visibleUserMessages.filter { $0.attachments == [second] }.count, 1) } + /// Two sends of the *same* text share one dedupe key, unlike the attachment + /// cases above. Suppression counts represented rows instead of testing set + /// membership, so the first matching transcript row retires exactly one echo — + /// `sending` now releases as soon as the host accepts a message, which makes + /// back-to-back identical sends easy to produce. + func testIdenticalEchoesAreRetiredOneRowAtATime() { + let echoes = [ + WorkLocalEchoMessage(text: "continue", timestamp: iso(now)), + WorkLocalEchoMessage(text: "continue", timestamp: iso(now.addingTimeInterval(1))), + ] + let transcript = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: iso(now), + sequence: 1, + event: .userMessage( + text: "continue", + attachments: nil, + turnId: nil, + steerId: nil, + deliveryState: nil, + processed: nil + ) + ) + ] + + let snapshot = buildWorkChatTimelineSnapshot( + transcript: transcript, + fallbackEntries: [], + artifacts: [], + localEchoMessages: echoes + ) + let visibleUserMessages = snapshot.timeline.compactMap { entry -> WorkChatMessage? in + if case .message(let message) = entry.payload, message.role == "user" { return message } + return nil + } + // One transcript row plus the still-unrepresented second echo. + XCTAssertEqual(visibleUserMessages.count, 2) + XCTAssertEqual(visibleUserMessages.filter { $0.markdown == "continue" }.count, 2) + + let remaining = workUnrepresentedLocalEchoMessages( + echoes, + representedKeyCounts: workRepresentedEchoKeyCounts(from: transcript) + ) + XCTAssertEqual(remaining.count, 1) + XCTAssertEqual(remaining.first?.id, echoes[1].id, "the newer echo must survive") + } + + /// Reconciliation runs repeatedly against the same transcript — `loadTranscript` + /// reconciles, then the post-send pass reconciles again — so retiring by a + /// consumed count would retire the survivor on the second call. + func testRepeatedReconciliationAgainstOneRowKeepsTheUnrepresentedEcho() { + var echoes = [ + WorkLocalEchoMessage(text: "continue", timestamp: iso(now)), + WorkLocalEchoMessage(text: "continue", timestamp: iso(now.addingTimeInterval(1))), + ] + let oneRow = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: iso(now), + sequence: 1, + event: .userMessage( + text: "continue", + attachments: nil, + turnId: nil, + steerId: nil, + deliveryState: nil, + processed: nil + ) + ) + ] + + for pass in 1...3 { + echoes = workLocalEchoesRetiredByTranscript(echoes, transcript: oneRow) + XCTAssertEqual( + echoes.count, 2, + "pass \(pass): one canonical row must not retire both identical echoes" + ) + } + // The rendered timeline still shows one row and one echo, not two of each. + let snapshot = buildWorkChatTimelineSnapshot( + transcript: oneRow, + fallbackEntries: [], + artifacts: [], + localEchoMessages: echoes + ) + let userMessages = snapshot.timeline.filter { entry in + if case .message(let message) = entry.payload { return message.role == "user" } + return false + } + XCTAssertEqual(userMessages.count, 2) + } + + func testBothIdenticalEchoesRetireOnceBothRowsArrive() { + let echoes = [ + WorkLocalEchoMessage(text: "continue", timestamp: iso(now)), + WorkLocalEchoMessage(text: "continue", timestamp: iso(now.addingTimeInterval(1))), + ] + let transcript = (1...2).map { sequence in + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: iso(now.addingTimeInterval(TimeInterval(sequence))), + sequence: sequence, + event: .userMessage( + text: "continue", + attachments: nil, + turnId: nil, + steerId: nil, + deliveryState: nil, + processed: nil + ) + ) + } + + let counts = workRepresentedEchoKeyCounts(from: transcript) + XCTAssertEqual(counts.values.reduce(0, +), 2) + XCTAssertTrue(workUnrepresentedLocalEchoMessages(echoes, representedKeyCounts: counts).isEmpty) + } + // MARK: - Fixtures private func makeSession( diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 6a4acf914..090ec12d8 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -561,6 +561,53 @@ The Work model/activity parity path is concentrated in these files: fallback, and the non-queueable `chat.cancelScheduledWork` wrapper used by Chat Info. +#### Perceived latency in the chat surface + +Three mechanisms keep the transcript feeling native. They are easy to regress, +because each one trades a simpler implementation for a property the eye notices. + +**The user's own bubble paints on the tap frame.** Every other timeline change +goes through the 90 ms coalescing rebuild in `scheduleTimelineSnapshotRebuild`, +which is right for host deltas arriving 6-7×/s and wrong for the one change the +user just caused. `applyLocalEchoTailImmediatelyIfPossible` appends the echo to +the existing snapshot synchronously and retires any in-flight rebuild +generation, so a coalesced fold cannot overwrite the bubble it was built +without. Image sends echo *before* the upload: the composer's downscaled +`UIImage` renders behind an uploading state under an `ade-pending-upload://` +placeholder ref (`WorkPendingUploadPreviewStore`), swapped for the real host +path before the message is sent so the echo's dedupe key still matches the +transcript row that returns. `sending` releases when the host accepts the +message; the transcript/artifact/summary/session refresh runs behind the +composer, chained so two quick sends cannot interleave two transcript loads. + +Because that makes back-to-back identical sends easy, echo suppression counts +represented rows rather than testing set membership — two sends of "continue" +share one dedupe key, and one matching transcript row must retire exactly one of +them (`workUnrepresentedLocalEchoMessages`). + +**Prepended history does not move the reader.** Older pages insert above the +viewport, so the `LazyVStack` grows upward while `contentOffset` stays put. The +correction is measured on the row that led the list before the insert, via a +single geometry probe that rides that row (`WorkChatPrependProbePreferenceKey`), +and is applied through `ScrollPosition` in a non-animated transaction. +Deliberately not total content height: a reply streaming into the tail grows the +content at the same time, and a reader scrolled back through history is exactly +when that happens, so a total-height correction would add the tail's growth and +overshoot. Bottom-follow, the jump-to-latest pill, and the initial force-pin are +untouched. + +**Long replies cost O(tail), not O(message).** `parseMarkdownBlocksForStreaming` +already split prose at a stable boundary; syntax highlighting now does the same, +reusing an already-highlighted stable prefix split at the last line boundary +provably outside a block comment, backtick/triple-quote string, or HTML comment. +Attributes are applied by painting a role per UTF-16 position and appending runs +over immutable text — never by retaining an `AttributedString` index across an +attribute assignment, which is undefined. Streaming tail revisions render from +their own small cache instead of the shared 256-entry inline-markdown cache, so +one long turn cannot evict every completed message and force a main-thread +re-parse on scrollback; the final revision is promoted. All derived render +caches drop on `applicationDidReceiveMemoryWarning`. + Deployment target: iOS 26+. iPhone and iPad (adaptive layouts planned for Phase 7).