codeburn/mac/Sources/CodeBurnMenubar/Data/WorkflowStrip.swift
Resham Joshi 8097052f67
menubar: add Workflow strip after the Models section (#785)
Compact one-row strip: correction rate with count, median time to first
edit, and the top reworked file, plus one coaching note derived locally
with the same thresholds and copy as the CLI's workflow-insights
buildCoachingNotes. Decodes two new optional payload blocks
(current.workflow and current.topReworkedFiles), so payloads from older
CLIs still parse. The section hides entirely when there is no signal and
individual stats never render as zero placeholders. It reads the store's
current payload, so it follows the selected agent tab automatically.

Adds unit tests for the duration formatter, note selection, model
derivation, and payload decoding.

Co-authored-by: reviewer <review@local>
2026-07-20 13:55:55 -07:00

127 lines
5.6 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Foundation
/// Pure, testable derivation of the menubar Workflow strip from the decoded
/// payload. The view renders exactly these values; all formatting and the
/// coaching-note selection live here so they can be unit-tested without SwiftUI.
///
/// Every field is nil unless it carries real signal: a stat is never shown as a
/// zero placeholder. `isEmpty` is true when none of the three stats fire, and
/// the section renders nothing in that case.
struct WorkflowStripModel: Equatable {
/// Correction rate as a percent with the raw count, e.g. "3% (5)".
let corrections: String?
/// Median time to the first edit, e.g. "6m".
let firstEdit: String?
/// Most reworked file, basename only, e.g. "sdk.py".
let reworkedName: String?
/// Distinct-session count for the most reworked file, e.g. 15.
let reworkedSessions: Int?
/// One coaching note, or nil. Mirrors the first-firing note in
/// src/workflow-insights.ts buildCoachingNotes over the payload's signals.
let note: String?
/// Nothing worth showing -> the section hides itself.
var isEmpty: Bool {
corrections == nil && firstEdit == nil && reworkedName == nil
}
/// The reworked stat as one string, e.g. "sdk.py ×15" (used for the exact
/// rendered value and by tests).
var reworked: String? {
guard let reworkedName, let reworkedSessions else { return nil }
return "\(reworkedName) ×\(reworkedSessions)"
}
}
extension WorkflowStripModel {
// Coaching-note thresholds, matching src/workflow-insights.ts exactly.
static let correctionHighRate = 0.15
static let correctionMinCount = 3
static let churnMinSessions = 3
static let ttfeSlowMs: Double = 5 * 60 * 1000
init(workflow: WorkflowBlock?, topReworkedFiles: [ReworkedFileEntry]) {
let topFile = topReworkedFiles.first
// Corrections: only when the displayed rate is a real, non-zero percent.
// The rate is the headline; a count of corrections that rounds to 0% (a
// handful over thousands of turns) carries no signal, so the whole stat
// hides rather than reading "0% (6)".
if let rate = workflow?.correctionRate, let count = workflow?.corrections,
count > 0, Int((rate * 100).rounded()) >= 1 {
corrections = "\(Self.percent(rate)) (\(count))"
} else {
corrections = nil
}
// First edit: only a real, positive median.
if let ms = workflow?.medianTimeToFirstEditMs, ms > 0 {
firstEdit = Self.formatDuration(ms: ms)
} else {
firstEdit = nil
}
// Top reworked file: basename ×sessions.
if let file = topFile, file.sessions > 0 {
reworkedName = Self.basename(file.path)
reworkedSessions = file.sessions
} else {
reworkedName = nil
reworkedSessions = nil
}
note = Self.coachingNote(
corrections: workflow?.corrections ?? 0,
correctionRate: workflow?.correctionRate,
topFile: topFile,
medianTimeToFirstEditMs: workflow?.medianTimeToFirstEditMs
)
}
/// Picks the single coaching note: the first threshold that fires, in the
/// same order as buildCoachingNotes (the worst-one-shot note is omitted
/// because that signal is not in the menubar payload). Copy is byte-for-byte
/// the CLI's, no em-dashes.
static func coachingNote(corrections: Int,
correctionRate: Double?,
topFile: ReworkedFileEntry?,
medianTimeToFirstEditMs: Double?) -> String? {
if let rate = correctionRate, rate >= correctionHighRate, corrections >= correctionMinCount {
return "You corrected the assistant on \(percent(rate)) of prompts (\(corrections) times). State the requirements in the first message to cut the back and forth."
}
if let file = topFile, file.sessions >= churnMinSessions {
return "\(basename(file.path)) was reworked across \(file.sessions) sessions (\(file.edits) edits). A focused pass on it may cost less than the repeated churn."
}
if let ms = medianTimeToFirstEditMs, ms >= ttfeSlowMs {
return "Median time to first edit is \(formatDurationShort(ms: ms)). Point the assistant at the target file to cut the exploration before it starts editing."
}
return nil
}
/// "First edit" stat duration: <60s -> "Ns", <60m -> "Nm", else "Nh Nm".
static func formatDuration(ms: Double) -> String {
if ms < 60_000 { return "\(Int((ms / 1000).rounded()))s" }
let totalMinutes = Int((ms / 60_000).rounded())
if totalMinutes < 60 { return "\(totalMinutes)m" }
return "\(totalMinutes / 60)h \(totalMinutes % 60)m"
}
/// Mirrors formatDurationShort in src/workflow-insights.ts: whole seconds
/// under a minute, whole minutes above. Used only inside the coaching note so
/// the copy matches the CLI exactly.
static func formatDurationShort(ms: Double) -> String {
if ms >= 60_000 { return "\(Int((ms / 60_000).rounded()))m" }
return "\(Int((ms / 1000).rounded()))s"
}
/// Rounded percent from a 0-1 rate, matching JS `Math.round(rate * 100)`.
static func percent(_ rate: Double) -> String {
"\(Int((rate * 100).rounded()))%"
}
/// Last path component. The payload is already basename-only, but an older
/// CLI could emit a fuller (forward-slash) path.
static func basename(_ path: String) -> String {
path.split(separator: "/").last.map(String.init) ?? path
}
}