From eff6f46ad93e7f08b76f50e60262191bf48074e0 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 10 Jun 2026 12:00:10 +0300 Subject: [PATCH] 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. --- .claude/skills/clack-cli-patterns | 1 + .claude/skills/drag-to-reorder | 1 + .claude/skills/locale-ui-patterns | 1 + .claude/skills/settings-ui-patterns | 1 + .claude/skills/theme-system | 1 + .claude/skills/ui-api-decoupling | 1 + CLAUDE.md | 1 + packages/ui/src/apps/MobileApp.tsx | 821 ++++++++++++++++-- .../src/assets/provider-logos/anthropic.svg | 3 + .../assets/provider-logos/github-copilot.svg | 3 + .../ui/src/assets/provider-logos/google.svg | 3 + .../assets/provider-logos/kimi-for-coding.svg | 3 + .../provider-logos/minimax-cn-coding-plan.svg | 3 + .../provider-logos/minimax-coding-plan.svg | 3 + .../ui/src/assets/provider-logos/nano-gpt.svg | 3 + .../ui/src/assets/provider-logos/openai.svg | 3 + .../src/assets/provider-logos/openrouter.svg | 8 + .../ui/src/assets/provider-logos/wafer.ai.svg | 3 + .../assets/provider-logos/zai-coding-plan.svg | 3 + .../provider-logos/zhipuai-coding-plan.svg | 3 + .../chat/MobileSessionStatusBar.tsx | 69 +- packages/ui/src/components/layout/Header.tsx | 13 +- .../ui/src/components/layout/MainLayout.tsx | 38 +- .../ui/src/components/layout/VSCodeLayout.tsx | 38 +- .../ui/src/components/mcp/McpDropdown.tsx | 45 +- .../sections/SectionPlaceholder.tsx | 5 +- .../sections/openchamber/AboutSettings.tsx | 169 ++-- .../sections/openchamber/OpenChamberPage.tsx | 2 +- .../openchamber/OpenChamberVisualSettings.tsx | 28 +- .../ui/src/components/ui/CommandPalette.tsx | 5 +- .../ui/src/components/ui/ProviderLogo.tsx | 3 + .../ui/src/components/views/SettingsView.tsx | 22 +- packages/ui/src/constants/sidebar.ts | 4 +- packages/ui/src/hooks/useProviderLogo.ts | 34 + packages/ui/src/hooks/useUpdatePolling.ts | 46 + .../ui/src/lib/i18n/messages/en.settings.ts | 3 +- packages/ui/src/lib/i18n/messages/en.ts | 8 +- .../ui/src/lib/i18n/messages/es.settings.ts | 3 +- packages/ui/src/lib/i18n/messages/es.ts | 8 +- .../ui/src/lib/i18n/messages/ko.settings.ts | 3 +- packages/ui/src/lib/i18n/messages/ko.ts | 8 +- .../ui/src/lib/i18n/messages/pl.settings.ts | 3 +- packages/ui/src/lib/i18n/messages/pl.ts | 8 +- .../src/lib/i18n/messages/pt-BR.settings.ts | 3 +- packages/ui/src/lib/i18n/messages/pt-BR.ts | 8 +- .../ui/src/lib/i18n/messages/uk.settings.ts | 3 +- packages/ui/src/lib/i18n/messages/uk.ts | 8 +- .../src/lib/i18n/messages/zh-CN.settings.ts | 3 +- packages/ui/src/lib/i18n/messages/zh-CN.ts | 8 +- .../src/lib/i18n/messages/zh-TW.settings.ts | 3 +- packages/ui/src/lib/i18n/messages/zh-TW.ts | 8 +- packages/ui/src/lib/quota/utils.ts | 1 + packages/ui/src/lib/settings/metadata.ts | 4 +- packages/ui/src/stores/useUIStore.ts | 7 - packages/vscode/src/quotaProviders.ts | 4 +- .../web/server/lib/quota/providers/codex.js | 4 +- 56 files changed, 1145 insertions(+), 353 deletions(-) create mode 120000 .claude/skills/clack-cli-patterns create mode 120000 .claude/skills/drag-to-reorder create mode 120000 .claude/skills/locale-ui-patterns create mode 120000 .claude/skills/settings-ui-patterns create mode 120000 .claude/skills/theme-system create mode 120000 .claude/skills/ui-api-decoupling create mode 120000 CLAUDE.md create mode 100644 packages/ui/src/assets/provider-logos/anthropic.svg create mode 100644 packages/ui/src/assets/provider-logos/github-copilot.svg create mode 100644 packages/ui/src/assets/provider-logos/google.svg create mode 100644 packages/ui/src/assets/provider-logos/kimi-for-coding.svg create mode 100644 packages/ui/src/assets/provider-logos/minimax-cn-coding-plan.svg create mode 100644 packages/ui/src/assets/provider-logos/minimax-coding-plan.svg create mode 100644 packages/ui/src/assets/provider-logos/nano-gpt.svg create mode 100644 packages/ui/src/assets/provider-logos/openai.svg create mode 100644 packages/ui/src/assets/provider-logos/openrouter.svg create mode 100644 packages/ui/src/assets/provider-logos/wafer.ai.svg create mode 100644 packages/ui/src/assets/provider-logos/zai-coding-plan.svg create mode 100644 packages/ui/src/assets/provider-logos/zhipuai-coding-plan.svg create mode 100644 packages/ui/src/hooks/useUpdatePolling.ts 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,