feat(desktop): polish desktop shell and shared WebUI surfaces (#4195)

* feat(desktop): add native host scaffold

* feat(webui): track turns and usage in gateway

* feat(webui): polish desktop chat experience

* feat(apps): add ArcGIS and Joplin logos

* feat(desktop): polish shell and shared surfaces

* fix(webui): avoid preview chips for glob references

* test: align CI expectations for token fallback

* feat(webui): preview prompt rail entries

* feat(webui): add prompt navigator drawer

* style(webui): refine prompt navigator placement

* style(webui): align prompt navigator with header actions

* style(webui): simplify prompt navigator header

* refactor(webui): clean thread resource refresh

* feat(desktop): add native reply notifications

* fix(webui): preserve desktop restart and replay state

* fix(desktop): harden gateway proxy startup

* fix(web): fall back when readability is unavailable

* fix(desktop): hide window instead of closing on macos

* fix(webui): unify desktop header actions

* fix(webui): simplify prompt history rows

* fix(desktop): log notification delivery failures

* chore(desktop): clean source package artifacts

* fix(cron): support one-time relative reminders

* fix(webui): reveal scroll button in place

* Revert "fix(cron): support one-time relative reminders"

This reverts commit 4c4661da120a3c7283e0768412bae48604e7390b.

* refactor(webui): extract token usage heatmap

* docs(desktop): clarify contributor guides

---------

Co-authored-by: chengyongru <2755839590@qq.com>
This commit is contained in:
Xubin Ren
2026-06-06 19:49:33 +08:00
committed by GitHub
co-authored by chengyongru
parent a1b9577224
commit ab9f49970d
103 changed files with 10483 additions and 1003 deletions
+351 -197
View File
@@ -13,6 +13,7 @@ import {
Bot,
Brain,
Check,
CircleAlert,
ChevronDown,
ChevronLeft,
ChevronRight,
@@ -52,6 +53,8 @@ import {
import { useTranslation } from "react-i18next";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { SkillsCatalogSettings } from "@/components/settings/SkillsCatalogSettings";
import { TokenUsageHeatmap } from "@/components/settings/TokenUsageHeatmap";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -73,6 +76,7 @@ import { Textarea } from "@/components/ui/textarea";
import {
createModelConfiguration,
fetchSettings,
fetchSettingsUsage,
fetchCliApps,
fetchMcpPresets,
fetchProviderModels,
@@ -99,6 +103,7 @@ import {
providerDisplayLabel,
} from "@/lib/provider-brand";
import { cn } from "@/lib/utils";
import { shortWorkspacePath } from "@/lib/workspace";
import { useClient } from "@/providers/ClientProvider";
import type {
CliAppInfo,
@@ -109,6 +114,7 @@ import type {
NetworkSafetySettingsUpdate,
ProviderModelsPayload,
SettingsPayload,
SkillSummary,
WebSearchSettingsUpdate,
WebuiDefaultAccessMode,
} from "@/lib/types";
@@ -120,6 +126,7 @@ export type SettingsSectionKey =
| "image"
| "browser"
| "apps"
| "skills"
| "runtime"
| "advanced";
@@ -167,7 +174,6 @@ type ProviderApiType = "auto" | "chat_completions" | "responses";
type ProviderForm = { apiKey: string; apiBase: string; apiType: ProviderApiType };
type CustomMcpTransport = "stdio" | "streamableHttp" | "sse";
const NANOBOT_ICON_SRC = "/brand/nanobot_icon.png";
const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 262_144] as const;
const DEFERRED_MODEL_LIST_PROVIDERS = new Set([
"aihubmix",
@@ -265,15 +271,18 @@ const DEFAULT_CUSTOM_MCP_FORM: CustomMcpForm = {
interface SettingsViewProps {
theme: "light" | "dark";
initialSection?: SettingsSectionKey;
initialSettings?: SettingsPayload | null;
showSidebar?: boolean;
onToggleTheme: () => void;
onBackToChat: () => void;
onModelNameChange: (modelName: string | null) => void;
onSettingsChange?: (payload: SettingsPayload) => void;
skills?: SkillSummary[];
onWorkspaceSettingsChange?: () => void | Promise<void>;
onSectionChange?: (section: SettingsSectionKey) => void;
onLogout?: () => void;
onRestart?: () => void;
onNativeEngineRestart?: () => Promise<string>;
isRestarting?: boolean;
hostChromeInset?: boolean;
}
@@ -311,27 +320,150 @@ function editableDefaultProvider(payload: SettingsPayload): string {
return base?.provider ?? payload.agent.provider ?? payload.agent.resolved_provider ?? "";
}
function settingsProviderRow(
payload: SettingsPayload,
provider: string | null | undefined,
): SettingsPayload["providers"][number] | null {
if (!provider) return null;
return payload.providers.find((row) => row.name === provider) ?? null;
}
function settingsProviderConfigured(
payload: SettingsPayload,
provider: string | null | undefined,
): boolean {
const row = settingsProviderRow(payload, provider);
if (row) return row.configured;
return payload.agent.has_api_key;
}
const DEFAULT_AGENT_SETTINGS_DRAFT: AgentSettingsDraft = {
model: "",
provider: "",
modelPreset: "default",
presetLabel: "Default",
contextWindowTokens: 65_536,
timezone: "UTC",
botName: "nanobot",
botIcon: "",
toolHintMaxLength: 40,
};
const DEFAULT_WEB_SEARCH_FORM: WebSearchSettingsUpdate = {
provider: "duckduckgo",
apiKey: "",
baseUrl: "",
maxResults: 5,
timeout: 30,
useJinaReader: true,
};
const DEFAULT_IMAGE_GENERATION_FORM: ImageGenerationSettingsUpdate = {
enabled: false,
provider: "openrouter",
model: "openai/gpt-5.4-image-2",
defaultAspectRatio: "1:1",
defaultImageSize: "1K",
maxImagesPerTurn: 4,
};
const DEFAULT_NETWORK_SAFETY_FORM: NetworkSafetySettingsUpdate = {
webuiAllowLocalServiceAccess: true,
webuiDefaultAccessMode: "default",
};
function agentDraftFromPayload(payload: SettingsPayload): AgentSettingsDraft {
const fallbackDefault = defaultPreset(payload);
const activePresetName = modelPresetValue(payload);
const activePreset =
payload.model_presets.find((preset) => preset.name === activePresetName) ?? fallbackDefault;
return {
model: activePreset?.model ?? payload.agent.model,
provider: activePreset?.is_default
? editableDefaultProvider(payload)
: activePreset?.provider ?? editableDefaultProvider(payload),
modelPreset: activePresetName,
presetLabel: activePreset?.label ?? activePresetName,
contextWindowTokens: normalizeContextWindowTokens(
activePreset?.context_window_tokens ?? payload.agent.context_window_tokens,
),
timezone: payload.agent.timezone,
botName: payload.agent.bot_name,
botIcon: payload.agent.bot_icon,
toolHintMaxLength: payload.agent.tool_hint_max_length,
};
}
function webSearchFormFromPayload(
payload: SettingsPayload,
previous?: WebSearchSettingsUpdate,
): WebSearchSettingsUpdate {
return {
provider: payload.web_search.provider,
apiKey: previous?.provider === payload.web_search.provider ? previous.apiKey ?? "" : "",
baseUrl: payload.web_search.base_url ?? "",
maxResults: payload.web_search.max_results,
timeout: payload.web_search.timeout,
useJinaReader: payload.web.fetch.use_jina_reader,
};
}
function imageGenerationFormFromPayload(payload: SettingsPayload): ImageGenerationSettingsUpdate {
return {
enabled: payload.image_generation.enabled,
provider: payload.image_generation.provider,
model: payload.image_generation.model,
defaultAspectRatio: payload.image_generation.default_aspect_ratio,
defaultImageSize: payload.image_generation.default_image_size,
maxImagesPerTurn: payload.image_generation.max_images_per_turn,
};
}
function networkSafetyFormFromPayload(payload: SettingsPayload): NetworkSafetySettingsUpdate {
return {
webuiAllowLocalServiceAccess:
payload.advanced.webui_allow_local_service_access ??
payload.advanced.allow_local_preview_access ??
true,
webuiDefaultAccessMode: visibleWebuiDefaultAccessMode(
payload.advanced.webui_default_access_mode,
),
};
}
function pendingRestartSectionsFromPayload(payload: SettingsPayload): PendingRestartSections {
const sections = payload.restart_required_sections ?? [];
return {
runtime: sections.includes("runtime"),
browser: sections.includes("browser"),
image: sections.includes("image"),
};
}
export function SettingsView({
theme,
initialSection = "overview",
initialSettings = null,
showSidebar = true,
onToggleTheme,
onBackToChat,
onModelNameChange,
onSettingsChange,
skills = [],
onWorkspaceSettingsChange,
onSectionChange,
onLogout,
onRestart,
onNativeEngineRestart,
isRestarting = false,
hostChromeInset = false,
}: SettingsViewProps) {
const { t } = useTranslation();
const { token } = useClient();
const [settings, setSettings] = useState<SettingsPayload | null>(null);
const [settings, setSettings] = useState<SettingsPayload | null>(() => initialSettings);
const [cliApps, setCliApps] = useState<CliAppsPayload | null>(null);
const [mcpPresets, setMcpPresets] = useState<McpPresetsPayload | null>(null);
const [loading, setLoading] = useState(true);
const [loading, setLoading] = useState(() => initialSettings === null);
const [cliAppsLoading, setCliAppsLoading] = useState(true);
const [mcpPresetsLoading, setMcpPresetsLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -370,26 +502,18 @@ export function SettingsView({
EMPTY_PENDING_RESTART_SECTIONS,
);
const [localPrefs, setLocalPrefs] = useState<LocalPreferences>(() => readLocalPreferences());
const [webSearchForm, setWebSearchForm] = useState<WebSearchSettingsUpdate>({
provider: "duckduckgo",
apiKey: "",
baseUrl: "",
maxResults: 5,
timeout: 30,
useJinaReader: true,
});
const [imageGenerationForm, setImageGenerationForm] = useState<ImageGenerationSettingsUpdate>({
enabled: false,
provider: "openrouter",
model: "openai/gpt-5.4-image-2",
defaultAspectRatio: "1:1",
defaultImageSize: "1K",
maxImagesPerTurn: 4,
});
const [networkSafetyForm, setNetworkSafetyForm] = useState<NetworkSafetySettingsUpdate>({
webuiAllowLocalServiceAccess: true,
webuiDefaultAccessMode: "default",
});
const [webSearchForm, setWebSearchForm] = useState<WebSearchSettingsUpdate>(() =>
initialSettings ? webSearchFormFromPayload(initialSettings) : DEFAULT_WEB_SEARCH_FORM,
);
const [imageGenerationForm, setImageGenerationForm] = useState<ImageGenerationSettingsUpdate>(
() =>
initialSettings
? imageGenerationFormFromPayload(initialSettings)
: DEFAULT_IMAGE_GENERATION_FORM,
);
const [networkSafetyForm, setNetworkSafetyForm] = useState<NetworkSafetySettingsUpdate>(() =>
initialSettings ? networkSafetyFormFromPayload(initialSettings) : DEFAULT_NETWORK_SAFETY_FORM,
);
useEffect(() => {
setActiveSection(initialSection);
@@ -404,17 +528,9 @@ export function SettingsView({
);
const [webSearchKeyVisible, setWebSearchKeyVisible] = useState(false);
const [webSearchKeyEditing, setWebSearchKeyEditing] = useState(false);
const [form, setForm] = useState<AgentSettingsDraft>({
model: "",
provider: "",
modelPreset: "default",
presetLabel: "Default",
contextWindowTokens: 65_536,
timezone: "UTC",
botName: "nanobot",
botIcon: "",
toolHintMaxLength: 40,
});
const [form, setForm] = useState<AgentSettingsDraft>(() =>
initialSettings ? agentDraftFromPayload(initialSettings) : DEFAULT_AGENT_SETTINGS_DRAFT,
);
const text = useCallback(
(key: string, fallback: string, options?: Record<string, unknown>) =>
@@ -423,59 +539,27 @@ export function SettingsView({
);
const applyPayload = useCallback((payload: SettingsPayload) => {
const fallbackDefault = defaultPreset(payload);
const activePresetName = modelPresetValue(payload);
const activePreset =
payload.model_presets.find((preset) => preset.name === activePresetName) ?? fallbackDefault;
setSettings(payload);
setForm({
model: activePreset?.model ?? payload.agent.model,
provider: activePreset?.is_default
? editableDefaultProvider(payload)
: activePreset?.provider ?? editableDefaultProvider(payload),
modelPreset: activePresetName,
presetLabel: activePreset?.label ?? activePresetName,
contextWindowTokens: normalizeContextWindowTokens(
activePreset?.context_window_tokens ?? payload.agent.context_window_tokens,
),
timezone: payload.agent.timezone,
botName: payload.agent.bot_name,
botIcon: payload.agent.bot_icon,
toolHintMaxLength: payload.agent.tool_hint_max_length,
});
setWebSearchForm((prev) => ({
provider: payload.web_search.provider,
apiKey: prev.provider === payload.web_search.provider ? prev.apiKey ?? "" : "",
baseUrl: payload.web_search.base_url ?? "",
maxResults: payload.web_search.max_results,
timeout: payload.web_search.timeout,
useJinaReader: payload.web.fetch.use_jina_reader,
}));
setImageGenerationForm({
enabled: payload.image_generation.enabled,
provider: payload.image_generation.provider,
model: payload.image_generation.model,
defaultAspectRatio: payload.image_generation.default_aspect_ratio,
defaultImageSize: payload.image_generation.default_image_size,
maxImagesPerTurn: payload.image_generation.max_images_per_turn,
});
setNetworkSafetyForm({
webuiAllowLocalServiceAccess: payload.advanced.webui_allow_local_service_access ?? payload.advanced.allow_local_preview_access ?? true,
webuiDefaultAccessMode: visibleWebuiDefaultAccessMode(payload.advanced.webui_default_access_mode),
});
setForm(agentDraftFromPayload(payload));
setWebSearchForm((prev) => webSearchFormFromPayload(payload, prev));
setImageGenerationForm(imageGenerationFormFromPayload(payload));
setNetworkSafetyForm(networkSafetyFormFromPayload(payload));
if (payload.restart_required_sections) {
setPendingRestartSections({
runtime: payload.restart_required_sections.includes("runtime"),
browser: payload.restart_required_sections.includes("browser"),
image: payload.restart_required_sections.includes("image"),
});
setPendingRestartSections(pendingRestartSectionsFromPayload(payload));
}
onSettingsChange?.(payload);
}, [onSettingsChange]);
useEffect(() => {
if (!initialSettings || settings !== null) return;
applyPayload(initialSettings);
setLoading(false);
}, [applyPayload, initialSettings, settings]);
useEffect(() => {
let cancelled = false;
setLoading(true);
const showLoading = settings === null;
if (showLoading) setLoading(true);
fetchSettings(token)
.then((payload) => {
if (!cancelled) {
@@ -484,7 +568,7 @@ export function SettingsView({
}
})
.catch((err) => {
if (!cancelled) setError((err as Error).message);
if (!cancelled && showLoading) setError((err as Error).message);
})
.finally(() => {
if (!cancelled) setLoading(false);
@@ -494,6 +578,34 @@ export function SettingsView({
};
}, [applyPayload, token]);
const hasSettings = settings !== null;
useEffect(() => {
if (activeSection !== "overview" || !hasSettings) return;
let cancelled = false;
const refresh = () => {
fetchSettingsUsage(token)
.then((usage) => {
if (cancelled) return;
setSettings((current) => (current ? { ...current, usage } : current));
})
.catch(() => {});
};
void refresh();
const interval = window.setInterval(refresh, 5000);
const onFocus = () => refresh();
const onVisibilityChange = () => {
if (document.visibilityState === "visible") refresh();
};
window.addEventListener("focus", onFocus);
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
cancelled = true;
window.clearInterval(interval);
window.removeEventListener("focus", onFocus);
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, [activeSection, hasSettings, token]);
useEffect(() => {
if (activeSection !== "apps") return;
let cancelled = false;
@@ -629,12 +741,15 @@ export function SettingsView({
const restartViaSettingsSurface = useCallback(async () => {
const isNativeHost = (settings?.surface ?? settings?.runtime_surface) === "native";
const hostApi = getHostApi();
if (isNativeHost && settings?.runtime_capabilities?.can_restart_engine && hostApi) {
if (
isNativeHost &&
settings?.runtime_capabilities?.can_restart_engine &&
onNativeEngineRestart
) {
setHostEngineApplying(true);
try {
await hostApi.restartEngine();
const payload = await fetchSettings(token);
const nextToken = await onNativeEngineRestart();
const payload = await fetchSettings(nextToken);
applyPayload(payload);
setPendingRestartSections(EMPTY_PENDING_RESTART_SECTIONS);
setError(null);
@@ -646,21 +761,25 @@ export function SettingsView({
return;
}
onRestart?.();
}, [applyPayload, onRestart, settings, token]);
}, [applyPayload, onNativeEngineRestart, onRestart, settings]);
const maybeRestartHostEngine = useCallback(
async (payload: RestartAwarePayload) => {
const surface = payload.surface ?? payload.runtime_surface ?? settings?.surface ?? settings?.runtime_surface;
const capabilities = payload.runtime_capabilities ?? settings?.runtime_capabilities;
const isNativeHost = surface === "native";
const hostApi = getHostApi();
if (!payload.requires_restart || !isNativeHost || !capabilities?.can_restart_engine || !hostApi) {
if (
!payload.requires_restart ||
!isNativeHost ||
!capabilities?.can_restart_engine ||
!onNativeEngineRestart
) {
return;
}
setHostEngineApplying(true);
try {
await hostApi.restartEngine();
const refreshed = await fetchSettings(token);
const nextToken = await onNativeEngineRestart();
const refreshed = await fetchSettings(nextToken);
applyPayload(refreshed);
setPendingRestartSections(EMPTY_PENDING_RESTART_SECTIONS);
setError(null);
@@ -670,7 +789,7 @@ export function SettingsView({
setHostEngineApplying(false);
}
},
[applyPayload, settings, token],
[applyPayload, onNativeEngineRestart, settings],
);
const saveModelSettings = async () => {
@@ -1135,8 +1254,6 @@ export function SettingsView({
<OverviewSettings
settings={settings}
requiresRestart={hasPendingRestart}
onRestart={restartViaSettingsSurface}
isRestarting={isRestarting || hostEngineApplying}
showBrandLogos={localPrefs.brandLogos}
onSelectSection={selectSection}
/>
@@ -1290,6 +1407,8 @@ export function SettingsView({
isRestarting={isRestarting || hostEngineApplying}
/>
);
case "skills":
return <SkillsCatalogSettings skills={skills} />;
case "runtime":
return (
<RuntimeSettings
@@ -1354,10 +1473,20 @@ export function SettingsView({
)}
>
<div className="mb-7">
<p className="mb-2 text-[13px] font-medium text-muted-foreground">
{!showSidebar ? (
<button
type="button"
onClick={onBackToChat}
className="mb-4 inline-flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground lg:hidden"
>
<ChevronLeft className="h-3.5 w-3.5" aria-hidden />
{t("settings.backToChat")}
</button>
) : null}
<p className="mb-2 text-[12px] font-normal text-muted-foreground">
{t("settings.sidebar.title")}
</p>
<h1 className="text-[28px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[34px]">
<h1 className="text-[24px] font-normal leading-tight tracking-normal text-foreground sm:text-[28px]">
{text(`settings.nav.${activeSection}`, titleForSection(activeSection))}
</h1>
</div>
@@ -1437,7 +1566,7 @@ function SettingsSidebar({
{t("settings.backToChat")}
</button>
<div className="mb-3 px-1 md:mb-4 md:px-2">
<h2 className="text-[21px] font-semibold tracking-[-0.02em] text-foreground">
<h2 className="text-[18px] font-normal tracking-normal text-foreground">
{t("settings.sidebar.title")}
</h2>
</div>
@@ -1488,15 +1617,11 @@ function SettingsSidebar({
function OverviewSettings({
settings,
requiresRestart,
onRestart,
isRestarting,
onSelectSection,
showBrandLogos,
}: {
settings: SettingsPayload;
requiresRestart: boolean;
onRestart?: () => void;
isRestarting?: boolean;
onSelectSection: (section: SettingsSectionKey) => void;
showBrandLogos: boolean;
}) {
@@ -1504,6 +1629,16 @@ function OverviewSettings({
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const activePreset = settings.agent.model_preset || "default";
const activeProvider = settings.agent.resolved_provider ?? settings.agent.provider;
const activeProviderConfigured = settingsProviderConfigured(settings, activeProvider);
const activeProviderLabel = providerDisplayLabel(settings.providers, activeProvider);
const activeModelValue = activeProviderConfigured
? settings.agent.model
: tx("settings.values.notConfigured", "Not configured");
const activeModelCaption = activeProviderConfigured
? `${activeProvider} · ${activePreset}`
: activeProviderLabel || settings.agent.model
? [activeProviderLabel, settings.agent.model].filter(Boolean).join(" · ")
: tx("settings.byok.noConfiguredProviders", "No configured providers");
const webStatus = settings.web.enable
? tx("settings.values.enabled", "Enabled")
: tx("settings.values.disabled", "Disabled");
@@ -1515,48 +1650,23 @@ function OverviewSettings({
? tx("settings.values.configured", "Configured")
: tx("settings.values.notConfigured", "Not configured")
}`;
const isNativeHost = (settings.surface ?? settings.runtime_surface) === "native";
const workspaceCaption = shortWorkspacePath(settings.runtime.workspace_path);
const runtimeTitle = isNativeHost
? tx("settings.rows.engine", "Engine")
: tx("settings.rows.gateway", "Gateway");
const runtimeValue = isNativeHost
? tx("settings.values.privateEngine", "Private engine")
: `${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`;
const runtimeCaption = isNativeHost
? tx("settings.values.unixSocket", "Unix socket")
: requiresRestart
? tx("settings.values.restartPending", "Restart pending")
: tx("settings.values.ready", "Ready");
return (
<div className="space-y-7">
<section>
<div className="overflow-hidden rounded-[22px] border border-border/45 bg-card/86 shadow-[0_18px_65px_rgba(15,23,42,0.075)] backdrop-blur-xl dark:border-white/10 dark:shadow-[0_18px_65px_rgba(0,0,0,0.24)]">
<div className="flex flex-col gap-4 px-5 py-5 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-3">
<NanobotBrandLogo size="lg" testId="overview-nanobot-logo" />
<div className="min-w-0">
<div className="text-[12px] font-medium text-muted-foreground">nanobot</div>
<div className="mt-0.5 truncate text-[18px] font-semibold leading-6 text-foreground">
{settings.agent.model}
</div>
<div className="mt-0.5 truncate text-[13px] leading-5 text-muted-foreground">
{activeProvider} · {activePreset}
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
<StatusPill tone={requiresRestart ? "neutral" : "success"}>
{requiresRestart
? tx("settings.values.restartPending", "Restart pending")
: tx("settings.values.ready", "Ready")}
</StatusPill>
{requiresRestart && onRestart ? (
<Button
size="sm"
variant="ghost"
onClick={onRestart}
disabled={isRestarting}
className="rounded-full"
>
{isRestarting ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
)}
{isRestarting ? t("app.system.restarting") : t("app.system.restart")}
</Button>
) : null}
</div>
</div>
</div>
<TokenUsageHeatmap usage={settings.usage} />
</section>
<section>
@@ -1566,8 +1676,8 @@ function OverviewSettings({
icon={Bot}
valueLogoProvider={activeProvider}
title={tx("settings.overview.model", "Current model")}
value={settings.agent.model}
caption={`${activeProvider} · ${activePreset}`}
value={activeModelValue}
caption={activeModelCaption}
showBrandLogos={showBrandLogos}
onClick={() => onSelectSection("models")}
/>
@@ -1603,20 +1713,16 @@ function OverviewSettings({
<SettingsGroup>
<OverviewListRow
icon={Server}
title={tx("settings.rows.gateway", "Gateway")}
value={`${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`}
caption={
requiresRestart
? tx("settings.values.restartPending", "Restart pending")
: tx("settings.values.ready", "Ready")
}
title={runtimeTitle}
value={runtimeValue}
caption={runtimeCaption}
onClick={() => onSelectSection("runtime")}
/>
<OverviewListRow
icon={HardDrive}
title={tx("settings.overview.workspace", "Workspace")}
value={settings.runtime.workspace_path}
caption={settings.runtime.config_path}
value={tx("settings.values.defaultWorkspace", "Default workspace")}
caption={workspaceCaption}
onClick={() => onSelectSection("runtime")}
/>
</SettingsGroup>
@@ -1885,9 +1991,8 @@ function ModelsSettings({
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const configuredProviders = settings.providers.filter((provider) => provider.configured);
const oauthProviders = settings.providers.filter((provider) => provider.auth_type === "oauth");
const showAutoProvider = defaultPreset(settings)?.provider === "auto" || form.provider === "auto";
const selectableProviders = uniqueProviders([...configuredProviders, ...oauthProviders]);
const selectableProviders = uniqueProviders(configuredProviders);
const providerOptions = showAutoProvider
? [{ name: "auto", label: tx("settings.values.auto", "Auto") }, ...selectableProviders]
: selectableProviders;
@@ -1900,6 +2005,7 @@ function ModelsSettings({
const selectedProviderNeedsSignIn =
selectedProvider?.auth_type === "oauth" && !selectedProvider.configured;
const selectedProviderSigningIn = providerSaving === selectedProvider?.name;
const selectedProviderConfigured = settingsProviderConfigured(settings, form.provider);
const modelFieldsMissing =
!form.model.trim() ||
!form.provider.trim() ||
@@ -1918,6 +2024,7 @@ function ModelsSettings({
settings={settings}
draftModel={form.model}
draftProvider={form.provider}
providerConfigured={selectedProviderConfigured}
showProviderLogos={showBrandLogos}
onChange={(modelPreset) => {
const nextPreset = settings.model_presets.find((preset) => preset.name === modelPreset);
@@ -2871,9 +2978,11 @@ function AppsCatalogSettings({
const loading = (cliAppsLoading || mcpPresetsLoading) && !cliApps && !mcpPresets;
const statusMessage = cliError || mcpError || (!focusedApp ? cliMessage || mcpMessage : null);
const statusIsError = Boolean(cliError || mcpError);
const caption = tx("settings.apps.caption", "{{cli}} CLI · {{mcp}} MCP")
.replace("{{cli}}", String(cliApps?.installed_count ?? 0))
.replace("{{mcp}}", String(mcpPresets?.installed_count ?? 0));
const caption = t("settings.apps.caption", {
cli: cliApps?.installed_count ?? 0,
mcp: mcpPresets?.installed_count ?? 0,
defaultValue: "{{cli}} CLI · {{mcp}} MCP",
});
return (
<div className="space-y-7">
@@ -3255,7 +3364,10 @@ function McpAppsCatalogRow({
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-[12.5px] font-semibold text-foreground">
{tx("settings.mcp.connectTitle", "Connect {{name}}").replace("{{name}}", preset.display_name)}
{t("settings.mcp.connectTitle", {
name: preset.display_name,
defaultValue: "Connect {{name}}",
})}
</div>
<p className="mt-0.5 text-[11.5px] text-muted-foreground">
{tx("settings.mcp.connectHint", "Add the key from your account settings.")}
@@ -4060,10 +4172,12 @@ function RuntimeSettings({
<section>
<SettingsSectionTitle>{t("settings.sections.system")}</SettingsSectionTitle>
<SettingsGroup>
<ReadOnlyRow
title={tx("settings.rows.gateway", "Gateway")}
value={`${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`}
/>
{!isNativeHost ? (
<ReadOnlyRow
title={tx("settings.rows.gateway", "Gateway")}
value={`${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`}
/>
) : null}
<ReadOnlyRow title={t("settings.rows.configPath")} value={settings.runtime.config_path} />
<ReadOnlyRow title={tx("settings.rows.workspacePath", "Default workspace")} value={settings.runtime.workspace_path} />
{onRestart && !requiresRestartPending ? (
@@ -4369,7 +4483,14 @@ function ModelIdPicker({
const [error, setError] = useState<string | null>(null);
const effectiveProvider =
provider === "auto" ? settings.agent.resolved_provider ?? provider : provider;
const canFetchModels = Boolean(effectiveProvider && effectiveProvider !== "auto");
const hasConcreteProvider = Boolean(effectiveProvider && effectiveProvider !== "auto");
const providerRow = settingsProviderRow(settings, effectiveProvider);
const providerConfigured = settingsProviderConfigured(settings, effectiveProvider);
const providerRequiresConfiguration = hasConcreteProvider && !providerConfigured;
const providerUsesManualModelIds =
hasConcreteProvider && providerConfigured && providerRow?.auth_type === "oauth";
const canFetchModels =
hasConcreteProvider && providerConfigured && !providerUsesManualModelIds;
const normalizedQuery = query.trim().toLowerCase();
const providerModels = payload?.models ?? [];
const visibleModels = providerModels
@@ -4390,13 +4511,15 @@ function ModelIdPicker({
const hasModelList = payload?.status === "available";
const showModels = Boolean(hasModelList && payload && (!isCatalog || normalizedQuery));
const customCandidate = query.trim();
const allowCustomModel = !providerRequiresConfiguration;
const exactQueryMatch = providerModels.some((model) => model.id === customCandidate);
const providerModelCount = payload?.model_count ?? providerModels.length;
const modelUnconfigured = !value.trim() || !providerConfigured;
useEffect(() => {
if (!open) return;
setQuery("");
}, [open, effectiveProvider]);
setQuery(providerUsesManualModelIds || !hasConcreteProvider ? value : "");
}, [open, effectiveProvider, hasConcreteProvider, providerUsesManualModelIds, value]);
useEffect(() => {
if (!open || !shouldFetchModels) {
@@ -4443,7 +4566,11 @@ function ModelIdPicker({
)}
>
<span className="flex min-w-0 items-center gap-2">
<ProviderPickerIcon provider={effectiveProvider} showBrandLogos={showProviderLogos} />
<ProviderPickerIcon
provider={effectiveProvider}
showBrandLogos={showProviderLogos}
unconfigured={!providerConfigured}
/>
<span className="min-w-0 truncate font-medium text-foreground">
{model.label ?? model.id}
</span>
@@ -4467,7 +4594,11 @@ function ModelIdPicker({
)}
>
<span className="flex min-w-0 items-center gap-2">
<ProviderPickerIcon provider={effectiveProvider} showBrandLogos={showProviderLogos} />
<ProviderPickerIcon
provider={effectiveProvider}
showBrandLogos={showProviderLogos}
unconfigured={modelUnconfigured}
/>
<span
className={cn(
"min-w-0 truncate font-medium",
@@ -4500,7 +4631,15 @@ function ModelIdPicker({
</div>
</div>
{!canFetchModels ? (
{providerRequiresConfiguration ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.providerNotConfigured", "Configure this provider before loading models.")}
</div>
) : providerUsesManualModelIds ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
</div>
) : !canFetchModels ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.autoProviderCustomOnly", "Auto provider mode uses custom model IDs.")}
</div>
@@ -4544,7 +4683,7 @@ function ModelIdPicker({
</div>
) : null}
{customCandidate && !exactQueryMatch && customCandidate !== value ? (
{allowCustomModel && customCandidate && !exactQueryMatch && customCandidate !== value ? (
<>
{showModels ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem
@@ -4581,17 +4720,31 @@ function formatContextWindow(tokens: number): string {
function ProviderPickerIcon({
provider,
showBrandLogos,
unconfigured = false,
}: {
provider: string;
showBrandLogos: boolean;
unconfigured?: boolean;
}) {
const [logoIndex, setLogoIndex] = useState(0);
const brand = providerBrand(provider);
const Icon = PROVIDER_ICONS[provider] ?? Sparkles;
const Icon = PROVIDER_ICONS[provider] ?? Hexagon;
const logoUrl = brand?.logoUrls[logoIndex];
useEffect(() => setLogoIndex(0), [provider]);
if (unconfigured) {
return (
<span
data-testid="provider-picker-unconfigured-icon"
className="grid h-5 w-5 shrink-0 place-items-center text-amber-700 dark:text-amber-200"
aria-hidden
>
<CircleAlert className="h-4 w-4" strokeWidth={1.8} />
</span>
);
}
if (showBrandLogos && logoUrl) {
return (
<span
@@ -4901,32 +5054,6 @@ function ProviderIcon({
);
}
function NanobotBrandLogo({
size = "sm",
testId,
}: {
size?: "sm" | "lg";
testId?: string;
}) {
return (
<span
data-testid={testId}
className={cn(
"grid shrink-0 place-items-center overflow-hidden border border-border/45 bg-background shadow-[inset_0_0_0_1px_rgba(0,0,0,0.025)]",
size === "lg" ? "h-12 w-12 rounded-[16px]" : "h-9 w-9 rounded-[12px]",
)}
aria-hidden
>
<img
src={NANOBOT_ICON_SRC}
alt=""
className={cn("select-none object-contain", size === "lg" ? "h-10 w-10" : "h-7 w-7")}
draggable={false}
/>
</span>
);
}
function OverviewRowIcon({
icon: Icon,
}: {
@@ -5090,6 +5217,7 @@ function ModelPresetPicker({
settings,
draftModel,
draftProvider,
providerConfigured,
showProviderLogos,
onChange,
onCreateConfiguration,
@@ -5099,6 +5227,7 @@ function ModelPresetPicker({
settings: SettingsPayload;
draftModel: string;
draftProvider: string;
providerConfigured: boolean;
showProviderLogos: boolean;
onChange: (preset: string) => void;
onCreateConfiguration: () => void;
@@ -5126,6 +5255,7 @@ function ModelPresetPicker({
settings={settings}
draftModel={draftModel}
draftProvider={draftProvider}
forceUnconfigured={selectedPreset?.is_default ? !providerConfigured : undefined}
showProviderLogos={showProviderLogos}
compact
/>
@@ -5190,6 +5320,7 @@ function ModelPresetOptionContent({
settings,
draftModel,
draftProvider,
forceUnconfigured,
showProviderLogos,
compact = false,
}: {
@@ -5197,27 +5328,50 @@ function ModelPresetOptionContent({
settings: SettingsPayload;
draftModel: string;
draftProvider: string;
forceUnconfigured?: boolean;
showProviderLogos: boolean;
compact?: boolean;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const provider = modelPresetProviderKey(preset, settings, {
draftProvider: preset.is_default ? draftProvider : undefined,
});
const model = preset.is_default ? draftModel : preset.model;
const providerName = providerDisplayLabel(settings.providers, provider);
const providerConfigured =
forceUnconfigured === undefined
? settingsProviderConfigured(settings, provider)
: !forceUnconfigured;
const title = providerConfigured ? model || preset.label : tx("settings.values.notConfigured", "Not configured");
const caption = providerConfigured
? `${providerName}${preset.label ? ` · ${preset.label}` : ""}`
: providerName || model || preset.label
? [providerName, model || preset.label].filter(Boolean).join(" · ")
: tx("settings.byok.noConfiguredProviders", "No configured providers");
return (
<span className="flex min-w-0 items-center gap-2.5">
<ProviderPickerIcon provider={provider} showBrandLogos={showProviderLogos} />
<ProviderPickerIcon
provider={provider}
showBrandLogos={showProviderLogos}
unconfigured={!providerConfigured}
/>
<span className="min-w-0 text-left leading-tight">
<span className="block truncate font-medium text-foreground">{model || preset.label}</span>
<span
className={cn(
"block truncate font-medium",
providerConfigured ? "text-foreground" : "text-amber-800 dark:text-amber-200",
)}
>
{title}
</span>
<span
className={cn(
"mt-0.5 block truncate text-muted-foreground",
compact ? "text-[11.5px]" : "text-[12px]",
)}
>
{providerName}
{preset.label ? ` · ${preset.label}` : ""}
{caption}
</span>
</span>
</span>
@@ -0,0 +1,417 @@
import { useEffect, useState, type ReactNode } from "react";
import type { TFunction } from "i18next";
import { Brain, Check, CircleAlert, KeyRound, Loader2, Terminal } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet";
import { fetchSkillDetail } from "@/lib/api";
import type { SkillDetail, SkillSummary } from "@/lib/types";
import { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider";
export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
const { t } = useTranslation();
const availableCount = skills.filter((skill) => skill.available).length;
const [selectedSkill, setSelectedSkill] = useState<SkillSummary | null>(null);
return (
<div className="space-y-7">
<section className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<p className="max-w-[680px] text-[13px] leading-5 text-muted-foreground">
{t("settings.skills.description", {
defaultValue: "Review the instruction skills this agent can load during a conversation.",
})}
</p>
<span className="text-[12px] font-medium text-muted-foreground">
{t("settings.skills.caption", {
available: availableCount,
total: skills.length,
defaultValue: "{{available}} available · {{total}} total",
})}
</span>
</section>
<section>
<div className="flex items-center justify-between border-b border-border/45 pb-3">
<h2 className="mb-2 px-1 text-[13px] font-semibold tracking-[-0.01em] text-foreground/85">
{t("settings.skills.featured", { defaultValue: "Agent skills" })}
</h2>
<span className="rounded-full bg-muted px-2.5 py-1 text-[12px] font-medium text-muted-foreground">
{skills.length}
</span>
</div>
{skills.length ? (
<div className="grid gap-x-10 gap-y-1 py-3 md:grid-cols-2">
{skills.map((skill) => (
<SkillCatalogRow
key={`${skill.source}:${skill.name}`}
skill={skill}
onSelect={setSelectedSkill}
/>
))}
</div>
) : (
<div className="px-3 py-12 text-center text-sm text-muted-foreground">
{t("settings.skills.empty", { defaultValue: "No skills are available." })}
</div>
)}
</section>
<SkillDetailSheet
skill={selectedSkill}
open={selectedSkill !== null}
onOpenChange={(open) => {
if (!open) setSelectedSkill(null);
}}
/>
</div>
);
}
function SkillCatalogRow({
skill,
onSelect,
}: {
skill: SkillSummary;
onSelect: (skill: SkillSummary) => void;
}) {
const { t } = useTranslation();
const sourceLabel = skillSourceLabel(skill.source, t);
const StatusIcon = skill.available ? Check : CircleAlert;
const statusLabel = skill.available
? t("settings.skills.statusAvailable", { defaultValue: "Available" })
: t("settings.skills.statusUnavailable", { defaultValue: "Unavailable" });
return (
<button
type="button"
aria-label={t("settings.skills.openDetails", {
name: skill.name,
defaultValue: "Open details for {{name}}",
})}
onClick={() => onSelect(skill)}
className={cn(
"group flex min-w-0 items-center gap-3 rounded-[16px] px-3 py-3 text-left transition-colors",
"hover:bg-muted/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
!skill.available && "opacity-65",
)}
>
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-[14px] bg-muted/70 text-muted-foreground">
<Brain className="h-5 w-5" strokeWidth={1.8} aria-hidden />
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<h3 className="truncate text-[15px] font-semibold leading-5 text-foreground">
{skill.name}
</h3>
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-semibold leading-none text-muted-foreground">
{sourceLabel}
</span>
</div>
<p className="mt-1 line-clamp-2 text-[13px] leading-5 text-muted-foreground">
{skill.description}
</p>
{!skill.available && skill.unavailable_reason ? (
<p className="mt-1 truncate text-[12px] leading-4 text-muted-foreground/80">
{t("settings.skills.unavailableReason", {
reason: skill.unavailable_reason,
defaultValue: "Missing: {{reason}}",
})}
</p>
) : null}
</div>
<span
title={!skill.available && skill.unavailable_reason ? skill.unavailable_reason : undefined}
className={cn(
"hidden shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-[12px] font-medium sm:inline-flex",
skill.available
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: "bg-muted text-muted-foreground",
)}
>
<StatusIcon className="h-3.5 w-3.5" aria-hidden />
{statusLabel}
</span>
</button>
);
}
function SkillDetailSheet({
skill,
open,
onOpenChange,
}: {
skill: SkillSummary | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { token } = useClient();
const { t } = useTranslation();
const [detail, setDetail] = useState<SkillDetail | null>(null);
const [loading, setLoading] = useState(false);
const [loadFailed, setLoadFailed] = useState(false);
useEffect(() => {
if (!open || !skill) return;
let cancelled = false;
setDetail(null);
setLoading(true);
setLoadFailed(false);
fetchSkillDetail(token, skill.name)
.then((payload) => {
if (!cancelled) setDetail(payload);
})
.catch(() => {
if (!cancelled) setLoadFailed(true);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open, skill, token]);
if (!skill) return null;
const activeSkill = detail ?? skill;
const sourceLabel = skillSourceLabel(activeSkill.source, t);
const statusLabel = activeSkill.available
? t("settings.skills.statusAvailable", { defaultValue: "Available" })
: t("settings.skills.statusUnavailable", { defaultValue: "Unavailable" });
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="w-[min(34rem,calc(100vw-1rem))] max-w-none gap-0 overflow-hidden p-0 sm:max-w-none"
>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
<div className="flex items-start gap-3 pr-8">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-[15px] bg-muted/70 text-muted-foreground">
<Brain className="h-5 w-5" strokeWidth={1.8} aria-hidden />
</div>
<div className="min-w-0">
<SheetTitle className="truncate text-[20px] font-semibold">
{activeSkill.name}
</SheetTitle>
<SheetDescription className="sr-only">
{t("settings.skills.detailDescription", {
name: activeSkill.name,
defaultValue: "Details for {{name}}.",
})}
</SheetDescription>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[12px] text-muted-foreground">
<Pill>{sourceLabel}</Pill>
<Pill tone={activeSkill.available ? "success" : "muted"}>{statusLabel}</Pill>
</div>
</div>
</div>
{loading ? (
<div className="mt-8 flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
{t("settings.skills.loadingDetail", { defaultValue: "Loading skill details..." })}
</div>
) : loadFailed ? (
<div className="mt-8 rounded-[16px] bg-destructive/10 px-3 py-3 text-sm text-destructive">
{t("settings.skills.loadFailed", { defaultValue: "Could not load skill details." })}
</div>
) : (
<div className="mt-7 space-y-6">
<DetailSection title={t("settings.skills.descriptionTitle", { defaultValue: "Description" })}>
<p className="text-[14px] leading-6 text-muted-foreground">{activeSkill.description}</p>
</DetailSection>
<div className="grid grid-cols-2 gap-2">
<MetaItem
label={t("settings.skills.source", { defaultValue: "Source" })}
value={sourceLabel}
/>
<MetaItem
label={t("settings.skills.status", { defaultValue: "Status" })}
value={statusLabel}
/>
</div>
{!activeSkill.available && activeSkill.unavailable_reason ? (
<DetailSection
title={t("settings.skills.unavailableReasonLabel", {
defaultValue: "Unavailable reason",
})}
>
<p className="text-[13px] leading-5 text-destructive/85">
{activeSkill.unavailable_reason}
</p>
</DetailSection>
) : null}
{detail ? <RequirementsSection detail={detail} /> : null}
{detail ? <RawInstructionsBlock markdown={detail.raw_markdown} /> : null}
</div>
)}
</div>
</SheetContent>
</Sheet>
);
}
function RawInstructionsBlock({ markdown }: { markdown: string }) {
const { t } = useTranslation();
const content =
markdown ||
t("settings.skills.rawInstructionsEmpty", {
defaultValue: "No raw instructions.",
});
return (
<details className="group rounded-[18px] border border-border/45 bg-muted/20 px-3 py-3">
<summary className="cursor-pointer select-none text-[13px] font-medium text-foreground/90 transition-colors hover:text-foreground">
{t("settings.skills.rawInstructions", { defaultValue: "Raw SKILL.md" })}
</summary>
<div className="mt-3 overflow-hidden rounded-[14px] border border-border/35 bg-background/70">
<pre
className={cn(
"max-h-[min(42vh,32rem)] overflow-auto overscroll-contain px-3.5 py-3 pr-4",
"whitespace-pre-wrap break-words font-mono text-[12px] leading-[1.7] text-foreground/62",
"scrollbar-thin scrollbar-track-transparent",
"[&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar]:w-1.5",
"[&::-webkit-scrollbar-thumb]:bg-muted-foreground/25",
)}
>
{content}
</pre>
</div>
</details>
);
}
function MetaItem({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-[16px] bg-muted/35 px-3 py-2.5">
<div className="text-[11px] text-muted-foreground">{label}</div>
<div className="mt-0.5 truncate text-[13px] font-medium text-foreground">{value}</div>
</div>
);
}
function RequirementsSection({ detail }: { detail: SkillDetail }) {
const { t } = useTranslation();
const { bins, env, missing_bins, missing_env } = detail.requirements;
const hasRequirements = bins.length > 0 || env.length > 0;
return (
<DetailSection title={t("settings.skills.requirements", { defaultValue: "Requirements" })}>
{hasRequirements ? (
<div className="space-y-3">
{missing_bins.length ? (
<RequirementLine
title={t("settings.skills.missingCommands", { defaultValue: "Missing CLI" })}
items={missing_bins}
tone="danger"
icon={<Terminal className="h-3.5 w-3.5" aria-hidden />}
/>
) : null}
{missing_env.length ? (
<RequirementLine
title={t("settings.skills.missingEnvironment", { defaultValue: "Missing ENV" })}
items={missing_env}
tone="danger"
icon={<KeyRound className="h-3.5 w-3.5" aria-hidden />}
/>
) : null}
{bins.length ? (
<RequirementLine
title={t("settings.skills.commands", { defaultValue: "Commands" })}
items={bins}
icon={<Terminal className="h-3.5 w-3.5" aria-hidden />}
/>
) : null}
{env.length ? (
<RequirementLine
title={t("settings.skills.environment", { defaultValue: "Environment variables" })}
items={env}
icon={<KeyRound className="h-3.5 w-3.5" aria-hidden />}
/>
) : null}
</div>
) : (
<p className="text-[13px] text-muted-foreground">
{t("settings.skills.noRequirements", { defaultValue: "No explicit requirements." })}
</p>
)}
</DetailSection>
);
}
function DetailSection({ title, children }: { title: string; children: ReactNode }) {
return (
<section>
<h3 className="mb-2 text-[12px] font-medium text-muted-foreground">{title}</h3>
{children}
</section>
);
}
function RequirementLine({
title,
items,
icon,
tone = "muted",
}: {
title: string;
items: string[];
icon: ReactNode;
tone?: "muted" | "danger";
}) {
return (
<div className="space-y-1.5">
<div
className={cn(
"flex items-center gap-1.5 text-[12px]",
tone === "danger" ? "text-destructive" : "text-muted-foreground",
)}
>
{icon}
{title}
</div>
<div className="flex flex-wrap gap-1.5">
{items.map((item) => (
<Pill key={item}>{item}</Pill>
))}
</div>
</div>
);
}
function Pill({
children,
tone = "muted",
}: {
children: ReactNode;
tone?: "muted" | "success";
}) {
return (
<span
className={cn(
"inline-flex max-w-full items-center rounded-full px-2 py-0.5 text-[11px] font-medium",
tone === "success"
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: "bg-muted text-muted-foreground",
)}
>
{children}
</span>
);
}
function skillSourceLabel(source: string, t: TFunction): string {
if (source === "workspace") {
return t("settings.skills.sourceWorkspace", { defaultValue: "Custom" });
}
if (source === "builtin") {
return t("settings.skills.sourceBuiltin", { defaultValue: "Built-in" });
}
return source;
}
@@ -0,0 +1,224 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { SettingsPayload } from "@/lib/types";
type TokenUsagePayload = NonNullable<SettingsPayload["usage"]>;
type TokenUsageDay = TokenUsagePayload["days"][number];
type TokenUsageCell = {
date: string;
total: number;
estimated: number;
requests: number;
sources: NonNullable<TokenUsageDay["sources"]>;
future: boolean;
};
type TokenUsageMonthLabel = {
label: string;
column: number;
};
const TOKEN_HEATMAP_CELLS = 371;
const TOKEN_HEATMAP_COLUMNS = Math.ceil(TOKEN_HEATMAP_CELLS / 7);
const TOKEN_USAGE_SOURCE_ORDER = ["user", "api", "cron", "dream", "system"] as const;
function startOfUtcDay(date: Date): Date {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
}
function addUtcDays(date: Date, days: number): Date {
const next = new Date(date);
next.setUTCDate(next.getUTCDate() + days);
return next;
}
function isoDay(date: Date): string {
return date.toISOString().slice(0, 10);
}
function buildTokenUsageCalendar(
days: TokenUsageDay[] | undefined,
monthFormatter: Intl.DateTimeFormat,
): { cells: TokenUsageCell[]; monthLabels: TokenUsageMonthLabel[] } {
const byDate = new Map((days ?? []).map((day) => [day.date, day]));
const today = startOfUtcDay(new Date());
const end = addUtcDays(today, 6 - today.getUTCDay());
const start = addUtcDays(end, -(TOKEN_HEATMAP_CELLS - 1));
const seenMonths = new Set<string>();
const monthLabels: TokenUsageMonthLabel[] = [];
const cells = Array.from({ length: TOKEN_HEATMAP_CELLS }, (_, index) => {
const date = addUtcDays(start, index);
const key = isoDay(date);
const row = byDate.get(key);
const monthKey = key.slice(0, 7);
if (!seenMonths.has(monthKey)) {
seenMonths.add(monthKey);
monthLabels.push({
label: monthFormatter.format(date),
column: Math.floor(index / 7) + 1,
});
}
return {
date: key,
total: row?.total_tokens ?? 0,
estimated: row?.estimated_tokens ?? 0,
requests: row?.requests ?? 0,
sources: row?.sources ?? {},
future: date > today,
};
});
return { cells, monthLabels };
}
function tokenUsageSourceLabel(
source: string,
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
): string {
if (source === "user") return tx("settings.usage.sources.user", "Chat");
if (source === "api") return tx("settings.usage.sources.api", "API");
if (source === "cron") return tx("settings.usage.sources.cron", "Automations");
if (source === "dream") return tx("settings.usage.sources.dream", "Memory");
return tx("settings.usage.sources.system", "System");
}
function tokenUsageSourceBreakdown(
cell: TokenUsageCell,
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
): string {
const known = TOKEN_USAGE_SOURCE_ORDER.filter((source) => cell.sources[source]?.total_tokens > 0);
const extra = Object.keys(cell.sources)
.filter((source) => !TOKEN_USAGE_SOURCE_ORDER.includes(source as typeof TOKEN_USAGE_SOURCE_ORDER[number]))
.filter((source) => cell.sources[source]?.total_tokens > 0)
.sort();
return [...known, ...extra]
.map((source) => {
const label = tokenUsageSourceLabel(source, tx);
const tokens = formatCompactTokens(cell.sources[source]?.total_tokens ?? 0);
return `${label} ${tokens}`;
})
.join(" · ");
}
function formatCompactTokens(tokens: number): string {
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(tokens >= 10_000_000 ? 0 : 1)}M`;
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(tokens >= 10_000 ? 0 : 1)}K`;
return String(tokens);
}
function tokenUsageLevel(tokens: number, max: number): number {
if (tokens <= 0 || max <= 0) return 0;
const ratio = tokens / max;
if (ratio >= 0.75) return 4;
if (ratio >= 0.45) return 3;
if (ratio >= 0.2) return 2;
return 1;
}
function tokenUsageCellClass(level: number, future: boolean): string {
if (future) return "bg-transparent ring-1 ring-neutral-200/70 dark:ring-white/[0.045]";
if (level === 4) return "bg-sky-300 dark:bg-sky-300";
if (level === 3) return "bg-sky-400/85 dark:bg-sky-500/80";
if (level === 2) return "bg-sky-500/60 dark:bg-sky-700/85";
if (level === 1) return "bg-sky-500/30 dark:bg-sky-900/80";
return "bg-neutral-200/70 ring-1 ring-black/[0.025] dark:bg-white/[0.08] dark:ring-white/[0.035]";
}
export function TokenUsageHeatmap({ usage }: { usage?: TokenUsagePayload }) {
const { t, i18n } = useTranslation();
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
t(key, { defaultValue: fallback, ...(values ?? {}) });
const monthFormatter = useMemo(
() => new Intl.DateTimeFormat(i18n.language, { month: "short", timeZone: "UTC" }),
[i18n.language],
);
const { cells, monthLabels } = useMemo(
() => buildTokenUsageCalendar(usage?.days, monthFormatter),
[monthFormatter, usage?.days],
);
const maxTokens = Math.max(0, ...cells.map((cell) => cell.total));
return (
<div className="overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div className="mx-auto w-full min-w-[760px] max-w-[1054px] px-0.5">
<div className="mb-2 flex justify-end">
<span className="text-[11px] font-normal leading-none text-muted-foreground/64">
{tx("settings.usage.shortTitle", "Token Usage")}
</span>
</div>
<div
className="mb-2 grid h-4 gap-1.5 text-[10px] font-normal leading-4 text-muted-foreground/62"
style={{ gridTemplateColumns: `repeat(${TOKEN_HEATMAP_COLUMNS}, minmax(0, 1fr))` }}
aria-hidden
>
{monthLabels.map((month) => (
<span
key={`${month.label}-${month.column}`}
className="truncate"
style={{ gridColumnStart: month.column, gridColumnEnd: "span 4" }}
>
{month.label}
</span>
))}
</div>
<div
className="grid grid-flow-col grid-rows-7 gap-1.5"
style={{ gridTemplateColumns: `repeat(${TOKEN_HEATMAP_COLUMNS}, minmax(0, 1fr))` }}
aria-label={tx("settings.usage.title", "Token activity")}
>
<TooltipProvider delayDuration={120} skipDelayDuration={80}>
{cells.map((cell) => {
const level = tokenUsageLevel(cell.total, maxTokens);
const baseLabel = cell.future
? cell.date
: tx("settings.usage.cellTitle", "{{date}}: {{tokens}} tokens, {{requests}} requests", {
date: cell.date,
tokens: formatCompactTokens(cell.total),
requests: cell.requests,
});
const label = cell.future || cell.estimated <= 0
? baseLabel
: `${baseLabel} · ${
cell.estimated >= cell.total
? tx("settings.usage.estimated", "estimated")
: tx("settings.usage.includesEstimates", "includes estimates")
}`;
const breakdown = cell.future ? "" : tokenUsageSourceBreakdown(cell, tx);
const ariaLabel = breakdown ? `${label} · ${breakdown}` : label;
return (
<Tooltip key={cell.date}>
<TooltipTrigger asChild>
<span
aria-label={ariaLabel}
className={cn(
"aspect-square w-full rounded-[4px] transition-transform hover:scale-110",
tokenUsageCellClass(level, cell.future),
)}
/>
</TooltipTrigger>
<TooltipContent
side="top"
align="center"
className="rounded-[10px] border-border/45 bg-popover px-2.5 py-1.5 text-[11px] font-normal text-popover-foreground shadow-lg"
>
<span className="block">{label}</span>
{breakdown ? (
<span className="mt-1 block text-muted-foreground">{breakdown}</span>
) : null}
</TooltipContent>
</Tooltip>
);
})}
</TooltipProvider>
</div>
</div>
</div>
);
}