Skip to content

Commit 2047eb7

Browse files
feat(desktop): redesign UI around install and usage jobs
Align the Electron app with the product/design specs: marketplace-style agents, denser activity, home usage totals, and shared theme tokens so the local sub-harness is clearer at a glance.
1 parent 054a7d1 commit 2047eb7

31 files changed

Lines changed: 4517 additions & 690 deletions

AGENTS.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,13 @@
55
- Developer-facing observability belongs in OSS; deeper reliability and governance programs may span OSS and Enterprise.
66
- Avoid empty or placeholder PRs when stacking branches; prefer draft PRs with real implementation, then thorough review before marking ready.
77
- When designing or documenting control plane behavior, treat YAML configuration (`config/agentfield.yaml` and `AGENTFIELD_CONFIG_FILE`) as a first-class surface alongside environment variables.
8+
- AgentField Desktop targets GitHub-comfortable developers (not infra experts); primary jobs are installing agent nodes from GitHub and seeing runs/cost as a local sub-harness for coding agents.
9+
- Desktop UI should use shared theme tokens rather than hardcoded page styles; treat Agents as a marketplace-style library (installed agents + add), and design Activity for high-volume dense/filterable runs rather than large cards.
10+
- Locked desktop decisions: gold/amber accent; cold-launch to Home when agents exist (add/empty flow when none); usage totals on Home plus Activity per-row when the API allows; keep the update banner across views.
811

912
## Learned Workspace Facts
1013

11-
- Monorepo: Go control plane in `control-plane/`, SDKs in `sdk/`, embedded admin UI in `control-plane/web/client/`.
14+
- Monorepo: Go control plane in `control-plane/`, SDKs in `sdk/`, embedded admin UI in `control-plane/web/client/`, Electron desktop app in `desktop/`.
1215
- Agent-node manifests (`agentfield-package.yaml`) carry a `config_version` (schema version, e.g. `v1`; absent = `v0`) that is separate from the node's own `version:`. Bump `config_version` only for breaking format changes, never for additive fields. The single reader is `packages.ParsePackageMetadata` (`control-plane/internal/packages/installer.go`); the authoring contract lives in `docs/installing-agent-nodes.md`.
16+
- Desktop design/product specs live in `DESIGN.md` and `PRODUCT.md`.
17+
- Desktop featured-catalog copy is maintained in `desktop/src/shared/catalog.ts`; post-install descriptions come from each agent's `agentfield-package.yaml` (marketplace cards do not fetch YAML live).

DESIGN.md

Lines changed: 798 additions & 0 deletions
Large diffs are not rendered by default.

desktop/package-lock.json

Lines changed: 75 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

desktop/package.json

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

desktop/src/main/agentfield.test.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
deriveAgentBadge,
88
fetchControlPlaneNodes,
99
fetchExecutions,
10+
fetchUsageStats,
1011
getAgentFieldHome,
1112
getSnapshot,
1213
readInstalledAgents,
@@ -380,6 +381,98 @@ describe('fetchExecutions', () => {
380381
})
381382
})
382383

