diff --git a/Packages/Examples/MessageListSimulatorApp/MessageListSimulatorApp.xcodeproj/project.pbxproj b/Packages/Examples/MessageListSimulatorApp/MessageListSimulatorApp.xcodeproj/project.pbxproj index d8e64c84..d30637a5 100644 --- a/Packages/Examples/MessageListSimulatorApp/MessageListSimulatorApp.xcodeproj/project.pbxproj +++ b/Packages/Examples/MessageListSimulatorApp/MessageListSimulatorApp.xcodeproj/project.pbxproj @@ -342,7 +342,7 @@ repositoryURL = "https://github.com/rxtech-lab/RxAgentSDK.git"; requirement = { kind = upToNextMinorVersion; - minimumVersion = 1.0.4; + minimumVersion = 1.0.8; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Packages/Examples/MessageListSimulatorApp/MessageListSimulatorApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Packages/Examples/MessageListSimulatorApp/MessageListSimulatorApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e18a30a4..7b4b209e 100644 --- a/Packages/Examples/MessageListSimulatorApp/MessageListSimulatorApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Packages/Examples/MessageListSimulatorApp/MessageListSimulatorApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/rxtech-lab/RxAgentSDK.git", "state" : { - "revision" : "63971d4980dcdffe9633604ad7fec5c7ab97e55d", - "version" : "1.0.4" + "revision" : "15cd2bde13eef44e9fa02e1c7197ba771ad6e53e", + "version" : "1.0.8" } } ], diff --git a/Packages/Package.resolved b/Packages/Package.resolved index 814ec1db..8dbda3c4 100644 --- a/Packages/Package.resolved +++ b/Packages/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "8e5837723fa63b74c2d907fed60b831efc1c1012d2dbd52125a648387ab46c79", + "originHash" : "ca568b6f8d3295750a13825a306b766d046c4d763552c1a5a8c3797d2bdd705a", "pins" : [ { "identity" : "libwebp-xcode", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/rxtech-lab/RxAgentSDK.git", "state" : { - "revision" : "63971d4980dcdffe9633604ad7fec5c7ab97e55d", - "version" : "1.0.4" + "revision" : "15cd2bde13eef44e9fa02e1c7197ba771ad6e53e", + "version" : "1.0.8" } }, { diff --git a/Packages/Package.swift b/Packages/Package.swift index bbfcac0f..927246cc 100644 --- a/Packages/Package.swift +++ b/Packages/Package.swift @@ -14,7 +14,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/nalexn/ViewInspector", from: "0.10.0"), - .package(url: "https://github.com/rxtech-lab/RxAgentSDK.git", .upToNextMinor(from: "1.0.4")), + .package(url: "https://github.com/rxtech-lab/RxAgentSDK.git", .upToNextMinor(from: "1.0.8")), .package(url: "https://github.com/SDWebImage/libwebp-Xcode", from: "1.5.0"), ], targets: [ diff --git a/Packages/Sources/RxCodeChatKit/BubbleStyle.swift b/Packages/Sources/RxCodeChatKit/BubbleStyle.swift index 38187a7c..3f7fb3e6 100644 --- a/Packages/Sources/RxCodeChatKit/BubbleStyle.swift +++ b/Packages/Sources/RxCodeChatKit/BubbleStyle.swift @@ -105,7 +105,7 @@ extension View { // MARK: - AnyInsettableShape -private struct AnyInsettableShape: InsettableShape, @unchecked Sendable { +nonisolated private struct AnyInsettableShape: InsettableShape, @unchecked Sendable { private let _path: (CGRect) -> Path private let _inset: (CGFloat) -> AnyInsettableShape diff --git a/Packages/Sources/RxCodeChatKit/IMETextView.swift b/Packages/Sources/RxCodeChatKit/IMETextView.swift index f700f0b2..34f9c599 100644 --- a/Packages/Sources/RxCodeChatKit/IMETextView.swift +++ b/Packages/Sources/RxCodeChatKit/IMETextView.swift @@ -34,7 +34,7 @@ public struct IMETextView: NSViewRepresentable { var onTextViewReady: ((NSTextView) -> Void)? = nil /// Thumbnail for the `[ImageN]` chip with the given 1-based index. When it /// returns an image, the chip draws it in place of its leading bracket. - var chipThumbnail: ((Int) -> NSImage?)? = nil + var chipThumbnail: (@MainActor (Int) -> NSImage?)? = nil public init( text: Binding, @@ -55,7 +55,7 @@ public struct IMETextView: NSViewRepresentable { isEditable: Bool = true, accessibilityIdentifier: String = "chat-input-text-view", onTextViewReady: ((NSTextView) -> Void)? = nil, - chipThumbnail: ((Int) -> NSImage?)? = nil + chipThumbnail: (@MainActor (Int) -> NSImage?)? = nil ) { self._text = text self._isFocused = isFocused @@ -238,7 +238,7 @@ nonisolated private func enumerateChipRanges(in text: String, _ body: (NSRange, fileprivate final class ChipLayoutManager: NSLayoutManager, NSLayoutManagerDelegate, @unchecked Sendable { /// Set from the main actor; AppKit calls layout and drawing on the main thread. - nonisolated(unsafe) var thumbnail: ((Int) -> NSImage?)? + nonisolated(unsafe) var thumbnail: (@MainActor (Int) -> NSImage?)? nonisolated override init() { super.init() diff --git a/RxCode.xcodeproj/project.pbxproj b/RxCode.xcodeproj/project.pbxproj index 9c47707d..fcaa3f81 100644 --- a/RxCode.xcodeproj/project.pbxproj +++ b/RxCode.xcodeproj/project.pbxproj @@ -1737,7 +1737,7 @@ repositoryURL = "https://github.com/rxtech-lab/RxAgentSDK"; requirement = { kind = exactVersion; - version = 1.0.7; + version = 1.0.8; }; }; E6A001002F8A000100000001 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = { diff --git a/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 05337f87..e1f599af 100644 --- a/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -141,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/rxtech-lab/RxAgentSDK.git", "state" : { - "revision" : "3d8882c020eb2b19379ea0aa6ac9facc28863c27", - "version" : "1.0.7" + "revision" : "15cd2bde13eef44e9fa02e1c7197ba771ad6e53e", + "version" : "1.0.8" } }, { diff --git a/RxCode.xcodeproj/xcshareddata/xcodecloud/manifest.json b/RxCode.xcodeproj/xcshareddata/xcodecloud/manifest.json new file mode 100644 index 00000000..5cd14f54 --- /dev/null +++ b/RxCode.xcodeproj/xcshareddata/xcodecloud/manifest.json @@ -0,0 +1,9 @@ +{ + "id" : "78cf6ff7-750c-4249-a8ac-409d691a9453", + "targets" : [ + { + "id" : "0E62358B-547B-4F76-8715-D1ECABA7A06A", + "name" : "RxCodeMobile" + } + ] +} \ No newline at end of file diff --git a/RxCode/App/AppState+Messaging.swift b/RxCode/App/AppState+Messaging.swift index e42625a1..7c86d7e4 100644 --- a/RxCode/App/AppState+Messaging.swift +++ b/RxCode/App/AppState+Messaging.swift @@ -547,6 +547,9 @@ extension AppState { return } logger.info("[IDE_SEND_THREAD] record stream completion stream=\(streamId) session=\(sessionId, privacy: .public) error=\(error ?? "", privacy: .public) assistantChars=\(assistantText.count, privacy: .public)") + if error == nil { + reconcileTaskCompletionLabel(sessionId: sessionId, assistantText: assistantText) + } let completion = StreamCompletion( sessionId: sessionId, assistantText: assistantText, diff --git a/RxCode/App/AppState+TaskRuns.swift b/RxCode/App/AppState+TaskRuns.swift index d204315a..b4e6fe61 100644 --- a/RxCode/App/AppState+TaskRuns.swift +++ b/RxCode/App/AppState+TaskRuns.swift @@ -738,6 +738,25 @@ extension AppState { } } + /// Labels a completion-check thread from the response its run actually + /// finished with. The spawn only waits `timeoutSeconds`; a slow check is + /// labelled from partial text that has no `TASK_RESULT:` marker yet, so it + /// reads "Unverified" even after the run goes on to end with COMPLETE. + /// Called for every finished stream; a no-op for other threads and for + /// responses without a verdict. + func reconcileTaskCompletionLabel(sessionId: String, assistantText: String) { + let resolved = resolveCurrentSessionId(sessionId) + guard let summary = allSessionSummaries.first(where: { $0.id == resolved }) + ?? threadStore.fetch(id: resolved)?.toSummary(), + taskCompletionCheckState(for: summary) != nil, + let parentThreadId = summary.parentThreadId, + let verified = Self.taskCompletionVerdict(from: assistantText) + else { return } + let label = verified ? Self.taskCompletionVerifiedLabel : Self.taskCompletionUnverifiedLabel + guard summary.threadLabel != label else { return } + setTaskCompletionLabel(resolved, parentThreadId: parentThreadId, verified: verified) + } + private func updateTaskAttention(_ id: UUID, reason: String?) { guard let task = self.task(id: id) else { return } var board = taskBoard(for: task.projectId) diff --git a/RxCode/Resources/Localizable.xcstrings b/RxCode/Resources/Localizable.xcstrings index 26e523fb..0c8bfad4 100644 --- a/RxCode/Resources/Localizable.xcstrings +++ b/RxCode/Resources/Localizable.xcstrings @@ -12910,6 +12910,12 @@ } } } + }, + "Least recent" : { + + }, + "Least used" : { + }, "Left in 5h" : { "localizations" : { @@ -14469,6 +14475,12 @@ } } } + }, + "Most recent" : { + + }, + "Most used" : { + }, "Most used model" : { "localizations" : { @@ -22741,6 +22753,9 @@ } } } + }, + "Showing up to %lld tasks per provider" : { + }, "Shown only — nothing is passed back to the session." : { "localizations" : { @@ -23214,6 +23229,9 @@ } } } + }, + "Sort by" : { + }, "Source" : { "localizations" : { diff --git a/RxCode/Services/ClaudeService+Process.swift b/RxCode/Services/ClaudeService+Process.swift index c6179812..839957a7 100644 --- a/RxCode/Services/ClaudeService+Process.swift +++ b/RxCode/Services/ClaudeService+Process.swift @@ -113,7 +113,7 @@ extension ClaudeCodeServer { var rawLineCount = 0 var capturedSessionId: String? - for await line in Self.asyncLines(from: stdout.fileHandleForReading, log: log) { + for await line in stdout.fileHandleForReading.lineStream() { guard !line.isEmpty else { continue } guard let data = line.data(using: .utf8) else { continue } @@ -168,45 +168,6 @@ extension ClaudeCodeServer { } } - /// Stream lines from `handle` using a Dispatch-backed `readabilityHandler`. - /// We use this instead of `FileHandle.AsyncBytes.lines` because the async - /// iterator can wedge when multiple concurrent pipe readers exist (the - /// cross-project send case: one CLI is mid-tool-call while another is - /// just starting). Dispatch's readable source delivers each chunk via a - /// per-handle background callback that doesn't share global async state, - /// so a second simultaneous reader is unaffected by the first's progress. - private static func asyncLines(from handle: FileHandle, log: Logger) -> AsyncStream { - AsyncStream { continuation in - // `buffer` is touched only from the readabilityHandler, which Dispatch - // serializes onto a single internal queue per FileHandle — no lock needed. - nonisolated(unsafe) var buffer = Data() - handle.readabilityHandler = { fh in - let chunk = fh.availableData - if chunk.isEmpty { - // EOF — flush any trailing non-terminated line, then finish. - if !buffer.isEmpty, let trailing = String(data: buffer, encoding: .utf8) { - continuation.yield(trailing) - buffer.removeAll(keepingCapacity: false) - } - fh.readabilityHandler = nil - continuation.finish() - return - } - buffer.append(chunk) - while let newlineIdx = buffer.firstIndex(of: 0x0A) { - let lineData = buffer[buffer.startIndex.. AsyncStream { + AsyncStream { continuation in + // `buffer` is touched only from the readabilityHandler, which Dispatch + // serializes onto a single internal queue per FileHandle — no lock needed. + nonisolated(unsafe) var buffer = Data() + readabilityHandler = { fh in + let chunk = fh.availableData + if chunk.isEmpty { + // EOF — flush any trailing non-terminated line, then finish. + if !buffer.isEmpty, let trailing = String(data: buffer, encoding: .utf8) { + continuation.yield(trailing) + buffer.removeAll(keepingCapacity: false) + } + fh.readabilityHandler = nil + continuation.finish() + return + } + buffer.append(chunk) + while let newlineIdx = buffer.firstIndex(of: 0x0A) { + let lineData = buffer[buffer.startIndex..: View { +struct RateLimitPopoverScaffold: View { let title: String let subtitle: String @ViewBuilder let content: Content @@ -52,7 +52,7 @@ private struct CappedScrollView: View { /// "How to read this" block: each term shown in the table next to what it /// means. -private struct RateLimitGlossary: View { +struct RateLimitGlossary: View { struct Entry: Identifiable { let term: LocalizedStringKey let detail: LocalizedStringKey @@ -94,7 +94,7 @@ private struct RateLimitGlossary: View { /// One provider's block in a popover table: colored header, then rows /// separated by dividers. -private struct ProviderTableSection: View { +struct ProviderTableSection: View { let provider: AgentProvider let rows: [Row] @ViewBuilder let row: (Row) -> RowContent @@ -115,7 +115,7 @@ private struct ProviderTableSection: View { } } -private func emptyMessage(_ text: LocalizedStringKey) -> some View { +func emptyMessage(_ text: LocalizedStringKey) -> some View { Text(text) .font(.system(size: 12)) .foregroundStyle(ClaudeTheme.textTertiary) @@ -128,7 +128,7 @@ private func providerColor(_ provider: AgentProvider) -> Color { /// False for plans without a separate 5-hour limit. @MainActor -private func hasFiveHourLimit(_ provider: AgentProvider, in appState: AppState) -> Bool { +func hasFiveHourLimit(_ provider: AgentProvider, in appState: AppState) -> Bool { appState.cachedRateLimitUsage(for: provider)?.hasFiveHourLimit ?? true } @@ -411,135 +411,6 @@ struct RateLimitHistoryPopover: View { } } -// MARK: - Task costs - -/// Popover listing the usage-limit cost of each measured task per provider, -/// with each provider's most and least expensive tasks called out. -struct RateLimitTaskCostPopover: View { - @Environment(AppState.self) private var appState - - let providers: [AgentProvider] - let range: UsageStatsRange - - @State private var summaries: [AgentProvider: RateLimitTaskCostSummary] = [:] - - private var isEmpty: Bool { - summaries.values.allSatisfy(\.isEmpty) - } - - /// Tasks ranked by 5-hour cost, unmeasurable ones last. - private func rankedTasks(_ summary: RateLimitTaskCostSummary) -> [RateLimitTaskCostSnapshot] { - summary.tasks.sorted { - let lhs = $0.fiveHourDelta ?? -1 - let rhs = $1.fiveHourDelta ?? -1 - if lhs != rhs { return lhs > rhs } - return $0.endedAt > $1.endedAt - } - } - - var body: some View { - RateLimitPopoverScaffold( - title: String(localized: "Limit cost per task"), - subtitle: String(localized: "\(range.localizedLongTitle) · change in each limit between a task's start and finish.") - ) { - if isEmpty { - emptyMessage("No measured tasks in this window yet.") - } else { - ForEach(providers, id: \.self) { provider in - let summary = summaries[provider] ?? RateLimitTaskCostSummary() - if !summary.isEmpty { - ProviderTableSection(provider: provider, rows: rankedTasks(summary)) { task in - row(task, provider: provider, summary: summary) - } - } - } - RateLimitGlossary(entries: [ - .init(term: "Row", detail: "One finished task: its chat, model, finish time and how long it ran."), - .init(term: "5h · 7d", detail: "Percentage points of the 5-hour and 7-day limits the task used, measured from its start to its finish."), - .init(term: "Most · Least", detail: "The provider's most and least expensive tasks in this window."), - .init(term: "—", detail: "The limit reset while the task ran, so its cost can't be measured."), - .init(term: "Ran with others", detail: "Tasks running at the same time share one limit, so their costs are approximate and are left out of the averages once enough solo tasks exist."), - ]) - } - } - .task(id: appState.rateLimitHistoryRevision) { - var result: [AgentProvider: RateLimitTaskCostSummary] = [:] - for provider in providers { - result[provider] = appState.rateLimitTaskCostSummary(for: provider, range: range) - } - summaries = result - } - } - - private func row(_ task: RateLimitTaskCostSnapshot, provider: AgentProvider, summary: RateLimitTaskCostSummary) -> some View { - HStack(alignment: .firstTextBaseline, spacing: 12) { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text(task.threadTitle) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(ClaudeTheme.textPrimary) - .lineLimit(1) - if task.id == summary.mostExpensive?.id { - badge("Most") - .help("The most expensive task in this window.") - } else if task.id == summary.leastExpensive?.id { - badge("Least") - .help("The least expensive task in this window.") - } - } - Text(subtitle(for: task, provider: provider)) - .font(.system(size: 10.5)) - .foregroundStyle(ClaudeTheme.textTertiary) - .lineLimit(1) - } - Spacer(minLength: 8) - if hasFiveHourLimit(provider, in: appState) { - cost("5h used", task.fiveHourDelta) - } - cost("7d used", task.sevenDayDelta) - } - } - - private func subtitle(for task: RateLimitTaskCostSnapshot, provider: AgentProvider) -> String { - var parts: [String] = [] - if !task.model.isEmpty { - parts.append(appState.usageModelDisplayName(task.model, provider: provider)) - } - parts.append(task.endedAt.formatted(date: .abbreviated, time: .shortened)) - parts.append(BriefingUsageStatsView.formatDuration(task.endedAt.timeIntervalSince(task.startedAt))) - if task.concurrentRuns > 0 { - parts.append(task.concurrentRuns == 1 - ? String(localized: "ran with 1 other task") - : String(localized: "ran with \(task.concurrentRuns) other tasks")) - } - return parts.joined(separator: " · ") - } - - private func cost(_ label: LocalizedStringKey, _ delta: Double?) -> some View { - VStack(alignment: .trailing, spacing: 1) { - Text(BriefingRateLimitStatsView.formatOptionalDelta(delta)) - .font(.system(size: 12, weight: .semibold).monospacedDigit()) - .foregroundStyle(ClaudeTheme.textPrimary) - Text(label) - .font(.system(size: 10)) - .foregroundStyle(ClaudeTheme.textTertiary) - } - .frame(width: 64, alignment: .trailing) - .help(delta == nil - ? String(localized: "The limit reset during this task, so its cost can't be measured.") - : String(localized: "Percentage points of this limit the task used.")) - } - - private func badge(_ text: LocalizedStringKey) -> some View { - Text(text) - .font(.system(size: 9.5, weight: .semibold)) - .foregroundStyle(ClaudeTheme.accent) - .padding(.horizontal, 6) - .padding(.vertical, 1) - .background(Capsule().fill(ClaudeTheme.accent.opacity(0.12))) - } -} - // MARK: - Model estimates /// Popover with limit cost and remaining-task estimates per model for every diff --git a/RxCode/Views/Sidebar/RateLimitTaskCostPopover.swift b/RxCode/Views/Sidebar/RateLimitTaskCostPopover.swift new file mode 100644 index 00000000..bead867c --- /dev/null +++ b/RxCode/Views/Sidebar/RateLimitTaskCostPopover.swift @@ -0,0 +1,189 @@ +import SwiftUI +import RxCodeCore + +// MARK: - Task costs + +/// Popover listing the usage-limit cost of each measured task per provider, +/// with each provider's most and least expensive tasks called out. +struct RateLimitTaskCostPopover: View { + @Environment(AppState.self) private var appState + + let providers: [AgentProvider] + let range: UsageStatsRange + + @State private var summaries: [AgentProvider: RateLimitTaskCostSummary] = [:] + @AppStorage("rateLimitTaskCostSortOrder") private var sortOrder: SortOrder = .mostRecent + + /// Tasks listed per provider. + private static let maxTasksPerProvider = 10 + + enum SortOrder: String, CaseIterable { + case mostRecent + case leastRecent + case mostUsed + case leastUsed + + var title: LocalizedStringKey { + switch self { + case .mostRecent: "Most recent" + case .leastRecent: "Least recent" + case .mostUsed: "Most used" + case .leastUsed: "Least used" + } + } + } + + private var isEmpty: Bool { + summaries.values.allSatisfy(\.isEmpty) + } + + /// The provider's first tasks in the chosen order. Cost is the 5-hour + /// delta, or the 7-day delta for plans without a 5-hour limit; + /// unmeasurable tasks sort last either way. + private func rankedTasks(_ summary: RateLimitTaskCostSummary, provider: AgentProvider) -> [RateLimitTaskCostSnapshot] { + let usesFiveHour = hasFiveHourLimit(provider, in: appState) + func cost(_ task: RateLimitTaskCostSnapshot) -> Double? { + usesFiveHour ? task.fiveHourDelta : task.sevenDayDelta + } + let sorted = summary.tasks.sorted { lhs, rhs in + switch sortOrder { + case .mostRecent: + return lhs.endedAt > rhs.endedAt + case .leastRecent: + return lhs.endedAt < rhs.endedAt + case .mostUsed, .leastUsed: + switch (cost(lhs), cost(rhs)) { + case let (l?, r?) where l != r: + return sortOrder == .mostUsed ? l > r : l < r + case (_?, nil): + return true + case (nil, _?): + return false + default: + return lhs.endedAt > rhs.endedAt + } + } + } + return Array(sorted.prefix(Self.maxTasksPerProvider)) + } + + private var sortPicker: some View { + HStack { + Text("Showing up to \(Self.maxTasksPerProvider) tasks per provider") + .font(.system(size: 11)) + .foregroundStyle(ClaudeTheme.textTertiary) + Spacer() + Picker("Sort by", selection: $sortOrder) { + ForEach(SortOrder.allCases, id: \.self) { order in + Text(order.title).tag(order) + } + } + .pickerStyle(.menu) + .fixedSize() + .font(.system(size: 11)) + } + } + + var body: some View { + RateLimitPopoverScaffold( + title: String(localized: "Limit cost per task"), + subtitle: String(localized: "\(range.localizedLongTitle) · change in each limit between a task's start and finish.") + ) { + if isEmpty { + emptyMessage("No measured tasks in this window yet.") + } else { + sortPicker + ForEach(providers, id: \.self) { provider in + let summary = summaries[provider] ?? RateLimitTaskCostSummary() + if !summary.isEmpty { + ProviderTableSection(provider: provider, rows: rankedTasks(summary, provider: provider)) { task in + row(task, provider: provider, summary: summary) + } + } + } + RateLimitGlossary(entries: [ + .init(term: "Row", detail: "One finished task: its chat, model, finish time and how long it ran."), + .init(term: "5h · 7d", detail: "Percentage points of the 5-hour and 7-day limits the task used, measured from its start to its finish."), + .init(term: "Most · Least", detail: "The provider's most and least expensive tasks in this window."), + .init(term: "—", detail: "The limit reset while the task ran, so its cost can't be measured."), + .init(term: "Ran with others", detail: "Tasks running at the same time share one limit, so their costs are approximate and are left out of the averages once enough solo tasks exist."), + ]) + } + } + .task(id: appState.rateLimitHistoryRevision) { + var result: [AgentProvider: RateLimitTaskCostSummary] = [:] + for provider in providers { + result[provider] = appState.rateLimitTaskCostSummary(for: provider, range: range) + } + summaries = result + } + } + + private func row(_ task: RateLimitTaskCostSnapshot, provider: AgentProvider, summary: RateLimitTaskCostSummary) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(task.threadTitle) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(ClaudeTheme.textPrimary) + .lineLimit(1) + if task.id == summary.mostExpensive?.id { + badge("Most") + .help("The most expensive task in this window.") + } else if task.id == summary.leastExpensive?.id { + badge("Least") + .help("The least expensive task in this window.") + } + } + Text(subtitle(for: task, provider: provider)) + .font(.system(size: 10.5)) + .foregroundStyle(ClaudeTheme.textTertiary) + .lineLimit(1) + } + Spacer(minLength: 8) + if hasFiveHourLimit(provider, in: appState) { + cost("5h used", task.fiveHourDelta) + } + cost("7d used", task.sevenDayDelta) + } + } + + private func subtitle(for task: RateLimitTaskCostSnapshot, provider: AgentProvider) -> String { + var parts: [String] = [] + if !task.model.isEmpty { + parts.append(appState.usageModelDisplayName(task.model, provider: provider)) + } + parts.append(task.endedAt.formatted(date: .abbreviated, time: .shortened)) + parts.append(BriefingUsageStatsView.formatDuration(task.endedAt.timeIntervalSince(task.startedAt))) + if task.concurrentRuns > 0 { + parts.append(task.concurrentRuns == 1 + ? String(localized: "ran with 1 other task") + : String(localized: "ran with \(task.concurrentRuns) other tasks")) + } + return parts.joined(separator: " · ") + } + + private func cost(_ label: LocalizedStringKey, _ delta: Double?) -> some View { + VStack(alignment: .trailing, spacing: 1) { + Text(BriefingRateLimitStatsView.formatOptionalDelta(delta)) + .font(.system(size: 12, weight: .semibold).monospacedDigit()) + .foregroundStyle(ClaudeTheme.textPrimary) + Text(label) + .font(.system(size: 10)) + .foregroundStyle(ClaudeTheme.textTertiary) + } + .frame(width: 64, alignment: .trailing) + .help(delta == nil + ? String(localized: "The limit reset during this task, so its cost can't be measured.") + : String(localized: "Percentage points of this limit the task used.")) + } + + private func badge(_ text: LocalizedStringKey) -> some View { + Text(text) + .font(.system(size: 9.5, weight: .semibold)) + .foregroundStyle(ClaudeTheme.accent) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(Capsule().fill(ClaudeTheme.accent.opacity(0.12))) + } +} diff --git a/RxCodeTests/TaskBoardHookTests.swift b/RxCodeTests/TaskBoardHookTests.swift index c47a4037..d82dc92b 100644 --- a/RxCodeTests/TaskBoardHookTests.swift +++ b/RxCodeTests/TaskBoardHookTests.swift @@ -368,6 +368,27 @@ final class TaskBoardHookTests: XCTestCase { XCTAssertEqual(appState.threadStore.fetch(id: realId)?.threadLabel, AppState.taskCompletionVerifiedLabel) } + /// A check that outlived the spawn's wait was labelled from partial text; + /// its run finishing with a verdict has to correct the label. + func testLateCompletionCheckVerdictRelabelsTheThread() { + appState.threadStore = ThreadStore.inMemory() + appState.allSessionSummaries = [ + checkThread(id: "check-late", label: AppState.taskCompletionUnverifiedLabel), + checkThread(id: "chat", label: nil) + ] + for summary in appState.allSessionSummaries { appState.threadStore.upsert(summary) } + + appState.reconcileTaskCompletionLabel(sessionId: "check-late", assistantText: "Still checking…") + XCTAssertEqual(appState.allSessionSummaries[0].threadLabel, AppState.taskCompletionUnverifiedLabel) + + appState.reconcileTaskCompletionLabel(sessionId: "check-late", assistantText: "All done.\nTASK_RESULT: COMPLETE") + XCTAssertEqual(appState.allSessionSummaries[0].threadLabel, AppState.taskCompletionVerifiedLabel) + XCTAssertEqual(appState.threadStore.fetch(id: "check-late")?.threadLabel, AppState.taskCompletionVerifiedLabel) + + appState.reconcileTaskCompletionLabel(sessionId: "chat", assistantText: "TASK_RESULT: COMPLETE") + XCTAssertNil(appState.allSessionSummaries[1].threadLabel) + } + /// A check interrupted by quitting the app has no run left to finish it, so /// loading the store settles it rather than leaving a perpetual "Verifying". func testInterruptedCompletionChecksAreFinalizedOnLoad() {