feat: surface Copilot usage and quota data across the UI

Add three layers of usage visibility:

- ChatPane footer: premium request count, AIU consumed, and quota
  remaining below the existing context-window bar
- Settings / CopilotStatusCard: on-demand account quota section with
  progress bars, overage indicators, and reset dates fetched via
  the new get-quota sidecar command
- Activity Panel: per-agent token/cost/duration totals on each agent
  row and a Session Usage summary section between agents and timeline

Backend (sidecar):
- New get-quota command using SDK account.getQuota RPC
- New assistant-usage turn-scoped event from SDK assistant.usage
- QuotaSnapshotMapper for both typed and untyped SDK quota payloads
- DTOs: GetQuotaCommandDto, QuotaSnapshotDto, AccountQuotaResultEventDto,
  AssistantUsageEventDto

Frontend:
- Shared types: AssistantUsageEvent, QuotaSnapshot, GetQuotaCommand
- IPC bridge: getQuota channel, assistant-usage event dispatch
- State: SessionRequestUsageMap accumulator with per-agent breakdown
- 8 new tests for accumulator logic and formatting helpers

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-29 00:46:09 +01:00
co-authored by Copilot
parent 48efbf36f9
commit 92832c6116
26 changed files with 978 additions and 18 deletions
+22
View File
@@ -1297,6 +1297,10 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return queryWorkspaceSessions(workspace, input);
}
async getQuota(): Promise<Record<string, import('@shared/contracts/sidecar').QuotaSnapshot>> {
return this.sidecar.getQuota();
}
async refreshProjectGitContext(projectId?: string): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const projects = projectId
@@ -1893,6 +1897,24 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
agentName: event.agentName,
});
return;
case 'assistant-usage':
this.emitSessionEvent({
sessionId,
kind: 'assistant-usage',
occurredAt,
agentId: event.agentId,
agentName: event.agentName,
usageModel: event.model,
usageInputTokens: event.inputTokens,
usageOutputTokens: event.outputTokens,
usageCacheReadTokens: event.cacheReadTokens,
usageCacheWriteTokens: event.cacheWriteTokens,
usageCost: event.cost,
usageDuration: event.duration,
usageTotalNanoAiu: event.totalNanoAiu,
usageQuotaSnapshots: event.quotaSnapshots,
});
return;
}
}
+1
View File
@@ -173,6 +173,7 @@ export function registerIpcHandlers(window: BrowserWindow, service: AryxAppServi
ipcMain.handle(ipcChannels.selectSession, (_event, sessionId?: string) => service.selectSession(sessionId));
ipcMain.handle(ipcChannels.openAppDataFolder, () => service.openAppDataFolder());
ipcMain.handle(ipcChannels.resetLocalWorkspace, () => service.resetLocalWorkspace());
ipcMain.handle(ipcChannels.getQuota, () => service.getQuota());
service.on('workspace-updated', (workspace) => {
window.webContents.send(ipcChannels.workspaceUpdated, workspace);
+3 -1
View File
@@ -11,6 +11,7 @@ import type {
SessionUsageEvent,
SessionCompactionEvent,
PendingMessagesModifiedEvent,
AssistantUsageEvent,
} from '@shared/contracts/sidecar';
import type { ChatMessageRecord } from '@shared/domain/session';
@@ -20,7 +21,8 @@ export type TurnScopedEvent =
| HookLifecycleEvent
| SessionUsageEvent
| SessionCompactionEvent
| PendingMessagesModifiedEvent;
| PendingMessagesModifiedEvent
| AssistantUsageEvent;
export interface RunTurnPendingCommand {
kind: 'run-turn';
+28
View File
@@ -16,6 +16,7 @@ import type {
RunTurnCommand,
CopilotSessionListFilter,
CopilotSessionInfo,
QuotaSnapshot,
} from '@shared/contracts/sidecar';
import type { ApprovalDecision } from '@shared/domain/approval';
import type { ChatMessageRecord } from '@shared/domain/session';
@@ -80,6 +81,12 @@ type PendingCommand =
resolve: () => void;
reject: (error: Error) => void;
})
| ({
processId: number;
kind: 'get-quota';
resolve: (snapshots: Record<string, QuotaSnapshot>) => void;
reject: (error: Error) => void;
})
| ({
processId: number;
} & RunTurnPendingCommand);
@@ -185,6 +192,13 @@ export class SidecarClient {
});
}
async getQuota(): Promise<Record<string, QuotaSnapshot>> {
return this.dispatch<Record<string, QuotaSnapshot>>({
type: 'get-quota',
requestId: `get-quota-${Date.now()}`,
});
}
async dispose(): Promise<void> {
const state = this.processState;
if (!state) {
@@ -341,6 +355,13 @@ export class SidecarClient {
resolve: resolve as () => void,
reject,
});
} else if (command.type === 'get-quota') {
this.pending.set(command.requestId, {
processId: state.id,
kind: 'get-quota',
resolve: resolve as (snapshots: Record<string, QuotaSnapshot>) => void,
reject,
});
} else {
this.pending.set(command.requestId, {
processId: state.id,
@@ -424,10 +445,17 @@ export class SidecarClient {
case 'session-usage':
case 'session-compaction':
case 'pending-messages-modified':
case 'assistant-usage':
if (pending.kind === 'run-turn' && shouldHandleRunTurnEvent(pending)) {
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onTurnScopedEvent(event));
}
return;
case 'quota-result':
if (pending.kind === 'get-quota') {
pending.resolve(event.quotaSnapshots);
this.pending.delete(event.requestId);
}
return;
case 'sessions-listed':
if (pending.kind === 'list-sessions') {
pending.resolve(event.sessions);