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
2 changes: 1 addition & 1 deletion .agents/skills/superset-settings/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ Booleans accept `true/false/on/off/1/0/yes/no`.

| Section | Keys |
| --- | --- |
| behavior | `confirmOnQuit`, `fileOpenMode` (`split-pane\|new-tab`), `showResourceMonitor`, `openLinksInApp`, `defaultEditor` (vscode, cursor, zed, ...) |
| behavior | `confirmOnQuit`, `fileOpenMode` (`split-pane\|new-tab`), `showResourceMonitor`, `showUsageInSidebar`, `openLinksInApp`, `defaultEditor` (vscode, cursor, zed, ...) |
| git | `branchPrefixMode` (`none\|github\|author\|custom`), `branchPrefixCustom`, `worktreeBaseDir`; host-wide, written through the local host service, so the app (or `superset start`) must be running |
| notifications | `selectedRingtoneId` (shamisen, arcade, ping, quick, doowap, woman, african, afrobeat, edm, comeback, shabala), `notificationSoundsMuted`, `notificationVolume` (0-100) |
| terminal | `terminalLinkBehavior` (`external-editor\|file-viewer`), `terminalParkedRuntimeCap` (2-64), `showPresetsBar`, `useCompactTerminalAddButton`, `autoApplyDefaultPreset`, `waitForSetupBeforeAgent` |
Expand Down
25 changes: 25 additions & 0 deletions apps/desktop/src/lib/trpc/routers/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
DEFAULT_OPEN_LINKS_IN_APP,
DEFAULT_SHOW_PRESETS_BAR,
DEFAULT_SHOW_RESOURCE_MONITOR,
DEFAULT_SHOW_USAGE_IN_SIDEBAR,
DEFAULT_TERMINAL_COPY_ON_SELECT,
DEFAULT_TERMINAL_LINK_BEHAVIOR,
DEFAULT_TERMINAL_PARKED_RUNTIME_CAP,
Expand Down Expand Up @@ -1107,6 +1108,30 @@ export const createSettingsRouter = () => {
return { success: true };
}),

getShowUsageInSidebar: publicProcedure.query(() => {
const row = getSettings();
return row.showUsageInSidebar ?? DEFAULT_SHOW_USAGE_IN_SIDEBAR;
}),

setShowUsageInSidebar: publicProcedure
.input(z.object({ enabled: z.boolean() }))
.mutation(({ input }) => {
// Target the row getSettings() reads: legacy DBs can hold a non-1
// row id, and upserting id 1 there would split settings across
// two rows.
const { id } = getSettings();
localDb
.insert(settings)
.values({ id, showUsageInSidebar: input.enabled })
.onConflictDoUpdate({
target: settings.id,
set: { showUsageInSidebar: input.enabled },
})
.run();

return { success: true };
}),

