mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-21 05:23:19 +00:00
Merge remote-tracking branch 'origin/main' into wt1234
This commit is contained in:
commit
6fa5dfbeb7
20 changed files with 1516 additions and 143 deletions
|
|
@ -28,8 +28,8 @@ There is no separate build step required to run the dev CLI. `npm run dev` runs
|
|||
| `npm run test:locks` | Runs the three parallelism-sensitive `cache-refresh-lock` suites serially. |
|
||||
| `npm run test:watch` | Same scope as `npm test`, in watch mode. |
|
||||
| `npm run dev -- status` | Runs the CLI in dev mode against your real data. |
|
||||
| `npm run build` | Bundles the litellm pricing snapshot, then runs `tsup` to produce `dist/cli.js`. |
|
||||
| `npm run bundle-litellm` | Refreshes `src/data/litellm-snapshot.json` from the upstream litellm repo. |
|
||||
| `npm run build` | Builds the CLI and dashboard from the checked-in pricing catalogs without mutating tracked source files. |
|
||||
| `npm run bundle-litellm` | Explicitly refreshes the checked-in pricing catalogs from their upstream sources. Review and commit the resulting data changes separately. |
|
||||
|
||||
To test a specific suite, run vitest directly with a path:
|
||||
|
||||
|
|
|
|||
|
|
@ -1685,6 +1685,23 @@ final class AppStore {
|
|||
}
|
||||
}
|
||||
|
||||
/// Sessions the CLI reported as live under this provider, newest first. Nil
|
||||
/// when the payload carries no live-session block at all (a CLI that predates
|
||||
/// it), which the popover renders as an absent section rather than as
|
||||
/// "none running".
|
||||
func capacityDockLiveSessions(for provider: CapacityDockProvider) -> [LiveSession]? {
|
||||
guard let block = menubarPayload?.liveSessions else { return nil }
|
||||
return block.sessions.filter { $0.provider == provider.id }
|
||||
}
|
||||
|
||||
/// Today's totals for the dock popover. The background loop keeps the
|
||||
/// menu-bar status payload fresh, so it wins whenever the badge is already
|
||||
/// scoped to today; otherwise fall back to the popover's own today cache.
|
||||
var capacityDockToday: CurrentBlock? {
|
||||
if menubarPeriod == .today, let payload = menubarPayload { return payload.current }
|
||||
return todayPayload?.current
|
||||
}
|
||||
|
||||
func capacityDockQuotaSummary(for provider: CapacityDockProvider) -> QuotaSummary? {
|
||||
if let filter = provider.legacyFilter {
|
||||
return quotaSummary(for: filter)
|
||||
|
|
|
|||
|
|
@ -132,14 +132,23 @@ final class CapacityDockController {
|
|||
func refreshQuotaPresentation() {
|
||||
guard model.preferences.isEnabled else { return }
|
||||
if let provider = model.hoveredProvider {
|
||||
model.detailHeight = CapacityDockMetrics.detailHeight(
|
||||
quota: store.capacityDockQuotaSummary(for: provider),
|
||||
scale: model.detailScale
|
||||
)
|
||||
model.detailHeight = glanceDetailHeight(for: provider)
|
||||
layoutDetail(for: provider, transaction: .immediate)
|
||||
}
|
||||
}
|
||||
|
||||
/// Panel height for the glance popover, measured from the same store content
|
||||
/// `CapacityDockDetailView` renders.
|
||||
private func glanceDetailHeight(for provider: CapacityDockProvider) -> CGFloat {
|
||||
return CapacityDockMetrics.detailHeight(
|
||||
quota: store.capacityDockQuotaSummary(for: provider),
|
||||
sessionCount: store.capacityDockLiveSessions(for: provider)?.count,
|
||||
hasToday: store.capacityDockToday != nil,
|
||||
tailEdge: model.detailTailEdge,
|
||||
scale: model.detailScale
|
||||
)
|
||||
}
|
||||
|
||||
func reposition() {
|
||||
guard model.preferences.isEnabled, !model.interaction.isDragging else { return }
|
||||
layoutRail(preserveCurrentTop: false, animate: false)
|
||||
|
|
@ -160,10 +169,7 @@ final class CapacityDockController {
|
|||
!snapshot.selectedProviders.contains(hovered) {
|
||||
hideDetail(animated: false)
|
||||
} else if let hovered = model.hoveredProvider {
|
||||
model.detailHeight = CapacityDockMetrics.detailHeight(
|
||||
quota: store.capacityDockQuotaSummary(for: hovered),
|
||||
scale: model.detailScale
|
||||
)
|
||||
model.detailHeight = glanceDetailHeight(for: hovered)
|
||||
}
|
||||
|
||||
guard snapshot.isEnabled else {
|
||||
|
|
@ -550,10 +556,7 @@ final class CapacityDockController {
|
|||
let wasShowingDetail = detailPanel?.isVisible == true && model.hoveredProvider != nil
|
||||
detailIsDismissing = false
|
||||
model.hoveredProvider = provider
|
||||
model.detailHeight = CapacityDockMetrics.detailHeight(
|
||||
quota: store.capacityDockQuotaSummary(for: provider),
|
||||
scale: model.detailScale
|
||||
)
|
||||
model.detailHeight = glanceDetailHeight(for: provider)
|
||||
ensureDetailPanel()
|
||||
layoutDetail(for: provider, transaction: wasShowingDetail ? .detailFollow : .detailPresent)
|
||||
detailPanel?.orderFrontRegardless()
|
||||
|
|
@ -959,6 +962,9 @@ final class CapacityDockController {
|
|||
preferredEdge: model.attachmentEdge
|
||||
)
|
||||
model.detailTailEdge = side.opposite
|
||||
// The tail's own allowance is part of the content insets, so the height
|
||||
// is only exact once the side is known.
|
||||
model.detailHeight = glanceDetailHeight(for: provider)
|
||||
let target = CapacityDockPlacement.detailFrame(
|
||||
size: CGSize(width: model.detailWidth, height: model.detailHeight),
|
||||
railFrame: railPanel.frame,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ struct MenubarPayload: Codable, Sendable {
|
|||
let history: HistoryBlock
|
||||
let combined: CombinedUsage?
|
||||
let claudeConfigs: ClaudeConfigSelector?
|
||||
/// Sessions whose transcript was appended inside the CLI's liveness window.
|
||||
/// Absent on payloads from a CLI that predates the block, so absence means
|
||||
/// "unknown", not "nothing running": the popover hides the section either way.
|
||||
let liveSessions: LiveSessionsBlock?
|
||||
|
||||
init(generated: String,
|
||||
current: CurrentBlock,
|
||||
|
|
@ -21,7 +25,9 @@ struct MenubarPayload: Codable, Sendable {
|
|||
history: HistoryBlock,
|
||||
combined: CombinedUsage?,
|
||||
claudeConfigs: ClaudeConfigSelector? = nil,
|
||||
stale: Bool? = nil) {
|
||||
stale: Bool? = nil,
|
||||
liveSessions: LiveSessionsBlock? = nil) {
|
||||
self.liveSessions = liveSessions
|
||||
self.generated = generated
|
||||
self.stale = stale
|
||||
self.current = current
|
||||
|
|
@ -32,7 +38,7 @@ struct MenubarPayload: Codable, Sendable {
|
|||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case generated, stale, current, optimize, history, combined, claudeConfigs
|
||||
case generated, stale, current, optimize, history, combined, claudeConfigs, liveSessions
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
|
|
@ -44,6 +50,72 @@ struct MenubarPayload: Codable, Sendable {
|
|||
history = try c.decode(HistoryBlock.self, forKey: .history)
|
||||
combined = try c.decodeIfPresent(CombinedUsage.self, forKey: .combined)
|
||||
claudeConfigs = try c.decodeIfPresent(ClaudeConfigSelector.self, forKey: .claudeConfigs)
|
||||
liveSessions = try c.decodeIfPresent(LiveSessionsBlock.self, forKey: .liveSessions)
|
||||
}
|
||||
}
|
||||
|
||||
struct LiveSessionsBlock: Codable, Sendable {
|
||||
let windowSeconds: Int
|
||||
let sessions: [LiveSession]
|
||||
}
|
||||
|
||||
struct LiveSession: Codable, Sendable, Identifiable {
|
||||
let id: String
|
||||
/// Provider catalog id, matching the dock ring the session runs under.
|
||||
let provider: String
|
||||
let project: String
|
||||
let branch: String?
|
||||
let model: String?
|
||||
let contextTokens: Int?
|
||||
let contextWindow: Int?
|
||||
let startedAt: String
|
||||
let lastActivityAt: String
|
||||
/// Seconds since this session last wrote, as of the payload's build. Absent
|
||||
/// on payloads from a CLI that predates it, which reads as "not idle".
|
||||
var idleSeconds: Int? = nil
|
||||
|
||||
/// A live session that is waiting on the user rather than generating. The
|
||||
/// panel dims these so the two states are told apart at a glance.
|
||||
var isIdle: Bool { (idleSeconds ?? 0) > Self.idleThresholdSeconds }
|
||||
|
||||
static let idleThresholdSeconds = 120
|
||||
|
||||
/// Row title: the folder in flight, plus the branch when the transcript
|
||||
/// named one.
|
||||
var title: String {
|
||||
guard let branch, !branch.isEmpty else { return project }
|
||||
return "\(project) · \(branch)"
|
||||
}
|
||||
|
||||
/// Fraction of the context window in use, nil when the CLI could not read
|
||||
/// a usage record so the row renders without a ring.
|
||||
var contextFraction: Double? {
|
||||
guard let contextTokens, let contextWindow, contextWindow > 0 else { return nil }
|
||||
return min(max(Double(contextTokens) / Double(contextWindow), 0), 1)
|
||||
}
|
||||
|
||||
var contextRemaining: Int? {
|
||||
guard let contextTokens, let contextWindow else { return nil }
|
||||
return max(0, contextWindow - contextTokens)
|
||||
}
|
||||
|
||||
/// How long this session has been open, in the same shape the quota rows use
|
||||
/// for resets. Empty when the timestamp is unparseable.
|
||||
func elapsedLabel(now: Date = Date()) -> String {
|
||||
guard let started = Self.parseISO8601(startedAt) else { return "" }
|
||||
let minutes = Int(max(0, now.timeIntervalSince(started)) / 60)
|
||||
let hours = minutes / 60
|
||||
if hours > 0 { return "\(hours)h \(minutes % 60)m" }
|
||||
return "\(minutes)m"
|
||||
}
|
||||
|
||||
/// The CLI stamps milliseconds; the plain formatter rejects those, so try the
|
||||
/// fractional variant first.
|
||||
static func parseISO8601(_ value: String) -> Date? {
|
||||
let withFraction = ISO8601DateFormatter()
|
||||
withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = withFraction.date(from: value) { return date }
|
||||
return ISO8601DateFormatter().date(from: value)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,25 @@ import AppKit
|
|||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
private extension String {
|
||||
/// `asCompactTokens` prints an uppercase K; the glance popover uses the
|
||||
/// lowercase form (182k) beside the uppercase M and B.
|
||||
func lowercasedThousands() -> String { replacingOccurrences(of: "K", with: "k") }
|
||||
}
|
||||
|
||||
private extension View {
|
||||
/// Hairline between glance sections, drawn as an overlay so it never adds a
|
||||
/// point the computed panel height did not reserve.
|
||||
func dividerBelow() -> some View {
|
||||
overlay(alignment: .bottom) {
|
||||
Rectangle()
|
||||
.fill(Color.white.opacity(0.08))
|
||||
.frame(height: 0.5)
|
||||
.padding(.horizontal, CapacityDockGlance.contentInset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension Color {
|
||||
/// Warm off-white for Capacity Dock text: a very mild orange tint so bright
|
||||
/// labels on the dark card read softer than pure white and do not stress the eyes.
|
||||
|
|
@ -59,26 +78,146 @@ enum CapacityDockMetrics {
|
|||
+ alongPad
|
||||
}
|
||||
|
||||
static func detailHeight(quota: QuotaSummary?, scale: CGFloat) -> CGFloat {
|
||||
/// The glance popover's height is the sum of its blocks, not a fitted size:
|
||||
/// the hosting view has intrinsic sizing off, so every block below carries
|
||||
/// the same explicit frame height in `CapacityDockDetailView`. Keep the two
|
||||
/// in step, and keep every term a whole number of points (see `points`).
|
||||
static func detailHeight(
|
||||
quota: QuotaSummary?,
|
||||
sessionCount: Int?,
|
||||
hasToday: Bool,
|
||||
tailEdge: CapacityDockEdge,
|
||||
scale: CGFloat
|
||||
) -> CGFloat {
|
||||
guard let quota else { return 186 * scale }
|
||||
let rows = min(max(quota.details.count, quota.primary == nil ? 0 : 1), 5)
|
||||
let visibleFooter = CapacityDockQuotaPresentation.visibleFooterLines(
|
||||
quota.footerLines,
|
||||
connection: quota.connection
|
||||
)
|
||||
let footer = visibleFooter.isEmpty ? 0 : min(visibleFooter.count, 2) * 16 + 4
|
||||
let actionExtra: CGFloat = CapacityDockConnectionAction.resolve(quota: quota) == nil ? 0 : 38
|
||||
// Each section carries its own padding, so the panel adds none.
|
||||
var height = CapacityDockGlance.headerHeight
|
||||
// The tail only eats vertical room when it points up or down.
|
||||
if !tailEdge.isVertical { height += CapacityDockGlance.tailAllowance }
|
||||
if let sessionCount { height += CapacityDockGlance.sessionsHeight(count: sessionCount) }
|
||||
if hasToday { height += CapacityDockGlance.todayHeight }
|
||||
if CapacityDockGlance.drawsWindows(quota) {
|
||||
height += CapacityDockGlance.windows(quota).isEmpty
|
||||
? CapacityDockGlance.windowsEmptyHeight
|
||||
: CapacityDockGlance.windowsHeight
|
||||
}
|
||||
height += CapacityDockConnectionAction.resolve(quota: quota) == nil ? 0 : 38
|
||||
let connectionExtra: CGFloat = switch quota.connection {
|
||||
case .terminalFailure: 90
|
||||
case .disconnected: 18
|
||||
case .loading, .stale, .transientFailure: 16
|
||||
case .connected: 0
|
||||
}
|
||||
let base = min(
|
||||
470,
|
||||
max(132, 88 + CGFloat(rows) * 50 + CGFloat(footer) + actionExtra + connectionExtra)
|
||||
)
|
||||
return base * scale
|
||||
height += connectionExtra
|
||||
return (height * scale).rounded()
|
||||
}
|
||||
}
|
||||
|
||||
/// Fixed block heights shared by the glance popover's layout and its panel
|
||||
/// size, plus the ramps its numbers and rings use. Every section owns its own
|
||||
/// padding, so the panel itself only insets horizontally.
|
||||
enum CapacityDockGlance {
|
||||
/// The panel's padding on all four sides. Horizontally it is applied by each
|
||||
/// section rather than the panel, so group fills can still run edge to edge.
|
||||
static let contentInset: CGFloat = 16
|
||||
static let tailAllowance: CGFloat = 18
|
||||
|
||||
/// 16 top + 20 title + 8 bottom.
|
||||
static let headerHeight: CGFloat = 44
|
||||
static let sectionPadTop: CGFloat = 8
|
||||
static let sectionPadBottom: CGFloat = 10
|
||||
/// A 10.5pt caption's line box, shared by every section header.
|
||||
static let captionLine: CGFloat = 13
|
||||
/// Content driven: 8 padding + a 12pt line + 2 + a 10.5pt line + 8 padding.
|
||||
/// Held as a constant because the panel frame is computed, not fitted.
|
||||
static let pillHeight: CGFloat = 46
|
||||
static let pillGap: CGFloat = 6
|
||||
/// Three stacked lines, 13 + 13 + 12, with two 3pt gaps. Taller than the
|
||||
/// 17pt burned figure beside it, so it sets the row.
|
||||
static let todayContentHeight: CGFloat = 44
|
||||
/// 8 top + 24 percent + 2 + 13 label + 2 + 12 reset + 16 bottom.
|
||||
static let windowsHeight: CGFloat = 77
|
||||
/// 8 top + one secondary line + 16 bottom.
|
||||
static let windowsEmptyHeight: CGFloat = 37
|
||||
/// The list scrolls past this many pills rather than truncating.
|
||||
static let maxVisibleSessionRows = 4
|
||||
static let maxWindowColumns = 4
|
||||
|
||||
/// Height of the scrolling pill list: every pill up to the visible cap, then
|
||||
/// the list scrolls inside that.
|
||||
static func sessionListHeight(count: Int) -> CGFloat {
|
||||
let visible = min(count, maxVisibleSessionRows)
|
||||
guard visible > 0 else { return 0 }
|
||||
return CGFloat(visible) * pillHeight + CGFloat(visible - 1) * pillGap
|
||||
}
|
||||
|
||||
static func sessionsHeight(count: Int) -> CGFloat {
|
||||
var height = sectionPadTop + captionLine + sectionPadBottom
|
||||
if count > 0 { height += pillGap + sessionListHeight(count: count) }
|
||||
return height
|
||||
}
|
||||
|
||||
static let todayHeight: CGFloat = sectionPadTop + captionLine + pillGap
|
||||
+ todayContentHeight + sectionPadBottom
|
||||
|
||||
/// Four bands, matching the rail's own sense of escalation: comfortable,
|
||||
/// watch it, nearly out, over. Rings start green because a ring with no
|
||||
/// colour reads as broken.
|
||||
static func severityColor(_ fraction: Double) -> Color {
|
||||
if fraction >= 0.9 { return .red }
|
||||
if fraction >= 0.8 { return .orange }
|
||||
if fraction >= 0.7 { return .yellow }
|
||||
return .green
|
||||
}
|
||||
|
||||
/// Share of the pill the tint covers. Anything outside 0...1 is a bad
|
||||
/// context reading, not a reason to paint past the pill.
|
||||
static func gaugeFillFraction(_ fraction: Double) -> Double {
|
||||
min(max(fraction, 0), 1)
|
||||
}
|
||||
|
||||
/// Where the tint starts fading, as a share of the filled band. The fade is
|
||||
/// a fixed 12pt tail, so a short band fades over its whole length instead of
|
||||
/// inverting.
|
||||
static func pillFadeStart(filledWidth: CGFloat, scale: CGFloat) -> CGFloat {
|
||||
let fade = 12 * scale
|
||||
guard filledWidth > fade else { return 0 }
|
||||
return (filledWidth - fade) / filledWidth
|
||||
}
|
||||
|
||||
/// How far the reveal edge leans, as a share of the text's height.
|
||||
static let gaugeSlantRatio: CGFloat = 0.35
|
||||
|
||||
/// Where the slanted reveal edge crosses the top and bottom of the text box.
|
||||
/// The edge starts fully off the left and ends fully past the right, so 0
|
||||
/// reveals nothing and 1 fills every glyph. Both values are clamped into the
|
||||
/// box, which turns the parallelogram into a triangle at the two extremes
|
||||
/// rather than letting it fold over itself.
|
||||
static func gaugeRevealEdge(
|
||||
fraction: Double,
|
||||
width: CGFloat,
|
||||
height: CGFloat
|
||||
) -> (top: CGFloat, bottom: CGFloat) {
|
||||
let clamped = min(max(fraction, 0), 1)
|
||||
let slant = height * gaugeSlantRatio
|
||||
let bottom = (width + slant) * clamped
|
||||
let clamp = { (value: CGFloat) in min(max(value, 0), width) }
|
||||
return (clamp(bottom - slant), clamp(bottom))
|
||||
}
|
||||
|
||||
/// A provider that is not connected has no quota and no spend to stand in for
|
||||
/// one, so the windows row gives way to the reconnect guidance entirely.
|
||||
static func drawsWindows(_ quota: QuotaSummary) -> Bool {
|
||||
switch quota.connection {
|
||||
case .disconnected, .terminalFailure: return false
|
||||
case .connected, .loading, .stale, .transientFailure: return true
|
||||
}
|
||||
}
|
||||
|
||||
/// The windows the row draws, in the order the provider service reported them.
|
||||
static func windows(_ quota: QuotaSummary) -> [QuotaSummary.Window] {
|
||||
let ordered = quota.details.isEmpty ? [quota.primary].compactMap { $0 } : quota.details
|
||||
return Array(ordered.prefix(maxWindowColumns))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -485,6 +624,7 @@ struct CapacityDockDetailView: View {
|
|||
let model: CapacityDockViewModel
|
||||
let quota: (CapacityDockProvider) -> QuotaSummary?
|
||||
let onConnect: (CapacityDockProvider) -> Void
|
||||
@Environment(AppStore.self) private var store
|
||||
|
||||
var body: some View {
|
||||
let bubbleShape = CapacityDockBubbleShape(
|
||||
|
|
@ -514,83 +654,355 @@ struct CapacityDockDetailView: View {
|
|||
}
|
||||
|
||||
private var detailInsets: EdgeInsets {
|
||||
let horizontal = 22 * model.detailScale
|
||||
let vertical = 16 * model.detailScale
|
||||
let tailAllowance = 18 * model.detailScale
|
||||
let tailAllowance = CapacityDockGlance.tailAllowance * model.detailScale
|
||||
// Every section carries its own padding, so the panel adds only the tail's
|
||||
// allowance on whichever side the tail points.
|
||||
return EdgeInsets(
|
||||
top: vertical + (model.detailTailEdge == .top ? tailAllowance : 0),
|
||||
leading: horizontal + (model.detailTailEdge == .left ? tailAllowance : 0),
|
||||
bottom: vertical + (model.detailTailEdge == .bottom ? tailAllowance : 0),
|
||||
trailing: horizontal + (model.detailTailEdge == .right ? tailAllowance : 0)
|
||||
top: model.detailTailEdge == .top ? tailAllowance : 0,
|
||||
leading: model.detailTailEdge == .left ? tailAllowance : 0,
|
||||
bottom: model.detailTailEdge == .bottom ? tailAllowance : 0,
|
||||
trailing: model.detailTailEdge == .right ? tailAllowance : 0
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func detail(for provider: CapacityDockProvider, quota: QuotaSummary?) -> some View {
|
||||
VStack(alignment: .leading, spacing: 11 * model.detailScale) {
|
||||
HStack(spacing: 8 * model.detailScale) {
|
||||
if let image = ProviderIconCache.image(named: provider.iconName) {
|
||||
Image(nsImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.foregroundStyle(Color.capacityDockText)
|
||||
.frame(width: 24 * model.detailScale, height: 24 * model.detailScale)
|
||||
}
|
||||
Text("\(provider.displayName) Usage")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(Color.capacityDockText)
|
||||
Spacer(minLength: 8)
|
||||
if let plan = quota?.planLabel, !plan.isEmpty {
|
||||
Text(plan)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(Color.capacityDockText.opacity(0.62))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
if let quota {
|
||||
connectionLabel(quota.connection, provider: provider)
|
||||
if quota.details.isEmpty, let primary = quota.primary {
|
||||
CapacityDockQuotaRow(
|
||||
window: primary,
|
||||
scale: model.detailScale
|
||||
)
|
||||
} else {
|
||||
ForEach(Array(quota.details.prefix(5).enumerated()), id: \.offset) { _, window in
|
||||
CapacityDockQuotaRow(
|
||||
window: window,
|
||||
scale: model.detailScale
|
||||
)
|
||||
}
|
||||
}
|
||||
let footerLines = CapacityDockQuotaPresentation.visibleFooterLines(
|
||||
quota.footerLines,
|
||||
connection: quota.connection
|
||||
)
|
||||
if !footerLines.isEmpty {
|
||||
Divider().overlay(Color.capacityDockText.opacity(0.12))
|
||||
ForEach(Array(footerLines.prefix(2).enumerated()), id: \.offset) { _, line in
|
||||
Text(line)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(Color.capacityDockText.opacity(0.58))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if let quota {
|
||||
glance(for: provider, quota: quota)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 11 * model.detailScale) {
|
||||
header(provider, plan: nil)
|
||||
Text(ProviderConnectionGuidance.dockInstruction(for: provider))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Color.capacityDockText.opacity(0.62))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
connectButton(provider, quota: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if provider.catalogEntry.hasLiveCodeBurnQuotaAdapter,
|
||||
let action = CapacityDockConnectionAction.resolve(quota: quota) {
|
||||
let title = action.title(for: provider)
|
||||
Button(title) { onConnect(provider) }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(provider.ringColor)
|
||||
.controlSize(.small)
|
||||
.accessibilityLabel("\(title) \(provider.displayName)")
|
||||
/// Sections stack at fixed heights that mirror `CapacityDockMetrics.detailHeight`,
|
||||
/// because the panel frame is computed rather than fitted. Separators are
|
||||
/// overlays so a hairline never adds a point the height did not reserve.
|
||||
@ViewBuilder
|
||||
private func glance(for provider: CapacityDockProvider, quota: QuotaSummary) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
headerSection(provider, plan: quota.planLabel).dividerBelow()
|
||||
connectionLabel(quota.connection, provider: provider)
|
||||
if let sessions = store.capacityDockLiveSessions(for: provider) {
|
||||
sessionsSection(sessions).dividerBelow()
|
||||
}
|
||||
if let today = store.capacityDockToday {
|
||||
todaySection(today).dividerBelow()
|
||||
}
|
||||
if CapacityDockGlance.drawsWindows(quota) { windowsSection(quota) }
|
||||
Spacer(minLength: 0)
|
||||
connectButton(provider, quota: quota)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func headerSection(_ provider: CapacityDockProvider, plan: String?) -> some View {
|
||||
let s = model.detailScale
|
||||
header(provider, plan: plan)
|
||||
.padding(.top, CapacityDockGlance.contentInset * s)
|
||||
.padding(.bottom, 8 * s)
|
||||
.padding(.horizontal, CapacityDockGlance.contentInset * s)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func header(_ provider: CapacityDockProvider, plan: String?) -> some View {
|
||||
let s = model.detailScale
|
||||
HStack(spacing: 8 * s) {
|
||||
if let image = ProviderIconCache.image(named: provider.iconName) {
|
||||
Image(nsImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
// Matches the 17pt title's cap height plus a little, so the
|
||||
// mark reads as its equal rather than as a bullet.
|
||||
.frame(width: 18 * s, height: 18 * s)
|
||||
}
|
||||
Text(provider.displayName)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(Color.capacityDockText)
|
||||
Spacer(minLength: 6)
|
||||
if let plan, !plan.isEmpty {
|
||||
Text(plan)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(Color.capacityDockText.opacity(0.6))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.frame(height: 20 * s)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sectionCaption(_ title: String, trailing: String?) -> some View {
|
||||
let s = model.detailScale
|
||||
HStack(spacing: 8 * s) {
|
||||
Text(title)
|
||||
.font(.system(size: 10.5, weight: .semibold))
|
||||
if let trailing {
|
||||
Spacer(minLength: 8)
|
||||
Text(trailing)
|
||||
.font(.system(size: 10.5))
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
.foregroundStyle(Color.capacityDockText.opacity(0.6))
|
||||
.frame(height: CapacityDockGlance.captionLine * s)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sessionsSection(_ sessions: [LiveSession]) -> some View {
|
||||
let s = model.detailScale
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
sectionCaption("Sessions", trailing: sessionsTrailing(sessions.count))
|
||||
if !sessions.isEmpty {
|
||||
ScrollView(.vertical) {
|
||||
VStack(spacing: CapacityDockGlance.pillGap * s) {
|
||||
ForEach(sessions) { session in
|
||||
sessionPill(session)
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollIndicators(.automatic)
|
||||
.frame(height: CapacityDockGlance.sessionListHeight(count: sessions.count) * s)
|
||||
.padding(.top, CapacityDockGlance.pillGap * s)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, CapacityDockGlance.sectionPadTop * s)
|
||||
.padding(.bottom, CapacityDockGlance.sectionPadBottom * s)
|
||||
.padding(.horizontal, CapacityDockGlance.contentInset * s)
|
||||
}
|
||||
|
||||
private func sessionsTrailing(_ count: Int) -> String {
|
||||
switch count {
|
||||
case 0: return "none running"
|
||||
case 1: return "1 running"
|
||||
default: return "\(count) running"
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sessionPill(_ session: LiveSession) -> some View {
|
||||
let s = model.detailScale
|
||||
let fraction = session.contextFraction
|
||||
HStack(spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 2 * s) {
|
||||
Text(session.title)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Color.capacityDockText)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.frame(height: 15 * s)
|
||||
Text(sessionSubtitle(session))
|
||||
.font(.system(size: 10.5))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(Color.capacityDockText.opacity(0.6))
|
||||
.lineLimit(1)
|
||||
.frame(height: 13 * s)
|
||||
// The model name identifies the row, so the project above it
|
||||
// gives up width first.
|
||||
.layoutPriority(1)
|
||||
}
|
||||
Spacer(minLength: 12)
|
||||
if let fraction {
|
||||
VStack(alignment: .trailing, spacing: 2 * s) {
|
||||
PercentGaugeText(
|
||||
label: "\(Int((fraction * 100).rounded()))%",
|
||||
fraction: fraction,
|
||||
font: .system(size: 12)
|
||||
)
|
||||
.frame(height: 15 * s)
|
||||
if let remaining = session.contextRemaining {
|
||||
Text("\(Double(remaining).asCompactTokens().lowercasedThousands()) left")
|
||||
.font(.system(size: 10))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(Color.capacityDockText.opacity(0.6))
|
||||
.frame(height: 13 * s)
|
||||
}
|
||||
}
|
||||
.layoutPriority(1)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8 * s)
|
||||
.padding(.horizontal, 12 * s)
|
||||
.frame(height: CapacityDockGlance.pillHeight * s)
|
||||
.background(
|
||||
ZStack(alignment: .leading) {
|
||||
Color.white.opacity(0.06)
|
||||
if let fraction {
|
||||
GeometryReader { geometry in
|
||||
pillFill(fraction: fraction, width: geometry.size.width, scale: s)
|
||||
}
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8 * s, style: .continuous))
|
||||
)
|
||||
// A session waiting on the user recedes; one that is generating does not.
|
||||
.opacity(session.isIdle ? 0.55 : 1)
|
||||
}
|
||||
|
||||
/// The pill itself is the context gauge: a tinted band over the first N% of
|
||||
/// its width, faded out at the leading edge so the boundary reads as a
|
||||
/// gradient rather than a rule.
|
||||
@ViewBuilder
|
||||
private func pillFill(fraction: Double, width: CGFloat, scale: CGFloat) -> some View {
|
||||
let filled = width * CapacityDockGlance.gaugeFillFraction(fraction)
|
||||
let colour = CapacityDockGlance.severityColor(fraction)
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: colour.opacity(0.14), location: 0),
|
||||
.init(
|
||||
color: colour.opacity(0.14),
|
||||
location: CapacityDockGlance.pillFadeStart(filledWidth: filled, scale: scale)
|
||||
),
|
||||
.init(color: colour.opacity(0), location: 1),
|
||||
],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
.frame(width: filled)
|
||||
}
|
||||
|
||||
private func sessionSubtitle(_ session: LiveSession) -> String {
|
||||
[session.model, session.elapsedLabel().isEmpty ? nil : session.elapsedLabel()]
|
||||
.compactMap { $0 }
|
||||
.joined(separator: " · ")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func todaySection(_ today: CurrentBlock) -> some View {
|
||||
let s = model.detailScale
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
sectionCaption("Today", trailing: nil)
|
||||
HStack(alignment: .center, spacing: 8 * s) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 5 * s) {
|
||||
Text(today.cost.asUSD())
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(Color.capacityDockText)
|
||||
Text("burned")
|
||||
.font(.system(size: 11.5))
|
||||
.foregroundStyle(Color.capacityDockText.opacity(0.6))
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
VStack(alignment: .trailing, spacing: 3 * s) {
|
||||
tokenLine("arrow.down", Double(today.inputTokens))
|
||||
tokenLine("arrow.up", Double(today.outputTokens))
|
||||
Text("\(today.calls.asThousandsSeparated()) calls")
|
||||
.font(.system(size: 10))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(Color.capacityDockText.opacity(0.6))
|
||||
.frame(height: 12 * s)
|
||||
}
|
||||
}
|
||||
.frame(height: CapacityDockGlance.todayContentHeight * s)
|
||||
.padding(.top, CapacityDockGlance.pillGap * s)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, CapacityDockGlance.sectionPadTop * s)
|
||||
.padding(.bottom, CapacityDockGlance.sectionPadBottom * s)
|
||||
.padding(.horizontal, CapacityDockGlance.contentInset * s)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func tokenLine(_ symbol: String, _ value: Double) -> some View {
|
||||
let s = model.detailScale
|
||||
HStack(spacing: 4 * s) {
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(Color.capacityDockText.opacity(0.6))
|
||||
Text(value.asCompactTokens().lowercasedThousands())
|
||||
.font(.system(size: 10.5))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(Color.capacityDockText)
|
||||
}
|
||||
.frame(height: 13 * s)
|
||||
}
|
||||
|
||||
/// One column per quota window, in the order the provider reported them.
|
||||
@ViewBuilder
|
||||
private func windowsSection(_ quota: QuotaSummary) -> some View {
|
||||
let s = model.detailScale
|
||||
let windows = CapacityDockGlance.windows(quota)
|
||||
Group {
|
||||
if windows.isEmpty {
|
||||
budgetLine()
|
||||
.frame(height: CapacityDockGlance.captionLine * s)
|
||||
} else {
|
||||
// A single window has no siblings to line up with, so it reads as
|
||||
// a left-aligned figure rather than a lone centred digit.
|
||||
let alignment: HorizontalAlignment = windows.count == 1 ? .leading : .center
|
||||
HStack(spacing: 0) {
|
||||
ForEach(Array(windows.enumerated()), id: \.offset) { _, window in
|
||||
windowColumn(window, alignment: alignment)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, CapacityDockGlance.sectionPadTop * s)
|
||||
.padding(.bottom, CapacityDockGlance.contentInset * s)
|
||||
.padding(.horizontal, CapacityDockGlance.contentInset * s)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func windowColumn(
|
||||
_ window: QuotaSummary.Window,
|
||||
alignment: HorizontalAlignment
|
||||
) -> some View {
|
||||
let s = model.detailScale
|
||||
VStack(alignment: alignment, spacing: 0) {
|
||||
PercentGaugeText(
|
||||
label: window.percentLabel,
|
||||
fraction: window.percent,
|
||||
font: .system(size: 20, weight: .semibold)
|
||||
)
|
||||
.frame(height: 24 * s)
|
||||
Text(CapacityDockQuotaPresentation.displayLabel(window.label))
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Color.capacityDockText.opacity(0.6))
|
||||
.lineLimit(1)
|
||||
.frame(height: CapacityDockGlance.captionLine * s)
|
||||
.padding(.top, 2 * s)
|
||||
Text(window.resetsInLabel)
|
||||
.font(.system(size: 10))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(Color.capacityDockText.opacity(0.3))
|
||||
.lineLimit(1)
|
||||
.frame(height: 12 * s)
|
||||
.padding(.top, 2 * s)
|
||||
}
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
alignment: alignment == .leading ? .leading : .center
|
||||
)
|
||||
}
|
||||
|
||||
/// No quota window exists for this provider, so money is the capacity.
|
||||
@ViewBuilder
|
||||
private func budgetLine() -> some View {
|
||||
let spend = store.capacityDockToday?.cost ?? 0
|
||||
let budget = store.activeDailyBudget
|
||||
Text(budget > 0 ? "today \(spend.asUSD()) of \(budget.asUSD())" : "no budget set")
|
||||
.font(.system(size: 11))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(Color.capacityDockText.opacity(0.6))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func connectButton(_ provider: CapacityDockProvider, quota: QuotaSummary?) -> some View {
|
||||
if provider.catalogEntry.hasLiveCodeBurnQuotaAdapter,
|
||||
let action = CapacityDockConnectionAction.resolve(quota: quota) {
|
||||
let title = action.title(for: provider)
|
||||
Button(title) { onConnect(provider) }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(provider.ringColor)
|
||||
.controlSize(.small)
|
||||
.accessibilityLabel("\(title) \(provider.displayName)")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -639,50 +1051,56 @@ struct CapacityDockDetailView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private struct CapacityDockQuotaRow: View {
|
||||
let window: QuotaSummary.Window
|
||||
let scale: CGFloat
|
||||
/// A percentage drawn as its own gauge: the glyphs sit dim, and the value's own
|
||||
/// severity colour fills them left to right up to the value, wiped off on a
|
||||
/// slant. One band colour, so the fill says how bad it is and the wipe says how
|
||||
/// far along it is.
|
||||
private struct PercentGaugeText: View {
|
||||
let label: String
|
||||
let fraction: Double
|
||||
let font: Font
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6 * scale) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8 * scale) {
|
||||
Text(CapacityDockQuotaPresentation.displayLabel(window.label))
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(Color.capacityDockText.opacity(0.82))
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Spacer(minLength: 8)
|
||||
Text(window.percentLabel)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(Color.capacityDockText)
|
||||
Text(label)
|
||||
.font(font)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(Color.white.opacity(0.3))
|
||||
.overlay {
|
||||
CapacityDockGlance.severityColor(fraction)
|
||||
.clipShape(PercentGaugeReveal(fraction: fraction))
|
||||
.mask {
|
||||
Text(label)
|
||||
.font(font)
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
GeometryReader { geometry in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Color.capacityDockText.opacity(0.14))
|
||||
Capsule()
|
||||
.fill(progressColor)
|
||||
.frame(width: max(2, geometry.size.width * min(max(window.percent, 0), 1)))
|
||||
}
|
||||
}
|
||||
.frame(height: 6 * scale)
|
||||
if !window.resetsInLabel.isEmpty {
|
||||
Text("Resets in \(window.resetsInLabel)")
|
||||
.font(.system(size: 10))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(Color.capacityDockText.opacity(0.5))
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel(label)
|
||||
}
|
||||
}
|
||||
|
||||
/// The slanted wipe. An overlay clip only, so it never changes layout size.
|
||||
private struct PercentGaugeReveal: Shape {
|
||||
var fraction: Double
|
||||
|
||||
var animatableData: Double {
|
||||
get { fraction }
|
||||
set { fraction = newValue }
|
||||
}
|
||||
|
||||
private var progressColor: Color {
|
||||
switch QuotaSummary.severity(for: window.percent) {
|
||||
case .normal: return .green
|
||||
case .warning: return .yellow
|
||||
case .critical: return .orange
|
||||
case .danger: return .red
|
||||
}
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let edge = CapacityDockGlance.gaugeRevealEdge(
|
||||
fraction: fraction,
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
)
|
||||
guard edge.bottom > 0 else { return Path() }
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: rect.minX, y: rect.minY))
|
||||
path.addLine(to: CGPoint(x: rect.minX + edge.top, y: rect.minY))
|
||||
path.addLine(to: CGPoint(x: rect.minX + edge.bottom, y: rect.maxY))
|
||||
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
284
mac/Tests/CodeBurnMenubarTests/CapacityDockGlanceTests.swift
Normal file
284
mac/Tests/CodeBurnMenubarTests/CapacityDockGlanceTests.swift
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
import Foundation
|
||||
import Testing
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
private func window(_ label: String, _ percent: Double, resetsAt: Date? = nil) -> QuotaSummary.Window {
|
||||
QuotaSummary.Window(label: label, percent: percent, resetsAt: resetsAt)
|
||||
}
|
||||
|
||||
private func quota(_ details: [QuotaSummary.Window], primary: QuotaSummary.Window? = nil) -> QuotaSummary {
|
||||
QuotaSummary(
|
||||
providerFilter: .all,
|
||||
connection: .connected,
|
||||
primary: primary,
|
||||
details: details,
|
||||
planLabel: "Max 20x",
|
||||
footerLines: []
|
||||
)
|
||||
}
|
||||
|
||||
@Suite("Capacity Dock glance popover")
|
||||
struct CapacityDockGlanceTests {
|
||||
@Test("Window columns keep the provider's own order and cap at four")
|
||||
func windowOrderAndCap() {
|
||||
let five = window("5-hour", 0.16)
|
||||
let weekly = window("Weekly", 0.14)
|
||||
let fable = window("Fable", 0.25)
|
||||
#expect(CapacityDockGlance.windows(quota([five, weekly, fable])) == [five, weekly, fable])
|
||||
let many = (0..<6).map { window("w\($0)", Double($0) / 10) }
|
||||
#expect(CapacityDockGlance.windows(quota(many)).count == CapacityDockGlance.maxWindowColumns)
|
||||
}
|
||||
|
||||
@Test("A provider with only a primary window still draws one column")
|
||||
func windowsFallBackToPrimary() {
|
||||
let primary = window("30-day", 0.21)
|
||||
#expect(CapacityDockGlance.windows(quota([], primary: primary)) == [primary])
|
||||
}
|
||||
|
||||
@Test("No windows at all leaves the row empty, so the budget line takes over")
|
||||
func windowsAbsent() {
|
||||
#expect(CapacityDockGlance.windows(quota([])).isEmpty)
|
||||
}
|
||||
|
||||
@Test("The pill tint ramps green, yellow, orange, red at 70, 80 and 90 percent")
|
||||
func severityRamp() {
|
||||
#expect(CapacityDockGlance.severityColor(0.0) == .green)
|
||||
#expect(CapacityDockGlance.severityColor(0.69) == .green)
|
||||
#expect(CapacityDockGlance.severityColor(0.70) == .yellow)
|
||||
#expect(CapacityDockGlance.severityColor(0.79) == .yellow)
|
||||
#expect(CapacityDockGlance.severityColor(0.80) == .orange)
|
||||
#expect(CapacityDockGlance.severityColor(0.89) == .orange)
|
||||
#expect(CapacityDockGlance.severityColor(0.90) == .red)
|
||||
#expect(CapacityDockGlance.severityColor(1.5) == .red)
|
||||
}
|
||||
|
||||
@Test("The pill fill fraction clamps into 0...1 so the tint cannot overrun")
|
||||
func pillFillFractionClamps() {
|
||||
#expect(CapacityDockGlance.gaugeFillFraction(0) == 0)
|
||||
#expect(CapacityDockGlance.gaugeFillFraction(0.73) == 0.73)
|
||||
#expect(CapacityDockGlance.gaugeFillFraction(1) == 1)
|
||||
// A bad context reading must not paint past the pill, or short of it.
|
||||
#expect(CapacityDockGlance.gaugeFillFraction(-0.4) == 0)
|
||||
#expect(CapacityDockGlance.gaugeFillFraction(2.5) == 1)
|
||||
}
|
||||
|
||||
@Test("The tint's fade tail never inverts on a short fill")
|
||||
func pillFadeStart() {
|
||||
// A long band keeps a 12pt tail, so the fade starts near its end.
|
||||
#expect(abs(CapacityDockGlance.pillFadeStart(filledWidth: 112, scale: 1) - 100.0 / 112.0) < 1e-9)
|
||||
// Shorter than the tail, the whole band is the fade.
|
||||
#expect(CapacityDockGlance.pillFadeStart(filledWidth: 8, scale: 1) == 0)
|
||||
#expect(CapacityDockGlance.pillFadeStart(filledWidth: 0, scale: 1) == 0)
|
||||
// The tail scales with the panel.
|
||||
#expect(CapacityDockGlance.pillFadeStart(filledWidth: 48, scale: 2) == 0.5)
|
||||
}
|
||||
|
||||
@Test("The reveal is empty at zero, full at one, and slanted between")
|
||||
func gaugeRevealEdges() {
|
||||
let width: CGFloat = 40
|
||||
let height: CGFloat = 20
|
||||
func edge(_ fraction: Double) -> (top: CGFloat, bottom: CGFloat) {
|
||||
CapacityDockGlance.gaugeRevealEdge(fraction: fraction, width: width, height: height)
|
||||
}
|
||||
// Nothing coloured at 0, every glyph coloured at 1.
|
||||
#expect(edge(0) == (top: 0, bottom: 0))
|
||||
#expect(edge(1) == (top: width, bottom: width))
|
||||
// Mid-run the bottom leads the top, which is what makes the wipe diagonal.
|
||||
let mid = edge(0.5)
|
||||
#expect(mid.bottom > mid.top)
|
||||
#expect(mid.bottom - mid.top == height * CapacityDockGlance.gaugeSlantRatio)
|
||||
#expect(mid.bottom > 0 && mid.bottom < width)
|
||||
}
|
||||
|
||||
@Test("The reveal never goes backwards or escapes the text box")
|
||||
func gaugeRevealIsMonotonicAndClamped() {
|
||||
let width: CGFloat = 40
|
||||
let height: CGFloat = 20
|
||||
var previous: (top: CGFloat, bottom: CGFloat) = (0, 0)
|
||||
for step in 0...20 {
|
||||
let edge = CapacityDockGlance.gaugeRevealEdge(
|
||||
fraction: Double(step) / 20,
|
||||
width: width,
|
||||
height: height
|
||||
)
|
||||
#expect(edge.top >= previous.top)
|
||||
#expect(edge.bottom >= previous.bottom)
|
||||
#expect(edge.top >= 0 && edge.top <= width)
|
||||
#expect(edge.bottom >= 0 && edge.bottom <= width)
|
||||
#expect(edge.bottom >= edge.top)
|
||||
previous = edge
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Out-of-range percentages clamp rather than overdraw")
|
||||
func gaugeRevealClampsOutOfRange() {
|
||||
let width: CGFloat = 40
|
||||
#expect(CapacityDockGlance.gaugeRevealEdge(fraction: -0.5, width: width, height: 20).bottom == 0)
|
||||
#expect(CapacityDockGlance.gaugeRevealEdge(fraction: 1.8, width: width, height: 20).top == width)
|
||||
}
|
||||
|
||||
@Test("A low percentage colours only the start of the run")
|
||||
func gaugeRevealAtLowPercent() {
|
||||
// 16% of a two-glyph run reaches into the second glyph and no further.
|
||||
let edge = CapacityDockGlance.gaugeRevealEdge(fraction: 0.16, width: 40, height: 20)
|
||||
#expect(edge.bottom > 0)
|
||||
#expect(edge.bottom < 40 * 0.3)
|
||||
}
|
||||
|
||||
@Test("Sessions grow by a pill plus its gap up to the visible cap, then scroll")
|
||||
func sessionsSectionHeight() {
|
||||
let empty = CapacityDockGlance.sessionsHeight(count: 0)
|
||||
#expect(
|
||||
empty == CapacityDockGlance.sectionPadTop
|
||||
+ CapacityDockGlance.captionLine
|
||||
+ CapacityDockGlance.sectionPadBottom
|
||||
)
|
||||
#expect(CapacityDockGlance.sessionListHeight(count: 0) == 0)
|
||||
let step = CapacityDockGlance.pillGap + CapacityDockGlance.pillHeight
|
||||
for count in 1...CapacityDockGlance.maxVisibleSessionRows {
|
||||
let grown = CapacityDockGlance.sessionsHeight(count: count)
|
||||
- CapacityDockGlance.sessionsHeight(count: count - 1)
|
||||
#expect(grown == step)
|
||||
}
|
||||
// Four pills and their three gaps, then the list scrolls inside that.
|
||||
let fourPills: CGFloat = 4 * CapacityDockGlance.pillHeight + 3 * CapacityDockGlance.pillGap
|
||||
#expect(CapacityDockGlance.sessionListHeight(count: 4) == fourPills)
|
||||
#expect(fourPills == 202)
|
||||
let capped = CapacityDockGlance.sessionsHeight(count: 4)
|
||||
#expect(CapacityDockGlance.sessionsHeight(count: 5) == capped)
|
||||
#expect(CapacityDockGlance.sessionsHeight(count: 40) == capped)
|
||||
}
|
||||
|
||||
@Test("Height is the exact sum of the sections the view draws")
|
||||
func heightIsTheSumOfItsSections() {
|
||||
func height(_ count: Int?, hasToday: Bool, windows: [QuotaSummary.Window]) -> CGFloat {
|
||||
CapacityDockMetrics.detailHeight(
|
||||
quota: quota(windows),
|
||||
sessionCount: count,
|
||||
hasToday: hasToday,
|
||||
tailEdge: .right,
|
||||
scale: 1
|
||||
)
|
||||
}
|
||||
let three = [window("Weekly", 0.24), window("5-hour", 0.26), window("Fable", 0.4)]
|
||||
let full = height(1, hasToday: true, windows: three)
|
||||
#expect(
|
||||
full == CapacityDockGlance.headerHeight
|
||||
+ CapacityDockGlance.sessionsHeight(count: 1)
|
||||
+ CapacityDockGlance.todayHeight
|
||||
+ CapacityDockGlance.windowsHeight
|
||||
)
|
||||
// 44 header + 83 sessions + 81 today + 77 windows
|
||||
#expect(full == 285)
|
||||
// The panel opens and closes on the same 16pt inset it uses sideways.
|
||||
let headerParts: CGFloat = CapacityDockGlance.contentInset + 20 + 8
|
||||
#expect(CapacityDockGlance.headerHeight == headerParts)
|
||||
#expect(
|
||||
CapacityDockGlance.windowsHeight
|
||||
== CapacityDockGlance.sectionPadTop + 53 + CapacityDockGlance.contentInset
|
||||
)
|
||||
// Today is three stacked lines (13 + 3 + 13 + 3 + 12) inside its padding.
|
||||
#expect(CapacityDockGlance.todayContentHeight == 44)
|
||||
#expect(CapacityDockGlance.todayHeight == 81)
|
||||
// Past four sessions the list scrolls, so the panel stops growing.
|
||||
let capped = height(4, hasToday: true, windows: three)
|
||||
#expect(height(12, hasToday: true, windows: three) == capped)
|
||||
#expect(capped == 44 + CapacityDockGlance.sessionsHeight(count: 4) + 81 + 77)
|
||||
// Each section is independently droppable.
|
||||
#expect(full - height(nil, hasToday: true, windows: three) == CapacityDockGlance.sessionsHeight(count: 1))
|
||||
#expect(full - height(1, hasToday: false, windows: three) == CapacityDockGlance.todayHeight)
|
||||
#expect(
|
||||
height(1, hasToday: true, windows: []) - full
|
||||
== CapacityDockGlance.windowsEmptyHeight - CapacityDockGlance.windowsHeight
|
||||
)
|
||||
// Column count does not change the row's height.
|
||||
#expect(height(1, hasToday: true, windows: [three[0]]) == full)
|
||||
}
|
||||
|
||||
@Test("A vertical tail adds its allowance to the panel height")
|
||||
func tailAllowance() {
|
||||
func height(_ tailEdge: CapacityDockEdge) -> CGFloat {
|
||||
CapacityDockMetrics.detailHeight(
|
||||
quota: quota([window("5-hour", 0.2)]),
|
||||
sessionCount: 2,
|
||||
hasToday: true,
|
||||
tailEdge: tailEdge,
|
||||
scale: 1
|
||||
)
|
||||
}
|
||||
#expect(height(.top) - height(.right) == CapacityDockGlance.tailAllowance)
|
||||
#expect(height(.bottom) == height(.top))
|
||||
#expect(height(.left) == height(.right))
|
||||
}
|
||||
|
||||
@Test("Every scaled height stays a whole point so the panel cannot thrash")
|
||||
func heightIsAlwaysWhole() {
|
||||
for scale in [0.9, 1.0, 1.15, 1.25, 1.4] {
|
||||
let height = CapacityDockMetrics.detailHeight(
|
||||
quota: quota([window("5-hour", 0.2), window("Weekly", 0.5)]),
|
||||
sessionCount: 4,
|
||||
hasToday: true,
|
||||
tailEdge: .bottom,
|
||||
scale: CGFloat(scale)
|
||||
)
|
||||
#expect(height == height.rounded())
|
||||
}
|
||||
}
|
||||
|
||||
@Test("A session reports its context fraction and what is left")
|
||||
func sessionContextMath() {
|
||||
let session = LiveSession(
|
||||
id: "s1",
|
||||
provider: "claude",
|
||||
project: "codeburn",
|
||||
branch: "feat/dock",
|
||||
model: "Opus 4.8",
|
||||
contextTokens: 160_000,
|
||||
contextWindow: 200_000,
|
||||
startedAt: "2026-09-01T10:00:00.000Z",
|
||||
lastActivityAt: "2026-09-01T12:00:00.000Z"
|
||||
)
|
||||
#expect(session.title == "codeburn · feat/dock")
|
||||
#expect(session.contextFraction == 0.8)
|
||||
#expect(session.contextRemaining == 40_000)
|
||||
let now = LiveSession.parseISO8601("2026-09-01T12:04:00.000Z")!
|
||||
#expect(session.elapsedLabel(now: now) == "2h 4m")
|
||||
}
|
||||
|
||||
@Test("A session with no usage record renders unfilled rather than at zero")
|
||||
func sessionWithoutContext() {
|
||||
let session = LiveSession(
|
||||
id: "s2",
|
||||
provider: "codex",
|
||||
project: "rakazo",
|
||||
branch: nil,
|
||||
model: nil,
|
||||
contextTokens: nil,
|
||||
contextWindow: nil,
|
||||
startedAt: "not-a-date",
|
||||
lastActivityAt: "2026-09-01T12:00:00.000Z"
|
||||
)
|
||||
#expect(session.title == "rakazo")
|
||||
#expect(session.contextFraction == nil)
|
||||
#expect(session.contextRemaining == nil)
|
||||
#expect(session.elapsedLabel() == "")
|
||||
}
|
||||
|
||||
@Test("A payload without the live-session block decodes as unknown, not as empty")
|
||||
func liveSessionsAreOptional() throws {
|
||||
let json = """
|
||||
{"windowSeconds":120,"sessions":[
|
||||
{"id":"a","provider":"claude","project":"codeburn","branch":"main","model":"Opus 4.8",
|
||||
"contextTokens":100,"contextWindow":200000,
|
||||
"startedAt":"2026-09-01T10:00:00.000Z","lastActivityAt":"2026-09-01T12:00:00.000Z"},
|
||||
{"id":"b","provider":"codex","project":"rakazo","branch":null,"model":null,
|
||||
"contextTokens":null,"contextWindow":null,
|
||||
"startedAt":"2026-09-01T10:00:00.000Z","lastActivityAt":"2026-09-01T12:00:00.000Z"}
|
||||
]}
|
||||
"""
|
||||
let block = try JSONDecoder().decode(LiveSessionsBlock.self, from: Data(json.utf8))
|
||||
#expect(block.sessions.count == 2)
|
||||
#expect(block.sessions.filter { $0.provider == "claude" }.count == 1)
|
||||
#expect(block.sessions[1].contextFraction == nil)
|
||||
}
|
||||
}
|
||||
|
|
@ -284,7 +284,21 @@ struct CapacityDockPresentationTests {
|
|||
footerLines: [reason]
|
||||
)
|
||||
|
||||
#expect(CapacityDockMetrics.detailHeight(quota: quota, scale: 1) >= 216)
|
||||
let height = CapacityDockMetrics.detailHeight(
|
||||
quota: quota,
|
||||
sessionCount: nil,
|
||||
hasToday: false,
|
||||
tailEdge: .right,
|
||||
scale: 1
|
||||
)
|
||||
// Worst case the card must not clip: the provider header, the guidance
|
||||
// block at full wrap ("Reconnect required", a 2-line reason and a 3-line
|
||||
// instruction at ~13pt each, plus their spacing), and the action button.
|
||||
// The windows row is absent, since a disconnected provider has no quota.
|
||||
let guidance: CGFloat = 84
|
||||
let actionButton: CGFloat = 38
|
||||
let floor: CGFloat = CapacityDockGlance.headerHeight + guidance + actionButton
|
||||
#expect(height >= floor)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@
|
|||
],
|
||||
"scripts": {
|
||||
"bundle-litellm": "node scripts/bundle-litellm.mjs",
|
||||
"build": "node scripts/bundle-litellm.mjs && tsup && node -e \"const fs=require('fs'); fs.copyFileSync('src/cli.ts','dist/cli.js'); fs.chmodSync('dist/cli.js',0o755)\" && npm run build:dash",
|
||||
"build": "tsup && node -e \"const fs=require('fs'); fs.copyFileSync('src/cli.ts','dist/cli.js'); fs.chmodSync('dist/cli.js',0o755)\" && npm run build:dash",
|
||||
"build:cli": "tsup && node -e \"const fs=require('fs'); fs.copyFileSync('src/cli.ts','dist/cli.js'); fs.chmodSync('dist/cli.js',0o755)\"",
|
||||
"build:dash": "cd dash && npm install --no-audit --no-fund --silent && npm run build",
|
||||
"build:dash": "cd dash && npm ci --no-audit --no-fund --silent && npm run build",
|
||||
"dev": "NODE_OPTIONS=--no-deprecation tsx src/cli.ts",
|
||||
"test": "vitest run tests --exclude \"tests/cache-refresh-lock*\"",
|
||||
"test:locks": "vitest run tests/cache-refresh-lock.test.ts tests/cache-refresh-lock-corrupt-body.test.ts tests/cache-refresh-lock-process.test.ts tests/cache-refresh-lock-status-snapshot.test.ts --poolOptions.forks.singleFork=true",
|
||||
|
|
|
|||
|
|
@ -132,7 +132,9 @@ function capture(bin, cacheDir, outDir, extra = {}) {
|
|||
}
|
||||
|
||||
// The one field that moves between two runs of the same payload.
|
||||
const stripGenerated = obj => JSON.parse(JSON.stringify(obj, (k, v) => (k.startsWith('generated') ? undefined : v)))
|
||||
// liveSessions is a wall-clock snapshot (idleSeconds, lastActivityAt), not a parse
|
||||
// product, so two invocations legitimately differ there.
|
||||
const stripGenerated = obj => JSON.parse(JSON.stringify(obj, (k, v) => (k.startsWith('generated') || k === 'liveSessions' ? undefined : v)))
|
||||
|
||||
// Shards carry real stat data and are published under a random filename, so
|
||||
// "identical" means identical after normalizing both away.
|
||||
|
|
|
|||
|
|
@ -410,6 +410,10 @@ async function findLastBoundary(filePath: string): Promise<{
|
|||
// 200K unless the session itself proves bigger.
|
||||
const MILLION_WINDOW_RE = /opus-4-8|\[1m\]/
|
||||
|
||||
export function reportedContextWindow(model: string, maxSeenTokens: number): number {
|
||||
return MILLION_WINDOW_RE.test(model) || maxSeenTokens > 220_000 ? 1_000_000 : 200_000
|
||||
}
|
||||
|
||||
export async function buildContextTree(session: SessionRef): Promise<ContextTreeResult> {
|
||||
const boundary = await findLastBoundary(session.filePath)
|
||||
const builder = new TreeBuilder()
|
||||
|
|
@ -445,8 +449,7 @@ export async function buildContextTree(session: SessionRef): Promise<ContextTree
|
|||
(builder.lastUsage.cache_creation_input_tokens ?? 0) +
|
||||
(builder.lastUsage.output_tokens ?? 0)
|
||||
builder.maxSeenTokens = Math.max(builder.maxSeenTokens, context)
|
||||
const million = MILLION_WINDOW_RE.test(builder.model) || builder.maxSeenTokens > 220_000
|
||||
reported = { context, window: million ? 1_000_000 : 200_000 }
|
||||
reported = { context, window: reportedContextWindow(builder.model, builder.maxSeenTokens) }
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
304
src/live-sessions.ts
Normal file
304
src/live-sessions.ts
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
/// "What is using the quota right now": sessions whose transcript was appended
|
||||
/// inside a short liveness window, with the context each one is holding. Feeds
|
||||
/// the optional `liveSessions` block of the menubar payload; the app renders
|
||||
/// only what it finds here.
|
||||
import { open, readdir, stat } from 'node:fs/promises'
|
||||
import { basename, join } from 'node:path'
|
||||
import { getClaudeConfigDirs, getDesktopSessionsDirs } from './providers/claude.js'
|
||||
import { reportedContextWindow } from './context-tree.js'
|
||||
import { getShortModelName } from './models.js'
|
||||
import type { ApiUsage, AssistantMessageContent, JournalEntry } from './types.js'
|
||||
|
||||
export const LIVE_WINDOW_SECONDS = 600
|
||||
|
||||
/// Only the tail of a live transcript is read, and it is scanned backwards only
|
||||
/// as far as the last assistant turn. A long-running session's file reaches tens
|
||||
/// of MB and this runs on every payload build; the fields we want (last model,
|
||||
/// last usage, current branch) all sit at the end.
|
||||
export const TAIL_BYTES = 256 * 1024
|
||||
|
||||
export type LiveSession = {
|
||||
id: string
|
||||
/// Provider catalog id, matching the dock ring the session runs under.
|
||||
provider: string
|
||||
project: string
|
||||
/// Git branch of the last turn, the second half of the row's title. Null when
|
||||
/// the transcript never named one (non-Claude tools, or a non-repo cwd).
|
||||
branch: string | null
|
||||
/// Short display name of the model that answered last.
|
||||
model: string | null
|
||||
/// Tokens the next turn would resend, and the window they are measured against.
|
||||
/// Both null when the tail held no assistant usage, so the app omits the ring.
|
||||
contextTokens: number | null
|
||||
contextWindow: number | null
|
||||
startedAt: string
|
||||
lastActivityAt: string
|
||||
/// Seconds since this session last wrote, at the moment the payload was
|
||||
/// built. A session can be live but waiting on the user, which reads very
|
||||
/// differently from one that is generating.
|
||||
idleSeconds: number
|
||||
}
|
||||
|
||||
export type LiveSessionsBlock = {
|
||||
windowSeconds: number
|
||||
sessions: LiveSession[]
|
||||
}
|
||||
|
||||
export type LiveSessionInput = {
|
||||
id: string
|
||||
provider: string
|
||||
project: string
|
||||
branch: string | null
|
||||
model: string | null
|
||||
contextTokens: number | null
|
||||
contextWindow: number | null
|
||||
startedMs: number
|
||||
lastActivityMs: number
|
||||
/// Last-activity time of each sub-agent run belonging to this session.
|
||||
subagentActivityMs: number[]
|
||||
}
|
||||
|
||||
/// Pure core: liveness and parent/sub-agent folding, newest first.
|
||||
export function buildLiveSessions(
|
||||
inputs: LiveSessionInput[],
|
||||
nowMs: number,
|
||||
windowSeconds: number = LIVE_WINDOW_SECONDS,
|
||||
): LiveSessionsBlock {
|
||||
const windowMs = windowSeconds * 1000
|
||||
const isLive = (ms: number) => ms > 0 && nowMs - ms <= windowMs
|
||||
// A session working through a sub-agent leaves its own transcript untouched
|
||||
// for as long as that agent runs, so its sub-agents count as its activity.
|
||||
const activityOf = (input: LiveSessionInput) => Math.max(input.lastActivityMs, ...input.subagentActivityMs)
|
||||
const sessions = inputs
|
||||
.filter(input => isLive(activityOf(input)))
|
||||
.map(input => ({
|
||||
id: input.id,
|
||||
provider: input.provider,
|
||||
project: input.project,
|
||||
branch: input.branch,
|
||||
model: input.model,
|
||||
contextTokens: input.contextTokens,
|
||||
contextWindow: input.contextWindow,
|
||||
startedAt: new Date(input.startedMs).toISOString(),
|
||||
lastActivityAt: new Date(activityOf(input)).toISOString(),
|
||||
idleSeconds: Math.max(0, Math.round((nowMs - activityOf(input)) / 1000)),
|
||||
}))
|
||||
.sort((a, b) => b.lastActivityAt.localeCompare(a.lastActivityAt))
|
||||
return { windowSeconds, sessions }
|
||||
}
|
||||
|
||||
async function readTail(filePath: string, maxBytes: number): Promise<string> {
|
||||
const handle = await open(filePath, 'r')
|
||||
try {
|
||||
const { size } = await handle.stat()
|
||||
const start = size > maxBytes ? size - maxBytes : 0
|
||||
const buffer = Buffer.alloc(size - start)
|
||||
if (buffer.length === 0) return ''
|
||||
await handle.read(buffer, 0, buffer.length, start)
|
||||
const text = buffer.toString('utf8')
|
||||
// A mid-file start almost certainly lands inside a line; drop that fragment.
|
||||
return start > 0 ? text.slice(text.indexOf('\n') + 1) : text
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
/// Tokens the next request would carry: everything the model reads back plus
|
||||
/// what it just wrote. Mirrors the reported-context sum in context-tree.ts.
|
||||
function contextOf(usage: ApiUsage): number {
|
||||
return (usage.input_tokens ?? 0)
|
||||
+ (usage.cache_read_input_tokens ?? 0)
|
||||
+ (usage.cache_creation_input_tokens ?? 0)
|
||||
+ (usage.output_tokens ?? 0)
|
||||
}
|
||||
|
||||
function assistantMessage(entry: JournalEntry): AssistantMessageContent | null {
|
||||
const message = entry.message
|
||||
if (!message || typeof message !== 'object') return null
|
||||
const candidate = message as Partial<AssistantMessageContent>
|
||||
if (candidate.role !== 'assistant' || !candidate.usage) return null
|
||||
return candidate as AssistantMessageContent
|
||||
}
|
||||
|
||||
type ScannedFile = {
|
||||
sessionId: string
|
||||
cwd: string
|
||||
isSidechain: boolean
|
||||
branch: string | null
|
||||
model: string | null
|
||||
contextTokens: number | null
|
||||
contextWindow: number | null
|
||||
}
|
||||
|
||||
/// Reads one live transcript tail and walks it backwards, stopping at the last
|
||||
/// assistant turn. Never throws: an unreadable or unexpected file yields a bare
|
||||
/// record rather than taking the payload down.
|
||||
export async function scanTranscript(filePath: string): Promise<ScannedFile> {
|
||||
const result: ScannedFile = {
|
||||
sessionId: basename(filePath).replace(/\.jsonl$/, ''),
|
||||
cwd: '',
|
||||
isSidechain: false,
|
||||
branch: null,
|
||||
model: null,
|
||||
contextTokens: null,
|
||||
contextWindow: null,
|
||||
}
|
||||
let text = ''
|
||||
try {
|
||||
text = await readTail(filePath, TAIL_BYTES)
|
||||
} catch {
|
||||
return result
|
||||
}
|
||||
const lines = text.split('\n')
|
||||
let needsIdentity = true
|
||||
let needsUsage = true
|
||||
// Backwards: the newest turn wins for every field, so the first hit going up
|
||||
// is the answer and there is no reason to parse the rest of the tail.
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
if (!needsIdentity && !needsUsage) break
|
||||
const line = lines[index]
|
||||
if (!line || !line.trim()) continue
|
||||
let entry: JournalEntry
|
||||
try {
|
||||
entry = JSON.parse(line) as JournalEntry
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (needsIdentity) {
|
||||
if (typeof entry.sessionId === 'string' && entry.sessionId) result.sessionId = entry.sessionId
|
||||
if (typeof entry.cwd === 'string' && entry.cwd) result.cwd = entry.cwd
|
||||
if (entry.isSidechain === true) result.isSidechain = true
|
||||
if (typeof entry.gitBranch === 'string' && entry.gitBranch) result.branch = entry.gitBranch
|
||||
// cwd and branch travel together on every user/assistant entry, so one
|
||||
// entry carrying them settles identity.
|
||||
if (result.cwd && result.branch) needsIdentity = false
|
||||
}
|
||||
if (!needsUsage) continue
|
||||
const assistant = assistantMessage(entry)
|
||||
if (!assistant) continue
|
||||
const context = contextOf(assistant.usage)
|
||||
if (context <= 0) continue
|
||||
result.contextTokens = context
|
||||
const rawModel = typeof assistant.model === 'string' ? assistant.model : ''
|
||||
if (rawModel) result.model = getShortModelName(rawModel)
|
||||
// ponytail: the window comes from this one turn, so a session that compacted
|
||||
// below 220k on a 1M model reads as 200k until its next big turn. Track a
|
||||
// running max again if that misreport ever matters.
|
||||
result.contextWindow = reportedContextWindow(rawModel, context)
|
||||
needsUsage = false
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Claude nests sub-agent transcripts under their parent's own directory
|
||||
/// (`<parent-session-id>/subagents/...`), so the parent file is that directory
|
||||
/// plus `.jsonl`. Returns null for any other shape.
|
||||
function parentTranscriptPath(sidechainPath: string): string | null {
|
||||
const marker = sidechainPath.lastIndexOf('/subagents/')
|
||||
if (marker <= 0) return null
|
||||
return `${sidechainPath.slice(0, marker)}.jsonl`
|
||||
}
|
||||
|
||||
type FileTimes = { path: string; mtimeMs: number; birthtimeMs: number }
|
||||
|
||||
/// Transcript roots, straight from the Claude config rather than through the
|
||||
/// provider registry. `discoverAllSessions` walks every provider's tree and
|
||||
/// costs about half a second on a large history; this block only ever reads
|
||||
/// Claude transcripts, and it runs on every payload build.
|
||||
async function transcriptRoots(): Promise<string[]> {
|
||||
const configured = await getClaudeConfigDirs().catch(() => [])
|
||||
return [...configured.map(dir => join(dir, 'projects')), ...getDesktopSessionsDirs()]
|
||||
}
|
||||
|
||||
/// Every transcript touched inside the window. One recursive listing per root,
|
||||
/// then a stat per file: nothing is opened until it is known to be live.
|
||||
async function liveTranscripts(nowMs: number, windowMs: number): Promise<FileTimes[]> {
|
||||
const paths: string[] = []
|
||||
for (const root of await transcriptRoots()) {
|
||||
const entries = await readdir(root, { recursive: true, withFileTypes: true }).catch(() => [])
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue
|
||||
paths.push(join(entry.parentPath, entry.name))
|
||||
}
|
||||
}
|
||||
const stats = await Promise.all(paths.map(async path => {
|
||||
const info = await stat(path).catch(() => null)
|
||||
if (!info?.isFile()) return null
|
||||
if (nowMs - info.mtimeMs > windowMs) return null
|
||||
return { path, mtimeMs: info.mtimeMs, birthtimeMs: info.birthtimeMs || info.mtimeMs }
|
||||
}))
|
||||
return stats.filter((entry): entry is FileTimes => entry !== null)
|
||||
}
|
||||
|
||||
/// Walks the live transcripts and builds the pure-core inputs. Sidechain
|
||||
/// (sub-agent) transcripts fold into their parent session: they keep it alive
|
||||
/// while it waits on them.
|
||||
export async function collectLiveSessionInputs(
|
||||
nowMs: number,
|
||||
windowSeconds: number,
|
||||
): Promise<LiveSessionInput[]> {
|
||||
const windowMs = windowSeconds * 1000
|
||||
const inputs = new Map<string, LiveSessionInput>()
|
||||
type PendingSidechain = { sessionId: string; mtimeMs: number; path: string }
|
||||
const pendingSidechains: PendingSidechain[] = []
|
||||
|
||||
const toInput = (scan: ScannedFile, times: Omit<FileTimes, 'path'>, fallbackProject: string): LiveSessionInput => ({
|
||||
id: scan.sessionId,
|
||||
provider: 'claude',
|
||||
project: scan.cwd ? basename(scan.cwd) : fallbackProject,
|
||||
branch: scan.branch,
|
||||
model: scan.model,
|
||||
contextTokens: scan.contextTokens,
|
||||
contextWindow: scan.contextWindow,
|
||||
startedMs: times.birthtimeMs,
|
||||
lastActivityMs: times.mtimeMs,
|
||||
subagentActivityMs: [],
|
||||
})
|
||||
|
||||
/// A parent whose own transcript is idle because its sub-agent is doing the
|
||||
/// work. Its file is not live, so read it here to recover its details.
|
||||
const loadIdleParent = async (sidechain: PendingSidechain): Promise<LiveSessionInput | null> => {
|
||||
const parentPath = parentTranscriptPath(sidechain.path)
|
||||
if (!parentPath) return null
|
||||
const info = await stat(parentPath).catch(() => null)
|
||||
if (!info?.isFile()) return null
|
||||
const scan = await scanTranscript(parentPath)
|
||||
return toInput(
|
||||
scan,
|
||||
{ mtimeMs: info.mtimeMs, birthtimeMs: info.birthtimeMs || info.mtimeMs },
|
||||
basename(parentPath).replace(/\.jsonl$/, ''),
|
||||
)
|
||||
}
|
||||
|
||||
for (const file of await liveTranscripts(nowMs, windowMs)) {
|
||||
const scan = await scanTranscript(file.path)
|
||||
if (scan.isSidechain) {
|
||||
pendingSidechains.push({ sessionId: scan.sessionId, mtimeMs: file.mtimeMs, path: file.path })
|
||||
continue
|
||||
}
|
||||
inputs.set(scan.sessionId, toInput(scan, file, basename(file.path).replace(/\.jsonl$/, '')))
|
||||
}
|
||||
|
||||
// Claude writes the PARENT session id into every sidechain line, so the scan's
|
||||
// session id is the parent to attach to.
|
||||
for (const sidechain of pendingSidechains) {
|
||||
let parent = inputs.get(sidechain.sessionId)
|
||||
if (!parent) {
|
||||
const recovered = await loadIdleParent(sidechain)
|
||||
if (!recovered) continue
|
||||
parent = recovered
|
||||
inputs.set(parent.id, parent)
|
||||
}
|
||||
parent.subagentActivityMs.push(sidechain.mtimeMs)
|
||||
}
|
||||
|
||||
return [...inputs.values()]
|
||||
}
|
||||
|
||||
export async function collectLiveSessions(
|
||||
nowMs: number = Date.now(),
|
||||
windowSeconds: number = LIVE_WINDOW_SECONDS,
|
||||
): Promise<LiveSessionsBlock> {
|
||||
const inputs = await collectLiveSessionInputs(nowMs, windowSeconds)
|
||||
return buildLiveSessions(inputs, nowMs, windowSeconds)
|
||||
}
|
||||
|
|
@ -31,10 +31,12 @@ function redactSessionDetails(details: Array<{ cost: number; savingsUSD: number;
|
|||
}
|
||||
|
||||
export function redactProjectNames(payload: MenubarPayload, includeNames: boolean): MenubarPayload {
|
||||
if (includeNames) return payload
|
||||
const timeline = payload.history?.timeline
|
||||
// Live sessions name projects and branches; MCP consumers never need them.
|
||||
const { liveSessions: _liveSessions, ...rest } = payload
|
||||
if (includeNames) return rest
|
||||
const timeline = rest.history?.timeline
|
||||
return {
|
||||
...payload,
|
||||
...rest,
|
||||
current: {
|
||||
...payload.current,
|
||||
topProjects: payload.current.topProjects.map(p => ({
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ import type { GranularHistory } from './granular-history.js'
|
|||
import { getShortModelName } from './models.js'
|
||||
import type { ReworkedFile } from './workflow-insights.js'
|
||||
import type { PrRow, BranchRow } from './sessions-report.js'
|
||||
import type { LiveSessionsBlock } from './live-sessions.js'
|
||||
|
||||
const TOP_ACTIVITIES_LIMIT = 20
|
||||
const TOP_MODELS_LIMIT = 20
|
||||
|
|
@ -212,6 +213,10 @@ export type MenubarPayload = {
|
|||
/// section AND its command wrote it. Surfaces render what they recognize
|
||||
/// and ignore the rest; absence always means "no plugin output today".
|
||||
plugins?: Record<string, unknown>
|
||||
/// Add-only. Sessions whose transcript was appended inside the liveness
|
||||
/// window, with the context each is holding. Omitted when the producer did
|
||||
/// not compute it, so absence means "unknown", never "nothing is running".
|
||||
liveSessions?: LiveSessionsBlock
|
||||
current: {
|
||||
label: string
|
||||
cost: number
|
||||
|
|
|
|||
|
|
@ -10,8 +10,11 @@ export function sanitizeForSharing(payload: MenubarPayload): MenubarPayload {
|
|||
// Older peers may predate the history field even though current producers
|
||||
// always include it, so keep the boundary tolerant while sanitizing.
|
||||
const timeline = payload.history?.timeline
|
||||
// Live sessions name the project and branch in flight, the most identifying
|
||||
// block of all.
|
||||
const { liveSessions: _liveSessions, ...rest } = payload
|
||||
return {
|
||||
...payload,
|
||||
...rest,
|
||||
current: {
|
||||
...payload.current,
|
||||
topProjects: [],
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { parseAllSessions, filterProjectsByName, filterProjectsByDays, filterPro
|
|||
import { findUnpricedModels, getFlatRateModelsConfigHash, getLocalModelSavingsConfigHash, getPriceOverridesConfigHash, getShortModelName, isExpectedFreeModel } from './models.js'
|
||||
import { getAllProviders, safeDiscoverSessions } from './providers/index.js'
|
||||
import { loadPlugins, pluginPayloadSections } from './plugins/loader.js'
|
||||
import { collectLiveSessions } from './live-sessions.js'
|
||||
import { claude, getClaudeConfigDirs, getDesktopSessionsDirs } from './providers/claude.js'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKeyInTz } from './day-aggregator.js'
|
||||
|
|
@ -1180,5 +1181,10 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
|
|||
// default, so the payload is byte-identical without plugins installed).
|
||||
const pluginSections = await pluginPayloadSections(await loadPlugins())
|
||||
if (Object.keys(pluginSections).length > 0) payload.plugins = pluginSections
|
||||
// Add-only live-session block. Its own disk pass is independent of the
|
||||
// aggregation above, so a failure here must leave the rest of the payload
|
||||
// intact rather than blank the menubar.
|
||||
const liveSessions = await collectLiveSessions().catch(() => null)
|
||||
if (liveSessions) payload.liveSessions = liveSessions
|
||||
return payload
|
||||
}
|
||||
|
|
|
|||
182
tests/live-sessions.test.ts
Normal file
182
tests/live-sessions.test.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { LIVE_WINDOW_SECONDS, TAIL_BYTES, buildLiveSessions, scanTranscript, type LiveSessionInput } from '../src/live-sessions.js'
|
||||
|
||||
const NOW = Date.parse('2026-09-01T12:00:00.000Z')
|
||||
|
||||
function input(over: Partial<LiveSessionInput> = {}): LiveSessionInput {
|
||||
return {
|
||||
id: 'session-a',
|
||||
provider: 'claude',
|
||||
project: 'codeburn',
|
||||
branch: 'main',
|
||||
model: 'Opus 4.8',
|
||||
contextTokens: 100_000,
|
||||
contextWindow: 200_000,
|
||||
startedMs: NOW - 3_600_000,
|
||||
lastActivityMs: NOW - 10_000,
|
||||
subagentActivityMs: [],
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildLiveSessions', () => {
|
||||
it('keeps sessions touched inside the window and drops older ones', () => {
|
||||
const block = buildLiveSessions([
|
||||
input({ id: 'fresh', lastActivityMs: NOW - 10_000 }),
|
||||
input({ id: 'stale', lastActivityMs: NOW - 3_600_000 }),
|
||||
], NOW, LIVE_WINDOW_SECONDS)
|
||||
expect(block.sessions.map(s => s.id)).toEqual(['fresh'])
|
||||
expect(block.windowSeconds).toBe(LIVE_WINDOW_SECONDS)
|
||||
})
|
||||
|
||||
it('counts a session idle for minutes as still live, at ten minutes wide', () => {
|
||||
expect(LIVE_WINDOW_SECONDS).toBe(600)
|
||||
// A session waiting on the user for five minutes is still open, not gone.
|
||||
const block = buildLiveSessions([
|
||||
input({ id: 'thinking', lastActivityMs: NOW - 5_000 }),
|
||||
input({ id: 'waiting', lastActivityMs: NOW - 300_000 }),
|
||||
], NOW, LIVE_WINDOW_SECONDS)
|
||||
expect(block.sessions.map(s => s.id)).toEqual(['thinking', 'waiting'])
|
||||
expect(block.sessions.map(s => s.idleSeconds)).toEqual([5, 300])
|
||||
})
|
||||
|
||||
it('reports idle from the sub-agent that kept the session alive', () => {
|
||||
const block = buildLiveSessions([
|
||||
input({ lastActivityMs: NOW - 400_000, subagentActivityMs: [NOW - 20_000] }),
|
||||
], NOW, LIVE_WINDOW_SECONDS)
|
||||
expect(block.sessions[0]!.idleSeconds).toBe(20)
|
||||
})
|
||||
|
||||
it('keeps a parent alive while only its sub-agent is writing', () => {
|
||||
const block = buildLiveSessions([
|
||||
input({ id: 'parent', lastActivityMs: NOW - 3_600_000, subagentActivityMs: [NOW - 5_000] }),
|
||||
], NOW, LIVE_WINDOW_SECONDS)
|
||||
expect(block.sessions).toHaveLength(1)
|
||||
// The sub-agent's write is the session's last activity.
|
||||
expect(block.sessions[0]!.lastActivityAt).toBe(new Date(NOW - 5_000).toISOString())
|
||||
})
|
||||
|
||||
it('ignores a sub-agent that is itself stale', () => {
|
||||
const block = buildLiveSessions([
|
||||
input({ lastActivityMs: NOW - 3_600_000, subagentActivityMs: [NOW - 3_700_000] }),
|
||||
], NOW, LIVE_WINDOW_SECONDS)
|
||||
expect(block.sessions).toEqual([])
|
||||
})
|
||||
|
||||
it('sorts newest first and carries the context fields through', () => {
|
||||
const block = buildLiveSessions([
|
||||
input({ id: 'older', lastActivityMs: NOW - 60_000 }),
|
||||
input({ id: 'newer', lastActivityMs: NOW - 1_000, contextTokens: 42, contextWindow: 1_000_000 }),
|
||||
], NOW, LIVE_WINDOW_SECONDS)
|
||||
expect(block.sessions.map(s => s.id)).toEqual(['newer', 'older'])
|
||||
expect(block.sessions[0]).toMatchObject({ contextTokens: 42, contextWindow: 1_000_000, branch: 'main' })
|
||||
})
|
||||
|
||||
it('treats a missing timestamp as not live rather than as the epoch', () => {
|
||||
expect(buildLiveSessions([input({ lastActivityMs: 0 })], NOW, LIVE_WINDOW_SECONDS).sessions).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('scanTranscript', () => {
|
||||
async function transcript(lines: unknown[]): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'live-sessions-'))
|
||||
const path = join(dir, 'abc123.jsonl')
|
||||
await writeFile(path, lines.map(l => JSON.stringify(l)).join('\n'))
|
||||
return path
|
||||
}
|
||||
|
||||
it('reads branch, model and the last assistant context', async () => {
|
||||
const path = await transcript([
|
||||
{ type: 'user', sessionId: 's1', cwd: '/Users/x/codeburn', gitBranch: 'feat/dock' },
|
||||
{
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
model: 'claude-opus-4-5-20260101',
|
||||
usage: { input_tokens: 10, cache_read_input_tokens: 90_000, cache_creation_input_tokens: 1_000, output_tokens: 500 },
|
||||
},
|
||||
},
|
||||
])
|
||||
const scan = await scanTranscript(path)
|
||||
expect(scan.sessionId).toBe('s1')
|
||||
expect(scan.cwd).toBe('/Users/x/codeburn')
|
||||
expect(scan.branch).toBe('feat/dock')
|
||||
expect(scan.contextTokens).toBe(91_510)
|
||||
expect(scan.contextWindow).toBe(200_000)
|
||||
expect(scan.model).toBeTruthy()
|
||||
})
|
||||
|
||||
it('takes the last assistant turn, not the largest', async () => {
|
||||
const usage = (input: number) => ({
|
||||
type: 'assistant',
|
||||
message: { role: 'assistant', model: 'claude-sonnet-4-5', usage: { input_tokens: input } },
|
||||
})
|
||||
const scan = await scanTranscript(await transcript([usage(150_000), usage(20_000)]))
|
||||
expect(scan.contextTokens).toBe(20_000)
|
||||
})
|
||||
|
||||
it('widens the window when the session outgrew 200k', async () => {
|
||||
const scan = await scanTranscript(await transcript([
|
||||
{ type: 'assistant', message: { role: 'assistant', model: 'claude-sonnet-4-5', usage: { input_tokens: 300_000 } } },
|
||||
{ type: 'assistant', message: { role: 'assistant', model: 'claude-sonnet-4-5', usage: { input_tokens: 250_000 } } },
|
||||
]))
|
||||
expect(scan.contextWindow).toBe(1_000_000)
|
||||
})
|
||||
|
||||
it('flags a sidechain and degrades with no usage at all', async () => {
|
||||
const scan = await scanTranscript(await transcript([
|
||||
{ type: 'user', sessionId: 'parent-1', isSidechain: true, cwd: '/Users/x/eywa' },
|
||||
]))
|
||||
expect(scan.isSidechain).toBe(true)
|
||||
expect(scan.sessionId).toBe('parent-1')
|
||||
expect(scan.contextTokens).toBeNull()
|
||||
expect(scan.contextWindow).toBeNull()
|
||||
expect(scan.model).toBeNull()
|
||||
})
|
||||
|
||||
it('reads only the last 256 KB, so anything older than the tail is not seen', async () => {
|
||||
expect(TAIL_BYTES).toBe(256 * 1024)
|
||||
const usage = {
|
||||
type: 'assistant',
|
||||
message: { role: 'assistant', model: 'claude-sonnet-4-5', usage: { input_tokens: 4242 } },
|
||||
}
|
||||
// One real turn, then enough filler to push it clear out of the tail window.
|
||||
const filler = { type: 'user', cwd: '/Users/x/codeburn', gitBranch: 'main', pad: 'x'.repeat(2000) }
|
||||
const fillerCount = Math.ceil(TAIL_BYTES / JSON.stringify(filler).length) + 20
|
||||
const dir = await mkdtemp(join(tmpdir(), 'live-sessions-'))
|
||||
const path = join(dir, 'long.jsonl')
|
||||
await writeFile(path, [usage, ...Array(fillerCount).fill(filler)].map(l => JSON.stringify(l)).join('\n'))
|
||||
|
||||
const scan = await scanTranscript(path)
|
||||
// The identity fields still resolve, because they repeat on every entry.
|
||||
expect(scan.branch).toBe('main')
|
||||
// The assistant turn is beyond the tail, so no context is reported at all
|
||||
// rather than a stale figure read from the head of a huge file.
|
||||
expect(scan.contextTokens).toBeNull()
|
||||
expect(scan.contextWindow).toBeNull()
|
||||
})
|
||||
|
||||
it('still finds a turn that sits just inside the tail', async () => {
|
||||
const filler = { type: 'user', cwd: '/Users/x/codeburn', gitBranch: 'main', pad: 'x'.repeat(2000) }
|
||||
const fillerCount = Math.floor(TAIL_BYTES / JSON.stringify(filler).length / 2)
|
||||
const usage = {
|
||||
type: 'assistant',
|
||||
message: { role: 'assistant', model: 'claude-sonnet-4-5', usage: { input_tokens: 4242 } },
|
||||
}
|
||||
const dir = await mkdtemp(join(tmpdir(), 'live-sessions-'))
|
||||
const path = join(dir, 'short.jsonl')
|
||||
await writeFile(path, [usage, ...Array(fillerCount).fill(filler)].map(l => JSON.stringify(l)).join('\n'))
|
||||
expect((await scanTranscript(path)).contextTokens).toBe(4242)
|
||||
})
|
||||
|
||||
it('survives malformed lines and an unreadable file', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'live-sessions-'))
|
||||
const path = join(dir, 'broken.jsonl')
|
||||
await writeFile(path, '{not json\n{"type":"user","gitBranch":"main"}\n')
|
||||
expect((await scanTranscript(path)).branch).toBe('main')
|
||||
expect((await scanTranscript(join(dir, 'missing.jsonl'))).contextTokens).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -72,4 +72,21 @@ describe('redact', () => {
|
|||
expect(out.current.topProjects[0]!.sessionDetails![0]!.date).toBe('2026-06-01')
|
||||
expect(out.history.timeline?.sessionSeries[0]!.label).toContain('secret-client-repo')
|
||||
})
|
||||
it('drops the live-session block even when names are included', () => {
|
||||
const withSessions: MenubarPayload = {
|
||||
...payload(),
|
||||
liveSessions: {
|
||||
windowSeconds: 120,
|
||||
sessions: [{
|
||||
id: 'a', provider: 'claude', project: 'secret-client-repo', branch: 'secret-branch',
|
||||
model: 'Opus 4.8', contextTokens: 1, contextWindow: 200_000,
|
||||
startedAt: '2026-09-01T10:00:00.000Z', lastActivityAt: '2026-09-01T12:00:00.000Z',
|
||||
}],
|
||||
},
|
||||
}
|
||||
expect(redactProjectNames(withSessions, false).liveSessions).toBeUndefined()
|
||||
const included = redactProjectNames(withSessions, true)
|
||||
expect(included.liveSessions).toBeUndefined()
|
||||
expect(JSON.stringify(included)).not.toContain('secret-branch')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -262,7 +262,10 @@ function stripVolatile(payload: unknown): unknown {
|
|||
if (payload && typeof payload === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(payload as Record<string, unknown>)
|
||||
.filter(([k]) => !k.toLowerCase().startsWith('generated'))
|
||||
// `generated` is a timestamp, and `liveSessions` is a wall-clock
|
||||
// snapshot of what is running rather than anything the parse produced,
|
||||
// so neither can be compared across two separate CLI invocations.
|
||||
.filter(([k]) => !k.toLowerCase().startsWith('generated') && k !== 'liveSessions')
|
||||
.map(([k, v]) => [k, stripVolatile(v)]),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
18
tests/release-build-contract.test.ts
Normal file
18
tests/release-build-contract.test.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {
|
||||
scripts: Record<string, string>
|
||||
}
|
||||
|
||||
describe('release build contract', () => {
|
||||
it('builds from checked-in pricing catalogs instead of mutating them from the network', () => {
|
||||
expect(packageJson.scripts['bundle-litellm']).toBe('node scripts/bundle-litellm.mjs')
|
||||
expect(packageJson.scripts.build).not.toContain('bundle-litellm')
|
||||
})
|
||||
|
||||
it('installs dashboard dependencies without rewriting the lockfile', () => {
|
||||
expect(packageJson.scripts['build:dash']).toContain('npm ci')
|
||||
expect(packageJson.scripts['build:dash']).not.toContain('npm install')
|
||||
})
|
||||
})
|
||||
|
|
@ -61,4 +61,21 @@ describe('sanitizeForSharing', () => {
|
|||
const clean = sanitizeForSharing(fixture())
|
||||
expect(JSON.stringify(clean)).not.toContain('secret-project')
|
||||
})
|
||||
|
||||
it('drops the live-session block, which names the project and branch in flight', () => {
|
||||
const withSessions = {
|
||||
...fixture(),
|
||||
liveSessions: {
|
||||
windowSeconds: 120,
|
||||
sessions: [{
|
||||
id: 'a', provider: 'claude', project: 'secret-project', branch: 'secret-branch',
|
||||
model: 'Opus 4.8', contextTokens: 1, contextWindow: 200_000,
|
||||
startedAt: '2026-09-01T10:00:00.000Z', lastActivityAt: '2026-09-01T12:00:00.000Z',
|
||||
}],
|
||||
},
|
||||
}
|
||||
const clean = sanitizeForSharing(withSessions)
|
||||
expect(clean.liveSessions).toBeUndefined()
|
||||
expect(JSON.stringify(clean)).not.toContain('secret-branch')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue