diff --git a/.claude/skills/clack-cli-patterns b/.claude/skills/clack-cli-patterns new file mode 120000 index 00000000..a7844cda --- /dev/null +++ b/.claude/skills/clack-cli-patterns @@ -0,0 +1 @@ +../../.agents/skills/clack-cli-patterns \ No newline at end of file diff --git a/.claude/skills/drag-to-reorder b/.claude/skills/drag-to-reorder new file mode 120000 index 00000000..50522f5b --- /dev/null +++ b/.claude/skills/drag-to-reorder @@ -0,0 +1 @@ +../../.agents/skills/drag-to-reorder \ No newline at end of file diff --git a/.claude/skills/locale-ui-patterns b/.claude/skills/locale-ui-patterns new file mode 120000 index 00000000..eb714fba --- /dev/null +++ b/.claude/skills/locale-ui-patterns @@ -0,0 +1 @@ +../../.agents/skills/locale-ui-patterns \ No newline at end of file diff --git a/.claude/skills/settings-ui-patterns b/.claude/skills/settings-ui-patterns new file mode 120000 index 00000000..341c7f73 --- /dev/null +++ b/.claude/skills/settings-ui-patterns @@ -0,0 +1 @@ +../../.agents/skills/settings-ui-patterns \ No newline at end of file diff --git a/.claude/skills/theme-system b/.claude/skills/theme-system new file mode 120000 index 00000000..ff5ed9e6 --- /dev/null +++ b/.claude/skills/theme-system @@ -0,0 +1 @@ +../../.agents/skills/theme-system \ No newline at end of file diff --git a/.claude/skills/ui-api-decoupling b/.claude/skills/ui-api-decoupling new file mode 120000 index 00000000..960b4d43 --- /dev/null +++ b/.claude/skills/ui-api-decoupling @@ -0,0 +1 @@ +../../.agents/skills/ui-api-decoupling \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index 594703e9..5e94dc67 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -1,38 +1,52 @@ import React from 'react'; -import { - RiFileTextLine, - RiGitBranchLine, - RiMenuLine, - RiMore2Line, - RiSettings3Line, -} from '@remixicon/react'; +import { Icon } from '@/components/icon/Icon'; +import type { IconName } from '@/components/icon/icons'; +import { McpIcon } from '@/components/icons/McpIcon'; +import { McpDropdownContent } from '@/components/mcp/McpDropdown'; +import { AboutSettings } from '@/components/sections/openchamber/AboutSettings'; +import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast'; +import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { ChatView } from '@/components/views/ChatView'; import { SettingsView } from '@/components/views/SettingsView'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; +import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider'; import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { TooltipProvider } from '@/components/ui/tooltip'; import { Toaster } from '@/components/ui/sonner'; import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon'; +import { preloadProviderLogos } from '@/hooks/useProviderLogo'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useRouter } from '@/hooks/useRouter'; +import { useUpdatePolling } from '@/hooks/useUpdatePolling'; import { useWindowTitle } from '@/hooks/useWindowTitle'; import { opencodeClient } from '@/lib/opencode/client'; -import type { RuntimeAPIs } from '@/lib/api/types'; +import type { ProjectEntry, RuntimeAPIs } from '@/lib/api/types'; import { useI18n } from '@/lib/i18n'; +import { resolveProjectForDirectory, resolveProjectForSessionDirectory } from '@/lib/projectResolution'; +import { formatQuotaResetLabel, formatQuotaValueLabel, formatWindowLabel, QUOTA_PROVIDERS } from '@/lib/quota'; +import { getDisplayModelName } from '@/lib/quota/model-families'; import { runtimeFetch } from '@/lib/runtime-fetch'; +import { sessionEvents } from '@/lib/sessionEvents'; import { cn } from '@/lib/utils'; import { useConfigStore } from '@/stores/useConfigStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; -import { useGitStatus, useGitStore } from '@/stores/useGitStore'; +import { useGitStatus, useGitStore, useIsGitRepo } from '@/stores/useGitStore'; +import { useMcpConfigStore, type McpDraft } from '@/stores/useMcpConfigStore'; +import { useMcpStore } from '@/stores/useMcpStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore'; import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager'; +import type { QuotaProviderId, UsageWindow } from '@/types'; import type { WorktreeMetadata } from '@/types/worktree'; -import { useUIStore } from '@/stores/useUIStore'; +import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore'; +import { useUpdateStore } from '@/stores/useUpdateStore'; +import { useSelectionStore } from '@/sync/selection-store'; import { useSessionUIStore } from '@/sync/session-ui-store'; -import { SyncProvider, useSession } from '@/sync/sync-context'; +import { SyncProvider, useSession, useSessionMessages } from '@/sync/sync-context'; import { SyncAppEffects } from './AppEffects'; import { MobileChangesSurface } from './MobileChangesSurface'; @@ -54,6 +68,7 @@ const MOBILE_SETTINGS_PAGES = [ 'providers', 'usage', 'voice', + 'about', ] as const; type MobileAppProps = { @@ -63,6 +78,22 @@ type MobileAppProps = { const normalizePath = (value?: string | null): string => (value || '').replace(/\\/g, '/').replace(/\/+$/g, ''); +const getNumericLimit = (limit: unknown, key: 'context' | 'output'): number | undefined => { + if (!limit || typeof limit !== 'object') return undefined; + const value = (limit as Partial>)[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +}; + +const getTokenCount = (value: unknown): number => ( + typeof value === 'number' && Number.isFinite(value) ? value : 0 +); + +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 getProjectLabel = (path: string): string => { const normalized = normalizePath(path); if (!normalized) return ''; @@ -71,13 +102,254 @@ const getProjectLabel = (path: string): string => { }; type OverflowItem = { - key: 'files' | 'changes' | 'settings'; - Icon: typeof RiFileTextLine; + key: 'files' | 'changes' | 'mcp' | 'update' | 'settings'; + icon?: IconName; + iconNode?: React.ReactNode; label: string; badge?: number; onSelect: () => void; }; +type ContextDisplay = { + percentage: number; + tokens: string; + colorClass: string; +} | null; + +const getProjectDisplayLabel = (project: ProjectEntry | null, fallbackDirectory: string): string => { + if (project) return project.label?.trim() || getProjectLabel(project.path); + return getProjectLabel(fallbackDirectory); +}; + +type MobileUsageLimitRow = { + key: string; + label: string; + subtitle?: string; + window: UsageWindow; +}; + +type MobileUsageProviderGroup = { + providerId: QuotaProviderId; + providerName: string; + rows: MobileUsageLimitRow[]; + status: string | null; +}; + +const getWindowValueClass = (window: UsageWindow): string => { + const usedPercent = window.usedPercent; + if (typeof usedPercent !== 'number' || !Number.isFinite(usedPercent)) return 'text-foreground'; + if (usedPercent >= 80) return 'text-[var(--status-error)]'; + if (usedPercent >= 50) return 'text-[var(--status-warning)]'; + return 'text-foreground'; +}; + +const MetadataRow: React.FC<{ + icon: IconName; + label: string; + children: React.ReactNode; +}> = ({ icon, label, children }) => ( +
+ + + + {label} + + {children} + +
+); + +const SessionMetadataOverlay: React.FC<{ + open: boolean; + onClose: () => void; + anchorRef: React.RefObject; + contextDisplay: ContextDisplay; + branchLabel: string; + usageGroups: MobileUsageProviderGroup[]; + usageDisplayMode: 'usage' | 'remaining'; + isUsageLoading: boolean; + timeFormatPreference: TimeFormatPreference; +}> = ({ open, onClose, anchorRef, contextDisplay, branchLabel, usageGroups, usageDisplayMode, isUsageLoading, timeFormatPreference }) => { + const { t } = useI18n(); + const panelRef = React.useRef(null); + const [shouldRender, setShouldRender] = React.useState(open); + const [isExiting, setIsExiting] = React.useState(false); + + React.useEffect(() => { + if (open) { + setShouldRender(true); + setIsExiting(false); + return; + } + + if (!shouldRender) return; + setIsExiting(true); + const timeoutId = window.setTimeout(() => { + setShouldRender(false); + setIsExiting(false); + }, 140); + return () => window.clearTimeout(timeoutId); + }, [open, shouldRender]); + + React.useEffect(() => { + if (!open) return; + const handleKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', handleKey); + return () => document.removeEventListener('keydown', handleKey); + }, [onClose, open]); + + React.useEffect(() => { + if (!open) return; + + const closeIfOutside = (event: PointerEvent | WheelEvent) => { + const target = event.target; + if (!(target instanceof Node)) { + onClose(); + return; + } + if (panelRef.current?.contains(target) || anchorRef.current?.contains(target)) return; + onClose(); + }; + + document.addEventListener('pointerdown', closeIfOutside, true); + document.addEventListener('wheel', closeIfOutside, true); + return () => { + document.removeEventListener('pointerdown', closeIfOutside, true); + document.removeEventListener('wheel', closeIfOutside, true); + }; + }, [anchorRef, onClose, open]); + + if (!shouldRender) return null; + + return ( +
+
+
+ + {branchLabel} + + {contextDisplay ? ( + + + {contextDisplay.percentage.toFixed(1)}% + {contextDisplay.tokens} + + + ) : null} + +
+
+ +
+ ); +}; + +const MobileUsageLimits: React.FC<{ + groups: MobileUsageProviderGroup[]; + displayMode: 'usage' | 'remaining'; + isLoading: boolean; + timeFormatPreference: TimeFormatPreference; +}> = ({ groups, displayMode, isLoading, timeFormatPreference }) => { + const { t } = useI18n(); + const modeLabel = displayMode === 'remaining' ? t('header.services.remaining') : t('header.services.used'); + + if (groups.length === 0) return null; + + return ( +
+
+ + + + + {t('mobile.header.metadata.usage')} + + + {isLoading ? : null} + {modeLabel} + +
+ +
+ {groups.map((group) => ( +
+
+ + + {group.providerName} + + {group.status && group.rows.length === 0 ? ( + + {group.status} + + ) : null} +
+ {group.rows.length > 0 ? ( +
+ {group.rows.map((row) => { + const displayPercent = displayMode === 'remaining' ? row.window.remainingPercent : row.window.usedPercent; + const metricLabel = formatQuotaValueLabel(row.window.valueLabel, displayPercent); + const resetLabel = formatQuotaResetLabel( + row.window.resetAt, + row.window.resetAfterFormatted ?? row.window.resetAtFormatted, + timeFormatPreference, + ); + return ( +
+ + + {row.subtitle ? `${row.subtitle} · ${row.label}` : row.label} + + {resetLabel ? ( + {resetLabel} + ) : null} + + + {metricLabel === '-' ? '' : metricLabel} + +
+ ); + })} +
+ ) : null} + {group.status && group.rows.length > 0 ? ( +
{group.status}
+ ) : null} +
+ ))} +
+
+ ); +}; + const MobileOverflowMenu: React.FC<{ open: boolean; onClose: () => void; @@ -123,7 +395,7 @@ const MobileOverflowMenu: React.FC<{ onClose(); }} > - + {item.iconNode ?? (item.icon ? : null)} {item.label} {item.badge && item.badge > 0 ? ( @@ -136,72 +408,331 @@ const MobileOverflowMenu: React.FC<{ ); }; +const MobileSessionMetadataButton = React.memo(function MobileSessionMetadataButton({ + open, + onOpenChange, + currentSessionId, + effectiveDirectory, + gitDirectory, + isNewSessionDraftOpen, + primaryLabel, + secondaryLabel, +}: { + open: boolean; + onOpenChange: (open: boolean | ((open: boolean) => boolean)) => void; + currentSessionId: string | null; + effectiveDirectory: string | null; + gitDirectory: string | null; + isNewSessionDraftOpen: boolean; + primaryLabel: string; + secondaryLabel: string; +}) { + const { t } = useI18n(); + const { git } = useRuntimeAPIs(); + const metadataTriggerRef = React.useRef(null); + const activeSessionMessages = useSessionMessages(currentSessionId ?? '', effectiveDirectory || undefined); + const isGitRepo = useIsGitRepo(gitDirectory); + const gitStatus = useGitStatus(gitDirectory); + const ensureStatus = useGitStore((state) => state.ensureStatus); + const fetchStatus = useGitStore((state) => state.fetchStatus); + const providers = useConfigStore((state) => state.providers); + const currentProviderId = useConfigStore((state) => state.currentProviderId); + const currentModelId = useConfigStore((state) => state.currentModelId); + const getModelMetadata = useConfigStore((state) => state.getModelMetadata); + useConfigStore((state) => state.modelsMetadata.size); + const savedSessionModel = useSelectionStore( + React.useCallback( + (state) => (currentSessionId ? state.sessionModelSelections.get(currentSessionId) ?? null : null), + [currentSessionId], + ), + ); + const quotaResults = useQuotaStore((state) => state.results); + const loadQuotaSettings = useQuotaStore((state) => state.loadSettings); + const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas); + const isQuotaLoading = useQuotaStore((state) => state.isLoading); + const quotaDisplayMode = useQuotaStore((state) => state.displayMode); + const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds); + const selectedQuotaModels = useQuotaStore((state) => state.selectedModels); + const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); + + useQuotaAutoRefresh(); + + React.useEffect(() => { + if (!gitDirectory) return; + void ensureStatus(gitDirectory, git); + }, [ensureStatus, git, gitDirectory]); + + React.useEffect(() => { + if (!gitDirectory) return; + return sessionEvents.onGitRefreshHint((hint) => { + if (normalizePath(hint.directory) !== gitDirectory) return; + void fetchStatus(gitDirectory, git); + }); + }, [fetchStatus, git, gitDirectory]); + + React.useEffect(() => { + void loadQuotaSettings(); + }, [loadQuotaSettings]); + + React.useEffect(() => { + preloadProviderLogos(dropdownProviderIds); + }, [dropdownProviderIds]); + + React.useEffect(() => { + if (!open || isQuotaLoading) return; + const missingEnabledProvider = dropdownProviderIds.some((providerId) => ( + !quotaResults.some((result) => result.providerId === providerId) + )); + if (!missingEnabledProvider) return; + void fetchAllQuotas(); + }, [dropdownProviderIds, fetchAllQuotas, isQuotaLoading, open, quotaResults]); + + const latestMessageModel = React.useMemo(() => { + for (let i = activeSessionMessages.length - 1; i >= 0; i -= 1) { + const message = activeSessionMessages[i] as typeof activeSessionMessages[number] & { + model?: { providerID?: string; modelID?: string }; + }; + if (message.role !== 'user') continue; + const providerID = typeof message.model?.providerID === 'string' && message.model.providerID.trim().length > 0 + ? message.model.providerID + : undefined; + const modelID = typeof message.model?.modelID === 'string' && message.model.modelID.trim().length > 0 + ? message.model.modelID + : undefined; + if (providerID && modelID) return { providerID, modelID }; + } + return null; + }, [activeSessionMessages]); + + const modelRef = latestMessageModel + ?? (savedSessionModel ? { providerID: savedSessionModel.providerId, modelID: savedSessionModel.modelId } : null) + ?? (currentProviderId && currentModelId ? { providerID: currentProviderId, modelID: currentModelId } : null); + const provider = modelRef ? providers.find((entry) => entry.id === modelRef.providerID) : undefined; + const liveModel = provider?.models.find((model) => model.id === modelRef?.modelID); + const metadata = modelRef ? getModelMetadata(modelRef.providerID, modelRef.modelID) : undefined; + const contextLimit = getNumericLimit((liveModel as { limit?: unknown } | undefined)?.limit, 'context') + ?? metadata?.limit?.context + ?? 0; + const totalTokens = React.useMemo(() => { + for (let i = activeSessionMessages.length - 1; i >= 0; i -= 1) { + const message = activeSessionMessages[i] as typeof activeSessionMessages[number] & { + tokens?: { + input?: unknown; + output?: unknown; + reasoning?: unknown; + cache?: { read?: unknown; write?: unknown }; + }; + }; + if (message.role !== 'assistant' || !message.tokens) continue; + const total = getTokenCount(message.tokens.input) + + getTokenCount(message.tokens.output) + + getTokenCount(message.tokens.reasoning) + + getTokenCount(message.tokens.cache?.read) + + getTokenCount(message.tokens.cache?.write); + if (total > 0) return total; + } + return 0; + }, [activeSessionMessages]); + + const contextPercentage = + !isNewSessionDraftOpen && totalTokens > 0 && contextLimit > 0 + ? Math.min((totalTokens / contextLimit) * 100, 999) + : null; + const contextTokens = contextPercentage !== null + ? `${formatTokens(totalTokens)}/${formatTokens(contextLimit)}` + : null; + const contextColorClass = + contextPercentage === null + ? '' + : contextPercentage >= 90 + ? 'text-[var(--status-error)]' + : contextPercentage >= 75 + ? 'text-[var(--status-warning)]' + : 'text-[var(--status-success)]'; + const contextDisplay: ContextDisplay = contextPercentage !== null && contextTokens + ? { percentage: contextPercentage, tokens: contextTokens, colorClass: contextColorClass } + : null; + + const branchLabel = isGitRepo === true + ? (gitStatus?.current?.trim() || t('gitView.branch.detachedHead')) + : t('common.unavailable'); + + const usageGroups = React.useMemo(() => { + const resultsByProvider = new Map(quotaResults.map((result) => [result.providerId, result])); + return QUOTA_PROVIDERS + .filter((providerMeta) => dropdownProviderIds.includes(providerMeta.id)) + .filter((providerMeta) => resultsByProvider.get(providerMeta.id)?.configured === true) + .map((providerMeta) => { + const result = resultsByProvider.get(providerMeta.id)!; + const rows: MobileUsageLimitRow[] = []; + + for (const [label, window] of Object.entries(result?.usage?.windows ?? {})) { + rows.push({ + key: `window-${label}`, + label: formatWindowLabel(label), + window, + }); + } + + const modelEntries = Object.entries(result?.usage?.models ?? {}); + const providerSelectedModels = selectedQuotaModels[providerMeta.id] ?? []; + const visibleModelEntries = providerSelectedModels.length > 0 + ? modelEntries.filter(([modelName]) => providerSelectedModels.includes(modelName)) + : modelEntries; + for (const [modelName, modelUsage] of visibleModelEntries) { + const entries = Object.entries(modelUsage.windows ?? {}); + if (entries.length === 0) continue; + const [label, window] = entries[0]; + rows.push({ + key: `model-${modelName}-${label}`, + label: formatWindowLabel(label), + subtitle: getDisplayModelName(modelName), + window, + }); + } + + const status = !result.ok && result.error + ? result.error + : rows.length === 0 + ? t('header.services.noRateLimitsReported') + : null; + + return { + providerId: providerMeta.id, + providerName: providerMeta.name, + rows, + status, + }; + }); + }, [dropdownProviderIds, quotaResults, selectedQuotaModels, t]); + + React.useEffect(() => { + if (!open || usageGroups.length === 0) return; + preloadProviderLogos(usageGroups.map((group) => group.providerId)); + }, [open, usageGroups]); + + return ( + <> + + onOpenChange(false)} + anchorRef={metadataTriggerRef} + contextDisplay={contextDisplay} + branchLabel={branchLabel} + usageGroups={usageGroups} + usageDisplayMode={quotaDisplayMode} + isUsageLoading={isQuotaLoading} + timeFormatPreference={timeFormatPreference} + /> + + ); +}); + const MobileHeader: React.FC<{ onOpenSessions: () => void; onOpenMenu: () => void; }> = ({ onOpenSessions, onOpenMenu }) => { const { t } = useI18n(); + const [metadataOpen, setMetadataOpen] = React.useState(false); const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const currentSessionDirectory = useSessionUIStore( + React.useCallback((state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null), [currentSessionId]), + ); + const effectiveDirectory = currentSessionDirectory || currentDirectory; + const gitDirectory = normalizePath(effectiveDirectory) || null; const projects = useProjectsStore((state) => state.projects); - const currentSession = useSession(currentSessionId, currentDirectory || undefined); + const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); + const currentWorktreeMetadata = useSessionUIStore( + React.useCallback((state) => (currentSessionId ? state.worktreeMetadata.get(currentSessionId) ?? null : null), [currentSessionId]), + ); + const currentSession = useSession(currentSessionId, effectiveDirectory || undefined); + const isNewSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open)); const projectLabel = React.useMemo(() => { - const directory = normalizePath(currentDirectory); + const directory = normalizePath(effectiveDirectory); if (!directory) return t('mobile.header.noProject'); - const project = projects.find((entry) => { - const projectPath = normalizePath(entry.path); - return directory === projectPath || directory.startsWith(`${projectPath}/`); - }); - return project?.label?.trim() || getProjectLabel(project?.path || directory); - }, [currentDirectory, projects, t]); + const metadataProject = currentWorktreeMetadata?.projectDirectory + ? resolveProjectForDirectory(projects, currentWorktreeMetadata.projectDirectory) + : null; + const project = metadataProject ?? resolveProjectForSessionDirectory(projects, availableWorktreesByProject, directory); + return getProjectDisplayLabel(project, directory) || t('mobile.header.noProject'); + }, [availableWorktreesByProject, currentWorktreeMetadata?.projectDirectory, effectiveDirectory, projects, t]); const sessionTitle = currentSession?.title?.trim(); - const primaryLabel = sessionTitle || projectLabel; - const secondaryLabel = sessionTitle ? projectLabel : currentSessionId ? t('mobile.sessions.untitled') : ''; + const primaryLabel = sessionTitle || (currentSessionId ? t('mobile.sessions.untitled') : projectLabel); + const secondaryLabel = currentSessionId ? projectLabel : ''; + + React.useEffect(() => { + setMetadataOpen(false); + }, [currentSessionId, effectiveDirectory]); + + const handleOpenSessions = React.useCallback(() => { + setMetadataOpen(false); + onOpenSessions(); + }, [onOpenSessions]); + + const handleOpenMenu = React.useCallback(() => { + setMetadataOpen(false); + onOpenMenu(); + }, [onOpenMenu]); return ( -
-
- + <> +
+
+ - + - -
-
+ +
+
+ ); }; @@ -210,11 +741,23 @@ const MobileShell: React.FC = () => { const [sessionsSheetOpen, setSessionsSheetOpen] = React.useState(false); const [filesOpen, setFilesOpen] = React.useState(false); const [changesOpen, setChangesOpen] = React.useState(false); + const [mcpOpen, setMcpOpen] = React.useState(false); + const [isMcpRefreshing, setIsMcpRefreshing] = React.useState(false); const [settingsOpen, setSettingsOpen] = React.useState(false); + const [updateOpen, setUpdateOpen] = React.useState(false); + const [settingsInitialMobileStage, setSettingsInitialMobileStage] = React.useState<'nav' | 'page-content'>('nav'); const [overflowOpen, setOverflowOpen] = React.useState(false); // When set, the Changes surface opens directly into the per-file diff for this path. const [pendingChangesDiff, setPendingChangesDiff] = React.useState<{ path: string; staged: boolean } | null>(null); const currentDirectory = useDirectoryStore((state) => state.currentDirectory); + const setSettingsPage = useUIStore((state) => state.setSettingsPage); + const updateAvailable = useUpdateStore((state) => state.available); + const updateRuntimeType = useUpdateStore((state) => state.runtimeType); + const mcpServers = useMcpConfigStore((state) => state.mcpServers); + const setMcpDraft = useMcpConfigStore((state) => state.setMcpDraft); + const setSelectedMcp = useMcpConfigStore((state) => state.setSelectedMcp); + const refreshMcpStatus = useMcpStore((state) => state.refresh); + const loadMcpConfigs = useMcpConfigStore((state) => state.loadMcpConfigs); const gitStatus = useGitStatus(normalizePath(currentDirectory) || null); const dirtyChangeCount = gitStatus?.files?.length ?? 0; @@ -225,7 +768,10 @@ const MobileShell: React.FC = () => { setChangesOpen(true); }, openFiles: () => setFilesOpen(true), - openSettings: () => setSettingsOpen(true), + openSettings: () => { + setSettingsInitialMobileStage('nav'); + setSettingsOpen(true); + }, }), [], ); @@ -235,29 +781,92 @@ const MobileShell: React.FC = () => { setPendingChangesDiff(null); }, []); + const showUpdateItem = updateAvailable && (updateRuntimeType === 'desktop' || updateRuntimeType === 'web'); + + const openMcpCreateSettings = React.useCallback(() => { + const baseName = 'new-mcp-server'; + let newName = baseName; + let counter = 1; + while (mcpServers.some((server) => server.name === newName)) { + newName = `${baseName}-${counter}`; + counter += 1; + } + + const draft: McpDraft = { + name: newName, + scope: 'user', + type: 'local', + command: [], + url: '', + environment: [], + headers: [], + oauthEnabled: true, + oauthClientId: '', + oauthClientSecret: '', + oauthScope: '', + oauthRedirectUri: '', + timeout: '', + enabled: true, + }; + + setMcpDraft(draft); + setSelectedMcp(newName); + setSettingsPage('mcp'); + setMcpOpen(false); + setSettingsInitialMobileStage('page-content'); + setSettingsOpen(true); + }, [mcpServers, setMcpDraft, setSelectedMcp, setSettingsPage]); + + const refreshMcpOverlay = React.useCallback(() => { + if (isMcpRefreshing) return; + setIsMcpRefreshing(true); + const directory = currentDirectory || null; + const minSpinPromise = new Promise((resolve) => window.setTimeout(resolve, 500)); + void Promise.all([ + refreshMcpStatus({ directory, silent: true }), + loadMcpConfigs({ force: true }), + minSpinPromise, + ]).finally(() => setIsMcpRefreshing(false)); + }, [currentDirectory, isMcpRefreshing, loadMcpConfigs, refreshMcpStatus]); + const overflowItems: OverflowItem[] = React.useMemo( () => [ { key: 'files', - Icon: RiFileTextLine, + icon: 'file-text', label: t('mobile.menu.files'), onSelect: () => setFilesOpen(true), }, { key: 'changes', - Icon: RiGitBranchLine, + icon: 'git-branch', label: t('mobile.menu.changes'), badge: dirtyChangeCount, onSelect: () => setChangesOpen(true), }, + { + key: 'mcp', + iconNode: , + label: t('mobile.menu.mcp'), + onSelect: () => setMcpOpen(true), + }, + ...(showUpdateItem ? [{ + key: 'update' as const, + icon: 'download' as const, + label: t('mobile.menu.update'), + onSelect: () => setUpdateOpen(true), + }] : []), { key: 'settings', - Icon: RiSettings3Line, + icon: 'settings-3', label: t('mobile.menu.settings'), - onSelect: () => setSettingsOpen(true), + onSelect: () => { + setSettingsInitialMobileStage('nav'); + setSettingsOpen(true); + }, }, ], - [dirtyChangeCount, t], + [dirtyChangeCount, showUpdateItem, t], ); return ( @@ -320,6 +929,62 @@ const MobileShell: React.FC = () => { ) : null} + {mcpOpen ? ( + setMcpOpen(false)} + title={t('mcpDropdown.title')} + className="h-[72vh]" + contentMaxHeightClassName="max-h-full" + renderHeader={(closeButton) => ( +
+
+
+
+
+

+ {t('mcpDropdown.title')} +

+
+ + + {closeButton} +
+
+
+ )} + > + + + + + ) : null} + {settingsOpen ? ( { setSettingsOpen(false)} /> ) : null} + + {updateOpen ? ( + setUpdateOpen(false)} + ariaLabel={t('mobile.menu.update')} + title={t('mobile.menu.update')} + > + +
+ +
+
+
+ ) : null}
); @@ -457,6 +1138,7 @@ export function MobileApp({ apis }: MobileAppProps) { useAppFontEffects(); usePushVisibilityBeacon({ enabled: true }); + useUpdatePolling(); useWindowTitle(); useRouter(); @@ -467,6 +1149,7 @@ export function MobileApp({ apis }: MobileAppProps) {
+
diff --git a/packages/ui/src/assets/provider-logos/anthropic.svg b/packages/ui/src/assets/provider-logos/anthropic.svg new file mode 100644 index 00000000..3b7c2f04 --- /dev/null +++ b/packages/ui/src/assets/provider-logos/anthropic.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/provider-logos/github-copilot.svg b/packages/ui/src/assets/provider-logos/github-copilot.svg new file mode 100644 index 00000000..2d426f26 --- /dev/null +++ b/packages/ui/src/assets/provider-logos/github-copilot.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/provider-logos/google.svg b/packages/ui/src/assets/provider-logos/google.svg new file mode 100644 index 00000000..4ebfcfd2 --- /dev/null +++ b/packages/ui/src/assets/provider-logos/google.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/provider-logos/kimi-for-coding.svg b/packages/ui/src/assets/provider-logos/kimi-for-coding.svg new file mode 100644 index 00000000..8f2af02e --- /dev/null +++ b/packages/ui/src/assets/provider-logos/kimi-for-coding.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/provider-logos/minimax-cn-coding-plan.svg b/packages/ui/src/assets/provider-logos/minimax-cn-coding-plan.svg new file mode 100644 index 00000000..44c5eec2 --- /dev/null +++ b/packages/ui/src/assets/provider-logos/minimax-cn-coding-plan.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/provider-logos/minimax-coding-plan.svg b/packages/ui/src/assets/provider-logos/minimax-coding-plan.svg new file mode 100644 index 00000000..44c5eec2 --- /dev/null +++ b/packages/ui/src/assets/provider-logos/minimax-coding-plan.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/provider-logos/nano-gpt.svg b/packages/ui/src/assets/provider-logos/nano-gpt.svg new file mode 100644 index 00000000..1c1fc2ce --- /dev/null +++ b/packages/ui/src/assets/provider-logos/nano-gpt.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/provider-logos/openai.svg b/packages/ui/src/assets/provider-logos/openai.svg new file mode 100644 index 00000000..000f65c3 --- /dev/null +++ b/packages/ui/src/assets/provider-logos/openai.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/provider-logos/openrouter.svg b/packages/ui/src/assets/provider-logos/openrouter.svg new file mode 100644 index 00000000..7e8abc81 --- /dev/null +++ b/packages/ui/src/assets/provider-logos/openrouter.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/ui/src/assets/provider-logos/wafer.ai.svg b/packages/ui/src/assets/provider-logos/wafer.ai.svg new file mode 100644 index 00000000..26ce0d91 --- /dev/null +++ b/packages/ui/src/assets/provider-logos/wafer.ai.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/provider-logos/zai-coding-plan.svg b/packages/ui/src/assets/provider-logos/zai-coding-plan.svg new file mode 100644 index 00000000..d7da9b7c --- /dev/null +++ b/packages/ui/src/assets/provider-logos/zai-coding-plan.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/provider-logos/zhipuai-coding-plan.svg b/packages/ui/src/assets/provider-logos/zhipuai-coding-plan.svg new file mode 100644 index 00000000..3d0d0c45 --- /dev/null +++ b/packages/ui/src/assets/provider-logos/zhipuai-coding-plan.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/components/chat/MobileSessionStatusBar.tsx b/packages/ui/src/components/chat/MobileSessionStatusBar.tsx index 8e543a4f..b910d0d6 100644 --- a/packages/ui/src/components/chat/MobileSessionStatusBar.tsx +++ b/packages/ui/src/components/chat/MobileSessionStatusBar.tsx @@ -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 ( - - {percentage.toFixed(1)}% - {tokens} - - ); -} - // A single session row sized for comfortable touch. function SessionItem({ session, @@ -448,19 +416,19 @@ export const MobileSessionPanelTrigger: React.FC }) => { 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 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 = ({ }) => { 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 = ({ [filteredSessions], ); - // Token usage for the current session. - const currentModel = getCurrentModel(); - const limit = currentModel && typeof currentModel.limit === 'object' && currentModel.limit !== null - ? (currentModel.limit as Record) - : 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 = ({
-
)} - ), [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; } diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 01d7d024..af0b0f3a 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -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 = ({ }, [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: }); } 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: }, + { value: 'mcp', label: 'MCP', icon: } ); return base; }, [isDesktopApp, t]); @@ -1791,7 +1792,7 @@ export const Header: React.FC = ({ return servicesTabs.map((tab) => ({ id: tab.value, label: tab.label, - icon: , + icon: tab.icon, })); }, [servicesTabs]); @@ -1866,7 +1867,7 @@ export const Header: React.FC = ({ const mobileServicesTabItems = React.useMemo(() => { return [ { id: 'usage', label: t('layout.services.usage'), icon: }, - { id: 'mcp', label: 'MCP', icon: }, + { id: 'mcp', label: 'MCP', icon: }, ]; }, [t]); diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index dc8eff2e..bcc8eab3 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -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; diff --git a/packages/ui/src/components/layout/VSCodeLayout.tsx b/packages/ui/src/components/layout/VSCodeLayout.tsx index 39902873..2f5993be 100644 --- a/packages/ui/src/components/layout/VSCodeLayout.tsx +++ b/packages/ui/src/components/layout/VSCodeLayout.tsx @@ -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 = diff --git a/packages/ui/src/components/mcp/McpDropdown.tsx b/packages/ui/src/components/mcp/McpDropdown.tsx index d6976c89..55c696ac 100644 --- a/packages/ui/src/components/mcp/McpDropdown.tsx +++ b/packages/ui/src/components/mcp/McpDropdown.tsx @@ -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 = ({ active, className }) => { +export const McpDropdownContent: React.FC = ({ 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 = ({ active, return (
-
+ {!hideHeader ?
{t('mcpDropdown.title')}
@@ -125,19 +129,22 @@ export const McpDropdownContent: React.FC = ({ active,
)}
- +
+ {headerAction} + +
-
+
: null} -
+
{sortedNames.map((serverName) => { const serverStatus = status[serverName]; const tone = statusTone(serverStatus); @@ -148,7 +155,10 @@ export const McpDropdownContent: React.FC = ({ active, return (
@@ -156,7 +166,8 @@ export const McpDropdownContent: React.FC = ({ active, = ({ active,

{tooltip}

- {serverName} + + {serverName} +
diff --git a/packages/ui/src/components/sections/SectionPlaceholder.tsx b/packages/ui/src/components/sections/SectionPlaceholder.tsx index 0cbeca1e..394f5dc1 100644 --- a/packages/ui/src/components/sections/SectionPlaceholder.tsx +++ b/packages/ui/src/components/sections/SectionPlaceholder.tsx @@ -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 = ({ sectionI return (
- + {icon === 'mcp-custom' ? : }

{config.label}

@@ -31,7 +32,7 @@ export const SectionPlaceholder: React.FC = ({ sectionI return (

- + {icon === 'mcp-custom' ? : }

{config.label}

diff --git a/packages/ui/src/components/sections/openchamber/AboutSettings.tsx b/packages/ui/src/components/sections/openchamber/AboutSettings.tsx index 4c98af4b..8fb279da 100644 --- a/packages/ui/src/components/sections/openchamber/AboutSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/AboutSettings.tsx @@ -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 = ({ 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(null); const [openCodeVersion, setOpenCodeVersion] = React.useState(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 ( -
- {/* Version row with update status */} -
- - v{currentVersion} - +
+
+ +

OpenChamber

+
+

{t('aboutDialog.openChamberVersionLabel', { version: currentVersion })}

+

{t('aboutDialog.openCodeVersionLabel', { version: openCodeVersion || t('settings.openchamber.about.state.unknown') })}

+
+
+
{!updateStore.available && !updateStore.error && ( - + {isChecking ? : } + {isChecking ? t('settings.openchamber.about.state.checking') : t('settings.openchamber.about.actions.checkForUpdates')} + )} {!isChecking && updateStore.available && ( - + + {t('settings.openchamber.about.actions.updateToVersion', { version: updateStore.info?.version || '' })} + )}
-
- {t('settings.openchamber.about.field.openCodeVersion')} - {openCodeVersion || t('settings.openchamber.about.state.unknown')} -
- {updateStore.error && ( -

{updateStore.error}

+

+ {updateStore.error} +

)} - {/* Links row */} -
- - - GitHub - + +

+ {t('aboutDialog.footerNote')} +

+ { GitHub - - @btriapitsyn - + @openchamber_dev +
diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index ed24bd57..81424e33 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -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 ; + return ; }; // Sessions section: Default model & agent, Session retention diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index fd6853b8..0d764353 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -319,8 +319,6 @@ export const OpenChamberVisualSettings: React.FC 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 || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('diffLayout') - || (shouldShow('mobileStatusBar') && isMobile) || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('reasoning') @@ -1698,7 +1695,7 @@ export const OpenChamberVisualSettings: React.FC
)} - {(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')) && (
{shouldShow('reasoning') && (
)} - {shouldShow('mobileStatusBar') && isMobile && ( -
setShowMobileSessionStatusBar(!showMobileSessionStatusBar)} - onKeyDown={(event) => { - if (event.key === ' ' || event.key === 'Enter') { - event.preventDefault(); - setShowMobileSessionStatusBar(!showMobileSessionStatusBar); - } - }} - > - - {t('settings.openchamber.visual.field.showMobileStatusBar')} -
- )} - {shouldShow('dotfiles') && !isVSCodeRuntime() && (
{ return { id: `settings:${page.slug}`, title: page.title, - icon: , + icon: page.slug === 'mcp' + ? + : , searchText: `${page.title} ${page.group} ${keywords}`, onSelect: run(() => { setSettingsPage(page.slug); diff --git a/packages/ui/src/components/ui/ProviderLogo.tsx b/packages/ui/src/components/ui/ProviderLogo.tsx index babbec08..76c358c9 100644 --- a/packages/ui/src/components/ui/ProviderLogo.tsx +++ b/packages/ui/src/components/ui/ProviderLogo.tsx @@ -31,6 +31,9 @@ export const ProviderLogo: React.FC = ({ src={src} alt={alt || `${providerId} logo`} className={cn('dark:invert object-contain', className)} + loading="eager" + decoding="async" + fetchPriority="high" onError={handleError} /> ); diff --git a/packages/ui/src/components/views/SettingsView.tsx b/packages/ui/src/components/views/SettingsView.tsx index a07d43ff..1c115cff 100644 --- a/packages/ui/src/components/views/SettingsView.tsx +++ b/packages/ui/src/components/views/SettingsView.tsx @@ -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 = ({ onClose, forceMobile, isWindowed, visiblePageSlugs }) => { +export const SettingsView: React.FC = ({ 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 = ({ onClose, forceMobile const setSettingsPage = useUIStore((state) => state.setSettingsPage); const settingsSlug = resolveSettingsSlug(settingsPageRaw); - const [mobileStage, setMobileStage] = React.useState('nav'); + const [mobileStage, setMobileStage] = React.useState(initialMobileStage); const autoNavSlugRef = React.useRef(null); const [navWidth, setNavWidth] = React.useState(216); @@ -492,6 +498,8 @@ export const SettingsView: React.FC = ({ 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 = ({ onClose, forceMobile return ; case 'usage': return ; + case 'about': + return
; case 'magic-prompts': return ; case 'snippets': @@ -703,7 +713,7 @@ export const SettingsView: React.FC = ({ 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 ( @@ -719,7 +729,9 @@ export const SettingsView: React.FC = ({ onClose, forceMobile : 'text-foreground hover:bg-interactive-hover' )} > - + {page.slug === 'mcp' + ? + : } {getPageTitle(page.slug)} {(page.slug === 'voice' || page.slug === 'tunnel') && ( diff --git a/packages/ui/src/constants/sidebar.ts b/packages/ui/src/constants/sidebar.ts index 7cee770f..53ad2331 100644 --- a/packages/ui/src/constants/sidebar.ts +++ b/packages/ui/src/constants/sidebar.ts @@ -2,7 +2,7 @@ import type { IconName } from "@/components/icon/icons"; export type SidebarSection = 'sessions' | 'agents' | 'commands' | 'skills' | 'mcp' | 'providers' | 'usage' | 'git-identities' | 'settings'; -export type IconComponent = IconName; +export type IconComponent = IconName | 'mcp-custom'; export interface SidebarSectionConfig { id: SidebarSection; @@ -40,7 +40,7 @@ export const SIDEBAR_SECTIONS: SidebarSectionConfig[] = [ id: 'mcp', label: 'MCP', description: 'Manage Model Context Protocol servers and their configurations.', - icon: "plug-2", + icon: "mcp-custom", }, { id: 'providers', diff --git a/packages/ui/src/hooks/useProviderLogo.ts b/packages/ui/src/hooks/useProviderLogo.ts index 0efbc136..626cd348 100644 --- a/packages/ui/src/hooks/useProviderLogo.ts +++ b/packages/ui/src/hooks/useProviderLogo.ts @@ -14,6 +14,7 @@ const localLogoModules = import.meta.glob('../assets/provider-logos/*.sv }); const LOCAL_PROVIDER_LOGO_MAP = new Map(); +const PRELOADED_LOGO_SRCS = new Set(); const LOGO_ALIAS = new Map([ ['codex', 'openai'], @@ -50,6 +51,39 @@ const buildLogoCandidates = (providerId: string | null | undefined) => { return [...new Set(candidates)]; }; +const resolveProviderLogoSrc = (providerId: string | null | undefined): string | null => { + const candidates = buildLogoCandidates(providerId); + const localResolvedId = candidates.find((candidate) => LOCAL_PROVIDER_LOGO_MAP.has(candidate)) ?? null; + const localLogoSrc = localResolvedId ? LOCAL_PROVIDER_LOGO_MAP.get(localResolvedId) ?? null : null; + if (localLogoSrc) { + return localLogoSrc; + } + + const remoteResolvedId = candidates[0] ?? null; + return remoteResolvedId ? `https://models.dev/logos/${remoteResolvedId}.svg` : null; +}; + +export const preloadProviderLogo = (providerId: string | null | undefined): void => { + if (typeof Image === 'undefined') return; + const src = resolveProviderLogoSrc(providerId); + if (!src || PRELOADED_LOGO_SRCS.has(src)) return; + + PRELOADED_LOGO_SRCS.add(src); + const image = new Image(); + image.decoding = 'async'; + image.onerror = () => { + PRELOADED_LOGO_SRCS.delete(src); + }; + image.src = src; + void image.decode?.().catch(() => undefined); +}; + +export const preloadProviderLogos = (providerIds: readonly (string | null | undefined)[]): void => { + for (const providerId of providerIds) { + preloadProviderLogo(providerId); + } +}; + for (const [path, url] of Object.entries(localLogoModules)) { const match = path.match(/provider-logos\/([^/]+)\.svg$/i); if (match?.[1] && url) { diff --git a/packages/ui/src/hooks/useUpdatePolling.ts b/packages/ui/src/hooks/useUpdatePolling.ts new file mode 100644 index 00000000..79e13047 --- /dev/null +++ b/packages/ui/src/hooks/useUpdatePolling.ts @@ -0,0 +1,46 @@ +import React from 'react'; + +import { useUpdateStore } from '@/stores/useUpdateStore'; + +export function useUpdatePolling() { + const checkForUpdates = useUpdateStore((state) => state.checkForUpdates); + const checkForUpdatesRef = React.useRef(checkForUpdates); + + React.useEffect(() => { + checkForUpdatesRef.current = checkForUpdates; + }, [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 checkForUpdatesRef.current(); + 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); + } + }; + }, []); +} diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index ee673ea8..91f695bc 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -47,6 +47,7 @@ export const settingsDict = { 'settings.page.notifications.title': 'Notifications', 'settings.page.voice.title': 'Voice', 'settings.page.tunnel.title': 'Remote Tunnel', + 'settings.page.about.title': 'About', 'settings.page.snippets.title': 'Snippets', 'settings.snippets.sidebar.title': 'Snippets', 'settings.snippets.sidebar.total': 'Total: {count}', @@ -1639,8 +1640,6 @@ export const settingsDict = { 'settings.openchamber.visual.field.showToolFileIcons': 'Show Tool File Icons', 'settings.openchamber.visual.field.showTurnChangedFilesAria': 'Show changed files for completed turns', 'settings.openchamber.visual.field.showTurnChangedFiles': 'Show Changed Files for Completed Turns', - 'settings.openchamber.visual.field.showMobileStatusBarAria': 'Show mobile status bar', - 'settings.openchamber.visual.field.showMobileStatusBar': 'Show Mobile Status Bar', 'settings.openchamber.visual.field.showDotfilesAria': 'Show dotfiles', 'settings.openchamber.visual.field.showDotfiles': 'Show Dotfiles', 'settings.openchamber.visual.field.queueMessagesByDefaultAria': 'Queue messages by default', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index f835a133..cfecf214 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -32,9 +32,15 @@ export const dict = { 'mobile.nav.settings': 'Settings', 'mobile.surface.closeAria': 'Close', 'mobile.header.openMenuAria': 'Open menu', + 'mobile.header.openMetadataAria': 'Open session metadata', + 'mobile.header.metadata.context': 'Context', + 'mobile.header.metadata.branch': 'Branch', + 'mobile.header.metadata.usage': 'Usage', 'mobile.menu.titleAria': 'Workspace tools', 'mobile.menu.files': 'Files', 'mobile.menu.changes': 'Changes', + 'mobile.menu.mcp': 'MCP', + 'mobile.menu.update': 'Update', 'mobile.menu.settings': 'Settings', 'mobile.sessions.newChatCta': 'New chat in {project}', 'mobile.sessions.dateGroup.today': 'Today', @@ -2177,7 +2183,7 @@ export const dict = { 'mcpDropdown.status.unknownError': 'Unknown error', 'mcpDropdown.status.needsAuth': 'Needs authentication', 'mcpDropdown.status.needsRegistration': 'Needs registration: {error}', - 'mcpDropdown.empty.configureInConfig': 'Configure MCP servers in OpenCode config.', + 'mcpDropdown.empty.configureInConfig': 'Configure MCP servers in Settings.', 'sessionAuth.error.rateLimitTitle': 'Too many attempts', 'sessionAuth.error.networkTitle': 'Unable to reach server', 'sessionAuth.error.rateLimitDescriptionSingle': 'Please wait {minutes} minute before trying again.', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 02478da2..586719fa 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -47,6 +47,7 @@ export const settingsDict = { "settings.page.notifications.title": "Notificaciones", "settings.page.voice.title": "Voz", "settings.page.tunnel.title": "Túnel remoto", + "settings.page.about.title": "Acerca de", "settings.page.snippets.title": "Snippets", "settings.snippets.sidebar.title": "Snippets", "settings.snippets.sidebar.total": "Total: {count}", @@ -1606,8 +1607,6 @@ export const settingsDict = { "settings.openchamber.visual.field.showToolFileIcons": "Mostrar iconos de archivos de herramientas", "settings.openchamber.visual.field.showTurnChangedFilesAria": "Mostrar archivos cambiados en turnos completados", "settings.openchamber.visual.field.showTurnChangedFiles": "Mostrar archivos cambiados en turnos completados", - "settings.openchamber.visual.field.showMobileStatusBarAria": "Mostrar barra de estado móvil", - "settings.openchamber.visual.field.showMobileStatusBar": "Mostrar barra de estado móvil", "settings.openchamber.visual.field.showDotfilesAria": "Mostrar archivos ocultos", "settings.openchamber.visual.field.showDotfiles": "Mostrar archivos ocultos", "settings.openchamber.visual.field.queueMessagesByDefaultAria": "Poner mensaje en cola por defecto", diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 2e57faf2..8a72ecde 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -33,9 +33,15 @@ export const dict: Record = { "mobile.nav.settings": "Ajustes", "mobile.surface.closeAria": "Cerrar", "mobile.header.openMenuAria": "Abrir menú", + "mobile.header.openMetadataAria": "Abrir metadatos de la sesión", + "mobile.header.metadata.context": "Contexto", + "mobile.header.metadata.branch": "Rama", + "mobile.header.metadata.usage": "Uso", "mobile.menu.titleAria": "Herramientas del espacio de trabajo", "mobile.menu.files": "Archivos", "mobile.menu.changes": "Cambios", + "mobile.menu.mcp": "MCP", + "mobile.menu.update": "Actualizar", "mobile.menu.settings": "Ajustes", "mobile.sessions.newChatCta": "Nuevo chat en {project}", "mobile.sessions.dateGroup.today": "Hoy", @@ -2143,7 +2149,7 @@ export const dict: Record = { "mcpDropdown.status.unknownError": "Error desconocido", "mcpDropdown.status.needsAuth": "Necesita autenticación", "mcpDropdown.status.needsRegistration": "Necesita registro: {error}", - "mcpDropdown.empty.configureInConfig": "Configura los servidores MCP en la configuración de OpenCode.", + "mcpDropdown.empty.configureInConfig": "Configura los servidores MCP en Ajustes.", "sessionAuth.error.rateLimitTitle": "Demasiados intentos", "sessionAuth.error.networkTitle": "No se puede alcanzar el servidor", "sessionAuth.error.rateLimitDescriptionSingle": "Por favor, espera {minutes} minuto antes de intentarlo de nuevo.", diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 3ead35c2..c4b6b0fc 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -47,6 +47,7 @@ export const settingsDict = { 'settings.page.notifications.title': '알림', 'settings.page.voice.title': '음성', 'settings.page.tunnel.title': '원격 터널', + 'settings.page.about.title': '정보', 'settings.page.snippets.title': '스니펫', 'settings.snippets.sidebar.title': '스니펫', 'settings.snippets.sidebar.total': '총 {count}개', @@ -1606,8 +1607,6 @@ export const settingsDict = { 'settings.openchamber.visual.field.showToolFileIcons': '도구 파일 아이콘 표시', 'settings.openchamber.visual.field.showTurnChangedFilesAria': '완료된 턴의 변경 파일 표시', 'settings.openchamber.visual.field.showTurnChangedFiles': '완료된 턴의 변경 파일 표시', - 'settings.openchamber.visual.field.showMobileStatusBarAria': '모바일 상태 바 표시', - 'settings.openchamber.visual.field.showMobileStatusBar': '모바일 상태 바 표시', 'settings.openchamber.visual.field.showDotfilesAria': 'Dotfiles 표시', 'settings.openchamber.visual.field.showDotfiles': 'Dotfiles 표시', 'settings.openchamber.visual.field.queueMessagesByDefaultAria': '기본으로 메시지 대기열 사용', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 66b6bc37..78477bf2 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -33,9 +33,15 @@ export const dict: Record = { 'mobile.nav.settings': '설정', 'mobile.surface.closeAria': '닫기', 'mobile.header.openMenuAria': '메뉴 열기', + 'mobile.header.openMetadataAria': '세션 메타데이터 열기', + 'mobile.header.metadata.context': '컨텍스트', + 'mobile.header.metadata.branch': '브랜치', + 'mobile.header.metadata.usage': '사용량', 'mobile.menu.titleAria': '작업 공간 도구', 'mobile.menu.files': '파일', 'mobile.menu.changes': '변경사항', + 'mobile.menu.mcp': 'MCP', + 'mobile.menu.update': '업데이트', 'mobile.menu.settings': '설정', 'mobile.sessions.newChatCta': '{project}에서 새 채팅', 'mobile.sessions.dateGroup.today': '오늘', @@ -2177,7 +2183,7 @@ export const dict: Record = { 'mcpDropdown.status.unknownError': '알 수 없음 오류', 'mcpDropdown.status.needsAuth': '인증 필요', 'mcpDropdown.status.needsRegistration': '등록 필요: {error}', - 'mcpDropdown.empty.configureInConfig': 'OpenCode 설정에서 MCP 서버를 구성하세요.', + 'mcpDropdown.empty.configureInConfig': '설정에서 MCP 서버를 구성하세요.', 'sessionAuth.error.rateLimitTitle': '시도가 너무 많습니다', 'sessionAuth.error.networkTitle': '서버에 연결할 수 없습니다', 'sessionAuth.error.rateLimitDescriptionSingle': '{minutes}분 후 다시 시도하세요.', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 7af1bfdd..f3afff8c 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -933,8 +933,6 @@ export const settingsDict = { 'settings.openchamber.visual.field.showDotfilesAria': 'Pokaż pliki ukryte', 'settings.openchamber.visual.field.showExpandedBashToolsAria': 'Pokaż rozwinięte narzędzia bash', 'settings.openchamber.visual.field.showExpandedEditToolsAria': 'Pokaż rozwinięte narzędzia edycji', - 'settings.openchamber.visual.field.showMobileStatusBar': 'Pokaż mobilny pasek statusu', - 'settings.openchamber.visual.field.showMobileStatusBarAria': 'Pokaż mobilny pasek statusu', 'settings.openchamber.visual.field.showReasoningTraces': 'Pokaż ślady rozumowania', 'settings.openchamber.visual.field.showReasoningTracesAria': 'Pokaż ślady rozumowania', 'settings.openchamber.visual.field.collapsibleThinkingBlocks': 'Włącz zwijalne bloki rozumowania', @@ -1069,6 +1067,7 @@ export const settingsDict = { 'settings.page.skills.title': 'Umiejętności', 'settings.page.skillsCatalog.title': 'Katalog umiejętności', 'settings.page.tunnel.title': 'Zdalny Tunel', + 'settings.page.about.title': 'O aplikacji', 'settings.page.snippets.title': 'Fragmenty', 'settings.snippets.sidebar.title': 'Fragmenty', 'settings.snippets.sidebar.total': 'Suma: {count}', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index f913ef6d..434384f0 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -34,9 +34,15 @@ export const dict: Record = { 'mobile.nav.settings': 'Ustawienia', 'mobile.surface.closeAria': 'Zamknij', 'mobile.header.openMenuAria': 'Otwórz menu', + 'mobile.header.openMetadataAria': 'Otwórz metadane sesji', + 'mobile.header.metadata.context': 'Kontekst', + 'mobile.header.metadata.branch': 'Gałąź', + 'mobile.header.metadata.usage': 'Użycie', 'mobile.menu.titleAria': 'Narzędzia obszaru roboczego', 'mobile.menu.files': 'Pliki', 'mobile.menu.changes': 'Zmiany', + 'mobile.menu.mcp': 'MCP', + 'mobile.menu.update': 'Aktualizuj', 'mobile.menu.settings': 'Ustawienia', 'mobile.sessions.newChatCta': 'Nowy czat w {project}', 'mobile.sessions.dateGroup.today': 'Dzisiaj', @@ -2037,7 +2043,7 @@ export const dict: Record = { 'markdownRenderer.table.toast.downloadedAsFormat': 'Tabela została pobrana jako {format}', 'mcpDropdown.actions.openAria': 'Serwery MCP', 'mcpDropdown.actions.refreshAria': 'Odśwież', - 'mcpDropdown.empty.configureInConfig': 'Skonfiguruj serwery MCP w konfiguracji OpenCode.', + 'mcpDropdown.empty.configureInConfig': 'Skonfiguruj serwery MCP w Ustawieniach.', 'mcpDropdown.status.connected': 'Połączono', 'mcpDropdown.status.failed': 'Błąd: {error}', 'mcpDropdown.status.needsAuth': 'Wymaga uwierzytelnienia', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index fcd82608..2398e106 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -47,6 +47,7 @@ export const settingsDict = { "settings.page.notifications.title": "Notificações", "settings.page.voice.title": "Voz", "settings.page.tunnel.title": "Túnel remoto", + "settings.page.about.title": "Sobre", "settings.page.snippets.title": "Snippets", "settings.snippets.sidebar.title": "Snippets", "settings.snippets.sidebar.total": "Total: {count}", @@ -1606,8 +1607,6 @@ export const settingsDict = { "settings.openchamber.visual.field.showToolFileIcons": "Mostrar ícones de arquivos de ferramentas", "settings.openchamber.visual.field.showTurnChangedFilesAria": "Mostrar arquivos alterados em turnos concluídos", "settings.openchamber.visual.field.showTurnChangedFiles": "Mostrar arquivos alterados em turnos concluídos", - "settings.openchamber.visual.field.showMobileStatusBarAria": "Mostrar barra de status móvel", - "settings.openchamber.visual.field.showMobileStatusBar": "Mostrar barra de status móvel", "settings.openchamber.visual.field.showDotfilesAria": "Mostrar arquivos ocultos", "settings.openchamber.visual.field.showDotfiles": "Mostrar arquivos ocultos", "settings.openchamber.visual.field.queueMessagesByDefaultAria": "Colocar mensagens na fila por padrão", diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 4f382a37..324dc251 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -33,9 +33,15 @@ export const dict: Record = { "mobile.nav.settings": "Configurações", "mobile.surface.closeAria": "Fechar", "mobile.header.openMenuAria": "Abrir menu", + "mobile.header.openMetadataAria": "Abrir metadados da sessão", + "mobile.header.metadata.context": "Contexto", + "mobile.header.metadata.branch": "Branch", + "mobile.header.metadata.usage": "Uso", "mobile.menu.titleAria": "Ferramentas do espaço de trabalho", "mobile.menu.files": "Arquivos", "mobile.menu.changes": "Alterações", + "mobile.menu.mcp": "MCP", + "mobile.menu.update": "Atualizar", "mobile.menu.settings": "Configurações", "mobile.sessions.newChatCta": "Novo chat em {project}", "mobile.sessions.dateGroup.today": "Hoje", @@ -2143,7 +2149,7 @@ export const dict: Record = { "mcpDropdown.status.unknownError": "Erro desconhecido", "mcpDropdown.status.needsAuth": "Precisa de autenticação", "mcpDropdown.status.needsRegistration": "Precisa de registro: {error}", - "mcpDropdown.empty.configureInConfig": "Configure os servidores MCP na configurações do OpenCode.", + "mcpDropdown.empty.configureInConfig": "Configure os servidores MCP nas Configurações.", "sessionAuth.error.rateLimitTitle": "Muitas tentativas", "sessionAuth.error.networkTitle": "Não é possível acessar o servidor", "sessionAuth.error.rateLimitDescriptionSingle": "Aguarde {minutes} minuto antes de tentar novamente.", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 46b29c1a..53626e89 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -47,6 +47,7 @@ export const settingsDict = { "settings.page.notifications.title": "Сповіщення", "settings.page.voice.title": "Голос", "settings.page.tunnel.title": "Віддалений тунель", + "settings.page.about.title": "Про застосунок", "settings.page.snippets.title": "Сніпети", "settings.snippets.sidebar.title": "Сніпети", "settings.snippets.sidebar.total": "Всього: {count}", @@ -1606,8 +1607,6 @@ export const settingsDict = { "settings.openchamber.visual.field.showToolFileIcons": "Показати значки файлів інструментів", "settings.openchamber.visual.field.showTurnChangedFilesAria": "Показати змінені файли для завершених ходів", "settings.openchamber.visual.field.showTurnChangedFiles": "Показати змінені файли для завершених ходів", - "settings.openchamber.visual.field.showMobileStatusBarAria": "Показати рядок стану мобільного пристрою", - "settings.openchamber.visual.field.showMobileStatusBar": "Показати мобільний рядок стану", "settings.openchamber.visual.field.showDotfilesAria": "Показати dotfiles", "settings.openchamber.visual.field.showDotfiles": "Показати dotfiles", "settings.openchamber.visual.field.queueMessagesByDefaultAria": "Ставити повідомлення в чергу за замовчуванням", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 90ea40b0..4098c7c1 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -33,9 +33,15 @@ export const dict: Record = { "mobile.nav.settings": "Налаштування", "mobile.surface.closeAria": "Закрити", "mobile.header.openMenuAria": "Відкрити меню", + "mobile.header.openMetadataAria": "Відкрити метадані сесії", + "mobile.header.metadata.context": "Контекст", + "mobile.header.metadata.branch": "Гілка", + "mobile.header.metadata.usage": "Використання", "mobile.menu.titleAria": "Інструменти робочого простору", "mobile.menu.files": "Файли", "mobile.menu.changes": "Зміни", + "mobile.menu.mcp": "MCP", + "mobile.menu.update": "Оновити", "mobile.menu.settings": "Налаштування", "mobile.sessions.newChatCta": "Новий чат у {project}", "mobile.sessions.dateGroup.today": "Сьогодні", @@ -2143,7 +2149,7 @@ export const dict: Record = { "mcpDropdown.status.unknownError": "Невідома помилка", "mcpDropdown.status.needsAuth": "Потребує аутентифікації", "mcpDropdown.status.needsRegistration": "Потребує реєстрації: {error}", - "mcpDropdown.empty.configureInConfig": "Налаштувати сервери MCP у конфігурації OpenCode.", + "mcpDropdown.empty.configureInConfig": "Налаштуйте сервери MCP у Налаштуваннях.", "sessionAuth.error.rateLimitTitle": "Забагато спроб", "sessionAuth.error.networkTitle": "Неможливо отримати доступ до сервера", "sessionAuth.error.rateLimitDescriptionSingle": "Зачекайте {minutes} хвилину перед повторною спробою.", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 34fc5a41..e9ed4561 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -47,6 +47,7 @@ export const settingsDict = { 'settings.page.notifications.title': '通知', 'settings.page.voice.title': '语音', 'settings.page.tunnel.title': '远程隧道', + 'settings.page.about.title': '关于', 'settings.page.snippets.title': '代码片段', 'settings.snippets.sidebar.title': '代码片段', 'settings.snippets.sidebar.total': '共 {count} 个', @@ -1606,8 +1607,6 @@ export const settingsDict = { 'settings.openchamber.visual.field.showToolFileIcons': '显示工具文件图标', 'settings.openchamber.visual.field.showTurnChangedFilesAria': '显示已完成回合的更改文件', 'settings.openchamber.visual.field.showTurnChangedFiles': '显示已完成回合的更改文件', - 'settings.openchamber.visual.field.showMobileStatusBarAria': '显示移动端状态栏', - 'settings.openchamber.visual.field.showMobileStatusBar': '显示移动端状态栏', 'settings.openchamber.visual.field.showDotfilesAria': '显示点文件', 'settings.openchamber.visual.field.showDotfiles': '显示点文件', 'settings.openchamber.visual.field.queueMessagesByDefaultAria': '默认排队消息', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 2f59924a..64012bf0 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -33,9 +33,15 @@ export const dict: Record = { 'mobile.nav.settings': '设置', 'mobile.surface.closeAria': '关闭', 'mobile.header.openMenuAria': '打开菜单', + 'mobile.header.openMetadataAria': '打开会话元数据', + 'mobile.header.metadata.context': '上下文', + 'mobile.header.metadata.branch': '分支', + 'mobile.header.metadata.usage': '用量', 'mobile.menu.titleAria': '工作区工具', 'mobile.menu.files': '文件', 'mobile.menu.changes': '更改', + 'mobile.menu.mcp': 'MCP', + 'mobile.menu.update': '更新', 'mobile.menu.settings': '设置', 'mobile.sessions.newChatCta': '在 {project} 中新建会话', 'mobile.sessions.dateGroup.today': '今天', @@ -2143,7 +2149,7 @@ export const dict: Record = { 'mcpDropdown.status.unknownError': '未知错误', 'mcpDropdown.status.needsAuth': '需要认证', 'mcpDropdown.status.needsRegistration': '需要注册:{error}', - 'mcpDropdown.empty.configureInConfig': '请在 OpenCode 配置中设置 MCP 服务器。', + 'mcpDropdown.empty.configureInConfig': '请在设置中配置 MCP 服务器。', 'sessionAuth.error.rateLimitTitle': '尝试次数过多', 'sessionAuth.error.networkTitle': '无法连接服务器', 'sessionAuth.error.rateLimitDescriptionSingle': '请等待 {minutes} 分钟后再试。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index f4f0060e..d5c94241 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -44,6 +44,7 @@ 'settings.page.notifications.title': '通知', 'settings.page.voice.title': '語音', 'settings.page.tunnel.title': 'Remote Tunnel', + 'settings.page.about.title': '關於', 'settings.page.snippets.title': '程式片段', 'settings.snippets.sidebar.title': '程式片段', 'settings.snippets.sidebar.total': '共 {count} 個', @@ -1527,8 +1528,6 @@ 'settings.openchamber.visual.field.showToolFileIcons': '顯示工具檔案圖示', 'settings.openchamber.visual.field.showTurnChangedFilesAria': '顯示已完成回合的變更檔案', 'settings.openchamber.visual.field.showTurnChangedFiles': '顯示已完成回合的變更檔案', - 'settings.openchamber.visual.field.showMobileStatusBarAria': '顯示行動裝置狀態列', - 'settings.openchamber.visual.field.showMobileStatusBar': '顯示行動裝置狀態列', 'settings.openchamber.visual.field.showDotfilesAria': '顯示點檔案', 'settings.openchamber.visual.field.showDotfiles': '顯示點檔案', 'settings.openchamber.visual.field.queueMessagesByDefaultAria': '預設排隊訊息', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 796d7201..79b5e81f 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -33,9 +33,15 @@ export const dict: Record = { 'mobile.nav.settings': '設定', 'mobile.surface.closeAria': '關閉', 'mobile.header.openMenuAria': '開啟選單', + 'mobile.header.openMetadataAria': '開啟工作階段中繼資料', + 'mobile.header.metadata.context': '上下文', + 'mobile.header.metadata.branch': '分支', + 'mobile.header.metadata.usage': '用量', 'mobile.menu.titleAria': '工作區工具', 'mobile.menu.files': '檔案', 'mobile.menu.changes': '變更', + 'mobile.menu.mcp': 'MCP', + 'mobile.menu.update': '更新', 'mobile.menu.settings': '設定', 'mobile.sessions.newChatCta': '在 {project} 中新增聊天', 'mobile.sessions.dateGroup.today': '今天', @@ -2147,7 +2153,7 @@ export const dict: Record = { 'mcpDropdown.status.unknownError': '未知錯誤', 'mcpDropdown.status.needsAuth': '需要認證', 'mcpDropdown.status.needsRegistration': '需要註冊:{error}', - 'mcpDropdown.empty.configureInConfig': '請在 OpenCode 設定中設定 MCP 伺服器。', + 'mcpDropdown.empty.configureInConfig': '請在設定中設定 MCP 伺服器。', 'sessionAuth.error.rateLimitTitle': '嘗試次數過多', 'sessionAuth.error.networkTitle': '無法連線伺服器', 'sessionAuth.error.rateLimitDescriptionSingle': '請等待 {minutes} 分鐘後再試。', diff --git a/packages/ui/src/lib/quota/utils.ts b/packages/ui/src/lib/quota/utils.ts index fbac3d0d..71657857 100644 --- a/packages/ui/src/lib/quota/utils.ts +++ b/packages/ui/src/lib/quota/utils.ts @@ -78,6 +78,7 @@ export const formatWindowLabel = (label: string): string => { if (label === 'daily') return 'Daily'; if (label === 'monthly') return 'Monthly Limit'; if (label === 'credits') return 'Credits'; + if (label === 'credits_balance') return 'Credits Balance'; if (label === 'session') return 'Session'; if (label === 'premium') return 'Premium Interactions'; if (label === 'chat') return 'Chat Requests'; diff --git a/packages/ui/src/lib/settings/metadata.ts b/packages/ui/src/lib/settings/metadata.ts index 65fef558..66e54d59 100644 --- a/packages/ui/src/lib/settings/metadata.ts +++ b/packages/ui/src/lib/settings/metadata.ts @@ -22,7 +22,8 @@ export type SettingsPageSlug = | 'snippets' | 'notifications' | 'voice' - | 'tunnel'; + | 'tunnel' + | 'about'; export type SettingsPageGroup = | 'appearance' @@ -204,6 +205,7 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [ { slug: 'notifications', title: 'Notifications', group: 'general', kind: 'single', keywords: ['alerts', 'native', 'summary', 'summarization'], }, { slug: 'voice', title: 'Voice', group: 'advanced', kind: 'single', keywords: ['tts', 'speech', 'voice'], isAvailable: (ctx) => !ctx.isVSCode }, { slug: 'tunnel', title: 'Remote Tunnel', group: 'advanced', kind: 'single', keywords: ['tunnel', 'cloudflare', 'qr', 'remote', 'mobile', 'share'], isAvailable: (ctx) => !ctx.isVSCode }, + { slug: 'about', title: 'About', group: 'advanced', kind: 'single', keywords: ['about', 'version', 'updates', 'release', 'changelog'], }, ] as const; export const LEGACY_SIDEBAR_SECTION_TO_SETTINGS_SLUG: Record = { diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 878b8fe5..bbb9c2c5 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -620,7 +620,6 @@ interface UIStore { userMessageRenderingMode: UserMessageRenderingMode; stickyUserHeader: boolean; showSplitAssistantMessageActions: boolean; - showMobileSessionStatusBar: boolean; isMobileSessionStatusBarCollapsed: boolean; mobileSessionPanelOpen: boolean; mobileSessionFilterProjectId: string | null; @@ -763,7 +762,6 @@ interface UIStore { setUserMessageRenderingMode: (value: UserMessageRenderingMode) => void; setStickyUserHeader: (value: boolean) => void; setShowSplitAssistantMessageActions: (value: boolean) => void; - setShowMobileSessionStatusBar: (value: boolean) => void; setIsMobileSessionStatusBarCollapsed: (value: boolean) => void; setMobileSessionPanelOpen: (value: boolean) => void; setMobileSessionFilterProjectId: (value: string | null) => void; @@ -899,7 +897,6 @@ export const useUIStore = create()( userMessageRenderingMode: 'markdown', stickyUserHeader: false, showSplitAssistantMessageActions: false, - showMobileSessionStatusBar: false, isMobileSessionStatusBarCollapsed: false, mobileSessionPanelOpen: false, mobileSessionFilterProjectId: null, @@ -2010,9 +2007,6 @@ export const useUIStore = create()( setShowSplitAssistantMessageActions: (value) => { set({ showSplitAssistantMessageActions: value }); }, - setShowMobileSessionStatusBar: (value) => { - set({ showMobileSessionStatusBar: value }); - }, setIsMobileSessionStatusBarCollapsed: (value) => { set({ isMobileSessionStatusBarCollapsed: value }); }, @@ -2240,7 +2234,6 @@ export const useUIStore = create()( userMessageRenderingMode: state.userMessageRenderingMode, stickyUserHeader: state.stickyUserHeader, showSplitAssistantMessageActions: state.showSplitAssistantMessageActions, - showMobileSessionStatusBar: state.showMobileSessionStatusBar, isMobileSessionStatusBarCollapsed: state.isMobileSessionStatusBarCollapsed, mobileSessionFilterProjectId: state.mobileSessionFilterProjectId, shortcutOverrides: state.shortcutOverrides, diff --git a/packages/vscode/src/quotaProviders.ts b/packages/vscode/src/quotaProviders.ts index d1735f78..6d33f374 100644 --- a/packages/vscode/src/quotaProviders.ts +++ b/packages/vscode/src/quotaProviders.ts @@ -518,9 +518,9 @@ export const fetchCodexQuota = async (): Promise => { const valueLabel = unlimited ? 'Unlimited' : balance !== null - ? `$${formatMoney(balance)} remaining` + ? `$${formatMoney(balance)}` : null; - windows.credits = toUsageWindow({ + windows.credits_balance = toUsageWindow({ usedPercent: null, windowSeconds: null, resetAt: null, diff --git a/packages/web/server/lib/quota/providers/codex.js b/packages/web/server/lib/quota/providers/codex.js index 77c1ee0f..3c8d5358 100644 --- a/packages/web/server/lib/quota/providers/codex.js +++ b/packages/web/server/lib/quota/providers/codex.js @@ -84,9 +84,9 @@ export const fetchQuota = async () => { const label = unlimited ? 'Unlimited' : balance !== null - ? `$${formatMoney(balance)} remaining` + ? `$${formatMoney(balance)}` : null; - windows.credits = toUsageWindow({ + windows.credits_balance = toUsageWindow({ usedPercent: null, windowSeconds: null, resetAt: null,