384+
describe('fetchUsageStats', () => {
385+
// Contract: happy path maps totals + by_harness/by_agent, cost_usd → costUsd.
386+
it('parses cost + by_harness (and by_agent) from a 200 payload', async () => {
387+
const fetchImpl: FetchLike = async () =>
388+
jsonResponse({
389+
window: '24h',
390+
totals: {
391+
cost_usd: 1.23,
392+
input_tokens: 1000,
393+
output_tokens: 2000,
394+
total_tokens: 3000,
395+
executions_with_usage: 42
396+
},
397+
by_agent: [{ key: 'pr-af', cost_usd: 0.8, total_tokens: 2000, entries: 10 }],
398+
by_harness: [{ key: 'claude-code', cost_usd: 1.0, total_tokens: 1500, entries: 8 }]
399+
})
400+
await expect(fetchUsageStats('http://localhost:8080', fetchImpl)).resolves.toEqual({
401+
window: '24h',
402+
costUsd: 1.23,
403+
totalTokens: 3000,
404+
byAgent: [{ key: 'pr-af', costUsd: 0.8, totalTokens: 2000 }],
405+
byHarness: [{ key: 'claude-code', costUsd: 1.0, totalTokens: 1500 }]
406+
})
407+
})
408+
409+
// Contract: null cost_usd stays null; tokens still present (tokens-only UI).
410+
it('maps null cost_usd to costUsd null while keeping tokens', async () => {
411+
const fetchImpl: FetchLike = async () =>
412+
jsonResponse({
413+
window: '24h',
414+
totals: { cost_usd: null, total_tokens: 4500, executions_with_usage: 3 },
415+
by_agent: [],
416+
by_harness: [{ key: 'codex', cost_usd: null, total_tokens: 4500 }]
417+
})
418+
const result = await fetchUsageStats('http://localhost:8080', fetchImpl)
419+
expect(result).toEqual({
420+
window: '24h',
421+
costUsd: null,
422+
totalTokens: 4500,
423+
byAgent: [],
424+
byHarness: [{ key: 'codex', costUsd: null, totalTokens: 4500 }]
425+
})
426+
})
427+
428+
// Contract: 404 (older CP without the endpoint) → null so UI hides usage.
429+
it('returns null on 404', async () => {
430+
const fetchImpl: FetchLike = async () => jsonResponse({ error: 'not found' }, 404)
431+
expect(await fetchUsageStats('http://localhost:8080', fetchImpl)).toBeNull()
432+
})
433+
434+
// Contract: bad JSON / unexpected body → null, never throws across IPC.
435+
it('returns null on bad JSON', async () => {
436+
const fetchImpl: FetchLike = async () =>
437+
new Response('not json', { status: 200, headers: { 'content-type': 'application/json' } })
438+
expect(await fetchUsageStats('http://localhost:8080', fetchImpl)).toBeNull()
439+
})
440+
441+
it.each([401, 403, 500] as const)('returns null on %s', async (status) => {
442+
const fetchImpl: FetchLike = async () => jsonResponse({ error: 'nope' }, status)
443+
expect(await fetchUsageStats('http://localhost:8080', fetchImpl)).toBeNull()
444+
})
445+
446+
it('returns null when fetch rejects', async () => {
447+
const fetchImpl: FetchLike = async () => {
448+
throw new TypeError('fetch failed')
449+
}
450+
expect(await fetchUsageStats('http://localhost:8080', fetchImpl)).toBeNull()
451+
})
452+
453+
it('requests usage/stats?window=24h', async () => {
454+
let requested = ''
455+
const fetchImpl: FetchLike = async (input) => {
456+
requested = String(input)
457+
return jsonResponse({ window: '24h', totals: { total_tokens: 0 } })
458+
}
459+
await fetchUsageStats('http://example.test:1234', fetchImpl)
460+
expect(requested).toBe('http://example.test:1234/api/ui/v1/usage/stats?window=24h')
461+
})
462+
463+
it('tolerates missing by_agent / by_harness arrays as empty', async () => {
464+
const fetchImpl: FetchLike = async () =>
465+
jsonResponse({ window: '24h', totals: { cost_usd: 0.5, total_tokens: 100 } })
466+
await expect(fetchUsageStats('http://localhost:8080', fetchImpl)).resolves.toEqual({
467+
window: '24h',
468+
costUsd: 0.5,
469+
totalTokens: 100,
470+
byAgent: [],
471+
byHarness: []
472+
})
473+
})
474+
})
475+
383476
describe('install catalog', () => {
384477
it('every entry has a name, description, and an https or af:// source', () => {
385478
expect(CATALOG.length).toBeGreaterThan(0)
@@ -469,6 +562,13 @@ describe('getSnapshot', () => {
469562
executions: { today: 4, yesterday: 2 },
470563
success_rate: 100,
471564
packages: { available: 1, installed: 0 }
565+
}),
566+
'usage/stats?window=24h': () =>
567+
jsonResponse({
568+
window: '24h',
569+
totals: { cost_usd: 1.23, total_tokens: 3000 },
570+
by_agent: [],
571+
by_harness: [{ key: 'claude-code', cost_usd: 1.23, total_tokens: 3000 }]
472572
})
473573
})
474574

@@ -486,6 +586,13 @@ describe('getSnapshot', () => {
486586
executionsYesterday: 2,
487587
successRate: 100
488588
})
589+
expect(snapshot.usage).toEqual({
590+
window: '24h',
591+
costUsd: 1.23,
592+
totalTokens: 3000,
593+
byAgent: [],
594+
byHarness: [{ key: 'claude-code', costUsd: 1.23, totalTokens: 3000 }]
595+
})
489596
expect(Date.parse(snapshot.fetchedAt)).not.toBeNaN()
490597

491598
const badges = Object.fromEntries(
@@ -534,6 +641,9 @@ describe('getSnapshot', () => {
534641
// Nor may its workflow runs show up as activity.
535642
expect(snapshot.executions).toBeNull()
536643
expect(requested.some((url) => url.includes('/workflow-runs'))).toBe(false)
644+
// Usage is only fetched against a recognized control plane.
645+
expect(snapshot.usage).toBeNull()
646+
expect(requested.some((url) => url.includes('/usage/stats'))).toBe(false)
537647
})
538648

539649
it('reports an unreachable control plane and an absent registry gracefully', async () => {
@@ -546,5 +656,6 @@ describe('getSnapshot', () => {
546656
const snapshot = await getSnapshot({ homeDir: missing, fetchImpl })
547657
expect(snapshot.controlPlane.reachable).toBe(false)
548658
expect(snapshot.registry).toEqual({ exists: false, agents: [], error: undefined })
659+
expect(snapshot.usage).toBeNull()
549660
})
550661
})

0 commit comments

Comments
 (0)