Skip to content
Closed
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
470 changes: 470 additions & 0 deletions electron/ipc/antigravity-usage.test.ts

Large diffs are not rendered by default.

500 changes: 500 additions & 0 deletions electron/ipc/antigravity-usage.ts

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions electron/ipc/channel-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
"MCP_PublishTourRequest": "mcp_publish_tour_request",
"Remote_GetNotesRequest": "remote_get_notes_request",
"Remote_SetNotesRequest": "remote_set_notes_request",
"Remote_GetUsageRequest": "remote_get_usage_request",
"Remote_UpdateTaskStatus": "remote_update_task_status",
"Remote_RendererReply": "remote_renderer_reply",
"PlanContent": "plan_content",
Expand Down Expand Up @@ -146,6 +147,7 @@
"GetMCPLogs": "get_mcp_logs",
"GetClaudeUsage": "get_claude_usage",
"GetCodexUsage": "get_codex_usage",
"GetAntigravityUsage": "get_antigravity_usage",
"MCP_TaskCreated": "mcp_task_created",
"MCP_TaskClosed": "mcp_task_closed",
"MCP_TaskStateSync": "mcp_task_state_sync",
Expand Down
1 change: 1 addition & 0 deletions electron/ipc/register-wiring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ const NOT_HANDLED_HERE: readonly string[] = [
IPC.Remote_CreateTaskRequest,
IPC.Remote_GetNotesRequest,
IPC.Remote_GetProjectsRequest,
IPC.Remote_GetUsageRequest,
IPC.Remote_SetNotesRequest,
IPC.StepsContent,
IPC.SuperProductivityOpenTaskRequested,
Expand Down
4 changes: 4 additions & 0 deletions electron/ipc/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import { readCoverageSummary } from './coverage.js';
import { loadEslintQualityFindings } from './eslint-quality-findings.js';
import { buildVerifyEnv, validateVerifyCommand, verificationRunner } from './verify.js';
import { startRemoteServer, getMCPLogs, type RemoteProject } from '../remote/server.js';
import type { UsageProvider, UsageState } from './shared-types.js';
import type { RemoteAttentionState, RemoteAgent } from '../remote/protocol.js';
import { atomicWriteFileSync } from '../mcp/atomic.js';
import { getUserDataDir } from '../user-data-dir.js';
Expand Down Expand Up @@ -142,6 +143,7 @@ import { listCodexModels } from './codex-models.js';
import { getSystemMonospaceFonts } from './system-fonts.js';
import { fetchClaudeUsage } from './claude-usage.js';
import { fetchCodexUsage } from './codex-usage.js';
import { fetchAntigravityUsage } from './antigravity-usage.js';
import path from 'path';
import {
assertString,
Expand Down Expand Up @@ -1666,6 +1668,7 @@ export function registerAllHandlers(win: BrowserWindow): void {
callRenderer<{ notes: string }>(IPC.Remote_GetNotesRequest, { taskId }).then((r) => r.notes),
setTaskNotes: (taskId: string, notes: string) =>
callRenderer<{ ok: boolean }>(IPC.Remote_SetNotesRequest, { taskId, notes }).then(() => {}),
getUsage: () => callRenderer<Record<UsageProvider, UsageState>>(IPC.Remote_GetUsageRequest, {}),
getTaskAttention: (taskId: string): RemoteAttentionState => taskAttention.get(taskId) ?? 'idle',
getTaskContext: (taskId: string) => taskContext.get(taskId),
};
Expand Down Expand Up @@ -2214,6 +2217,7 @@ export function registerAllHandlers(win: BrowserWindow): void {

ipcMain.handle(IPC.GetClaudeUsage, () => fetchClaudeUsage());
ipcMain.handle(IPC.GetCodexUsage, () => fetchCodexUsage());
ipcMain.handle(IPC.GetAntigravityUsage, () => fetchAntigravityUsage());

// --- Forward window events to renderer ---
win.on('focus', () => {
Expand Down
13 changes: 12 additions & 1 deletion electron/ipc/shared-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ export interface StepEntry {
}

/** Agents whose subscription rate limits the app can read. */
export type UsageProvider = 'claude' | 'codex';
export type UsageProvider = 'claude' | 'codex' | 'antigravity';

export interface UsageWindow {
/** Percent of the window consumed, 0–100. */
Expand All @@ -222,6 +222,17 @@ export type UsageResult =
/** Transient failure — the renderer keeps its last good snapshot. */
| { status: 'error'; message: string };

/** The renderer's view of one provider's usage, also served to the phone. */
export interface UsageState {
fiveHour: UsageWindow | null;
sevenDay: UsageWindow | null;
/** When the current windows were fetched; null until the first success. */
fetchedAt: number | null;
/** `unavailable` means no subscription login — the bar hides. `error` keeps the last snapshot. */
status: 'idle' | 'ok' | 'error' | 'unavailable';
error: string | null;
}

export type UpdatePhase =
| 'unsupported'
| 'idle'
Expand Down
2 changes: 2 additions & 0 deletions electron/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ const ALLOWED_CHANNELS = new Set([
'mcp_publish_tour_request',
'remote_get_notes_request',
'remote_set_notes_request',
'remote_get_usage_request',
'remote_update_task_status',
'remote_renderer_reply',
'plan_content',
Expand Down Expand Up @@ -153,6 +154,7 @@ const ALLOWED_CHANNELS = new Set([
'get_mcp_logs',
'get_claude_usage',
'get_codex_usage',
'get_antigravity_usage',
'mcp_task_created',
'mcp_task_closed',
'mcp_task_state_sync',
Expand Down
45 changes: 45 additions & 0 deletions electron/remote/notes-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,3 +245,48 @@ describe('notes route without a renderer bridge', () => {
expect(res.status).toBe(503);
});
});

describe('GET /api/mobile/usage', () => {
const usage = {
claude: {
fiveHour: { usedPercent: 40, resetsAt: null },
sevenDay: null,
fetchedAt: 1,
status: 'ok' as const,
error: null,
},
codex: {
fiveHour: null,
sevenDay: null,
fetchedAt: null,
status: 'idle' as const,
error: null,
},
antigravity: {
fiveHour: null,
sevenDay: null,
fetchedAt: null,
status: 'idle' as const,
error: null,
},
};
const getUsage = vi.fn(async () => usage);
beforeEach(() => start({ getUsage }));

it('returns the desktop snapshot for mobile and paired tokens', async () => {
for (const token of [mobileToken, await pair()]) {
const res = await request('GET', '/api/mobile/usage', { token });
expect(res.status).toBe(200);
expect(res.json).toEqual(usage);
}
});

it('rejects agent tokens and non-GET methods', async () => {
getUsage.mockClear();
for (const token of [coordinatorToken, subtaskToken]) {
expect((await request('GET', '/api/mobile/usage', { token })).status).toBe(403);
}
expect((await request('POST', '/api/mobile/usage', { token: mobileToken })).status).toBe(405);
expect(getUsage).not.toHaveBeenCalled();
});
});
18 changes: 18 additions & 0 deletions electron/remote/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { parseCanvasView, type CanvasView } from '../shared/canvas-view.js';
import { parseAgentTourPayload, type AgentTourPayload } from '../shared/agent-tour.js';
import type { SessionCaller, SessionCapabilities } from '../shared/delegation-types.js';
import type { ReasoningDocument } from '../shared/reasoning.js';
import type { UsageProvider, UsageState } from '../ipc/shared-types.js';
import type { ReasoningUpdate } from '../shared/reasoning-state.js';
import type { Coordinator } from '../mcp/coordinator.js';
import { validateBranchName } from '../mcp/validation.js';
Expand Down Expand Up @@ -934,6 +935,8 @@ export function startRemoteServer(opts: {
getTaskNotes?: (taskId: string) => Promise<string>;
/** Persist a task's notes (renderer-backed). */
setTaskNotes?: (taskId: string, notes: string) => Promise<void>;
/** The desktop's last agent-subscription usage snapshot (renderer-backed). */
getUsage?: () => Promise<Record<UsageProvider, UsageState>>;
/** Renderer-derived task attention state (needs input, working, ready, …). */
getTaskAttention?: (taskId: string) => RemoteAttentionState;
getTaskContext?: (
Expand Down Expand Up @@ -1353,6 +1356,21 @@ export function startRemoteServer(opts: {
return jsonEnd(405, { error: 'method not allowed' });
}

// --- Subscription usage (read-only: mobile + paired) ---
// Serves the snapshot the desktop status bar already polls, so phones
// never add requests to the rate-limited usage endpoints.
if (url.pathname === '/api/mobile/usage') {
if (tokenClass !== 'mobile' && tokenClass !== 'paired')
return jsonEnd(403, { error: 'forbidden' });
if (req.method !== 'GET') return jsonEnd(405, { error: 'method not allowed' });
if (!opts.getUsage) return jsonEnd(503, { error: 'usage unavailable' });
opts
.getUsage()
.then((usage) => jsonEnd(200, usage))
.catch((err) => jsonEnd(500, { error: String(err) }));
return;
}

// --- Task notes (read: mobile + paired; write: paired) ---
// The notes textarea shown on the desktop task panel. The QR-code mobile
// token may read notes; writing them (text that lands in the desktop UI
Expand Down
18 changes: 17 additions & 1 deletion src/components/UsageStatusBar.client.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,15 @@ const { mockRefreshUsage, usage } = vi.hoisted(() => {
error: null,
} satisfies UsageState,
codex: idle,
antigravity: idle,
},
};
});

vi.mock('../store/store', () => ({
store: { usage },
refreshUsage: mockRefreshUsage,
USAGE_PROVIDERS: ['claude', 'codex'],
USAGE_PROVIDERS: ['claude', 'codex', 'antigravity'],
}));

const disposers: Array<() => void> = [];
Expand Down Expand Up @@ -91,4 +92,19 @@ describe('UsageStatusBar', () => {
container.querySelector<HTMLElement>('[role="status"]')?.click();
expect(mockRefreshUsage).toHaveBeenCalledWith('claude', { force: true });
});

it('does not display reset due when a window has 100% left', () => {
usage.antigravity = {
fiveHour: { usedPercent: 0, resetsAt: Date.now() - 60_000 },
sevenDay: null,
fetchedAt: Date.now(),
status: 'ok',
error: null,
};
const container = mount();
const entries = container.querySelectorAll('[role="status"]');
const agyEntry = Array.from(entries).find((e) => e.textContent?.includes('Antigravity'));
expect(agyEntry?.textContent).toContain('100% left');
expect(agyEntry?.textContent).not.toContain('reset due');
});
});
10 changes: 7 additions & 3 deletions src/components/UsageStatusBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,19 @@ import {
usageVisible,
} from './usage-format';

const PROVIDER_LABELS: Record<UsageProvider, string> = { claude: 'Claude', codex: 'Codex' };
const PROVIDER_LABELS: Record<UsageProvider, string> = {
claude: 'Claude',
codex: 'Codex',
antigravity: 'Antigravity',
};
const POPOVER_WIDTH = 300;

function UsageMeter(props: { label: string; window: UsageWindow; width?: number }) {
const warn = () => props.window.usedPercent >= USAGE_WARN_PERCENT;
const color = () => (warn() ? theme.warning : theme.accent);
const reset = () => formatReset(props.window.resetsAt);
// The bar drains: filled means budget still available, matching the "% left" readout.
const left = () => remainingPercent(props.window);
const reset = () => (left() === 100 ? '' : formatReset(props.window.resetsAt));

return (
<span style={{ display: 'inline-flex', 'align-items': 'center', gap: '6px' }}>
Expand Down Expand Up @@ -188,7 +192,7 @@ function ProviderUsage(props: { provider: UsageProvider }) {

/**
* Bottom bar with the rate-limit windows of every agent subscription the app
* can read (Claude Code, Codex). Hidden until the first successful read, and
* can read (Claude Code, Codex, Antigravity). Hidden until the first successful read, and
* permanently when no agent has a subscription login (API-key users).
*/
export function UsageStatusBar() {
Expand Down
1 change: 1 addition & 0 deletions src/ipc/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type {
StepEntry,
UsageProvider,
UsageResult,
UsageState,
UsageWindow,
VerificationRun,
VerificationRunStatus,
Expand Down
3 changes: 3 additions & 0 deletions src/remote/AgentList.client.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { AgentList } from './AgentList';
import { connect, reconnect } from './ws';
import type { RemoteAgent } from '../../electron/remote/protocol';

// Usage meters poll over HTTP; they have their own tests.
vi.mock('./UsageStrip', () => ({ UsageStrip: () => null }));

class Socket {
static OPEN = 1;
static CONNECTING = 0;
Expand Down
2 changes: 2 additions & 0 deletions src/remote/AgentList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { For, Show, createEffect, createMemo, createSignal } from 'solid-js';
import { agents, status, canControl } from './ws';
import { agentStatusDisplay } from './attention';
import { ConnectionBanner } from './ConnectionBanner';
import { UsageStrip } from './UsageStrip';
import { readLocal, writeLocal } from './storage';
import { ProjectSwatch } from '../components/ProjectSwatch';
import type { RemoteAgent } from '../../electron/remote/protocol';
Expand Down Expand Up @@ -72,6 +73,7 @@ export function AgentList(props: AgentListProps) {
</div>
</Show>
<main class="mobile-scroll">
<UsageStrip />
<Show when={agents().length > 0}>
<div class="mobile-search-field">
<input
Expand Down
3 changes: 3 additions & 0 deletions src/remote/App.client.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { render } from 'solid-js/web';
import { App } from './App';
import { reconnect } from './ws';

// Usage meters poll over HTTP; they have their own tests.
vi.mock('./UsageStrip', () => ({ UsageStrip: () => null }));

class Socket {
static OPEN = 1;
static CONNECTING = 0;
Expand Down
67 changes: 67 additions & 0 deletions src/remote/UsageStrip.client.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { afterEach, expect, it, vi } from 'vitest';
import { render } from 'solid-js/web';
import { UsageStrip } from './UsageStrip';
import { fetchUsage } from './api';

vi.mock('./ws', () => ({ status: () => 'connected' }));
vi.mock('./api', () => ({ fetchUsage: vi.fn() }));

let dispose: (() => void) | undefined;
afterEach(() => {
dispose?.();
document.body.innerHTML = '';
});

async function mount(): Promise<HTMLElement> {
const host = document.createElement('div');
document.body.append(host);
dispose = render(() => <UsageStrip />, host);
await vi.waitFor(() => expect(fetchUsage).toHaveBeenCalled());
await Promise.resolve();
return host;
}

it('shows the providers the desktop has a snapshot for', async () => {
vi.mocked(fetchUsage).mockResolvedValue({
claude: {
fiveHour: { usedPercent: 85, resetsAt: null },
sevenDay: { usedPercent: 20, resetsAt: null },
fetchedAt: 1,
status: 'ok',
error: null,
},
codex: { fiveHour: null, sevenDay: null, fetchedAt: null, status: 'unavailable', error: 'x' },
antigravity: {
fiveHour: { usedPercent: 30, resetsAt: null },
sevenDay: null,
fetchedAt: 1,
status: 'ok',
error: null,
},
});
const host = await mount();
await vi.waitFor(() => expect(host.querySelector('.mobile-usage')).not.toBeNull());
expect(host.textContent).toContain('Claude');
expect(host.textContent).toContain('Antigravity');
expect(host.textContent).not.toContain('Codex');
expect(host.textContent).toContain('15% left');
expect(host.textContent).toContain('80% left');
expect(host.textContent).toContain('70% left');
expect(host.querySelector('.usage-meter.warn')).not.toBeNull();
});

it('stays hidden when no subscription usage is readable', async () => {
vi.mocked(fetchUsage).mockResolvedValue({
claude: { fiveHour: null, sevenDay: null, fetchedAt: null, status: 'unavailable', error: 'x' },
codex: { fiveHour: null, sevenDay: null, fetchedAt: null, status: 'idle', error: null },
antigravity: {
fiveHour: null,
sevenDay: null,
fetchedAt: null,
status: 'unavailable',
error: null,
},
});
const host = await mount();
expect(host.querySelector('.mobile-usage')).toBeNull();
});
Loading