diff --git a/dash/src/components/Punchcard.tsx b/dash/src/components/Punchcard.tsx
index 203d0514..3cc5f68c 100644
--- a/dash/src/components/Punchcard.tsx
+++ b/dash/src/components/Punchcard.tsx
@@ -123,12 +123,12 @@ export function Punchcard({ timeline }: { timeline: GranularHistory }) {
onMouseEnter={(e) => {
if (!cell.covered || !wrapRef.current) return
const r = wrapRef.current.getBoundingClientRect()
- setHover({ x: e.clientX - r.left, y: e.clientY - r.top, wd, h })
+ setHover({ x: Math.min(Math.max(e.clientX - r.left, 70), r.width - 70), y: e.clientY - r.top, wd, h })
}}
onMouseMove={(e) => {
if (!cell.covered || !wrapRef.current) return
const r = wrapRef.current.getBoundingClientRect()
- setHover({ x: e.clientX - r.left, y: e.clientY - r.top, wd, h })
+ setHover({ x: Math.min(Math.max(e.clientX - r.left, 70), r.width - 70), y: e.clientY - r.top, wd, h })
}}
>
{WEEKDAYS_FULL[hover.wd]} {pad2(hover.h)}:00
diff --git a/mac/Sources/CodeBurnMenubar/Data/WorkflowStrip.swift b/mac/Sources/CodeBurnMenubar/Data/WorkflowStrip.swift
deleted file mode 100644
index 6f31afe5..00000000
--- a/mac/Sources/CodeBurnMenubar/Data/WorkflowStrip.swift
+++ /dev/null
@@ -1,127 +0,0 @@
-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 51f0426d..0aab756b 100644
--- a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift
+++ b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift
@@ -36,7 +36,6 @@ struct MenuBarContent: View {
ActivitySection()
Divider().opacity(0.5)
ModelsSection()
- WorkflowSection()
PullRequestsSection()
Divider().opacity(0.5)
ToolingSection()
diff --git a/mac/Sources/CodeBurnMenubar/Views/WorkflowSection.swift b/mac/Sources/CodeBurnMenubar/Views/WorkflowSection.swift
deleted file mode 100644
index 6d7593f5..00000000
--- a/mac/Sources/CodeBurnMenubar/Views/WorkflowSection.swift
+++ /dev/null
@@ -1,111 +0,0 @@
-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
deleted file mode 100644
index e42968d2..00000000
--- a/mac/Tests/CodeBurnMenubarTests/WorkflowStripTests.swift
+++ /dev/null
@@ -1,235 +0,0 @@
-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)
- }
-}