From 9963798cfc5191cf001872b535a9891477e25449 Mon Sep 17 00:00:00 2001 From: reviewer Date: Mon, 20 Jul 2026 22:24:10 +0200 Subject: [PATCH] menubar: add Workflow strip after the Models section 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. --- .../CodeBurnMenubar/Data/MenubarPayload.swift | 30 ++- .../CodeBurnMenubar/Data/WorkflowStrip.swift | 127 ++++++++++ .../Views/MenuBarContent.swift | 1 + .../Views/WorkflowSection.swift | 111 +++++++++ .../WorkflowStripTests.swift | 235 ++++++++++++++++++ 5 files changed, 503 insertions(+), 1 deletion(-) create mode 100644 mac/Sources/CodeBurnMenubar/Data/WorkflowStrip.swift create mode 100644 mac/Sources/CodeBurnMenubar/Views/WorkflowSection.swift create mode 100644 mac/Tests/CodeBurnMenubarTests/WorkflowStripTests.swift diff --git a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift index 14bb548f..69cdd926 100644 --- a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift +++ b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift @@ -178,6 +178,24 @@ struct RoutingWaste: Codable, Sendable { let byModel: [RoutingWasteModelEntry] } +/// Workflow-intelligence rollup for the period. `correctionRate` and +/// `medianTimeToFirstEditMs` are null when not computable (no user turns / no +/// session ever edited), so both are optional. +struct WorkflowBlock: Codable, Sendable { + let corrections: Int + let correctionRate: Double? + let medianTimeToFirstEditMs: Double? +} + +/// One entry of `topReworkedFiles`. `path` is basename-only (the CLI trims it +/// for privacy before the payload can leave the machine); `sessions` is the +/// distinct-session count and `edits` the total edit-family calls. +struct ReworkedFileEntry: Codable, Sendable { + let path: String + let sessions: Int + let edits: Int +} + struct CurrentBlock: Codable, Sendable { let label: String let cost: Double @@ -202,6 +220,13 @@ struct CurrentBlock: Codable, Sendable { let skills: [SkillEntry] let subagents: [SubagentEntry] let mcpServers: [McpServerEntry] + /// Workflow-intelligence rollup. Optional so payloads from older CLIs + /// (which never emit it) still decode; absent -> the Workflow strip hides. + /// Declared last with a default so the memberwise initializer stays + /// backward-compatible for existing construction sites. + var workflow: WorkflowBlock? = nil + /// Files most reworked by edit-family calls. Empty on older CLIs. + var topReworkedFiles: [ReworkedFileEntry] = [] } extension CurrentBlock { @@ -209,7 +234,8 @@ extension CurrentBlock { case label, cost, calls, sessions, oneShotRate, inputTokens, outputTokens, cacheHitPercent, codexCredits, topActivities, topModels, localModelSavings, providers, topProjects, modelEfficiency, topSessions, retryTax, routingWaste, - tools, skills, subagents, mcpServers + tools, skills, subagents, mcpServers, + workflow, topReworkedFiles } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) @@ -235,6 +261,8 @@ extension CurrentBlock { skills = try c.decodeIfPresent([SkillEntry].self, forKey: .skills) ?? [] subagents = try c.decodeIfPresent([SubagentEntry].self, forKey: .subagents) ?? [] mcpServers = try c.decodeIfPresent([McpServerEntry].self, forKey: .mcpServers) ?? [] + workflow = try c.decodeIfPresent(WorkflowBlock.self, forKey: .workflow) + topReworkedFiles = try c.decodeIfPresent([ReworkedFileEntry].self, forKey: .topReworkedFiles) ?? [] } } diff --git a/mac/Sources/CodeBurnMenubar/Data/WorkflowStrip.swift b/mac/Sources/CodeBurnMenubar/Data/WorkflowStrip.swift new file mode 100644 index 00000000..6f31afe5 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/WorkflowStrip.swift @@ -0,0 +1,127 @@ +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 + } +} diff --git a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift index b182561b..d92bd745 100644 --- a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift +++ b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift @@ -36,6 +36,7 @@ struct MenuBarContent: View { ActivitySection() Divider().opacity(0.5) ModelsSection() + WorkflowSection() Divider().opacity(0.5) ToolingSection() Divider().opacity(0.5) diff --git a/mac/Sources/CodeBurnMenubar/Views/WorkflowSection.swift b/mac/Sources/CodeBurnMenubar/Views/WorkflowSection.swift new file mode 100644 index 00000000..6d7593f5 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Views/WorkflowSection.swift @@ -0,0 +1,111 @@ +import SwiftUI + +/// Compact workflow-intelligence strip: one row of up to three stats plus one +/// coaching note. Reads whatever payload the store currently holds, so it +/// follows the selected agent tab automatically. Renders nothing when the +/// payload carries no workflow signal (older CLIs, or a period with nothing +/// measurable), so it never shows zero placeholders. +struct WorkflowSection: View { + @Environment(AppStore.self) private var store + + var body: some View { + let model = WorkflowStripModel( + workflow: store.payload.current.workflow, + topReworkedFiles: store.payload.current.topReworkedFiles + ) + if !model.isEmpty { + // Own the leading divider so the section slots into the popover's + // divider rhythm and leaves no doubled line when it's hidden. + VStack(spacing: 0) { + Divider().opacity(0.5) + VStack(alignment: .leading, spacing: 8) { + SectionCaption(text: "Workflow") + WorkflowStatRow(model: model) + if let note = model.note { + Text(note) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 11) + } + } + } +} + +private enum WorkflowStat: Identifiable { + case labeled(id: String, label: String, value: String) + case reworked(name: String, sessions: Int) + + var id: String { + switch self { + case .labeled(let id, _, _): return id + case .reworked: return "reworked" + } + } +} + +private struct WorkflowStatRow: View { + let model: WorkflowStripModel + + private var stats: [WorkflowStat] { + var s: [WorkflowStat] = [] + if let corrections = model.corrections { + s.append(.labeled(id: "corrections", label: "Corrections", value: corrections)) + } + if let firstEdit = model.firstEdit { + s.append(.labeled(id: "firstEdit", label: "First edit", value: firstEdit)) + } + if let name = model.reworkedName, let sessions = model.reworkedSessions { + s.append(.reworked(name: name, sessions: sessions)) + } + return s + } + + var body: some View { + HStack(spacing: 7) { + ForEach(Array(stats.enumerated()), id: \.element.id) { index, stat in + if index > 0 { + Text("·") + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + } + WorkflowStatCell(stat: stat) + } + Spacer(minLength: 0) + } + } +} + +private struct WorkflowStatCell: View { + let stat: WorkflowStat + + var body: some View { + HStack(spacing: 4) { + switch stat { + case .labeled(_, let label, let value): + Text(label) + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + Text(value) + .font(.codeMono(size: 12, weight: .medium)) + .foregroundStyle(.primary) + .tracking(-0.2) + case .reworked(let name, let sessions): + Text(name) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.middle) + Text("×\(sessions)") + .font(.codeMono(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .tracking(-0.2) + } + } + } +} diff --git a/mac/Tests/CodeBurnMenubarTests/WorkflowStripTests.swift b/mac/Tests/CodeBurnMenubarTests/WorkflowStripTests.swift new file mode 100644 index 00000000..e42968d2 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/WorkflowStripTests.swift @@ -0,0 +1,235 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +@Suite("WorkflowStrip -- duration formatter") +struct WorkflowStripDurationTests { + @Test("seconds under a minute") + func seconds() { + #expect(WorkflowStripModel.formatDuration(ms: 8_000) == "8s") + #expect(WorkflowStripModel.formatDuration(ms: 45_000) == "45s") + // Rounds to nearest second (away from zero on .5). + #expect(WorkflowStripModel.formatDuration(ms: 1_500) == "2s") + } + + @Test("whole minutes below an hour") + func minutes() { + #expect(WorkflowStripModel.formatDuration(ms: 60_000) == "1m") + #expect(WorkflowStripModel.formatDuration(ms: 360_000) == "6m") + // 1m30s rounds up to 2m. + #expect(WorkflowStripModel.formatDuration(ms: 90_000) == "2m") + } + + @Test("hours and minutes at and above an hour") + func hours() { + #expect(WorkflowStripModel.formatDuration(ms: 3_600_000) == "1h 0m") + #expect(WorkflowStripModel.formatDuration(ms: 3_900_000) == "1h 5m") + #expect(WorkflowStripModel.formatDuration(ms: 7_500_000) == "2h 5m") + } + + @Test("coaching-note short formatter matches the CLI (seconds/minutes only)") + func shortFormatter() { + #expect(WorkflowStripModel.formatDurationShort(ms: 45_000) == "45s") + #expect(WorkflowStripModel.formatDurationShort(ms: 300_000) == "5m") + // Above an hour the note still reads minutes, unlike the stat (which is "1h 5m"). + #expect(WorkflowStripModel.formatDurationShort(ms: 3_900_000) == "65m") + } +} + +@Suite("WorkflowStrip -- coaching note selection") +struct WorkflowStripNoteTests { + private func file(_ path: String, sessions: Int, edits: Int) -> ReworkedFileEntry { + ReworkedFileEntry(path: path, sessions: sessions, edits: edits) + } + + @Test("corrections note fires at threshold and wins over the others") + func correctionsWins() { + let note = WorkflowStripModel.coachingNote( + corrections: 5, + correctionRate: 0.2, + topFile: file("sdk.py", sessions: 15, edits: 42), + medianTimeToFirstEditMs: 600_000 + ) + #expect(note == "You corrected the assistant on 20% of prompts (5 times). State the requirements in the first message to cut the back and forth.") + } + + @Test("corrections note gated by rate and count") + func correctionsGating() { + // Rate below 0.15 -> falls through to churn. + #expect(WorkflowStripModel.coachingNote(corrections: 9, correctionRate: 0.1, topFile: file("a.py", sessions: 4, edits: 8), medianTimeToFirstEditMs: nil) + == "a.py was reworked across 4 sessions (8 edits). A focused pass on it may cost less than the repeated churn.") + // Count below 3 -> falls through to churn. + #expect(WorkflowStripModel.coachingNote(corrections: 2, correctionRate: 0.9, topFile: file("a.py", sessions: 4, edits: 8), medianTimeToFirstEditMs: nil) + == "a.py was reworked across 4 sessions (8 edits). A focused pass on it may cost less than the repeated churn.") + } + + @Test("churn note fires and uses the basename") + func churn() { + let note = WorkflowStripModel.coachingNote( + corrections: 0, + correctionRate: nil, + topFile: file("src/lib/sdk.py", sessions: 15, edits: 42), + medianTimeToFirstEditMs: nil + ) + #expect(note == "sdk.py was reworked across 15 sessions (42 edits). A focused pass on it may cost less than the repeated churn.") + } + + @Test("ttfe note fires at 5 minutes when nothing stronger does") + func ttfe() { + let note = WorkflowStripModel.coachingNote( + corrections: 0, + correctionRate: nil, + topFile: file("a.py", sessions: 2, edits: 3), + medianTimeToFirstEditMs: 300_000 + ) + #expect(note == "Median time to first edit is 5m. Point the assistant at the target file to cut the exploration before it starts editing.") + } + + @Test("no note when nothing crosses a threshold") + func none() { + #expect(WorkflowStripModel.coachingNote(corrections: 1, correctionRate: 0.05, topFile: file("a.py", sessions: 1, edits: 2), medianTimeToFirstEditMs: 120_000) == nil) + #expect(WorkflowStripModel.coachingNote(corrections: 0, correctionRate: nil, topFile: nil, medianTimeToFirstEditMs: nil) == nil) + } +} + +@Suite("WorkflowStrip -- model derivation") +struct WorkflowStripModelTests { + @Test("full payload renders all three stats and a note") + func full() { + let model = WorkflowStripModel( + workflow: WorkflowBlock(corrections: 5, correctionRate: 0.03, medianTimeToFirstEditMs: 360_000), + topReworkedFiles: [ReworkedFileEntry(path: "sdk.py", sessions: 15, edits: 42)] + ) + #expect(model.corrections == "3% (5)") + #expect(model.firstEdit == "6m") + #expect(model.reworked == "sdk.py ×15") + #expect(model.isEmpty == false) + // rate 0.03 < 0.15 so corrections note doesn't fire; churn does. + #expect(model.note == "sdk.py was reworked across 15 sessions (42 edits). A focused pass on it may cost less than the repeated churn.") + } + + @Test("basename is applied to the reworked stat") + func reworkedBasename() { + let model = WorkflowStripModel( + workflow: nil, + topReworkedFiles: [ReworkedFileEntry(path: "app/src/main.ts", sessions: 4, edits: 9)] + ) + #expect(model.reworkedName == "main.ts") + #expect(model.reworked == "main.ts ×4") + } + + @Test("nil workflow and empty files -> hidden") + func emptyHidden() { + let model = WorkflowStripModel(workflow: nil, topReworkedFiles: []) + #expect(model.isEmpty) + #expect(model.corrections == nil) + #expect(model.firstEdit == nil) + #expect(model.reworkedName == nil) + #expect(model.note == nil) + } + + @Test("all-empty workflow (zeros/nulls) -> hidden, no zero placeholders") + func allEmptyHidden() { + let model = WorkflowStripModel( + workflow: WorkflowBlock(corrections: 0, correctionRate: 0.0, medianTimeToFirstEditMs: nil), + topReworkedFiles: [] + ) + #expect(model.isEmpty) + #expect(model.corrections == nil) // 0 corrections is never "0% (0)" + #expect(model.firstEdit == nil) + } + + @Test("zero median and zero-session file are treated as absent") + func degenerateValues() { + let model = WorkflowStripModel( + workflow: WorkflowBlock(corrections: 0, correctionRate: nil, medianTimeToFirstEditMs: 0), + topReworkedFiles: [ReworkedFileEntry(path: "a.py", sessions: 0, edits: 0)] + ) + #expect(model.firstEdit == nil) + #expect(model.reworkedName == nil) + #expect(model.isEmpty) + } + + @Test("a correction rate that rounds to 0% hides the corrections stat") + func subOnePercentCorrections() { + // Mirrors real month data: 6 corrections over thousands of turns -> 0.17%. + let model = WorkflowStripModel( + workflow: WorkflowBlock(corrections: 6, correctionRate: 0.001692524682651622, medianTimeToFirstEditMs: 384_425), + topReworkedFiles: [ReworkedFileEntry(path: "sdk.py", sessions: 9, edits: 54)] + ) + #expect(model.corrections == nil) + #expect(model.firstEdit == "6m") + #expect(model.reworked == "sdk.py ×9") + #expect(model.note == "sdk.py was reworked across 9 sessions (54 edits). A focused pass on it may cost less than the repeated churn.") + #expect(model.isEmpty == false) + } + + @Test("only a reworked file present renders one stat and the churn note") + func onlyReworked() { + let model = WorkflowStripModel( + workflow: nil, + topReworkedFiles: [ReworkedFileEntry(path: "a.py", sessions: 4, edits: 10)] + ) + #expect(model.corrections == nil) + #expect(model.firstEdit == nil) + #expect(model.reworked == "a.py ×4") + #expect(model.isEmpty == false) + #expect(model.note == "a.py was reworked across 4 sessions (10 edits). A focused pass on it may cost less than the repeated churn.") + } +} + +@Suite("WorkflowStrip -- payload decoding") +struct WorkflowStripDecodeTests { + private func decodeCurrent(_ json: String) throws -> CurrentBlock { + try JSONDecoder().decode(CurrentBlock.self, from: Data(json.utf8)) + } + + @Test("decodes workflow and topReworkedFiles when present") + func present() throws { + let current = try decodeCurrent(""" + { + "label": "Month", "cost": 1, "calls": 2, "sessions": 3, + "inputTokens": 4, "outputTokens": 5, + "workflow": { "corrections": 5, "correctionRate": 0.03, "medianTimeToFirstEditMs": 360000 }, + "topReworkedFiles": [ { "path": "sdk.py", "sessions": 15, "edits": 42 } ] + } + """) + #expect(current.workflow?.corrections == 5) + #expect(current.workflow?.correctionRate == 0.03) + #expect(current.workflow?.medianTimeToFirstEditMs == 360_000) + #expect(current.topReworkedFiles.first?.path == "sdk.py") + #expect(current.topReworkedFiles.first?.sessions == 15) + #expect(current.topReworkedFiles.first?.edits == 42) + } + + @Test("decodes null workflow rates to nil") + func nulls() throws { + let current = try decodeCurrent(""" + { + "label": "Month", "cost": 1, "calls": 2, "sessions": 3, + "inputTokens": 4, "outputTokens": 5, + "workflow": { "corrections": 0, "correctionRate": null, "medianTimeToFirstEditMs": null }, + "topReworkedFiles": [] + } + """) + #expect(current.workflow?.corrections == 0) + #expect(current.workflow?.correctionRate == nil) + #expect(current.workflow?.medianTimeToFirstEditMs == nil) + #expect(current.topReworkedFiles.isEmpty) + } + + @Test("older payloads without the keys decode fine") + func backwardCompatible() throws { + let current = try decodeCurrent(""" + { + "label": "Month", "cost": 1, "calls": 2, "sessions": 3, + "inputTokens": 4, "outputTokens": 5 + } + """) + #expect(current.workflow == nil) + #expect(current.topReworkedFiles.isEmpty) + // And an older payload yields a hidden strip. + let model = WorkflowStripModel(workflow: current.workflow, topReworkedFiles: current.topReworkedFiles) + #expect(model.isEmpty) + } +}