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
99 changes: 71 additions & 28 deletions desktop/backend/services.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ type AntigravityService struct {
}

type ClaudeCodeService struct {
autoSync *usageAutoSyncRuntime
settingsMu sync.Mutex
application *app.Application
changes *ChangeNotifier
}
Expand Down Expand Up @@ -106,24 +108,25 @@ type AgentService struct {
}

type Services struct {
App *AppService
Agent *AgentService
Antigravity *AntigravityService
ClaudeCode *ClaudeCodeService
Codex *CodexService
GrokBuild *GrokBuildService
Profile *ProfileService
Switch *SwitchService
Doctor *DoctorService
Backup *BackupService
Usage *UsageService
Settings *SettingsService
changes *ChangeNotifier
codexUsageSync *usageAutoSyncRuntime
grokBuildUsageSync *usageAutoSyncRuntime
quota *codexQuotaRuntime
runtimes *agentRuntimeManager
backups *applicationBackupRuntime
App *AppService
Agent *AgentService
Antigravity *AntigravityService
ClaudeCode *ClaudeCodeService
Codex *CodexService
GrokBuild *GrokBuildService
Profile *ProfileService
Switch *SwitchService
Doctor *DoctorService
Backup *BackupService
Usage *UsageService
Settings *SettingsService
changes *ChangeNotifier
codexUsageSync *usageAutoSyncRuntime
grokBuildUsageSync *usageAutoSyncRuntime
claudeCodeUsageSync *usageAutoSyncRuntime
quota *codexQuotaRuntime
runtimes *agentRuntimeManager
backups *applicationBackupRuntime
}

