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
7 changes: 7 additions & 0 deletions changelog/unreleased/devin-quota-window-rows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### English

- Stack Devin daily, weekly, and monthly quota meters as separate full-width rows on the account card, instead of sitting side by side.

### 中文

- Devin 日额度、周额度、月额度在账号卡片上各自单独占一行铺满,不再并排挤在一起。
7 changes: 7 additions & 0 deletions changelog/unreleased/workbuddy-quota-expiry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### English

- Expose WorkBuddy credit-pack expiry in `GET /api/accounts` quota: a new `packages` array carries each pack's remain/used/size plus its `CycleEndTime` (as `end_time` and a Unix `ends_at`), and the top-level `expires_at` / `expiring_remain` report the soonest expiry and how much remaining credit lapses then. The console quota tooltip now shows "N credits expire on D". All fields are `omitempty`; providers that do not report expiry (Trae, Qoder) simply omit them.

### 中文

- 在 `GET /api/accounts` 的 quota 中透出 WorkBuddy 积分包到期时间:新增 `packages` 数组按包返回 remain/used/size 以及 `CycleEndTime`(`end_time` 原始串与 Unix 秒 `ends_at`),顶层 `expires_at` / `expiring_remain` 给出最近一次到期时间及该时点将过期的剩余量;控制台配额 tooltip 现在会显示「N 积分将于某日到期」。所有字段均为 `omitempty`,不上报到期信息的 provider(Trae、Qoder)保持缺省。
12 changes: 12 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ export type AccountQuotaWindow = {
exceeded?: boolean
}

export type AccountQuotaPackage = {
remain?: number
used?: number
size?: number
unit?: string
ends_at?: number
end_time?: string
}

