Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ final class LimitsSettingsStore: ObservableObject {
static let shared = LimitsSettingsStore(userDefaults: .standard)

/// All known provider identifiers, in default display order.
static let allProviders: [String] = ["claude", "codex", "cursor", "gemini", "kimi", "kiro", "grok", "copilot", "antigravity", "zcode", "opencodeGo", "commandCode", "qoder", "qoderCn", "codingPlan", "agentPlan", "devin", "workbuddy"]
static let allProviders: [String] = ["claude", "codex", "cursor", "gemini", "kimi", "kiro", "grok", "copilot", "antigravity", "zcode", "opencodeGo", "commandCode", "qoder", "qoderCn", "codingPlan", "agentPlan", "devin", "workbuddy", "workbuddyAi"]

static let displayNames: [String: String] = [
"claude": "Claude",
Expand All @@ -57,6 +57,7 @@ final class LimitsSettingsStore: ObservableObject {
"agentPlan": "Ark Agent Plan",
"devin": "Devin",
"workbuddy": "WorkBuddy",
"workbuddyAi": "WorkBuddy AI",
]

/// Providers whose visibility switch is also the consent to fetch them.
Expand Down Expand Up @@ -347,10 +348,9 @@ final class LimitsSettingsStore: ObservableObject {
var seen = Set<String>()
var ordered: [String] = []
for rawId in raw ?? [] {
let id = rawId == "workbuddyAi" ? "workbuddy" : rawId
guard allProviders.contains(id), !seen.contains(id) else { continue }
ordered.append(id)
seen.insert(id)
guard allProviders.contains(rawId), !seen.contains(rawId) else { continue }
ordered.append(rawId)
seen.insert(rawId)
}
for id in allProviders where !seen.contains(id) {
ordered.append(id)
Expand All @@ -362,8 +362,15 @@ final class LimitsSettingsStore: ObservableObject {
var visibility = Dictionary(uniqueKeysWithValues: allProviders.map { id in
(id, rawBool(raw?[id]) ?? defaultVisibility(forProvider: id))
})
if let legacy = rawBool(raw?["workbuddyAi"]) {
visibility["workbuddy"] = rawBool(raw?["workbuddy"]) == true || legacy
// The unified-row era folded both editions into one `workbuddy`
// switch. The split rows keep an explicitly stored AI value; without
// one the AI row inherits the unified switch so a user who had
// WorkBuddy visible keeps seeing both editions, and a user who had
// it hidden keeps both hidden.
if let explicitAi = rawBool(raw?["workbuddyAi"]) {
visibility["workbuddyAi"] = explicitAi
} else if let unified = rawBool(raw?["workbuddy"]) {
visibility["workbuddyAi"] = unified
}
return visibility
}
Expand Down
16 changes: 11 additions & 5 deletions TokenTrackerBar/TokenTrackerBar/Models/UsageLimits.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ struct UsageLimitsResponse: Codable, Equatable {
let agentPlan: AgentPlanLimits?
let devin: DevinLimits?
let workbuddy: WorkbuddyLimits?
let workbuddyAi: WorkbuddyLimits?

enum CodingKeys: String, CodingKey {
case fetchedAt = "fetched_at"
case claude, codex, cursor, gemini, kimi, kiro, grok, antigravity, copilot, zcode, qoder, qoderCn, codingPlan, agentPlan, devin, workbuddy
case claude, codex, cursor, gemini, kimi, kiro, grok, antigravity, copilot, zcode, qoder, qoderCn, codingPlan, agentPlan, devin, workbuddy, workbuddyAi
case opencodeGo = "opencodeGo"
case commandCode = "commandCode"
}
Expand Down Expand Up @@ -710,21 +711,24 @@ struct CodingPlanLimits: Codable, Equatable {

typealias AgentPlanLimits = CodingPlanLimits

/// WorkBuddy subscription-plan credits. China and international share one row.
/// Only one account's plan package is published;
/// reward grants are not a window.
/// One WorkBuddy edition's credits (China and AI each publish their own row).
/// The plan window is the subscription's base credits with its cycle reset;
/// the secondary window pools every non-plan credit group (gift, top-up and
/// platform rewards) and carries no reset, mirroring the desktop app's split.
struct WorkbuddyLimits: Codable, Equatable {
let configured: Bool
let error: String?
let planLabel: String?
let primaryWindow: GenericLimitWindow?
let secondaryWindow: GenericLimitWindow?
let cachedAt: String?
let stale: Bool?

enum CodingKeys: String, CodingKey {
case configured, error, stale
case planLabel = "plan_label"
case primaryWindow = "primary_window"
case secondaryWindow = "secondary_window"
case cachedAt = "cached_at"
}
}
Expand Down Expand Up @@ -813,6 +817,7 @@ extension UsageLimitsResponse {
(agentPlan?.configured ?? false, agentPlan?.error),
(devin?.configured ?? false, devin?.error),
(workbuddy?.configured ?? false, workbuddy?.error),
(workbuddyAi?.configured ?? false, workbuddyAi?.error),
]
return providers.contains { $0.0 && $0.1 == nil }
}
Expand Down Expand Up @@ -852,7 +857,8 @@ extension UsageLimitsResponse {
codingPlan: codingPlan,
agentPlan: agentPlan,
devin: .unconfigured,
workbuddy: workbuddy
workbuddy: workbuddy,
workbuddyAi: workbuddyAi
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ enum LimitResetProviderIconCatalog {
case "agentPlan": return "volcano-ark.svg"
case "devin": return "devin.svg"
case "workbuddy": return "codebuddy.svg"
case "workbuddyAi": return "codebuddy.svg"
default: return nil
}
}
Expand Down Expand Up @@ -293,6 +294,13 @@ extension UsageLimitsResponse {
if let workbuddy {
addGeneric("workbuddy", workbuddy.configured, workbuddy.error, [
("primary", Strings.workbuddyPlanLabel, workbuddy.primaryWindow),
("secondary", Strings.workbuddyAddonLabel, workbuddy.secondaryWindow),
])
}
if let workbuddyAi {
addGeneric("workbuddyAi", workbuddyAi.configured, workbuddyAi.error, [
("primary", Strings.workbuddyPlanLabel, workbuddyAi.primaryWindow),
("secondary", Strings.workbuddyAddonLabel, workbuddyAi.secondaryWindow),
])
}

Expand Down
3 changes: 3 additions & 0 deletions TokenTrackerBar/TokenTrackerBar/Utilities/Strings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ enum Strings {
// render the same label.
static var qoderPlanLabel: String { t("Plan", "套餐", "套餐", "プラン", "플랜") }
static var workbuddyPlanLabel: String { t("Plan", "套餐", "套餐", "プラン", "플랜") }
// WorkBuddy resource packages: every non-plan credit group (gift, top-up
// and platform rewards) pooled into one window, as the desktop app shows.
static var workbuddyAddonLabel: String { t("Add-on", "资源包", "資源包", "リソースパック", "리소스 팩") }
static var qoderBonusLabel: String { t("Bonus", "奖励", "獎勵", "ボーナス", "보너스") }
static var grokMonthLabel: String { t("Month", "月度", "月度", "月間", "월간") }
static var grokWeekLabel: String { t("Weekly", "周", "週", "週間", "주간") }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ struct LimitsSettingsView: View {
@ViewBuilder
private func providerIcon(id: String) -> some View {
switch id {
case "cursor", "kimi", "kiro", "grok", "copilot", "zcode", "opencodeGo", "commandCode", "qoder", "qoderCn", "codingPlan", "agentPlan", "devin", "workbuddy":
case "cursor", "kimi", "kiro", "grok", "copilot", "zcode", "opencodeGo", "commandCode", "qoder", "qoderCn", "codingPlan", "agentPlan", "devin", "workbuddy", "workbuddyAi":
let filename: String = {
switch id {
case "cursor": return "cursor.svg"
Expand All @@ -163,6 +163,7 @@ struct LimitsSettingsView: View {
case "agentPlan": return "volcano-ark.svg"
case "devin": return "devin.svg"
case "workbuddy": return "codebuddy.svg"
case "workbuddyAi": return "codebuddy.svg"
default: return "copilot.svg"
}
}()
Expand Down
16 changes: 15 additions & 1 deletion TokenTrackerBar/TokenTrackerBar/Views/UsageLimitsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ struct UsageLimitsView: View {
"agentPlan": "VolcanoArkLogo",
"devin": "DevinLogo",
"workbuddy": "CodeBuddyLogo",
"workbuddyAi": "CodeBuddyLogo",
]

var body: some View {
Expand Down Expand Up @@ -231,6 +232,10 @@ struct UsageLimitsView: View {
if let workbuddy = limits.workbuddy, workbuddy.configured, workbuddy.error == nil {
return toolSection(id: id, title: planTitle("WorkBuddy", workbuddy.planLabel), assetName: "CodeBuddyLogo", toolName: "WorkBuddy", specs: workbuddySpecs(workbuddy), updatedAtISO: workbuddy.cachedAt, isStale: workbuddy.stale ?? false)
}
case "workbuddyAi":
if let workbuddyAi = limits.workbuddyAi, workbuddyAi.configured, workbuddyAi.error == nil {
return toolSection(id: id, title: planTitle("WorkBuddy AI", workbuddyAi.planLabel), assetName: "CodeBuddyLogo", toolName: "WorkBuddy AI", specs: workbuddySpecs(workbuddyAi), updatedAtISO: workbuddyAi.cachedAt, isStale: workbuddyAi.stale ?? false)
}
default:
break
}
Expand Down Expand Up @@ -597,11 +602,20 @@ struct UsageLimitsView: View {

private func workbuddySpecs(_ w: WorkbuddyLimits) -> [LimitWindowSpec] {
var specs: [LimitWindowSpec] = []
if let window = w.primaryWindow {
// Empty 0/0 windows (no active plan / no resource credit) have no
// quota to read and would render as a full bar in remaining mode —
// drop them like a missing window. An exhausted plan (total > 0,
// remain 0) stays visible at 100%.
if let window = w.primaryWindow, window.limitCredits != 0 {
var spec = makeSpec(Strings.workbuddyPlanLabel, window.usedPercent, iso: window.resetAt)
spec.creditsDetail = window.creditsDetail
specs.append(spec)
}
if let window = w.secondaryWindow, window.limitCredits != 0 {
var spec = makeSpec(Strings.workbuddyAddonLabel, window.usedPercent, iso: window.resetAt)
spec.creditsDetail = window.creditsDetail
specs.append(spec)
}
return specs
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,30 @@ final class LimitsSettingsStoreTests: XCTestCase {
}
private var cancellables: Set<AnyCancellable> = []

func testLegacyWorkbuddyEditionsMigrateIntoOneSwitch() {
func testWorkbuddyEditionsStaySplitWithExplicitSwitches() {
let (store, _) = makeStore()
XCTAssertTrue(store.applyBridgeSnapshot([
"providerOrder": ["workbuddyAi", "claude", "workbuddy"],
"providerVisibility": ["workbuddy": false, "workbuddyAi": true],
"updatedAt": 100,
]))
XCTAssertEqual(Array(store.providerOrder.prefix(2)), ["workbuddy", "claude"])
XCTAssertEqual(store.providerOrder.filter { $0.hasPrefix("workbuddy") }, ["workbuddy"])
XCTAssertTrue(store.isVisible("workbuddy"))
XCTAssertNil(store.providerVisibility["workbuddyAi"])
store.setProviderVisibilityFromMenu("workbuddy", isVisible: false)
XCTAssertEqual(Array(store.providerOrder.prefix(3)), ["workbuddyAi", "claude", "workbuddy"])
XCTAssertFalse(store.isVisible("workbuddy"))
XCTAssertTrue(store.isVisible("workbuddyAi"))
store.setProviderVisibilityFromMenu("workbuddyAi", isVisible: false)
XCTAssertFalse(store.isVisible("workbuddyAi"))
XCTAssertFalse(store.isVisible("workbuddy"))
}

func testUnifiedWorkbuddySwitchInheritsIntoAiRow() {
let (store, _) = makeStore()
XCTAssertTrue(store.applyBridgeSnapshot([
"providerOrder": ["workbuddy"],
"providerVisibility": ["workbuddy": false],
"updatedAt": 100,
]))
XCTAssertFalse(store.isVisible("workbuddy"))
XCTAssertFalse(store.isVisible("workbuddyAi"))
}

func testWorkbuddyCreditAmountsDecodeWithoutAffectingOlderWindows() throws {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ const PROVIDER_NAMES = {
grok: 'Grok', zcode: 'ZCode', opencodeGo: 'OpenCode Go',
commandCode: 'Command Code', devin: 'Devin', qoder: 'Qoder',
qoderCn: 'Qoder CN', codingPlan: 'Coding Plan', agentPlan: 'Agent Plan',
workbuddy: 'WorkBuddy', workbuddyAi: 'WorkBuddy AI',
};

const WINDOW_NAMES = {
Expand Down
1 change: 1 addition & 0 deletions dashboard/src/content/copy.csv
Original file line number Diff line number Diff line change
Expand Up @@ -1647,6 +1647,7 @@ limits.provider.devin,ui,LimitsPage,UsageLimitsPanel,provider_devin,"Devin",,act
limits.provider.workbuddy,ui,LimitsPage,UsageLimitsPanel,provider_workbuddy,"WorkBuddy",,active
limits.provider.workbuddy_ai,ui,LimitsPage,UsageLimitsPanel,provider_workbuddy_ai,"WorkBuddy AI",,active
limits.label.workbuddy_plan,ui,LimitsPage,UsageLimitsPanel,workbuddy_plan,"Plan",,active
limits.label.workbuddy_addon,ui,LimitsPage,UsageLimitsPanel,workbuddy_addon,Add-on Credits,,active
limits.label.devin_daily,ui,LimitsPage,UsageLimitsPanel,devin_daily,"Daily",,active
limits.label.devin_weekly,ui,LimitsPage,UsageLimitsPanel,devin_weekly,"Weekly",,active
limits.devin.setupHint.title,ui,LimitsPage,UsageLimitsPanel,devin_hint_title,"Connect Devin",,active
Expand Down
1 change: 1 addition & 0 deletions dashboard/src/content/i18n/zh-TW/core.json
Original file line number Diff line number Diff line change
Expand Up @@ -782,6 +782,7 @@
"limits.provider.workbuddy": "WorkBuddy",
"limits.provider.workbuddy_ai": "WorkBuddy AI",
"limits.label.workbuddy_plan": "套餐",
"limits.label.workbuddy_addon": "資源包",
"limits.label.devin_daily": "每日",
"limits.label.devin_weekly": "每週",
"limits.devin.setupHint.title": "連接 Devin",
Expand Down
1 change: 1 addition & 0 deletions dashboard/src/content/i18n/zh/core.json
Original file line number Diff line number Diff line change
Expand Up @@ -841,6 +841,7 @@
"limits.provider.workbuddy": "WorkBuddy",
"limits.provider.workbuddy_ai": "WorkBuddy AI",
"limits.label.workbuddy_plan": "套餐",
"limits.label.workbuddy_addon": "资源包",
"limits.label.devin_daily": "每日",
"limits.label.devin_weekly": "每周",
"limits.devin.setupHint.title": "连接 Devin",
Expand Down
10 changes: 6 additions & 4 deletions dashboard/src/hooks/use-limits-display-prefs.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,7 @@ function defaultVisibility() {
function normalizeOrder(value) {
const known = [];
if (Array.isArray(value)) {
for (const rawId of value) {
const id = rawId === "workbuddyAi" ? "workbuddy" : rawId;
for (const id of value) {
if (ALL_LIMIT_PROVIDERS.includes(id) && !known.includes(id)) {
known.push(id);
}
Expand All @@ -95,8 +94,11 @@ function normalizeVisibility(value) {
for (const id of ALL_LIMIT_PROVIDERS) {
if (typeof value[id] === "boolean") merged[id] = value[id];
}
if (typeof value.workbuddyAi === "boolean") {
merged.workbuddy = value.workbuddy === true || value.workbuddyAi;
// The unified-row era folded both editions into one `workbuddy` switch.
// Without an explicit stored AI value the AI row inherits that switch, so
// nobody's WorkBuddy rows silently vanish (or reappear) across the split.
if (typeof value.workbuddyAi !== "boolean" && typeof value.workbuddy === "boolean") {
merged.workbuddyAi = value.workbuddy;
}
return merged;
}
Expand Down
22 changes: 15 additions & 7 deletions dashboard/src/hooks/use-limits-display-prefs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,23 +114,30 @@ describe("useLimitsDisplayPrefs", () => {
expect(Object.values(LIMIT_DISPLAY_MODES)).toEqual(["used", "remaining"]);
});

it("merges old WorkBuddy edition switches and order into one preference", () => {
it("keeps an explicit WorkBuddy AI switch and order after the edition split", () => {
setStoredSnapshot({ providerOrder: ["workbuddyAi", "claude", "workbuddy"],
providerVisibility: { workbuddy: false, workbuddyAi: true } });
const { result } = renderHook(() => useLimitsDisplayPrefs());
expect(result.current.order.slice(0, 2)).toEqual(["workbuddy", "claude"]);
expect(result.current.order.filter((id) => id.startsWith("workbuddy"))).toEqual(["workbuddy"]);
expect(result.current.visibility.workbuddy).toBe(true);
expect(result.current.visibility).not.toHaveProperty("workbuddyAi");
act(() => result.current.toggle("workbuddy"));
expect(result.current.order.slice(0, 3)).toEqual(["workbuddyAi", "claude", "workbuddy"]);
expect(result.current.visibility.workbuddy).toBe(false);
expect(readStoredSnapshot().providerVisibility).not.toHaveProperty("workbuddyAi");
expect(result.current.visibility.workbuddyAi).toBe(true);
act(() => result.current.toggle("workbuddyAi"));
expect(result.current.visibility.workbuddyAi).toBe(false);
expect(readStoredSnapshot().providerVisibility.workbuddyAi).toBe(false);
});

it("inherits the unified WorkBuddy switch when no AI value is stored", () => {
setStoredSnapshot({ providerVisibility: { workbuddy: false } });
const { result } = renderHook(() => useLimitsDisplayPrefs());
expect(result.current.visibility.workbuddy).toBe(false);
expect(result.current.visibility.workbuddyAi).toBe(false);
});

it("keeps WorkBuddy hidden when both former edition switches were off", () => {
setStoredSnapshot({ providerVisibility: { workbuddy: false, workbuddyAi: false } });
const { result } = renderHook(() => useLimitsDisplayPrefs());
expect(result.current.visibility.workbuddy).toBe(false);
expect(result.current.visibility.workbuddyAi).toBe(false);
});

it("matches the provider list used for visibility/order keys", () => {
Expand All @@ -147,6 +154,7 @@ describe("useLimitsDisplayPrefs", () => {
"devin",
"gemini",
"workbuddy",
"workbuddyAi",
"grok",
"kimi",
"kiro",
Expand Down
7 changes: 6 additions & 1 deletion dashboard/src/lib/limits-providers.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const LIMIT_PROVIDER_IDS = [
"agentPlan",
"devin",
"workbuddy",
"workbuddyAi",
];

/** Keys for ProviderIcon — mono logos use inline SVG; colored logos use /brand-logos/. */
Expand Down Expand Up @@ -50,8 +51,10 @@ export const LIMIT_PROVIDER_ICON_KEYS = {
agentPlan: "VOLCANO-ARK",
// Devin (devin.ai) — the three-hexagon "nodes" mark under /brand-logos/.
devin: "DEVIN",
// WorkBuddy uses one product row for either regional edition.
// WorkBuddy China and WorkBuddy AI each publish their own signed-in row.
// Both share the CodeBuddy brand mark.
workbuddy: "WORKBUDDY",
workbuddyAi: "WORKBUDDY",
};

export function limitProviderIconKey(id) {
Expand Down Expand Up @@ -96,6 +99,8 @@ export function limitProviderName(id) {
return copy("limits.provider.devin");
case "workbuddy":
return copy("limits.provider.workbuddy");
case "workbuddyAi":
return copy("limits.provider.workbuddy_ai");
default:
return String(id || "");
}
Expand Down
6 changes: 6 additions & 0 deletions dashboard/src/lib/pet-quips.js
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ const PET_LIMIT_PROVIDER_COPY_NAME_KEYS = {
commandCode: "limits.provider.command_code",
devin: "limits.provider.devin",
workbuddy: "limits.provider.workbuddy",
workbuddyAi: "limits.provider.workbuddy_ai",
};

// Unix timestamps are normally seconds; values above this order of magnitude
Expand Down Expand Up @@ -466,6 +467,11 @@ function collectPetLimitRows(limits) {
]);
addGeneric("workbuddy", limits.workbuddy, [
[copy("limits.label.workbuddy_plan"), limits.workbuddy?.primary_window],
[copy("limits.label.workbuddy_addon"), limits.workbuddy?.secondary_window],
]);
addGeneric("workbuddyAi", limits.workbuddyAi, [
[copy("limits.label.workbuddy_plan"), limits.workbuddyAi?.primary_window],
[copy("limits.label.workbuddy_addon"), limits.workbuddyAi?.secondary_window],
]);

rows.sort((a, b) => {
Expand Down
1 change: 1 addition & 0 deletions dashboard/src/pages/LimitsPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ export function LimitsPage() {
agentPlan={usageLimits?.agentPlan}
devin={usageLimits?.devin}
workbuddy={usageLimits?.workbuddy}
workbuddyAi={usageLimits?.workbuddyAi}
order={prefs.order}
visibility={prefs.visibility}
displayMode={prefs.displayMode}
Expand Down
Loading
Loading