feat: improve mobile UX (#1591)
Added a mobile MCP overlay so MCP tools can be opened and managed from the mobile UI without relying on desktop-only dropdown behavior. Improved mobile session panel touch handling so tapping the status/session area opens the right panel reliably on phones and tablets. Cleaned up mobile usage provider metadata by removing duplicate rows, hiding unset providers, and showing provider logos consistently. Added eager loading for provider logos used in mobile usage views to avoid delayed or missing icons when the panel opens. Refined the mobile update and about flows in OpenChamber settings so release/update information is easier to read on small screens. Adjusted related layout, header, VS Code layout, command palette, and settings text/localization details needed for the mobile polish.
This commit is contained in:
committed by
GitHub
parent
0153f8787d
commit
eff6f46ad9
@@ -2,13 +2,11 @@ import React from 'react';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useAllSessionStatuses, useAllLiveSessions } from '@/sync/sync-context';
|
||||
import { mergeSessionDirectoryMetadata, useGlobalSessionsStore, ensureGlobalSessionsLoaded, refreshGlobalSessions } from '@/stores/useGlobalSessionsStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { ProjectEntry } from '@/lib/api/types';
|
||||
import { cn, formatDirectoryName } from '@/lib/utils';
|
||||
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
|
||||
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, ProjectIconImage } from '@/lib/projectMeta';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
@@ -288,36 +286,6 @@ function UnreadIndicator({ count }: { count: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TokenUsageIndicator({ contextUsage }: { contextUsage: SessionContextUsage | null }) {
|
||||
if (!contextUsage || contextUsage.totalTokens === 0) return null;
|
||||
|
||||
// Recompute with a fraction: contextUsage.percentage is rounded to an integer
|
||||
// in the store, which would always render as "X.0%".
|
||||
const percentage = contextUsage.contextLimit > 0
|
||||
? Math.min((contextUsage.totalTokens / contextUsage.contextLimit) * 100, 999)
|
||||
: 0;
|
||||
const colorClass =
|
||||
percentage >= 90 ? 'text-[var(--status-error)]' :
|
||||
percentage >= 75 ? 'text-[var(--status-warning)]' : 'text-[var(--status-success)]';
|
||||
|
||||
const formatTokens = (value: number): string => {
|
||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
||||
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const tokens = contextUsage.contextLimit > 0
|
||||
? `${formatTokens(contextUsage.totalTokens)}/${formatTokens(contextUsage.contextLimit)}`
|
||||
: formatTokens(contextUsage.totalTokens);
|
||||
|
||||
return (
|
||||
<span className="flex items-baseline gap-1.5 text-[15px] tabular-nums">
|
||||
<span className={cn("font-medium", colorClass)}>{percentage.toFixed(1)}%</span>
|
||||
<span className="text-[var(--surface-mutedForeground)]">{tokens}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// A single session row sized for comfortable touch.
|
||||
function SessionItem({
|
||||
session,
|
||||
@@ -448,19 +416,19 @@ export const MobileSessionPanelTrigger: React.FC<MobileSessionPanelTriggerProps>
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const showMobileSessionStatusBar = useUIStore((state) => state.showMobileSessionStatusBar);
|
||||
const open = useUIStore((state) => state.mobileSessionPanelOpen);
|
||||
const setOpen = useUIStore((state) => state.setMobileSessionPanelOpen);
|
||||
const handledTouchRef = React.useRef(false);
|
||||
|
||||
// Ensure the cross-project session list is loaded once, so the panel reflects
|
||||
// every project, not just the active directory.
|
||||
React.useEffect(() => {
|
||||
if (isMobile && showMobileSessionStatusBar) {
|
||||
if (isMobile) {
|
||||
void ensureGlobalSessionsLoaded();
|
||||
}
|
||||
}, [isMobile, showMobileSessionStatusBar]);
|
||||
}, [isMobile]);
|
||||
|
||||
if (!isMobile || !showMobileSessionStatusBar) {
|
||||
if (!isMobile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -476,9 +444,17 @@ export const MobileSessionPanelTrigger: React.FC<MobileSessionPanelTriggerProps>
|
||||
if (event.pointerType === 'touch') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handledTouchRef.current = true;
|
||||
setOpen(!open);
|
||||
}
|
||||
}}
|
||||
onClick={() => setOpen(!open)}
|
||||
onClick={() => {
|
||||
if (handledTouchRef.current) {
|
||||
handledTouchRef.current = false;
|
||||
return;
|
||||
}
|
||||
setOpen(!open);
|
||||
}}
|
||||
title={t('mobile.sessions.search.section.sessions')}
|
||||
aria-label={t('mobile.sessions.search.section.sessions')}
|
||||
aria-expanded={open}
|
||||
@@ -493,15 +469,12 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const sessions = useAllProjectSessions();
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const sessionStatus = useAllSessionStatuses();
|
||||
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
||||
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
||||
const getContextUsage = useSessionUIStore((state) => state.getContextUsage);
|
||||
const getCurrentModel = useConfigStore((state) => state.getCurrentModel);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const showMobileSessionStatusBar = useUIStore((state) => state.showMobileSessionStatusBar);
|
||||
const open = useUIStore((state) => state.mobileSessionPanelOpen);
|
||||
const setOpen = useUIStore((state) => state.setMobileSessionPanelOpen);
|
||||
|
||||
@@ -554,15 +527,6 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
||||
[filteredSessions],
|
||||
);
|
||||
|
||||
// Token usage for the current session.
|
||||
const currentModel = getCurrentModel();
|
||||
const limit = currentModel && typeof currentModel.limit === 'object' && currentModel.limit !== null
|
||||
? (currentModel.limit as Record<string, unknown>)
|
||||
: null;
|
||||
const contextLimit = (limit && typeof limit.context === 'number' ? limit.context : 0);
|
||||
const outputLimit = (limit && typeof limit.output === 'number' ? limit.output : 0);
|
||||
const contextUsage = getContextUsage(contextLimit, outputLimit);
|
||||
|
||||
const handleSessionClick = (session: SessionWithStatus) => {
|
||||
setCurrentSession(session.id, sessionDirectory(session) || null);
|
||||
onSessionSwitch?.(session.id);
|
||||
@@ -603,7 +567,6 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
||||
<div className="flex items-center gap-3">
|
||||
<RunningIndicator count={totalRunning} />
|
||||
<UnreadIndicator count={totalUnread} />
|
||||
<TokenUsageIndicator contextUsage={contextUsage} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewChat}
|
||||
@@ -654,9 +617,9 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
), [t, totalRunning, totalUnread, contextUsage, projects, filterProjectId, setFilterProjectId, formatProjectLabel, currentTheme, getProjectStatus, handleNewChat, setOpen]);
|
||||
), [t, totalRunning, totalUnread, projects, filterProjectId, setFilterProjectId, formatProjectLabel, currentTheme, getProjectStatus, handleNewChat, setOpen]);
|
||||
|
||||
if (!isMobile || !showMobileSessionStatusBar) {
|
||||
if (!isMobile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import { UpdateDialog } from '@/components/ui/UpdateDialog';
|
||||
import { useDeviceInfo, useTabletStandalonePwaRuntime } from '@/lib/device';
|
||||
import { cn, hasModifier } from '@/lib/utils';
|
||||
import { McpDropdownContent } from '@/components/mcp/McpDropdown';
|
||||
import { McpIcon } from '@/components/icons/McpIcon';
|
||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||
import { formatQuotaValueLabel, formatQuotaResetLabel, formatWindowLabel, QUOTA_PROVIDERS, calculatePace, calculateExpectedUsagePercent } from '@/lib/quota';
|
||||
import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar';
|
||||
@@ -1776,13 +1777,13 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
}, [activeMainTab, isMobile, setActiveMainTab]);
|
||||
|
||||
const servicesTabs = React.useMemo(() => {
|
||||
const base: Array<{ value: 'instance' | 'usage' | 'mcp'; label: string; icon: IconName }> = [];
|
||||
const base: Array<{ value: 'instance' | 'usage' | 'mcp'; label: string; icon: React.ReactNode }> = [];
|
||||
if (isDesktopApp) {
|
||||
base.push({ value: 'instance', label: t('layout.services.instance'), icon: "server" });
|
||||
base.push({ value: 'instance', label: t('layout.services.instance'), icon: <Icon name="server" className="h-3.5 w-3.5" /> });
|
||||
}
|
||||
base.push(
|
||||
{ value: 'usage', label: t('layout.services.usage'), icon: "timer" },
|
||||
{ value: 'mcp', label: 'MCP', icon: "plug-2" }
|
||||
{ value: 'usage', label: t('layout.services.usage'), icon: <Icon name="timer" className="h-3.5 w-3.5" /> },
|
||||
{ value: 'mcp', label: 'MCP', icon: <McpIcon className="h-3.5 w-3.5" /> }
|
||||
);
|
||||
return base;
|
||||
}, [isDesktopApp, t]);
|
||||
@@ -1791,7 +1792,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
return servicesTabs.map((tab) => ({
|
||||
id: tab.value,
|
||||
label: tab.label,
|
||||
icon: <Icon name={tab.icon} className="h-3.5 w-3.5" />,
|
||||
icon: tab.icon,
|
||||
}));
|
||||
}, [servicesTabs]);
|
||||
|
||||
@@ -1866,7 +1867,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
const mobileServicesTabItems = React.useMemo<SortableTabsStripItem[]>(() => {
|
||||
return [
|
||||
{ id: 'usage', label: t('layout.services.usage'), icon: <Icon name="timer" className="h-3.5 w-3.5" /> },
|
||||
{ id: 'mcp', label: 'MCP', icon: <Icon name="command" className="h-3.5 w-3.5" /> },
|
||||
{ id: 'mcp', label: 'MCP', icon: <McpIcon className="h-3.5 w-3.5" /> },
|
||||
];
|
||||
}, [t]);
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import { DrawerProvider } from '@/contexts/DrawerContext';
|
||||
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useUpdateStore } from '@/stores/useUpdateStore';
|
||||
import { useUpdatePolling } from '@/hooks/useUpdatePolling';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||
@@ -219,41 +219,7 @@ export const MainLayout: React.FC = () => {
|
||||
}
|
||||
}, [isMobile, isSettingsDialogOpen, isRightSidebarOpen, setMobileSessionPanelOpen, setRightSidebarOpen]);
|
||||
|
||||
// Trigger initial update check shortly after mount, then repeat using server-suggested cadence.
|
||||
const checkForUpdates = useUpdateStore((state) => state.checkForUpdates);
|
||||
React.useEffect(() => {
|
||||
const initialDelayMs = 3000;
|
||||
const defaultIntervalMs = 60 * 60 * 1000;
|
||||
const minIntervalMs = 5 * 60 * 1000;
|
||||
const maxIntervalMs = 24 * 60 * 60 * 1000;
|
||||
let disposed = false;
|
||||
let timer: number | null = null;
|
||||
|
||||
const clampIntervalMs = (seconds: number): number => {
|
||||
const ms = Math.round(seconds * 1000);
|
||||
return Math.max(minIntervalMs, Math.min(maxIntervalMs, ms));
|
||||
};
|
||||
|
||||
const scheduleNext = (delayMs: number) => {
|
||||
if (disposed) return;
|
||||
timer = window.setTimeout(async () => {
|
||||
const suggestedSec = await checkForUpdates();
|
||||
const nextDelay = typeof suggestedSec === 'number' && Number.isFinite(suggestedSec)
|
||||
? clampIntervalMs(suggestedSec)
|
||||
: defaultIntervalMs;
|
||||
scheduleNext(nextDelay);
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
scheduleNext(initialDelayMs);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (timer !== null) {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
}, [checkForUpdates]);
|
||||
useUpdatePolling();
|
||||
|
||||
React.useEffect(() => {
|
||||
const previous = useUIStore.getState().isMobile;
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useUpdatePolling } from '@/hooks/useUpdatePolling';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { toast } from '@/components/ui';
|
||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||
@@ -31,7 +32,6 @@ import { PaceIndicator } from '@/components/sections/usage/PaceIndicator';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { formatQuotaValueLabel, formatQuotaResetLabel, formatWindowLabel, QUOTA_PROVIDERS, calculatePace, calculateExpectedUsagePercent } from '@/lib/quota';
|
||||
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
|
||||
import { useUpdateStore } from '@/stores/useUpdateStore';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { formatTimeForPreference } from '@/lib/timeFormat';
|
||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||
@@ -73,41 +73,7 @@ type VSCodeView = 'sessions' | 'chat' | 'settings';
|
||||
export const VSCodeLayout: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const runtimeApis = useRuntimeAPIs();
|
||||
const checkForUpdates = useUpdateStore((state) => state.checkForUpdates);
|
||||
|
||||
React.useEffect(() => {
|
||||
const initialDelayMs = 3000;
|
||||
const defaultIntervalMs = 60 * 60 * 1000;
|
||||
const minIntervalMs = 5 * 60 * 1000;
|
||||
const maxIntervalMs = 24 * 60 * 60 * 1000;
|
||||
let disposed = false;
|
||||
let timer: number | null = null;
|
||||
|
||||
const clampIntervalMs = (seconds: number): number => {
|
||||
const ms = Math.round(seconds * 1000);
|
||||
return Math.max(minIntervalMs, Math.min(maxIntervalMs, ms));
|
||||
};
|
||||
|
||||
const scheduleNext = (delayMs: number) => {
|
||||
if (disposed) return;
|
||||
timer = window.setTimeout(async () => {
|
||||
const suggestedSec = await checkForUpdates();
|
||||
const nextDelay = typeof suggestedSec === 'number' && Number.isFinite(suggestedSec)
|
||||
? clampIntervalMs(suggestedSec)
|
||||
: defaultIntervalMs;
|
||||
scheduleNext(nextDelay);
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
scheduleNext(initialDelayMs);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (timer !== null) {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
}, [checkForUpdates]);
|
||||
useUpdatePolling();
|
||||
|
||||
const viewMode = React.useMemo<'sidebar' | 'editor'>(() => {
|
||||
const configured =
|
||||
|
||||
@@ -62,9 +62,13 @@ interface McpDropdownProps {
|
||||
interface McpDropdownContentProps {
|
||||
active: boolean;
|
||||
className?: string;
|
||||
headerAction?: React.ReactNode;
|
||||
listClassName?: string;
|
||||
hideHeader?: boolean;
|
||||
mobileListDensity?: boolean;
|
||||
}
|
||||
|
||||
export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active, className }) => {
|
||||
export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active, className, headerAction, listClassName, hideHeader = false, mobileListDensity = false }) => {
|
||||
const { t } = useI18n();
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
const directory = currentDirectory ?? null;
|
||||
@@ -115,7 +119,7 @@ export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active,
|
||||
|
||||
return (
|
||||
<div className={cn('w-full', className)}>
|
||||
<div className="border-b border-[var(--interactive-border)]">
|
||||
{!hideHeader ? <div className="border-b border-[var(--interactive-border)]">
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-2.5">
|
||||
<div className="min-w-0 flex items-baseline gap-2">
|
||||
<div className="typography-ui-header font-semibold text-foreground">{t('mcpDropdown.title')}</div>
|
||||
@@ -125,19 +129,22 @@ export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active,
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
disabled={isSpinning}
|
||||
onClick={handleRefresh}
|
||||
aria-label={t('mcpDropdown.actions.refreshAria')}
|
||||
>
|
||||
<Icon name="refresh" className={cn('h-4 w-4', isSpinning && 'animate-spin')} />
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{headerAction}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
disabled={isSpinning}
|
||||
onClick={handleRefresh}
|
||||
aria-label={t('mcpDropdown.actions.refreshAria')}
|
||||
>
|
||||
<Icon name="refresh" className={cn('h-4 w-4', isSpinning && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> : null}
|
||||
|
||||
<div className="max-h-64 overflow-y-auto py-2">
|
||||
<div className={cn('max-h-64 overflow-y-auto py-2', mobileListDensity && 'space-y-1 py-3', listClassName)}>
|
||||
{sortedNames.map((serverName) => {
|
||||
const serverStatus = status[serverName];
|
||||
const tone = statusTone(serverStatus);
|
||||
@@ -148,7 +155,10 @@ export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active,
|
||||
return (
|
||||
<div
|
||||
key={serverName}
|
||||
className="flex items-center justify-between gap-2 px-4 py-1.5 rounded-lg hover:bg-interactive-hover/50"
|
||||
className={cn(
|
||||
'flex items-center justify-between rounded-lg hover:bg-interactive-hover/50',
|
||||
mobileListDensity ? 'gap-3 px-4 py-3' : 'gap-2 px-4 py-1.5',
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
@@ -156,7 +166,8 @@ export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active,
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
'h-2 w-2 rounded-full flex-shrink-0',
|
||||
'rounded-full flex-shrink-0',
|
||||
mobileListDensity ? 'h-2.5 w-2.5' : 'h-2 w-2',
|
||||
tone === 'success' && 'bg-status-success',
|
||||
tone === 'error' && 'bg-status-error',
|
||||
tone === 'warning' && 'bg-status-warning',
|
||||
@@ -169,7 +180,9 @@ export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active,
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="typography-ui-label truncate">{serverName}</span>
|
||||
<span className={cn('truncate', mobileListDensity ? 'text-[17px] leading-6 font-medium' : 'typography-ui-label')}>
|
||||
{serverName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SIDEBAR_SECTION_CONFIG_MAP, SIDEBAR_SECTION_DESCRIPTIONS } from '@/cons
|
||||
import type { SidebarSection } from '@/constants/sidebar';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { McpIcon } from '@/components/icons/McpIcon';
|
||||
|
||||
interface SectionPlaceholderProps {
|
||||
sectionId: SidebarSection;
|
||||
@@ -18,7 +19,7 @@ export const SectionPlaceholder: React.FC<SectionPlaceholderProps> = ({ sectionI
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<div className="rounded-full bg-accent/40 p-3 text-muted-foreground">
|
||||
<Icon name={icon} className="h-5 w-5" />
|
||||
{icon === 'mcp-custom' ? <McpIcon className="h-5 w-5" /> : <Icon name={icon} className="h-5 w-5" />}
|
||||
</div>
|
||||
<h3 className="typography-ui-label font-semibold text-foreground">{config.label}</h3>
|
||||
<p className="typography-meta max-w-xs text-muted-foreground">
|
||||
@@ -31,7 +32,7 @@ export const SectionPlaceholder: React.FC<SectionPlaceholderProps> = ({ sectionI
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 px-6 text-center">
|
||||
<div className="rounded-full bg-accent/40 p-4 text-muted-foreground">
|
||||
<Icon name={icon} className="h-8 w-8" />
|
||||
{icon === 'mcp-custom' ? <McpIcon className="h-8 w-8" /> : <Icon name={icon} className="h-8 w-8" />}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="typography-h2 font-semibold text-foreground">{config.label}</h2>
|
||||
|
||||
@@ -4,20 +4,27 @@ import { useShallow } from 'zustand/react/shallow';
|
||||
import { UpdateDialog } from '@/components/ui/UpdateDialog';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { toast } from '@/components/ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
const GITHUB_URL = 'https://github.com/btriapitsyn/openchamber';
|
||||
const GITHUB_URL = 'https://github.com/openchamber/openchamber';
|
||||
const DISCORD_URL = 'https://discord.gg/ZYRSdnwwKA';
|
||||
const X_URL = 'https://x.com/openchamber_dev';
|
||||
|
||||
const MIN_CHECKING_DURATION = 800; // ms
|
||||
|
||||
export const AboutSettings: React.FC = () => {
|
||||
type AboutSettingsProps = {
|
||||
initialUpdateDialogOpen?: boolean;
|
||||
};
|
||||
|
||||
export const AboutSettings: React.FC<AboutSettingsProps> = ({ initialUpdateDialogOpen = false }) => {
|
||||
const { t } = useI18n();
|
||||
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false);
|
||||
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(initialUpdateDialogOpen);
|
||||
const [showChecking, setShowChecking] = React.useState(false);
|
||||
const [openChamberVersion, setOpenChamberVersion] = React.useState<string | null>(null);
|
||||
const [openCodeVersion, setOpenCodeVersion] = React.useState<string | null>(null);
|
||||
const updateStore = useUpdateStore(useShallow((s) => ({
|
||||
info: s.info,
|
||||
@@ -34,21 +41,48 @@ export const AboutSettings: React.FC = () => {
|
||||
})));
|
||||
const { isMobile } = useDeviceInfo();
|
||||
|
||||
const currentVersion = updateStore.info?.currentVersion || 'unknown';
|
||||
const currentVersion = openChamberVersion || updateStore.info?.currentVersion || 'unknown';
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const loadOpenChamberVersion = async () => {
|
||||
try {
|
||||
const response = await runtimeFetch('/api/system/info', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const data = await response.json().catch(() => null) as { openchamberVersion?: unknown } | null;
|
||||
const version = typeof data?.openchamberVersion === 'string' && data.openchamberVersion.trim().length > 0
|
||||
? data.openchamberVersion.trim()
|
||||
: null;
|
||||
if (!cancelled) setOpenChamberVersion(version);
|
||||
} catch {
|
||||
if (!cancelled) setOpenChamberVersion(null);
|
||||
}
|
||||
};
|
||||
|
||||
void loadOpenChamberVersion();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const loadOpenCodeVersion = async () => {
|
||||
try {
|
||||
const response = await runtimeFetch('/api/opencode/version', {
|
||||
const response = await runtimeFetch('/api/opencode/upgrade-status', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const data = await response.json().catch(() => null) as { version?: unknown } | null;
|
||||
const version = typeof data?.version === 'string' && data.version.trim().length > 0
|
||||
? data.version.trim()
|
||||
const data = await response.json().catch(() => null) as { currentVersion?: unknown } | null;
|
||||
const version = typeof data?.currentVersion === 'string' && data.currentVersion.trim().length > 0
|
||||
? data.currentVersion.trim()
|
||||
: null;
|
||||
if (!cancelled) setOpenCodeVersion(version);
|
||||
} catch {
|
||||
@@ -86,82 +120,91 @@ export const AboutSettings: React.FC = () => {
|
||||
|
||||
const isChecking = updateStore.checking || showChecking;
|
||||
|
||||
// Compact mobile layout for sidebar footer
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="w-full space-y-2">
|
||||
{/* Version row with update status */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
v{currentVersion}
|
||||
</span>
|
||||
<div className="w-full space-y-6 pb-2">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<OpenChamberLogo width={72} height={72} />
|
||||
<h2 className="mt-4 typography-ui-header font-semibold text-foreground">OpenChamber</h2>
|
||||
<div className="mt-2 space-y-1 typography-ui text-muted-foreground">
|
||||
<p>{t('aboutDialog.openChamberVersionLabel', { version: currentVersion })}</p>
|
||||
<p>{t('aboutDialog.openCodeVersionLabel', { version: openCodeVersion || t('settings.openchamber.about.state.unknown') })}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
{!updateStore.available && !updateStore.error && (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => updateStore.checkForUpdates()}
|
||||
disabled={isChecking}
|
||||
className={cn(
|
||||
'typography-meta text-muted-foreground/60 hover:text-muted-foreground disabled:cursor-default',
|
||||
isChecking && 'animate-pulse [animation-duration:1s]'
|
||||
)}
|
||||
className="h-10 w-auto justify-center gap-2 rounded-xl px-4"
|
||||
>
|
||||
{t('settings.openchamber.about.actions.checkUpdates')}
|
||||
</button>
|
||||
{isChecking ? <Icon name="loader" className="size-4 animate-spin" /> : <Icon name="refresh" className="size-4" />}
|
||||
{isChecking ? t('settings.openchamber.about.state.checking') : t('settings.openchamber.about.actions.checkForUpdates')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isChecking && updateStore.available && (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => setUpdateDialogOpen(true)}
|
||||
className="flex items-center gap-1 typography-meta text-[var(--primary-base)] hover:underline"
|
||||
className="h-10 w-auto justify-center gap-2 rounded-xl px-4"
|
||||
>
|
||||
<Icon name="download" className="h-3.5 w-3.5" />
|
||||
{t('settings.openchamber.about.actions.update')}
|
||||
</button>
|
||||
<Icon name="download" className="size-4" />
|
||||
{t('settings.openchamber.about.actions.updateToVersion', { version: updateStore.info?.version || '' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.openchamber.about.field.openCodeVersion')}</span>
|
||||
<span className="typography-meta text-muted-foreground font-mono">{openCodeVersion || t('settings.openchamber.about.state.unknown')}</span>
|
||||
</div>
|
||||
|
||||
{updateStore.error && (
|
||||
<p className="typography-micro text-[var(--status-error)] truncate">{updateStore.error}</p>
|
||||
<p className="rounded-xl border border-[var(--status-error-border)] bg-[var(--status-error-background)] px-3 py-2 typography-meta text-[var(--status-error)]">
|
||||
{updateStore.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Links row */}
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href={GITHUB_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 typography-meta text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Icon name="github-fill" className="h-3.5 w-3.5" />
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<div className="flex items-center justify-center gap-5">
|
||||
<a
|
||||
href={GITHUB_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 typography-ui-label text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<Icon name="github-fill" className="size-5" />
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={DISCORD_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 typography-ui-label text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<Icon name="discord-fill" className="size-5" />
|
||||
<span>Discord</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="https://discord.gg/ZYRSdnwwKA"
|
||||
href={X_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 typography-meta text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="flex items-center gap-1.5 typography-ui-label text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<Icon name="discord-fill" className="h-3.5 w-3.5" />
|
||||
<span>Discord</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://x.com/btriapitsyn"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 typography-meta text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Icon name="twitter-xfill" className="h-3.5 w-3.5" />
|
||||
<span>@btriapitsyn</span>
|
||||
<Icon name="twitter-xfill" className="size-5" />
|
||||
<span>@openchamber_dev</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p className="text-center typography-ui text-muted-foreground/60">
|
||||
{t('aboutDialog.footerNote')}
|
||||
</p>
|
||||
|
||||
<UpdateDialog
|
||||
open={updateDialogOpen}
|
||||
onOpenChange={setUpdateDialogOpen}
|
||||
@@ -247,15 +290,15 @@ export const AboutSettings: React.FC = () => {
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://x.com/btriapitsyn"
|
||||
<a
|
||||
href={X_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground typography-meta transition-colors"
|
||||
>
|
||||
<Icon name="twitter-xfill" className="h-4 w-4" />
|
||||
<span>@btriapitsyn</span>
|
||||
</a>
|
||||
<span>@openchamber_dev</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ const VisualSectionContent: React.FC = () => {
|
||||
|
||||
// Chat section: User message rendering, Diff layout, Mobile status bar, Show reasoning traces, Queue mode, Persist draft
|
||||
const ChatSectionContent: React.FC = () => {
|
||||
return <OpenChamberVisualSettings visibleSettings={['chatRenderMode', 'messageTransport', 'activityRenderMode', 'userMessageRendering', 'mermaidRendering', 'reasoning', 'showToolFileIcons', 'showTurnChangedFiles', 'expandedTools', 'stickyUserHeader', 'wideChatLayout', 'splitAssistantMessageActions', 'diffLayout', 'mobileStatusBar', 'dotfiles', 'fileViewerPreview', 'queueMode', 'persistDraft', 'inputSpellcheck']} />;
|
||||
return <OpenChamberVisualSettings visibleSettings={['chatRenderMode', 'messageTransport', 'activityRenderMode', 'userMessageRendering', 'mermaidRendering', 'reasoning', 'showToolFileIcons', 'showTurnChangedFiles', 'expandedTools', 'stickyUserHeader', 'wideChatLayout', 'splitAssistantMessageActions', 'diffLayout', 'dotfiles', 'fileViewerPreview', 'queueMode', 'persistDraft', 'inputSpellcheck']} />;
|
||||
};
|
||||
|
||||
// Sessions section: Default model & agent, Session retention
|
||||
|
||||
@@ -319,8 +319,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
const setWeekStartPreference = useUIStore(state => state.setWeekStartPreference);
|
||||
const showSplitAssistantMessageActions = useUIStore(state => state.showSplitAssistantMessageActions);
|
||||
const setShowSplitAssistantMessageActions = useUIStore(state => state.setShowSplitAssistantMessageActions);
|
||||
const showMobileSessionStatusBar = useUIStore(state => state.showMobileSessionStatusBar);
|
||||
const setShowMobileSessionStatusBar = useUIStore(state => state.setShowMobileSessionStatusBar);
|
||||
const messageStreamTransport = useConfigStore((state) => state.settingsMessageStreamTransport);
|
||||
const setMessageStreamTransport = useConfigStore((state) => state.setSettingsMessageStreamTransport);
|
||||
const settingsDefaultFileViewerPreview = useConfigStore((state) => state.settingsDefaultFileViewerPreview);
|
||||
@@ -538,7 +536,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
|| shouldShow('wideChatLayout')
|
||||
|| shouldShow('splitAssistantMessageActions')
|
||||
|| shouldShow('diffLayout')
|
||||
|| (shouldShow('mobileStatusBar') && isMobile)
|
||||
|| shouldShow('dotfiles')
|
||||
|| shouldShow('fileViewerPreview')
|
||||
|| shouldShow('reasoning')
|
||||
@@ -1698,7 +1695,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || (shouldShow('mobileStatusBar') && isMobile) || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('queueMode') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && (
|
||||
{(shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('queueMode') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && (
|
||||
<section className="p-2 space-y-0.5">
|
||||
{shouldShow('reasoning') && (
|
||||
<div
|
||||
@@ -1871,29 +1868,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShow('mobileStatusBar') && isMobile && (
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2 py-0.5"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={showMobileSessionStatusBar}
|
||||
onClick={() => setShowMobileSessionStatusBar(!showMobileSessionStatusBar)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
setShowMobileSessionStatusBar(!showMobileSessionStatusBar);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={showMobileSessionStatusBar}
|
||||
onChange={setShowMobileSessionStatusBar}
|
||||
ariaLabel={t('settings.openchamber.visual.field.showMobileStatusBarAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.showMobileStatusBar')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShow('dotfiles') && !isVSCodeRuntime() && (
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2 py-0.5"
|
||||
|
||||
@@ -35,6 +35,7 @@ import { canUseElectronDesktopIPC, invokeDesktop, isDesktopShell, isVSCodeRuntim
|
||||
import { SETTINGS_PAGE_METADATA, type SettingsRuntimeContext } from '@/lib/settings/metadata';
|
||||
import { getSettingsNavIcon } from '@/components/views/SettingsView';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { McpIcon } from '@/components/icons/McpIcon';
|
||||
import { scoreByFuzzyQuery } from '@/lib/search/fuzzySearch';
|
||||
import { truncatePathMiddle } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -277,7 +278,9 @@ export const CommandPalette: React.FC = () => {
|
||||
return {
|
||||
id: `settings:${page.slug}`,
|
||||
title: page.title,
|
||||
icon: <Icon name={iconName} className="mr-2 h-4 w-4" />,
|
||||
icon: page.slug === 'mcp'
|
||||
? <McpIcon className="mr-2 h-4 w-4" />
|
||||
: <Icon name={iconName} className="mr-2 h-4 w-4" />,
|
||||
searchText: `${page.title} ${page.group} ${keywords}`,
|
||||
onSelect: run(() => {
|
||||
setSettingsPage(page.slug);
|
||||
|
||||
@@ -31,6 +31,9 @@ export const ProviderLogo: React.FC<ProviderLogoProps> = ({
|
||||
src={src}
|
||||
alt={alt || `${providerId} logo`}
|
||||
className={cn('dark:invert object-contain', className)}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
fetchPriority="high"
|
||||
onError={handleError}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -35,11 +35,13 @@ import { SnippetsPage } from '@/components/sections/snippets/SnippetsPage';
|
||||
import { GitPage } from '@/components/sections/git-identities/GitPage';
|
||||
import type { OpenChamberSection } from '@/components/sections/openchamber/types';
|
||||
import { OpenChamberPage } from '@/components/sections/openchamber/OpenChamberPage';
|
||||
import { AboutSettings } from '@/components/sections/openchamber/AboutSettings';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import type { IconName } from "@/components/icon/icons";
|
||||
import { McpIcon } from '@/components/icons/McpIcon';
|
||||
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
|
||||
import {
|
||||
SETTINGS_PAGE_METADATA,
|
||||
@@ -74,6 +76,7 @@ interface SettingsViewProps {
|
||||
isWindowed?: boolean;
|
||||
/** Restrict top-level settings navigation to a specific product surface. */
|
||||
visiblePageSlugs?: SettingsPageSlug[];
|
||||
initialMobileStage?: MobileStage;
|
||||
}
|
||||
|
||||
const pageOrder: SettingsPageSlug[] = [
|
||||
@@ -98,6 +101,7 @@ const pageOrder: SettingsPageSlug[] = [
|
||||
'skills.catalog',
|
||||
'voice',
|
||||
'tunnel',
|
||||
'about',
|
||||
];
|
||||
|
||||
const SNIPPETS_SETTINGS_ICON = { icon: 'chat-thread' } as const;
|
||||
@@ -177,7 +181,7 @@ export function getSettingsNavIcon(slug: SettingsPageSlug): IconName | null {
|
||||
case 'commands':
|
||||
return 'slash-commands-2';
|
||||
case 'mcp':
|
||||
return 'plug-2';
|
||||
return null;
|
||||
case 'plugins':
|
||||
return 'code-box';
|
||||
|
||||
@@ -195,6 +199,8 @@ export function getSettingsNavIcon(slug: SettingsPageSlug): IconName | null {
|
||||
return 'mic';
|
||||
case 'tunnel':
|
||||
return 'global';
|
||||
case 'about':
|
||||
return 'information';
|
||||
case 'home':
|
||||
return null;
|
||||
default:
|
||||
@@ -278,7 +284,7 @@ const SettingsHome: React.FC<{ onOpen: (slug: SettingsPageSlug) => void }> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile, isWindowed, visiblePageSlugs }) => {
|
||||
export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile, isWindowed, visiblePageSlugs, initialMobileStage = 'nav' }) => {
|
||||
const { t } = useI18n();
|
||||
const deviceInfo = useDeviceInfo();
|
||||
const isMobile = forceMobile ?? deviceInfo.isMobile;
|
||||
@@ -288,7 +294,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
||||
const settingsSlug = resolveSettingsSlug(settingsPageRaw);
|
||||
|
||||
const [mobileStage, setMobileStage] = React.useState<MobileStage>('nav');
|
||||
const [mobileStage, setMobileStage] = React.useState<MobileStage>(initialMobileStage);
|
||||
const autoNavSlugRef = React.useRef<string | null>(null);
|
||||
|
||||
const [navWidth, setNavWidth] = React.useState(216);
|
||||
@@ -492,6 +498,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
return t('settings.page.voice.title');
|
||||
case 'tunnel':
|
||||
return t('settings.page.tunnel.title');
|
||||
case 'about':
|
||||
return t('settings.page.about.title');
|
||||
case 'home':
|
||||
default:
|
||||
return t('settings.view.home.title');
|
||||
@@ -567,6 +575,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
return <ProvidersPage />;
|
||||
case 'usage':
|
||||
return <UsagePage />;
|
||||
case 'about':
|
||||
return <div className="h-full overflow-auto px-5 py-6"><AboutSettings /></div>;
|
||||
case 'magic-prompts':
|
||||
return <MagicPromptsPage />;
|
||||
case 'snippets':
|
||||
@@ -703,7 +713,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
{sortedFilteredPages.map((page) => {
|
||||
const selected = settingsSlug === page.slug;
|
||||
const iconName = getSettingsNavIcon(page.slug);
|
||||
if (!iconName) return null;
|
||||
if (!iconName && page.slug !== 'mcp') return null;
|
||||
|
||||
return (
|
||||
<Tooltip key={page.slug}>
|
||||
@@ -719,7 +729,9 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
: 'text-foreground hover:bg-interactive-hover'
|
||||
)}
|
||||
>
|
||||
<Icon name={iconName} className="h-4 w-4 shrink-0" />
|
||||
{page.slug === 'mcp'
|
||||
? <McpIcon className="h-4 w-4 shrink-0" />
|
||||
: <Icon name={iconName!} className="h-4 w-4 shrink-0" />}
|
||||
<span className="flex items-center gap-1.5 whitespace-nowrap overflow-hidden transition-opacity duration-150 opacity-100">
|
||||
<span className="typography-ui-label font-normal truncate">{getPageTitle(page.slug)}</span>
|
||||
{(page.slug === 'voice' || page.slug === 'tunnel') && (
|
||||
|
||||
Reference in New Issue
Block a user