export type AccountQuota = {
used?: number
total?: number
Expand All @@ -18,6 +27,9 @@ export type AccountQuota = {
unit?: string
exceeded?: boolean
windows?: AccountQuotaWindow[]
expires_at?: number
expiring_remain?: number
packages?: AccountQuotaPackage[]
has_add_on?: boolean
add_on_used?: number
add_on_total?: number
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/account/AccountCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ export function AccountCard({
addOnLabel={t('quotaAddOn')}
resourcePackageLabel={t('quotaResourcePackage')}
exceededLabel={t('quotaExceeded')}
provider={account.provider}
/>
) : <span className="text-[11px] text-foreground/65">{state === 'loading' ? t('quotaLoading') : t('quotaUnavailable')}</span>}

Expand Down
36 changes: 21 additions & 15 deletions frontend/src/components/account/QuotaMeter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Meter, Tooltip } from '@heroui/react'
import type { AccountQuota, AccountQuotaWindow } from '@/api/types'
import {
formatQuotaAmount,
quotaExpiryLabel,
quotaResetLabel,
quotaTone,
quotaUsedRatio,
Expand All @@ -20,16 +21,20 @@ type Props = {
addOnLabel: string
resourcePackageLabel: string
exceededLabel: string
provider?: string
}

function extraQuotaLines(quota: AccountQuota, remainingLabel: string, addOnLabel: string, resourcePackageLabel: string) {
function extraQuotaLines(quota: AccountQuota, remainingLabel: string, addOnLabel: string, resourcePackageLabel: string, t: Translate, provider?: string) {
const addOn = quota.has_add_on && quota.add_on_available !== false
? `${addOnLabel} ${formatQuotaAmount(quota.add_on_used)} / ${formatQuotaAmount(quota.add_on_total)} ${quota.add_on_unit || 'credits'}`
: ''
const resourcePackage = quota.has_resource_package && quota.resource_package_available !== false
? `${resourcePackageLabel} ${remainingLabel} ${formatQuotaAmount(quota.resource_package_remaining)} ${quota.resource_package_unit || 'credits'}`
: ''
return [addOn, resourcePackage].filter(Boolean)
// Package-expiry is a WorkBuddy-only surface; never render it for other
// providers even if a stale snapshot happens to carry the fields.
const expiry = provider === 'workbuddy' ? quotaExpiryLabel(quota, t) : ''
return [addOn, resourcePackage, expiry].filter(Boolean)
}

function WindowMeter({
Expand All @@ -52,8 +57,8 @@ function WindowMeter({

return (
<Tooltip>
<Tooltip.Trigger>
<div className="w-full cursor-help">
<Tooltip.Trigger className="block w-full min-w-0">
<div className="w-full min-w-0 cursor-help">
<Meter
className="account-meter account-meter--window w-full"
color={color}
Expand Down Expand Up @@ -87,13 +92,13 @@ function WindowMeter({

// Trae-style quota block: the remaining count is the headline number; the
// used / total pair plus unit sits underneath as a secondary mono line.
// Devin daily/weekly windows keep a titled bar with used percent and reset
// copy in one compact stack.
export function QuotaMeter({ quota, t, label, usedLabel, remainingLabel, addOnLabel, resourcePackageLabel, exceededLabel }: Props) {
// Devin daily / weekly / monthly windows each occupy a full-width row with
// a titled bar, used percent, and reset copy.
export function QuotaMeter({ quota, t, label, usedLabel, remainingLabel, addOnLabel, resourcePackageLabel, exceededLabel, provider }: Props) {
const unit = quota.unit || 'credits'
const used = `${formatQuotaAmount(quota.used)} / ${formatQuotaAmount(quota.total)}`
const remaining = `${formatQuotaAmount(quota.remaining)}`
const extra = extraQuotaLines(quota, remainingLabel, addOnLabel, resourcePackageLabel)
const extra = extraQuotaLines(quota, remainingLabel, addOnLabel, resourcePackageLabel, t, provider)
const windows = quotaWindows(quota)
const ratio = quotaUsedRatio(quota)
const tone = quotaTone(quota)
Expand All @@ -102,14 +107,15 @@ export function QuotaMeter({ quota, t, label, usedLabel, remainingLabel, addOnLa

if (windows.length > 0) {
return (
<div className="w-full space-y-3">
<div className="account-quota-windows flex w-full min-w-0 flex-col gap-3">
{windows.map((window, index) => (
<WindowMeter
key={window.id || String(index)}
window={window}
t={t}
extra={index === 0 ? extra : undefined}
/>
<div key={window.id || String(index)} className="w-full min-w-0">
<WindowMeter
window={window}
t={t}
extra={index === 0 ? extra : undefined}
/>
</div>
))}
</div>
)
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,10 +520,12 @@ export const messages: Record<Lang, Dict> = {
quotaRemaining: 'Remaining',
quotaDaily: 'Daily quota',
quotaWeekly: 'Weekly quota',
quotaMonthly: 'Monthly quota',
quotaUsedPercent: '{n}% used',
quotaResetsSoon: 'Resets soon',
quotaResetsInMinutes: 'Resets in {n} minutes',
quotaResetsInHours: 'Resets in {n} hours',
quotaExpiresOn: '{amount}{unit} expire on {date}',
quotaAddOn: 'Add-on',
quotaResourcePackage: 'Resource package',
quotaExceeded: 'Quota exceeded',
Expand Down Expand Up @@ -1162,10 +1164,12 @@ export const messages: Record<Lang, Dict> = {
quotaRemaining: '剩余',
quotaDaily: '日额度',
quotaWeekly: '周额度',
quotaMonthly: '月额度',
quotaUsedPercent: '{n}%',
quotaResetsSoon: '即将重置',
quotaResetsInMinutes: '{n} 分钟后重置',
quotaResetsInHours: '{n} 小时后重置',
quotaExpiresOn: '{amount}{unit} 将于 {date} 到期',
quotaAddOn: '附加包',
quotaResourcePackage: '资源包',
quotaExceeded: '额度已用尽',
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,14 @@ body {
height: 0.5rem;
}

/* HeroUI Tooltip.Trigger is inline-block; quota windows must each occupy a
full row on the account card instead of sitting side by side. */
.account-quota-windows .tooltip__trigger {
display: block;
width: 100%;
min-width: 0;
}

.runtime-meter {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
Expand Down
18 changes: 18 additions & 0 deletions frontend/src/lib/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export function quotaWindows(quota: AccountQuota) {
export function quotaWindowLabel(window: AccountQuotaWindow, t: (key: string) => string) {
if (window.id === 'daily') return t('quotaDaily')
if (window.id === 'weekly') return t('quotaWeekly')
if (window.id === 'monthly') return t('quotaMonthly')
return window.label || t('quota')
}

Expand All @@ -99,3 +100,20 @@ export function quotaResetLabel(resetAt: string | undefined, t: (key: string, va
if (minutes < 60) return t('quotaResetsInMinutes', { n: minutes })
return t('quotaResetsInHours', { n: Math.round(minutes / 60) })
}

// quotaExpiryLabel renders the soonest package expiry, e.g.
// "1,500 credits expire on 10/1". returns '' when the provider did not report
// an expiry.
export function quotaExpiryLabel(
quota: { expires_at?: number; expiring_remain?: number; unit?: string },
t: (key: string, vars?: Record<string, string | number>) => string,
) {
if (!quota.expires_at || quota.expires_at <= 0 || !Number.isFinite(quota.expires_at)) return ''
const date = new Date(quota.expires_at * 1000)
if (Number.isNaN(date.getTime())) return ''
const day = `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`
const amount = quota.expiring_remain && quota.expiring_remain > 0
? `${formatQuotaAmount(quota.expiring_remain)} `
: ''
return t('quotaExpiresOn', { amount, unit: quota.unit || 'credits', date: day })
}
58 changes: 38 additions & 20 deletions internal/accounts/quota.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,27 +15,45 @@ type QuotaWindow struct {
Exceeded bool `json:"exceeded,omitempty"`
}

// QuotaPackage is one upstream credit/resource pack that carries its own
// expiry. EndsAt is a Unix second; EndTime is the provider-native wall-clock
// string for display.
type QuotaPackage struct {
Remain float64 `json:"remain"`
Used float64 `json:"used"`
Size float64 `json:"size"`
Unit string `json:"unit,omitempty"`
EndsAt int64 `json:"ends_at,omitempty"`
EndTime string `json:"end_time,omitempty"`
}

// QuotaSnapshot is the quota state for one account.
// A zero value means "unknown"; only Exceeded influences routing.
type QuotaSnapshot struct {
Used float64 `json:"used"`
Total float64 `json:"total"`
Remaining float64 `json:"remaining"`
Percentage float64 `json:"percentage"`
Unit string `json:"unit"`
Exceeded bool `json:"exceeded"`
Windows []QuotaWindow `json:"windows,omitempty"`
HasAddOn bool `json:"has_add_on"`
AddOnUsed float64 `json:"add_on_used"`
AddOnTotal float64 `json:"add_on_total"`
AddOnRemaining float64 `json:"add_on_remaining"`
AddOnUnit string `json:"add_on_unit"`
AddOnAvailable *bool `json:"add_on_available,omitempty"`
HasResourcePackage bool `json:"has_resource_package"`
ResourcePackageUsed float64 `json:"resource_package_used"`
ResourcePackageTotal float64 `json:"resource_package_total"`
ResourcePackageRemaining float64 `json:"resource_package_remaining"`
ResourcePackageUnit string `json:"resource_package_unit"`
ResourcePackageAvailable *bool `json:"resource_package_available,omitempty"`
FetchedAt string `json:"fetched_at"`
Used float64 `json:"used"`
Total float64 `json:"total"`
Remaining float64 `json:"remaining"`
Percentage float64 `json:"percentage"`
Unit string `json:"unit"`
Exceeded bool `json:"exceeded"`
Windows []QuotaWindow `json:"windows,omitempty"`
// ExpiresAt is the soonest package expiry (Unix seconds); 0 means the
// provider did not report one. ExpiringRemain is the remaining amount that
// expires at that time.
ExpiresAt int64 `json:"expires_at,omitempty"`
ExpiringRemain float64 `json:"expiring_remain,omitempty"`
Packages []QuotaPackage `json:"packages,omitempty"`
HasAddOn bool `json:"has_add_on"`
AddOnUsed float64 `json:"add_on_used"`
AddOnTotal float64 `json:"add_on_total"`
AddOnRemaining float64 `json:"add_on_remaining"`
AddOnUnit string `json:"add_on_unit"`
AddOnAvailable *bool `json:"add_on_available,omitempty"`
HasResourcePackage bool `json:"has_resource_package"`
ResourcePackageUsed float64 `json:"resource_package_used"`
ResourcePackageTotal float64 `json:"resource_package_total"`
ResourcePackageRemaining float64 `json:"resource_package_remaining"`
ResourcePackageUnit string `json:"resource_package_unit"`
ResourcePackageAvailable *bool `json:"resource_package_available,omitempty"`
FetchedAt string `json:"fetched_at"`
}
21 changes: 21 additions & 0 deletions internal/providers/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,17 @@ type QuotaWindow struct {
Exceeded bool
}

// QuotaPackage is one upstream credit/resource pack with its own expiry.
// EndsAt is a Unix second; EndTime is the provider-native wall-clock string.
type QuotaPackage struct {
Remain float64
Used float64
Size float64
Unit string
EndsAt int64
EndTime string
}

// QuotaInfo is account usage for the console and exhausted-account routing.
// Callers must treat probe readiness and quota independently; quota errors
// never flip Ready.
Expand All @@ -163,6 +174,16 @@ type QuotaInfo struct {
Exceeded bool
FetchedAt string
Windows []QuotaWindow
// ProviderID identifies which adapter produced this info. Callers use it
// to gate provider-specific fields (e.g. package expiry) instead of
// trusting that every adapter populates them.
ProviderID string
// ExpiresAt is the soonest package expiry (Unix seconds); 0 means the
// provider did not report one. ExpiringRemain is the remaining amount that
// expires at that time. Packages carries the per-pack expiry detail.
ExpiresAt int64
ExpiringRemain float64
Packages []QuotaPackage
}

// AccountProber refreshes provider-native readiness and optional display quota.
Expand Down
Loading
Loading