type DashboardResult struct {
Expand Down Expand Up @@ -310,6 +313,18 @@ func NewServices(application *app.Application, info app.Info, env Environment, s
return application.Usage().SyncProviderBackground(ctx, grokconfig.ProviderID, onWorkDetected)
},
)
claudeCodeUsageSync := newUsageAutoSyncRuntime(
claudecodeconfig.ProviderID,
func(ctx context.Context) (usage.ProviderSyncSettings, error) {
value, err := application.ClaudeCode().GetSettings(ctx)
return usage.ProviderSyncSettings{
UsageSyncIntervalSeconds: value.UsageSyncIntervalSeconds,
}, err
},
func(ctx context.Context, onWorkDetected func()) (usage.BackgroundSyncOutcome, error) {
return application.Usage().SyncProviderBackground(ctx, claudecodeconfig.ProviderID, onWorkDetected)
},
)
quota := newCodexQuotaRuntime(application.Codex().ListAutomationTargets, application.Codex().RunCredentialJob)
backups := newApplicationBackupRuntime(
application.Settings().Get,
Expand All @@ -323,7 +338,7 @@ func NewServices(application *app.Application, info app.Info, env Environment, s
App: &AppService{application: application, info: info, env: env, startupErr: startupErr, changes: changes},
Agent: &AgentService{application: application, changes: changes},
Antigravity: &AntigravityService{application: application, changes: changes},
ClaudeCode: &ClaudeCodeService{application: application, changes: changes},
ClaudeCode: &ClaudeCodeService{application: application, changes: changes, autoSync: claudeCodeUsageSync},
Codex: &CodexService{application: application, changes: changes, autoSync: codexUsageSync, quota: quota},
GrokBuild: &GrokBuildService{application: application, changes: changes, autoSync: grokBuildUsageSync},
Profile: &ProfileService{application: application, changes: changes, quota: quota},
Expand All @@ -333,20 +348,23 @@ func NewServices(application *app.Application, info app.Info, env Environment, s
Usage: &UsageService{
application: application,
autoSync: map[string]*usageAutoSyncRuntime{
codexconfig.ProviderID: codexUsageSync,
grokconfig.ProviderID: grokBuildUsageSync,
codexconfig.ProviderID: codexUsageSync,
grokconfig.ProviderID: grokBuildUsageSync,
claudecodeconfig.ProviderID: claudeCodeUsageSync,
},
},
Settings: &SettingsService{application: application},
changes: changes,
codexUsageSync: codexUsageSync,
grokBuildUsageSync: grokBuildUsageSync,
quota: quota,
runtimes: runtimes,
backups: backups,
Settings: &SettingsService{application: application},
changes: changes,
codexUsageSync: codexUsageSync,
grokBuildUsageSync: grokBuildUsageSync,
claudeCodeUsageSync: claudeCodeUsageSync,
quota: quota,
runtimes: runtimes,
backups: backups,
}
runtimes.Register(agent.Codex, codexUsageSync)
runtimes.Register(agent.GrokBuild, grokBuildUsageSync)
runtimes.Register(agent.ClaudeCode, claudeCodeUsageSync)
runtimes.Register(agent.Codex, quota)
return services
}
Expand All @@ -358,13 +376,16 @@ func (s Services) SubscribeChanges(listener func(DesktopChangeEvent)) func() {
func (s Services) StartUsageAutoSync(ctx context.Context, emitter func(UsageAutoSyncStatus)) {
s.codexUsageSync.SetEmitter(emitter)
s.grokBuildUsageSync.SetEmitter(emitter)
s.claudeCodeUsageSync.SetEmitter(emitter)
s.runtimes.Activate(ctx, agent.Codex, s.codexUsageSync)
s.runtimes.Activate(ctx, agent.GrokBuild, s.grokBuildUsageSync)
s.runtimes.Activate(ctx, agent.ClaudeCode, s.claudeCodeUsageSync)
}

func (s Services) StopUsageAutoSync() {
s.runtimes.Deactivate(agent.Codex, s.codexUsageSync)
s.runtimes.Deactivate(agent.GrokBuild, s.grokBuildUsageSync)
s.runtimes.Deactivate(agent.ClaudeCode, s.claudeCodeUsageSync)
}

func (s Services) StartCodexQuotaRuntime(ctx context.Context, emitter func(CodexQuotaRuntimeStatus)) {
Expand Down Expand Up @@ -1349,3 +1370,25 @@ func FormatDesktopErrorPtr(err error) *DesktopError {
payload := FormatDesktopError(err)
return &payload
}

func (s *ClaudeCodeService) GetSettings(ctx context.Context) (claudecode.Settings, error) {
return s.application.ClaudeCode().GetSettings(ctx)
}

func (s *ClaudeCodeService) UpdateSettings(
ctx context.Context,
req claudecode.UpdateSettingsRequest,
) (claudecode.Settings, error) {
// Persist and update only this Provider's runtime under one serial order so
// a slower older request cannot restore a stale interval.
s.settingsMu.Lock()
defer s.settingsMu.Unlock()
value, err := s.application.ClaudeCode().UpdateSettings(ctx, req)
if err != nil {
return claudecode.Settings{}, err
}
if req.UsageSyncIntervalSeconds != nil {
s.autoSync.SetInterval(value.UsageSyncIntervalSeconds)
}
return value, nil
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ export function Detect(): $CancellablePromise<claudecode$0.ClaudeCodeDetectResul
return $Call.ByID(4104865097);
}

export function GetSettings(): $CancellablePromise<claudecode$0.Settings> {
return $Call.ByID(77725791);
}

export function ListProfiles(): $CancellablePromise<claudecode$0.ClaudeCodeProfileListResult> {
return $Call.ByID(2487267564);
}
Expand All @@ -40,3 +44,7 @@ export function ShowProfile(profileID: string): $CancellablePromise<claudecode$0
export function UpdateProfile(req: $models.UpdateClaudeCodeProfileRequest): $CancellablePromise<claudecode$0.ClaudeCodeProfileDetail> {
return $Call.ByID(3263369448, req);
}

export function UpdateSettings(req: claudecode$0.UpdateSettingsRequest): $CancellablePromise<claudecode$0.Settings> {
return $Call.ByID(325034858, req);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,7 @@ export type {
ClaudeCodeProfileDetail,
ClaudeCodeProfileListResult,
ClaudeCodeProfileSaveResult,
ClaudeCodeProfileSummary
ClaudeCodeProfileSummary,
Settings,
UpdateSettingsRequest
} from "./models.js";
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,11 @@ export interface ClaudeCodeProfileSummary {
"updated_at_unix_ms": number;
"warnings"?: string[] | null;
}

export interface Settings {
"usage_sync_interval_seconds": number;
}

export interface UpdateSettingsRequest {
"usage_sync_interval_seconds"?: number | null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@
// This file is automatically generated. DO NOT EDIT

export interface UsageAggregateSummary {
"missing_output_cost_event_count": number;
"missing_cache_ttl_event_count": number;
"missing_cache_rate_event_count": number;
"token_total_status": string;
"output_tokens_status": string;
"incomplete_event_count": number;
"conflicting_event_count": number;
"event_count": number;
"session_count": number;
"fresh_input_tokens": number;
Expand All @@ -28,6 +35,7 @@ export interface UsageAggregateSummary {
}

export interface UsageImportSummary {
"invalid_reasons": { [_ in string]?: number } | null;
"tracked_files": number;
"last_synced_at_unix_ms": number;
"invalid_lines": number;
Expand Down Expand Up @@ -85,6 +93,10 @@ export interface UsageResolvedRange {
}

export interface UsageSummaryResult {
"token_total_status": string;
"output_tokens_status": string;
"incomplete_event_count": number;
"conflicting_event_count": number;
"provider_id": string;
"source": string;
"sources": string[] | null;
Expand All @@ -94,6 +106,7 @@ export interface UsageSummaryResult {
"output_tokens": number;
"total_tokens": number;
"estimated_cost_usd": string | null;
"known_estimated_cost_usd": string;
"cost_status": string;
"unknown_cost_event_count": number;
"estimated_cost_event_count": number;
Expand Down
124 changes: 124 additions & 0 deletions desktop/frontend/src/features/settings/ClaudeCodeSettings.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<script lang="ts">
import { onMount } from "svelte";
import type { CancellablePromise } from "@wailsio/runtime";
import { _ } from "svelte-i18n";

import { ClaudeCodeService } from "../../../bindings/github.com/strahe/profiledeck/desktop/backend";
import type { Settings } from "../../../bindings/github.com/strahe/profiledeck/internal/claudecode/models";

import ContentContainer from "$lib/components/app/ContentContainer.svelte";
import { Button } from "$lib/components/ui/button";
import * as Empty from "$lib/components/ui/empty";
import { isCancelError } from "$lib/desktop-errors";
import { translate } from "$lib/i18n";

import UsageSyncSettings from "./UsageSyncSettings.svelte";
import { usageIntervals, usageSyncIntervalDefault } from "./usage-sync-settings";

let {
providerExists,
onOpenProfiles,
showError,
showNotice,
}: {
providerExists: boolean;
onOpenProfiles: () => void;
showError: (value: unknown) => void;
showNotice: (title: string, description: string) => void;
} = $props();

let settings = $state<Settings | null>(null);
let loading = $state(false);
let busy = $state(false);
let loadStarted = $state(false);
let request: CancellablePromise<Settings> | null = null;

onMount(() => {
return () => request?.cancel("Claude Code settings unmounted");
});

$effect(() => {
if (providerExists && !loadStarted) {
loadStarted = true;
void load();
} else if (!providerExists && loadStarted) {
request?.cancel("Claude Code provider unavailable");
request = null;
settings = null;
loading = false;
loadStarted = false;
}
});

async function load() {
request?.cancel("Claude Code settings request replaced");
loading = true;
const next = ClaudeCodeService.GetSettings();
request = next;
try {
const value = await next;
if (request === next) settings = value;
} catch (error) {
if (request === next && !isCancelError(error)) showError(error);
} finally {
if (request === next) {
request = null;
loading = false;
}
}
}

async function changeInterval(value: string) {
const interval = Number(value);
if (
busy
|| !usageIntervals.includes(interval as (typeof usageIntervals)[number])
|| settings?.usage_sync_interval_seconds === interval
) return;
busy = true;
request?.cancel("Claude Code settings update replaced");
const next = ClaudeCodeService.UpdateSettings({ usage_sync_interval_seconds: interval });
request = next;
try {
const value = await next;
if (request !== next) return;
settings = value;
showNotice(
translate("notice.settingsSaved.title"),
translate("notice.settingsSaved.claudeCodeDescription"),
);
} catch (error) {
if (request === next && !isCancelError(error)) {
showError(error);
void load();
}
} finally {
if (request === next) request = null;
busy = false;
}
}
</script>

<ContentContainer class="max-w-4xl">
{#if !providerExists}
<Empty.Root class="rounded-lg border bg-card py-12">
<Empty.Header>
<Empty.Title>{$_("usage.providerUnavailableTitle")}</Empty.Title>
<Empty.Description>
{$_("usage.providerUnavailableDescription", { values: { provider: "Claude Code" } })}
</Empty.Description>
</Empty.Header>
<Empty.Content>
<Button onclick={onOpenProfiles}>{$_("usage.openProfiles")}</Button>
</Empty.Content>
</Empty.Root>
{:else}
<UsageSyncSettings
id="claude-code-usage-sync"
interval={settings?.usage_sync_interval_seconds ?? usageSyncIntervalDefault}
{loading}
{busy}
onChange={changeInterval}
/>
{/if}
</ContentContainer>
3 changes: 2 additions & 1 deletion desktop/frontend/src/features/usage/UsageModelTable.svelte
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script lang="ts">
import { usageTokenLabel } from "./token-format";
import { _ } from "svelte-i18n";
import type { UsageModelSummary } from "../../../bindings/github.com/strahe/profiledeck/internal/usage/models";

Expand Down Expand Up @@ -65,7 +66,7 @@
</div>
</Table.Cell>
{/if}
<Table.Cell class="text-right tabular-nums">{formatInteger(model.summary.total_tokens)}</Table.Cell>
<Table.Cell class="text-right tabular-nums">{usageTokenLabel(formatInteger(model.summary.total_tokens), model.summary.token_total_status)}</Table.Cell>
<Table.Cell class="text-right tabular-nums">{formatInteger(model.summary.session_count)}</Table.Cell>
</Table.Row>
{/each}
Expand Down
Loading
Loading