getWorktreeBaseDir: publicProcedure.query(() => {
const row = getSettings();
return row.worktreeBaseDir ?? null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { HiOutlineClipboardDocumentList } from "react-icons/hi2";
import {
LuClock,
LuFileText,
LuGauge,
LuLayers,
LuPlus,
LuPuzzle,
Expand Down Expand Up @@ -50,6 +51,10 @@ import {
useTasksFilterStore,
} from "renderer/routes/_authenticated/_dashboard/tasks/stores/tasks-filter-state";
import { useHostWorkspaces } from "renderer/routes/_authenticated/providers/HostWorkspacesProvider";
import {
getUsageLastSection,
usageSectionPath,
} from "renderer/routes/_authenticated/settings/usage/utils/usageLastSection";
import { STROKE_WIDTH_THICK } from "renderer/screens/main/components/WorkspaceSidebar/constants";
import {
useOpenEmptyProjectModal,
Expand Down Expand Up @@ -204,6 +209,8 @@ export function DashboardSidebarHeader({
};

const isPagesEnabled = useFeatureFlagEnabled(FEATURE_FLAGS.PAGES) ?? false;
const { data: isUsageInSidebarEnabled } =
electronTrpc.settings.getShowUsageInSidebar.useQuery();

const handlePagesClick = () => {
navigate({ to: "/pages" });
Expand All @@ -227,6 +234,12 @@ export function DashboardSidebarHeader({
});
};

const handleUsageClick = () => {
// Reopen whichever Usage section (token / machine resources) was
// visited last.
navigate({ to: usageSectionPath(getUsageLastSection()) });
};

if (isCollapsed) {
return (
<div className="flex flex-col">
Expand Down Expand Up @@ -392,6 +405,26 @@ export function DashboardSidebarHeader({
</TooltipContent>
</Tooltip>

{isUsageInSidebarEnabled && (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleUsageClick}
aria-label={t({
message: "Usage",
})}
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-fill-hover"
>
<LuGauge className="size-3.5" strokeWidth={1.5} />
</button>
</TooltipTrigger>
<TooltipContent side="right">
<Trans>Usage</Trans>
</TooltipContent>
</Tooltip>
)}

{isPagesEnabled && (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
Expand Down Expand Up @@ -647,6 +680,25 @@ export function DashboardSidebarHeader({
</span>
</button>

{isUsageInSidebarEnabled && (
<button
type="button"
onClick={handleUsageClick}
aria-label={t({
message: "Usage",
})}
className="flex h-7 w-full items-center gap-2 rounded-md px-2 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-fill-hover hover:text-foreground"
>
<LuGauge
className="size-4 shrink-0 text-muted-foreground"
strokeWidth={1.5}
/>
<span className="flex-1 text-left">
<Trans>Usage</Trans>
</span>
</button>
)}

{isPagesEnabled && (
<button
type="button"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Trans, useLingui } from "@lingui/react/macro";
import { Tabs, TabsList, TabsTrigger } from "@superset/ui/tabs";
import { cn } from "@superset/ui/utils";
import { useState } from "react";
import { LuX } from "react-icons/lu";
import { LuRefreshCw, LuX } from "react-icons/lu";
import {
getPresetIcon,
useIsDarkTheme,
Expand Down Expand Up @@ -80,6 +80,12 @@ export function UsageHistorySection({ hostUrl }: { hostUrl: string | null }) {
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<Trans>Token usage</Trans>
</h2>
{historyQuery.isFetching && history && (
<output className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<LuRefreshCw className="size-3 animate-spin" aria-hidden="true" />
<Trans>Scanning transcript logs…</Trans>
</output>
)}
{firstDay && lastDay && (
<span className="text-[10px] text-muted-foreground">
<Trans>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Trans } from "@lingui/react/macro";
import { Label } from "@superset/ui/label";
import { Switch } from "@superset/ui/switch";
import { electronTrpc } from "renderer/lib/electron-trpc";

export function UsageSidebarToggle() {
const utils = electronTrpc.useUtils();
const { data: usageInSidebarEnabled, isLoading: isUsageInSidebarLoading } =
electronTrpc.settings.getShowUsageInSidebar.useQuery();
const setShowUsageInSidebar =
electronTrpc.settings.setShowUsageInSidebar.useMutation({
onMutate: async ({ enabled }) => {
await utils.settings.getShowUsageInSidebar.cancel();
const previous = utils.settings.getShowUsageInSidebar.getData();
utils.settings.getShowUsageInSidebar.setData(undefined, enabled);
return { previous };
},
onError: (_err, _vars, context) => {
if (context?.previous !== undefined) {
utils.settings.getShowUsageInSidebar.setData(
undefined,
context.previous,
);
}
},
onSettled: () => {
utils.settings.getShowUsageInSidebar.invalidate();
},
});

return (
<div className="flex shrink-0 items-center gap-3">
<Label
htmlFor="usage-in-sidebar"
className="text-xs text-muted-foreground"
>
<Trans>Show usage tab on sidebar</Trans>
</Label>
<span id="usage-in-sidebar-description" className="sr-only">
<Trans>
Show a Usage button in the home sidebar, under Pull requests
</Trans>
</span>
<Switch
id="usage-in-sidebar"
aria-describedby="usage-in-sidebar-description"
checked={usageInSidebarEnabled ?? false}
onCheckedChange={(enabled) => setShowUsageInSidebar.mutate({ enabled })}
disabled={
isUsageInSidebarLoading ||
usageInSidebarEnabled === undefined ||
setShowUsageInSidebar.isPending
}
/>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { UsageSidebarToggle } from "./UsageSidebarToggle";
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { afterEach, expect, mock, test } from "bun:test";
import { QueryClient, QueryObserver } from "@tanstack/react-query";
import { hostUsageHistoryOptions } from "./useHostUsageHistory";

const clients: QueryClient[] = [];
afterEach(() => {
for (const client of clients) client.clear();
clients.length = 0;
});
function setup() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 5 * 60_000 } },
});
clients.push(client);
return client;
}
const previous = {
days: 30,
buckets: [],
models: [],
projects: [],
projectDetails: {},
modelDetails: {},
scannedFiles: 1,
pricingTableUpdated: "2026-09-10",
totals: {
tokens: 123,
usd: 4,
uncachedInput: 123,
cachedInput: 0,
cacheWrite: 0,
output: 0,
reasoningOutput: 0,
cacheSavingsUsd: 0,
approximate: false,
},
};

test("retains stale history across remounts and a failed background scan", async () => {
const client = setup();
const options = hostUsageHistoryOptions("http://host-a", 30);
client.setQueryData(options.queryKey, previous, { updatedAt: 1 });
const request = mock(async () => {
throw new Error("offline");
});
const first = new QueryObserver(client, { ...options, queryFn: request });
const unsubscribe = first.subscribe(() => {});
expect(first.getCurrentResult().data).toEqual(previous);
expect(first.getCurrentResult().isFetching).toBe(true);
await first.refetch();
unsubscribe();
expect(
client.getQueryCache().find({ queryKey: options.queryKey })?.gcTime,
).toBe(24 * 60 * 60_000);
const next = new QueryObserver(client, { ...options, queryFn: request });
const stop = next.subscribe(() => {});
expect(next.getCurrentResult().data).toEqual(previous);
await next.refetch();
expect(next.getCurrentResult().isError).toBe(true);
expect(next.getCurrentResult().data).toEqual(previous);
stop();
});

test("a completed background scan replaces cached results", async () => {
const client = setup();
const options = hostUsageHistoryOptions("http://host-a", 30);
client.setQueryData(options.queryKey, previous, { updatedAt: 1 });
const fresh = { ...previous, totals: { ...previous.totals, tokens: 456 } };
const observer = new QueryObserver(client, {
...options,
queryFn: async () => fresh,
});
const stop = observer.subscribe(() => {});
expect(observer.getCurrentResult().data).toEqual(previous);
await observer.refetch();
expect(observer.getCurrentResult().data).toEqual(fresh);
stop();
});

test("range switches reuse this host's data, never another host's", () => {
const client = setup();
client.setQueryData(
hostUsageHistoryOptions("http://host-a", 30).queryKey,
previous,
);
const observer = new QueryObserver(client, {
...hostUsageHistoryOptions("http://host-a", 30),
enabled: false,
});
const stop = observer.subscribe(() => {});
observer.setOptions({
...hostUsageHistoryOptions("http://host-a", 7),
enabled: false,
});
expect(observer.getCurrentResult().data).toEqual(previous);
expect(observer.getCurrentResult().isPlaceholderData).toBe(true);
observer.setOptions({
...hostUsageHistoryOptions("http://host-b", 7),
enabled: false,
});
expect(observer.getCurrentResult().data).toBeUndefined();
stop();
});
Original file line number Diff line number Diff line change
@@ -1,28 +1,36 @@
import type { AppRouter } from "@superset/host-service";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { queryOptions, useQuery } from "@tanstack/react-query";
import type { inferRouterOutputs } from "@trpc/server";
import { getHostServiceClientByUrl } from "renderer/lib/host-service-client";

type RouterOutputs = inferRouterOutputs<AppRouter>;
export type UsageHistory = RouterOutputs["usage"]["history"];

const HISTORY_STALE_MS = 5 * 60 * 1000;
const HISTORY_CACHE_MS = 24 * 60 * 60 * 1000;

/**
* Token/cost history from the host's transcript logs. Expensive on the host
* (multi-GB scan in its worker pool), so no interval polling — refetched on
* range change and after staleness. `keepPreviousData` keeps the old chart
* up while a new range loads.
* range change and after staleness. Retain results across a day of navigation
* so returning to Usage refreshes in the background instead of blanking it.
*/
export function useHostUsageHistory(hostUrl: string | null, days: number) {
return useQuery({
export function hostUsageHistoryOptions(hostUrl: string | null, days: number) {
return queryOptions({
queryKey: ["host-usage-history", hostUrl, days] as const,
enabled: !!hostUrl,
queryFn: () => {
if (!hostUrl) return null;
return getHostServiceClientByUrl(hostUrl).usage.history.query({ days });
},
staleTime: HISTORY_STALE_MS,
placeholderData: keepPreviousData,
gcTime: HISTORY_CACHE_MS,
// A range change may reuse this host's chart, never another host's data.
placeholderData: (previousData, previousQuery) =>
previousQuery?.queryKey[1] === hostUrl ? previousData : undefined,
});
}

export function useHostUsageHistory(hostUrl: string | null, days: number) {
return useQuery(hostUsageHistoryOptions(hostUrl, days));
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type UsageQuotaWindow = UsageAccount["windows"][number];

export const HOST_USAGE_QUOTA_QUERY_KEY = ["host-usage-quota"] as const;
const USAGE_REFETCH_INTERVAL_MS = 5 * 60_000;
const USAGE_CACHE_MS = 24 * 60 * 60_000;

/**
* Subscription quota for every AI CLI login on the given host. The host
Expand All @@ -30,6 +31,8 @@ export function useHostUsageQuota(hostUrl: string | null) {
},
refetchInterval: USAGE_REFETCH_INTERVAL_MS,
staleTime: USAGE_REFETCH_INTERVAL_MS,
// Keep the last accounts visible when returning after a long absence.
gcTime: USAGE_CACHE_MS,
});

const refresh = useCallback(async () => {
Expand Down
Loading
Loading