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.
@@ -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<Record<'context' | 'output', unknown>>)[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 }) => (
|
||||
<div className="flex min-w-0 items-center gap-3 rounded-xl px-2.5 py-2.5">
|
||||
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
|
||||
<Icon name={icon} className="size-[18px]" />
|
||||
</span>
|
||||
<span className="shrink-0 typography-ui-label text-muted-foreground">{label}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-right typography-ui-label font-medium text-foreground">
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SessionMetadataOverlay: React.FC<{
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
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<HTMLDivElement>(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 (
|
||||
<div className="fixed inset-x-0 bottom-0 top-[calc(var(--oc-safe-area-top,0px)+var(--oc-header-height,56px))] z-20 pointer-events-none">
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-label={t('mobile.header.openMetadataAria')}
|
||||
className={cn(
|
||||
'mx-3 mt-2 overflow-y-auto overscroll-contain rounded-[20px] border border-border/40 bg-[var(--surface-elevated)] p-2 shadow-[0_12px_32px_rgb(0_0_0_/_0.2)] will-change-transform',
|
||||
isExiting ? 'pointer-events-none' : 'pointer-events-auto',
|
||||
)}
|
||||
style={{
|
||||
animation: `${isExiting ? 'session-metadata-out' : 'session-metadata-in'} ${isExiting ? 140 : 170}ms cubic-bezier(0.32, 0.72, 0, 1) forwards`,
|
||||
maxHeight: 'min(72dvh, calc(100dvh - var(--oc-safe-area-top, 0px) - var(--oc-header-height, 56px) - 1rem))',
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<MetadataRow icon="git-branch" label={t('mobile.header.metadata.branch')}>
|
||||
{branchLabel}
|
||||
</MetadataRow>
|
||||
{contextDisplay ? (
|
||||
<MetadataRow icon="pie-chart" label={t('mobile.header.metadata.context')}>
|
||||
<span className="inline-flex items-baseline gap-1.5 tabular-nums">
|
||||
<span className={cn('font-semibold', contextDisplay.colorClass)}>{contextDisplay.percentage.toFixed(1)}%</span>
|
||||
<span className="text-muted-foreground">{contextDisplay.tokens}</span>
|
||||
</span>
|
||||
</MetadataRow>
|
||||
) : null}
|
||||
<MobileUsageLimits
|
||||
groups={usageGroups}
|
||||
displayMode={usageDisplayMode}
|
||||
isLoading={isUsageLoading}
|
||||
timeFormatPreference={timeFormatPreference}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes session-metadata-in {
|
||||
from { opacity: 0; transform: translateY(-8px) scale(0.985); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes session-metadata-out {
|
||||
from { opacity: 1; transform: translateY(0) scale(1); }
|
||||
to { opacity: 0; transform: translateY(-6px) scale(0.985); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="pt-2.5">
|
||||
<div className="flex min-w-0 items-center gap-3 px-2.5 pb-1.5">
|
||||
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
|
||||
<Icon name="timer" className="size-[18px]" />
|
||||
</span>
|
||||
<span className="shrink-0 typography-ui-label text-muted-foreground">
|
||||
{t('mobile.header.metadata.usage')}
|
||||
</span>
|
||||
<span className="inline-flex min-w-0 flex-1 items-center justify-end gap-1.5 typography-ui-label text-muted-foreground">
|
||||
{isLoading ? <Icon name="refresh" className="size-3.5 animate-spin" /> : null}
|
||||
<span className="truncate">{modeLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{groups.map((group) => (
|
||||
<div key={group.providerId} className="min-w-0 rounded-xl bg-[var(--surface-muted)] p-2.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ProviderLogo providerId={group.providerId} className="size-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate typography-ui-label font-medium text-foreground">
|
||||
{group.providerName}
|
||||
</span>
|
||||
{group.status && group.rows.length === 0 ? (
|
||||
<span className="shrink-0 truncate typography-micro text-muted-foreground">
|
||||
{group.status}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{group.rows.length > 0 ? (
|
||||
<div className="mt-1.5 space-y-1">
|
||||
{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 (
|
||||
<div key={row.key} className="flex min-w-0 items-baseline justify-between gap-3">
|
||||
<span className="inline-flex min-w-0 flex-1 items-baseline gap-1.5">
|
||||
<span className="truncate typography-ui-label text-muted-foreground">
|
||||
{row.subtitle ? `${row.subtitle} · ${row.label}` : row.label}
|
||||
</span>
|
||||
{resetLabel ? (
|
||||
<span className="shrink-0 truncate typography-micro text-muted-foreground/70">{resetLabel}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className={cn('shrink-0 typography-ui-label font-semibold tabular-nums', getWindowValueClass(row.window))}>
|
||||
{metricLabel === '-' ? '' : metricLabel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{group.status && group.rows.length > 0 ? (
|
||||
<div className="mt-1.5 typography-micro text-muted-foreground">{group.status}</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileOverflowMenu: React.FC<{
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -123,7 +395,7 @@ const MobileOverflowMenu: React.FC<{
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<item.Icon className="size-5 shrink-0 text-muted-foreground" />
|
||||
{item.iconNode ?? (item.icon ? <Icon name={item.icon} className="size-5 shrink-0 text-muted-foreground" /> : null)}
|
||||
<span className="min-w-0 flex-1 truncate typography-ui-label text-foreground">{item.label}</span>
|
||||
{item.badge && item.badge > 0 ? (
|
||||
<span className="inline-flex size-2 shrink-0 rounded-full bg-primary" aria-hidden />
|
||||
@@ -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<HTMLButtonElement>(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<MobileUsageProviderGroup[]>(() => {
|
||||
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 (
|
||||
<>
|
||||
<button
|
||||
ref={metadataTriggerRef}
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center rounded-full px-2 py-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.header.openMetadataAria')}
|
||||
aria-expanded={open}
|
||||
onClick={() => onOpenChange((currentOpen) => !currentOpen)}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col leading-tight">
|
||||
<span className="block truncate typography-ui-label text-foreground">{primaryLabel}</span>
|
||||
{secondaryLabel ? (
|
||||
<span className="block truncate typography-micro text-muted-foreground">{secondaryLabel}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
<SessionMetadataOverlay
|
||||
open={open}
|
||||
onClose={() => 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 (
|
||||
<header
|
||||
className="relative z-30 flex shrink-0 items-center gap-1 border-b border-border/30 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80"
|
||||
style={{ paddingTop: 'var(--oc-safe-area-top, 0px)' }}
|
||||
>
|
||||
<div className="flex h-[var(--oc-header-height,56px)] w-full items-center gap-1 px-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.sessions.openSheetAria')}
|
||||
onClick={onOpenSessions}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<RiMenuLine className="size-5" />
|
||||
</button>
|
||||
<>
|
||||
<header
|
||||
className="relative z-30 flex shrink-0 items-center gap-1 border-b border-border/30 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80"
|
||||
style={{ paddingTop: 'var(--oc-safe-area-top, 0px)' }}
|
||||
>
|
||||
<div className="flex h-[var(--oc-header-height,56px)] w-full items-center gap-1 px-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.sessions.openSheetAria')}
|
||||
onClick={handleOpenSessions}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="menu" className="size-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center rounded-full px-2 py-1.5 text-left transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.sessions.openSheetAria')}
|
||||
onClick={onOpenSessions}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col leading-tight">
|
||||
<span className="block truncate typography-ui-label text-foreground">{primaryLabel}</span>
|
||||
{secondaryLabel ? (
|
||||
<span className="block truncate typography-micro text-muted-foreground">{secondaryLabel}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
<MobileSessionMetadataButton
|
||||
open={metadataOpen}
|
||||
onOpenChange={setMetadataOpen}
|
||||
currentSessionId={currentSessionId}
|
||||
effectiveDirectory={effectiveDirectory}
|
||||
gitDirectory={gitDirectory}
|
||||
isNewSessionDraftOpen={isNewSessionDraftOpen}
|
||||
primaryLabel={primaryLabel}
|
||||
secondaryLabel={secondaryLabel}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.header.openMenuAria')}
|
||||
onClick={onOpenMenu}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<RiMore2Line className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.header.openMenuAria')}
|
||||
onClick={handleOpenMenu}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="more-2" className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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: <McpIcon className="size-5 shrink-0 text-muted-foreground" />,
|
||||
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 = () => {
|
||||
</MobileSurfaceShell>
|
||||
) : null}
|
||||
|
||||
{mcpOpen ? (
|
||||
<MobileOverlayPanel
|
||||
open
|
||||
onClose={() => setMcpOpen(false)}
|
||||
title={t('mcpDropdown.title')}
|
||||
className="h-[72vh]"
|
||||
contentMaxHeightClassName="max-h-full"
|
||||
renderHeader={(closeButton) => (
|
||||
<div className="shrink-0">
|
||||
<div className="flex justify-center pt-2.5 pb-1">
|
||||
<div className="h-1 w-9 rounded-full bg-[color-mix(in_srgb,var(--surface-mutedForeground)_40%,transparent)]" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 px-4 pb-2">
|
||||
<h2 className="text-[16px] font-semibold text-[var(--surface-foreground)]">
|
||||
{t('mcpDropdown.title')}
|
||||
</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 items-center justify-center rounded-full text-[var(--surface-mutedForeground)] transition-colors hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]"
|
||||
onClick={openMcpCreateSettings}
|
||||
aria-label={t('settings.mcp.sidebar.actions.addServerTitle')}
|
||||
title={t('settings.mcp.sidebar.actions.addServerTitle')}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="add" className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 items-center justify-center rounded-full text-[var(--surface-mutedForeground)] transition-colors hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)] disabled:opacity-60"
|
||||
onClick={refreshMcpOverlay}
|
||||
disabled={isMcpRefreshing}
|
||||
aria-label={t('mcpDropdown.actions.refreshAria')}
|
||||
title={t('mcpDropdown.actions.refreshAria')}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="refresh" className={cn('h-5 w-5', isMcpRefreshing && 'animate-spin')} />
|
||||
</button>
|
||||
{closeButton}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<McpDropdownContent
|
||||
active
|
||||
className="h-full"
|
||||
listClassName="max-h-none"
|
||||
hideHeader
|
||||
mobileListDensity
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</MobileOverlayPanel>
|
||||
) : null}
|
||||
|
||||
{settingsOpen ? (
|
||||
<MobileSurfaceShell
|
||||
open
|
||||
@@ -331,12 +996,28 @@ const MobileShell: React.FC = () => {
|
||||
<SettingsView
|
||||
forceMobile
|
||||
isWindowed
|
||||
initialMobileStage={settingsInitialMobileStage}
|
||||
visiblePageSlugs={[...MOBILE_SETTINGS_PAGES]}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</MobileSurfaceShell>
|
||||
) : null}
|
||||
|
||||
{updateOpen ? (
|
||||
<MobileSurfaceShell
|
||||
open
|
||||
onClose={() => setUpdateOpen(false)}
|
||||
ariaLabel={t('mobile.menu.update')}
|
||||
title={t('mobile.menu.update')}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<div className="h-full overflow-auto px-5 py-4">
|
||||
<AboutSettings initialUpdateDialogOpen />
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
</MobileSurfaceShell>
|
||||
) : null}
|
||||
</div>
|
||||
</DedicatedMobileAppProvider>
|
||||
);
|
||||
@@ -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) {
|
||||
<TooltipProvider delayDuration={300} skipDelayDuration={150}>
|
||||
<div className="h-full bg-background text-foreground">
|
||||
<SyncAppEffects embeddedBackgroundWorkEnabled={isInitialized} />
|
||||
<OpenCodeUpdateToast />
|
||||
<MobileShell />
|
||||
<Toaster />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M26.9568 9.88184H22.1265L30.7753 31.7848H35.4917L26.9568 9.88184ZM13.028 9.88184L4.4917 31.7848H9.32203L11.2305 27.1793H20.2166L22.0126 31.6724H26.8444L18.0832 9.88184H13.028ZM12.5783 23.1361L15.4987 15.3853L18.5315 23.1361H12.5783Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 356 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M35.3993 26.4544C34.2871 28.3855 27.8314 32.9425 20 32.9425C12.1686 32.9425 5.71288 28.3855 4.60075 26.4544C4.5427 26.3532 4.50826 26.2401 4.5 26.1237V22.418C4.51199 22.3209 4.53496 22.2256 4.56846 22.1338C5.04896 20.9261 6.30833 19.1733 7.93325 18.7031C8.14896 18.149 8.468 17.3404 8.76508 16.7437C8.71727 16.2776 8.69485 15.8094 8.69792 15.3409C8.69792 13.6217 9.06217 12.113 10.1601 10.9906C10.6729 10.4662 11.3097 10.0644 12.064 9.76091C13.871 8.29357 16.4453 7.05745 19.9716 7.05745C23.4978 7.05745 26.129 8.29357 27.936 9.76091C28.6903 10.0644 29.3271 10.4662 29.8399 10.9906C30.9378 12.113 31.3021 13.6217 31.3021 15.3409C31.3021 15.8162 31.284 16.2877 31.2349 16.7437C31.532 17.3404 31.851 18.149 32.0667 18.7031C33.6917 19.1733 34.951 20.9261 35.4315 22.1338C35.4677 22.2248 35.4908 22.3205 35.5 22.418V26.1237C35.4917 26.2401 35.4573 26.3532 35.3993 26.4544ZM20.2222 18.7147H19.7778C19.6422 18.945 19.4889 19.1644 19.3193 19.3709C18.3247 20.5941 16.8419 21.2981 14.7881 21.2981C12.56 21.2981 10.9273 20.8344 9.90304 19.6719C9.86495 19.6283 9.82833 19.5835 9.79325 19.5375L9.66667 19.6719V28.1775C11.5202 29.1837 15.4972 30.992 20 30.992C24.5028 30.992 28.4798 29.1837 30.3333 28.1775V19.6719L30.2067 19.5375C30.2067 19.5375 30.1641 19.5957 30.097 19.6719C29.0727 20.8344 27.44 21.2981 25.2119 21.2981C23.1581 21.2981 21.6753 20.5941 20.6807 19.3709C20.5111 19.1644 20.3578 18.945 20.2222 18.7147ZM23.2292 22.9127C23.9383 22.9127 24.5208 23.4952 24.5208 24.2043V26.7877C24.5208 27.4968 23.9383 28.0793 23.2292 28.0793C22.52 28.0793 21.9375 27.4968 21.9375 26.7877V24.2043C21.9375 23.4952 22.52 22.9127 23.2292 22.9127ZM16.7708 22.9127C17.48 22.9127 18.0625 23.4952 18.0625 24.2043V26.7877C18.0625 27.4968 17.48 28.0793 16.7708 28.0793C16.0617 28.0793 15.4792 27.4968 15.4792 26.7877V24.2043C15.4792 23.4952 16.0617 22.9127 16.7708 22.9127ZM21.0501 14.9237C21.2258 16.289 21.5707 17.3947 22.1842 18.149C22.7551 18.8517 23.649 19.3606 25.2119 19.3606C27.2437 19.3606 28.1724 18.9253 28.6438 18.3905C29.1398 17.8287 29.3646 16.9051 29.3646 15.3409C29.3646 13.8684 29.0507 12.9552 28.454 12.3455C27.8378 11.7152 26.7502 11.2321 24.8063 11.0216C22.8856 10.8136 21.975 11.1998 21.5345 11.7049C21.187 12.1014 20.97 12.7485 20.9688 13.7431V13.7702C20.9688 14.1121 20.9959 14.4966 21.0501 14.9237ZM18.9499 14.9237C19.0041 14.4966 19.0312 14.1117 19.0312 13.7689V13.7431C19.03 12.7485 18.813 12.1014 18.4655 11.7049C18.025 11.1998 17.1144 10.8136 15.1937 11.0216C13.2498 11.2321 12.1622 11.7152 11.546 12.3455C10.9493 12.9552 10.6354 13.8684 10.6354 15.3409C10.6354 16.9051 10.8615 17.8287 11.3562 18.3905C11.8276 18.9253 12.7563 19.3606 14.7881 19.3606C16.351 19.3606 17.2449 18.8517 17.8158 18.149C18.4293 17.3947 18.7742 16.289 18.9499 14.9237Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M37 20.034C27.8809 20.5837 20.5808 27.8809 20.0326 37H19.966C19.4163 27.8809 12.1177 20.5837 3 20.034V19.9674C12.1191 19.4163 19.4163 12.1191 19.966 3H20.0326C20.5822 12.1191 27.8809 19.4163 37 19.9674V20.034Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 333 B |
|
After Width: | Height: | Size: 6.6 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.8758 9.20865C17.8758 8.59461 17.3777 8.09634 16.7663 8.09634C16.155 8.09634 15.6567 8.59575 15.6567 9.20865V27.6446C15.6567 29.0714 14.4985 30.2324 13.0755 30.2324C11.6523 30.2324 10.4941 29.0714 10.4941 27.6446V15.8167C10.4941 15.2027 9.99591 14.7044 9.38453 14.7044C8.77316 14.7044 8.275 15.2038 8.275 15.8167V20.8301C8.275 22.2567 7.11678 23.4179 5.69364 23.4179C4.2705 23.4179 3.1123 22.2567 3.1123 20.8301V19.0129C3.1123 18.6054 3.44177 18.2752 3.84822 18.2752C4.25467 18.2752 4.58413 18.6054 4.58413 19.0129V20.8301C4.58413 21.4441 5.08227 21.9424 5.69364 21.9424C6.30502 21.9424 6.80317 21.443 6.80317 20.8301V15.8167C6.80317 14.39 7.96139 13.2289 9.38453 13.2289C10.8077 13.2289 11.9659 14.39 11.9659 15.8167V27.6446C11.9659 28.2587 12.4641 28.7569 13.0755 28.7569C13.6868 28.7569 14.1849 28.2575 14.1849 27.6446V20.4123V9.20865C14.1849 7.78194 15.3431 6.62082 16.7663 6.62082C18.1894 6.62082 19.3476 7.78194 19.3476 9.20865V24.4746C19.3476 24.8821 19.0182 25.2123 18.6117 25.2123C18.2053 25.2123 17.8758 24.8821 17.8758 24.4746V9.20865ZM31.531 13.2289C30.1079 13.2289 28.9496 14.39 28.9496 15.8167V25.6969C28.9496 26.311 28.4515 26.8093 27.8401 26.8093C27.2287 26.8093 26.7306 26.3099 26.7306 25.6969V9.20865C26.7306 7.78194 25.5723 6.62082 24.1492 6.62082C22.7261 6.62082 21.5679 7.78194 21.5679 9.20865V30.1383C21.5679 30.7523 21.0697 31.2506 20.4583 31.2506C19.8469 31.2506 19.3488 30.7511 19.3488 30.1383V27.5471C19.3488 27.1396 19.0194 26.8093 18.6129 26.8093C18.2065 26.8093 17.877 27.1396 17.877 27.5471V30.1383C17.877 31.565 19.0352 32.7261 20.4583 32.7261C21.8815 32.7261 23.0397 31.565 23.0397 30.1383V9.20865C23.0397 8.59461 23.5378 8.09634 24.1492 8.09634C24.7605 8.09634 25.2587 8.59575 25.2587 9.20865V25.6969C25.2587 27.1237 26.417 28.2848 27.8401 28.2848C29.2632 28.2848 30.4215 27.1237 30.4215 25.6969V15.8167C30.4215 15.2027 30.9196 14.7044 31.531 14.7044C32.1424 14.7044 32.6405 15.2038 32.6405 15.8167V24.4746C32.6405 24.8821 32.97 25.2123 33.3764 25.2123C33.7829 25.2123 34.1123 24.8821 34.1123 24.4746V15.8167C34.1123 14.39 32.9541 13.2289 31.531 13.2289Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.8758 9.20865C17.8758 8.59461 17.3777 8.09634 16.7663 8.09634C16.155 8.09634 15.6567 8.59575 15.6567 9.20865V27.6446C15.6567 29.0714 14.4985 30.2324 13.0755 30.2324C11.6523 30.2324 10.4941 29.0714 10.4941 27.6446V15.8167C10.4941 15.2027 9.99591 14.7044 9.38453 14.7044C8.77316 14.7044 8.275 15.2038 8.275 15.8167V20.8301C8.275 22.2567 7.11678 23.4179 5.69364 23.4179C4.2705 23.4179 3.1123 22.2567 3.1123 20.8301V19.0129C3.1123 18.6054 3.44177 18.2752 3.84822 18.2752C4.25467 18.2752 4.58413 18.6054 4.58413 19.0129V20.8301C4.58413 21.4441 5.08227 21.9424 5.69364 21.9424C6.30502 21.9424 6.80317 21.443 6.80317 20.8301V15.8167C6.80317 14.39 7.96139 13.2289 9.38453 13.2289C10.8077 13.2289 11.9659 14.39 11.9659 15.8167V27.6446C11.9659 28.2587 12.4641 28.7569 13.0755 28.7569C13.6868 28.7569 14.1849 28.2575 14.1849 27.6446V20.4123V9.20865C14.1849 7.78194 15.3431 6.62082 16.7663 6.62082C18.1894 6.62082 19.3476 7.78194 19.3476 9.20865V24.4746C19.3476 24.8821 19.0182 25.2123 18.6117 25.2123C18.2053 25.2123 17.8758 24.8821 17.8758 24.4746V9.20865ZM31.531 13.2289C30.1079 13.2289 28.9496 14.39 28.9496 15.8167V25.6969C28.9496 26.311 28.4515 26.8093 27.8401 26.8093C27.2287 26.8093 26.7306 26.3099 26.7306 25.6969V9.20865C26.7306 7.78194 25.5723 6.62082 24.1492 6.62082C22.7261 6.62082 21.5679 7.78194 21.5679 9.20865V30.1383C21.5679 30.7523 21.0697 31.2506 20.4583 31.2506C19.8469 31.2506 19.3488 30.7511 19.3488 30.1383V27.5471C19.3488 27.1396 19.0194 26.8093 18.6129 26.8093C18.2065 26.8093 17.877 27.1396 17.877 27.5471V30.1383C17.877 31.565 19.0352 32.7261 20.4583 32.7261C21.8815 32.7261 23.0397 31.565 23.0397 30.1383V9.20865C23.0397 8.59461 23.5378 8.09634 24.1492 8.09634C24.7605 8.09634 25.2587 8.59575 25.2587 9.20865V25.6969C25.2587 27.1237 26.417 28.2848 27.8401 28.2848C29.2632 28.2848 30.4215 27.1237 30.4215 25.6969V15.8167C30.4215 15.2027 30.9196 14.7044 31.531 14.7044C32.1424 14.7044 32.6405 15.2038 32.6405 15.8167V24.4746C32.6405 24.8821 32.97 25.2123 33.3764 25.2123C33.7829 25.2123 34.1123 24.8821 34.1123 24.4746V15.8167C34.1123 14.39 32.9541 13.2289 31.531 13.2289Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M26.7,5.25l2.14-1.53,10.49,13.29-16.23,19.43-2.65,3L0.42,16.83l6.87-8.64,5.78,2.21,1.79-4.46L23.3,0.1l3.42,5.15ZM31.13,13.98L22.91,1.78l-7.1,4.95-5.45,13.53,20.77-6.28ZM36.74,15.57l-8.03-10.1-0.14-0.1-1.14,0.89,5.03,7.58,4.28,1.73ZM9.06,20.35l3.53-8.84-4.89-1.92-5.62,7.1,6.98,3.66ZM37.62,17.19l-5.3-2.14-9.22,19.52,14.52-17.38ZM30.95,15.24l-20.63,6.22,10.1,15.93,10.53-22.15ZM15.79,32.41l-6.86-10.82c-1.61-0.84-3.2-1.73-4.81-2.57-0.1-0.05-0.21-0.12-0.32-0.13l11.99,13.52Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 598 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M32.8377 17.282C33.2127 16.25 33.3072 15.218 33.2127 14.1875C33.1197 13.1571 32.7447 12.1251 32.2752 11.1876C31.4322 9.78209 30.2127 8.6571 28.8072 8.0001C27.3072 7.34461 25.7127 7.15711 24.1197 7.53211C23.3698 6.78212 22.5253 6.12512 21.5878 5.65713C20.6503 5.18913 19.5253 5.00013 18.4948 5.00013C16.8851 4.99074 15.3125 5.48246 13.9948 6.40712C12.6824 7.34311 11.7449 8.6571 11.2754 10.1571C10.1504 10.4376 9.21289 10.9071 8.27539 11.4696C7.4324 12.1251 6.77541 12.9696 6.21291 13.8126C5.36992 15.2195 5.08792 16.8125 5.27542 18.407C5.46399 19.9968 6.11605 21.496 7.1504 22.718C6.79608 23.7086 6.66795 24.7659 6.77541 25.8124C6.86991 26.8444 7.2449 27.8749 7.7129 28.8124C8.55739 30.2194 9.77538 31.3444 11.1824 31.9999C12.6824 32.6569 14.2753 32.8444 15.8698 32.4694C16.6198 33.2194 17.4628 33.8749 18.4003 34.3444C19.3378 34.8139 20.4628 34.9999 21.4948 34.9999C23.1043 35.0097 24.6769 34.5185 25.9947 33.5944C27.3072 32.6569 28.2447 31.3444 28.7127 29.8444C29.7719 29.6432 30.7682 29.1934 31.6197 28.5319C32.4627 27.8749 33.2127 27.1249 33.6822 26.1874C34.5251 24.7819 34.8071 23.1875 34.6196 21.5945C34.4322 20 33.8697 18.5015 32.8377 17.282ZM21.5878 33.0304C20.0878 33.0304 18.9628 32.5609 17.9323 31.7179C17.9323 31.7179 18.0253 31.6234 18.1198 31.6234L24.1197 28.1554C24.2862 28.0803 24.4196 27.9469 24.4947 27.7804C24.5698 27.636 24.6021 27.4731 24.5877 27.3109V18.875L27.1197 20.375V27.3124C27.1455 28.0547 27.0215 28.7945 26.755 29.4878C26.4885 30.181 26.085 30.8134 25.5687 31.3473C25.0523 31.8811 24.4337 32.3054 23.7497 32.5949C23.0658 32.8843 22.3305 33.0314 21.5878 33.0304ZM9.49488 27.8749C8.83789 26.7499 8.55739 25.4374 8.83789 24.125C8.83789 24.125 8.93239 24.2195 9.02539 24.2195L15.0253 27.6874C15.1693 27.7638 15.3325 27.7966 15.4948 27.7819C15.6823 27.7819 15.8698 27.7819 15.9628 27.6874L23.2753 23.4695V26.3749L17.1823 29.9374C16.5506 30.3042 15.8527 30.5427 15.1287 30.6393C14.4046 30.7358 13.6686 30.6884 12.9629 30.4999C11.4629 30.1249 10.2449 29.1874 9.49488 27.8749ZM7.9004 14.8445C8.56239 13.7234 9.58826 12.8627 10.8074 12.4056V19.532C10.8074 19.718 10.8074 19.907 10.9004 20C10.9755 20.1665 11.1089 20.2998 11.2754 20.375L18.5878 24.5944L16.0573 26.0944L10.0574 22.625C9.41842 22.2639 8.85742 21.7797 8.40684 21.2004C7.95627 20.6211 7.62506 19.9582 7.4324 19.25C7.05741 17.8445 7.1504 16.157 7.9004 14.8445ZM28.6197 19.625L21.3073 15.407L23.8377 13.9071L29.8377 17.375C30.7752 17.9375 31.5252 18.6875 31.9947 19.625C32.4642 20.5625 32.7447 21.5945 32.6502 22.7195C32.5603 23.7755 32.1699 24.7837 31.5252 25.6249C30.8697 26.4694 30.0252 27.1249 28.9947 27.4999V20.375C28.9947 20.1875 28.9947 20 28.9002 19.907C28.9002 19.907 28.8072 19.718 28.6197 19.625ZM31.1502 15.875C31.1502 15.875 31.0572 15.782 30.9627 15.782L24.9627 12.3126C24.7752 12.2196 24.6822 12.2196 24.4947 12.2196C24.3072 12.2196 24.1197 12.2196 24.0252 12.3126L16.7128 16.532V13.6251L22.8073 10.0626C23.7448 9.50009 24.7752 9.31259 25.9002 9.31259C26.9322 9.31259 27.9627 9.68759 28.9002 10.3446C29.7447 11.0001 30.4947 11.8446 30.8697 12.7821C31.2447 13.7196 31.3377 14.8445 31.1502 15.875ZM15.4003 21.125L12.8699 19.625V12.5946C12.8699 11.5626 13.1503 10.4376 13.7128 9.59459C14.2753 8.6571 15.1198 8.0001 16.0573 7.53211C17.0127 7.05249 18.0956 6.88812 19.1503 7.06261C20.1823 7.15711 21.2128 7.62511 22.0573 8.2821C22.0573 8.2821 21.9628 8.3751 21.8698 8.3751L15.8698 11.8446C15.7033 11.9197 15.57 12.0531 15.4948 12.2196C15.4003 12.4071 15.4003 12.5001 15.4003 12.6876V21.125ZM16.7128 18.125L19.9948 16.25L23.2753 18.125V21.875L19.9948 23.75L16.7128 21.875V18.125Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.10913 12.07C3.65512 12.07 5.76627 11.5988 6.85825 10.98C7.95023 10.3612 7.95023 10.3612 10.207 8.75965C13.0642 6.73196 15.0845 7.41088 18.3968 7.41088" fill="currentColor"/>
|
||||
<path d="M3.10913 12.07C3.65512 12.07 5.76627 11.5988 6.85825 10.98C7.95023 10.3612 7.95023 10.3612 10.207 8.75965C13.0642 6.73196 15.0845 7.41088 18.3968 7.41088" stroke="currentColor" stroke-width="3.27593"/>
|
||||
<path d="M21.6 7.43108L16.0037 10.6622V4.20001L21.6 7.43108Z" fill="currentColor" stroke="currentColor" stroke-width="0.0363992"/>
|
||||
<path d="M3 12.072C3.54599 12.072 5.65714 12.5432 6.74912 13.162C7.8411 13.7808 7.8411 13.7808 10.0978 15.3823C12.9551 17.41 14.9753 16.7311 18.2877 16.7311" fill="currentColor"/>
|
||||
<path d="M3 12.072C3.54599 12.072 5.65714 12.5432 6.74912 13.162C7.8411 13.7808 7.8411 13.7808 10.0978 15.3823C12.9551 17.41 14.9753 16.7311 18.2877 16.7311" stroke="currentColor" stroke-width="3.27593"/>
|
||||
<path d="M21.4909 16.7109L15.8945 13.4798V19.942L21.4909 16.7109Z" fill="currentColor" stroke="currentColor" stroke-width="0.0363992"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<image width="24" height="24" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAF0AAABcCAYAAAAMLblmAAAACXBIWXMAAAAAAAAAAQCEeRdzAAAHhklEQVR4nO1d21HjSBRtjJhvMhgtxlX8DRsBJgIggjERwESAiWAgAjwRYCJAGwHMH1U8VhvBst+89hxzRRkwuG+rJet1qlwSM1KrdXR1+nb37as5UzCEYbi4sLAQPj4+rs7NzX17enpaxI/7i8khs6xfWuA+1oNZV4IkB0Gwit0NEN1FpVaxHf0fyB5t8W8zrKF/zIT0hGiQuoc/uR1ZcdXI/Qi5kk6yW63WDsjdTYiuI3IhfXl5uQuSd7C7mcf1io5MSYdlh/Pz83sgvJfldcqGTEgflxH8WVsZ+QjeSRcpOTIld+2yhFfSl5aWfoLwXZ9lVhFeSKd2Q06O6WP7KG8CbvlD+TEeavz2P/HvIbfsRJkSyFlq0kVOjo3HmwWJEcr8jd0IvdPzi4uL2Pbcsc7WqMOFcrq+6uULqUiHnOzgpg58VESI/vXw8DCM4/jWtRw5N5LfqG7tdpuuKn/fPVQ1NZxJB+F7IKqf8vqUjMP7+/uDNERPw/X19RCbId6CPlzYLvbZEw6zut40OJHugfBcyH4LXCvGZsAfrL9nZkS+mnQPhA+h0z80Op0FYP0DWP4Q+r8rY0C5QUU6NTwF4THO3b66uoocz/cOecv6IH8A2Tk1OVm9Nenipbg2modoIPt5SokGIjt/4B77eVi9Fen0w6WXqQW1ex/W7cXDyRqoZ7/T6QwfHx/pAodZXceKdMdXL0aHaevy8vJcXasZgvWFja1nKTdTSWfD6XDxGI3l+qwbS1dQbkh8Vr3sT0kXHe8ryyw14QnGiD/1TfynpDvoeCUIT8CGH8Rv+ZaaD0l3kJVbanhVCE+QWDyIPzOexpcmkk5vReuP00spW6NpCxIPqd3Cm3/qo7yJpHOKTVnOYVncQlewU9dutw+xu5O2rHek08qx6SnKiNGlrsXEBe8TsruWtmF9R7rWytlwpqlA2QB+ttF5OktTxivSHax8ULWGcxrYbqWVmVekK62c7uG+64XLDI4jgStOiDh5M2/lxToYCLr2q25WnoD+O7yZQ9fBsRfSZVDf9snFQRAMXC5YFXACBtZOiVFb+7ila+YPo7paeYI01j4inTPo2HRtT6qrlr+Fq7WPSJfJWitw1r7uVp6A1g5ZPjHKKINEXjZsT2CYhOYCVQeMcABO9KSzh5WsepgGSEukr1p1IcMDnIa0lpiAei7haFOB41TRVjWCSmICWYZidTDeiL9ca1VlgJdzjcRQXjSDN5G6RjWAhARaHx/g4ND24FarFTvUqfKQ8RhrXQ/wlL7aPqWqTlL4gIRxW6lGYLvKjY1oumpVGxLabUe6sZwHhbQUMjqrQLDmh6TbWvp/ztWpASAvt7YybU068K9zjeoBlaU38ABYeUN6kdGQ7glMjaLRdNVgTYMPYT/gZSxJT9ZqNpgMTVaPwNbV0QwX1BGanj0tPTZ2PanQvUq1QGh7IIcB/rE9eGVlJWzG0yfDdtyFCGSgxurgh4eHrnleh9lgDJ1O5yXvmA0oL9YDWcwg51KpqkPLS3B/f38+Pz9vdTDeiDWnWlUcUIsN1SSGhBHExqIh4AQ251SLuh50VtBm2kh6pJxYtYpCDYKgZyS7RIOXxXD6YCOj0HVcgDEyDekCbcwLMSKdOVag67Yr6bqNxDzDIZ5/hBHpouuRsYxnZOYIbPrai1UNmnDEcbyMMqKRPLFtEJjYEg8511wtBUW6+HS4jgNZiWHTKCzW3dolnj90OfeFdJEYBodaeTF1tnbRcucUJa8mMSAxQ8mJawNmGeWFf7hevKwQRQhdz39FukSgRsayQWVaV/ipJ0XKVpQ1XD2WcbybruNyc00Pi0kbUI8/6yIzkpwhFd6RrrV2IKyLzDjmvnmHiRPTWmunzOBBnV9fX1d2lYZj7puJmEi6Y/KBg06n87uKQaYpcphNxIchGA6rgheZcGxlZaUySXYIDnnklmRH/PZt7B4rygvv7u5Oq0K8JN/3nkDt02Aj5qiVDpNmJK0SxCeE5544jYDM7OL14oxRqCi31MRnnQ9+KumUGTSQW9BrvmaawXoSf0aJkqzOpYB8PCXTFLBWsYyypoZ+uLYF50M6hn/bv7m5KfzSdtwjZZQTNJmGGVoHkDILM3zV0CUBAZOwgfjNL1++FDKbneg37yuXtFiqqF3mogXxxpH4VcjN30WzevmKwE+TYwSbOlQ6DfEErV7Govf59riU4QPSw+Q9dPO+tlN8elrizbNVHYF8nr/PfAN5yY5Y9s4sP1DivCjAA/FEiN8RZIdkDLB/koWnQ6s2z1+C6ZkCxOKnWolB4jnQZfxoYo8/lMf9SPIQRIxA0wwbj39yByR/w3azaF+ETL38hZaJGz337Nt25fXfY8gfl4Dzo1KMpTfPod1vwaUnoSxcsF6GMit4WXOUw+cNFqf1Dsv0AVmvC71E56Pmg4Gfw/vqOpkvze2jHmVEZksaafXyKZu+Kcjny4qCTNeRitb35PNlfdOQP0Iui3cnkK8dKq4Ucl0xnZDPffYM4XF8F9ewUH501pjZMvXky4ncl3EQPoQ1zSq1sqIQuQHE44mSv6Xbviodnq9GOj/m+Y0o/VvxPy8IndI+RM9sAAAAAElFTkSuQmCC"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20.1312 7.50002L17.4088 11.1913H5.81625L8.5375 7.50002H20.1325H20.1312ZM34.0675 28.81L31.3475 32.5H19.795L22.5125 28.81H34.0675ZM35 7.50002L16.58 32.5H5L23.42 7.50002H35Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 295 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20.1312 7.5L17.4088 11.1912H5.81625L8.5375 7.5H20.1325H20.1312ZM34.0675 28.81L31.3475 32.5H19.795L22.5125 28.81H34.0675ZM35 7.5L16.58 32.5H5L23.42 7.5H35Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 279 B |
@@ -2,13 +2,11 @@ import React from 'react';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useAllSessionStatuses, useAllLiveSessions } from '@/sync/sync-context';
|
||||
import { mergeSessionDirectoryMetadata, useGlobalSessionsStore, ensureGlobalSessionsLoaded, refreshGlobalSessions } from '@/stores/useGlobalSessionsStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { ProjectEntry } from '@/lib/api/types';
|
||||
import { cn, formatDirectoryName } from '@/lib/utils';
|
||||
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
|
||||
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, ProjectIconImage } from '@/lib/projectMeta';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
@@ -288,36 +286,6 @@ function UnreadIndicator({ count }: { count: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TokenUsageIndicator({ contextUsage }: { contextUsage: SessionContextUsage | null }) {
|
||||
if (!contextUsage || contextUsage.totalTokens === 0) return null;
|
||||
|
||||
// Recompute with a fraction: contextUsage.percentage is rounded to an integer
|
||||
// in the store, which would always render as "X.0%".
|
||||
const percentage = contextUsage.contextLimit > 0
|
||||
? Math.min((contextUsage.totalTokens / contextUsage.contextLimit) * 100, 999)
|
||||
: 0;
|
||||
const colorClass =
|
||||
percentage >= 90 ? 'text-[var(--status-error)]' :
|
||||
percentage >= 75 ? 'text-[var(--status-warning)]' : 'text-[var(--status-success)]';
|
||||
|
||||
const formatTokens = (value: number): string => {
|
||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
||||
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const tokens = contextUsage.contextLimit > 0
|
||||
? `${formatTokens(contextUsage.totalTokens)}/${formatTokens(contextUsage.contextLimit)}`
|
||||
: formatTokens(contextUsage.totalTokens);
|
||||
|
||||
return (
|
||||
<span className="flex items-baseline gap-1.5 text-[15px] tabular-nums">
|
||||
<span className={cn("font-medium", colorClass)}>{percentage.toFixed(1)}%</span>
|
||||
<span className="text-[var(--surface-mutedForeground)]">{tokens}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// A single session row sized for comfortable touch.
|
||||
function SessionItem({
|
||||
session,
|
||||
@@ -448,19 +416,19 @@ export const MobileSessionPanelTrigger: React.FC<MobileSessionPanelTriggerProps>
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const showMobileSessionStatusBar = useUIStore((state) => state.showMobileSessionStatusBar);
|
||||
const open = useUIStore((state) => state.mobileSessionPanelOpen);
|
||||
const setOpen = useUIStore((state) => state.setMobileSessionPanelOpen);
|
||||
const handledTouchRef = React.useRef(false);
|
||||
|
||||
// Ensure the cross-project session list is loaded once, so the panel reflects
|
||||
// every project, not just the active directory.
|
||||
React.useEffect(() => {
|
||||
if (isMobile && showMobileSessionStatusBar) {
|
||||
if (isMobile) {
|
||||
void ensureGlobalSessionsLoaded();
|
||||
}
|
||||
}, [isMobile, showMobileSessionStatusBar]);
|
||||
}, [isMobile]);
|
||||
|
||||
if (!isMobile || !showMobileSessionStatusBar) {
|
||||
if (!isMobile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -476,9 +444,17 @@ export const MobileSessionPanelTrigger: React.FC<MobileSessionPanelTriggerProps>
|
||||
if (event.pointerType === 'touch') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handledTouchRef.current = true;
|
||||
setOpen(!open);
|
||||
}
|
||||
}}
|
||||
onClick={() => setOpen(!open)}
|
||||
onClick={() => {
|
||||
if (handledTouchRef.current) {
|
||||
handledTouchRef.current = false;
|
||||
return;
|
||||
}
|
||||
setOpen(!open);
|
||||
}}
|
||||
title={t('mobile.sessions.search.section.sessions')}
|
||||
aria-label={t('mobile.sessions.search.section.sessions')}
|
||||
aria-expanded={open}
|
||||
@@ -493,15 +469,12 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const sessions = useAllProjectSessions();
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const sessionStatus = useAllSessionStatuses();
|
||||
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
||||
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
||||
const getContextUsage = useSessionUIStore((state) => state.getContextUsage);
|
||||
const getCurrentModel = useConfigStore((state) => state.getCurrentModel);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const showMobileSessionStatusBar = useUIStore((state) => state.showMobileSessionStatusBar);
|
||||
const open = useUIStore((state) => state.mobileSessionPanelOpen);
|
||||
const setOpen = useUIStore((state) => state.setMobileSessionPanelOpen);
|
||||
|
||||
@@ -554,15 +527,6 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
||||
[filteredSessions],
|
||||
);
|
||||
|
||||
// Token usage for the current session.
|
||||
const currentModel = getCurrentModel();
|
||||
const limit = currentModel && typeof currentModel.limit === 'object' && currentModel.limit !== null
|
||||
? (currentModel.limit as Record<string, unknown>)
|
||||
: null;
|
||||
const contextLimit = (limit && typeof limit.context === 'number' ? limit.context : 0);
|
||||
const outputLimit = (limit && typeof limit.output === 'number' ? limit.output : 0);
|
||||
const contextUsage = getContextUsage(contextLimit, outputLimit);
|
||||
|
||||
const handleSessionClick = (session: SessionWithStatus) => {
|
||||
setCurrentSession(session.id, sessionDirectory(session) || null);
|
||||
onSessionSwitch?.(session.id);
|
||||
@@ -603,7 +567,6 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
||||
<div className="flex items-center gap-3">
|
||||
<RunningIndicator count={totalRunning} />
|
||||
<UnreadIndicator count={totalUnread} />
|
||||
<TokenUsageIndicator contextUsage={contextUsage} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewChat}
|
||||
@@ -654,9 +617,9 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
), [t, totalRunning, totalUnread, contextUsage, projects, filterProjectId, setFilterProjectId, formatProjectLabel, currentTheme, getProjectStatus, handleNewChat, setOpen]);
|
||||
), [t, totalRunning, totalUnread, projects, filterProjectId, setFilterProjectId, formatProjectLabel, currentTheme, getProjectStatus, handleNewChat, setOpen]);
|
||||
|
||||
if (!isMobile || !showMobileSessionStatusBar) {
|
||||
if (!isMobile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import { UpdateDialog } from '@/components/ui/UpdateDialog';
|
||||
import { useDeviceInfo, useTabletStandalonePwaRuntime } from '@/lib/device';
|
||||
import { cn, hasModifier } from '@/lib/utils';
|
||||
import { McpDropdownContent } from '@/components/mcp/McpDropdown';
|
||||
import { McpIcon } from '@/components/icons/McpIcon';
|
||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||
import { formatQuotaValueLabel, formatQuotaResetLabel, formatWindowLabel, QUOTA_PROVIDERS, calculatePace, calculateExpectedUsagePercent } from '@/lib/quota';
|
||||
import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar';
|
||||
@@ -1776,13 +1777,13 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
}, [activeMainTab, isMobile, setActiveMainTab]);
|
||||
|
||||
const servicesTabs = React.useMemo(() => {
|
||||
const base: Array<{ value: 'instance' | 'usage' | 'mcp'; label: string; icon: IconName }> = [];
|
||||
const base: Array<{ value: 'instance' | 'usage' | 'mcp'; label: string; icon: React.ReactNode }> = [];
|
||||
if (isDesktopApp) {
|
||||
base.push({ value: 'instance', label: t('layout.services.instance'), icon: "server" });
|
||||
base.push({ value: 'instance', label: t('layout.services.instance'), icon: <Icon name="server" className="h-3.5 w-3.5" /> });
|
||||
}
|
||||
base.push(
|
||||
{ value: 'usage', label: t('layout.services.usage'), icon: "timer" },
|
||||
{ value: 'mcp', label: 'MCP', icon: "plug-2" }
|
||||
{ value: 'usage', label: t('layout.services.usage'), icon: <Icon name="timer" className="h-3.5 w-3.5" /> },
|
||||
{ value: 'mcp', label: 'MCP', icon: <McpIcon className="h-3.5 w-3.5" /> }
|
||||
);
|
||||
return base;
|
||||
}, [isDesktopApp, t]);
|
||||
@@ -1791,7 +1792,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
return servicesTabs.map((tab) => ({
|
||||
id: tab.value,
|
||||
label: tab.label,
|
||||
icon: <Icon name={tab.icon} className="h-3.5 w-3.5" />,
|
||||
icon: tab.icon,
|
||||
}));
|
||||
}, [servicesTabs]);
|
||||
|
||||
@@ -1866,7 +1867,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
const mobileServicesTabItems = React.useMemo<SortableTabsStripItem[]>(() => {
|
||||
return [
|
||||
{ id: 'usage', label: t('layout.services.usage'), icon: <Icon name="timer" className="h-3.5 w-3.5" /> },
|
||||
{ id: 'mcp', label: 'MCP', icon: <Icon name="command" className="h-3.5 w-3.5" /> },
|
||||
{ id: 'mcp', label: 'MCP', icon: <McpIcon className="h-3.5 w-3.5" /> },
|
||||
];
|
||||
}, [t]);
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import { DrawerProvider } from '@/contexts/DrawerContext';
|
||||
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useUpdateStore } from '@/stores/useUpdateStore';
|
||||
import { useUpdatePolling } from '@/hooks/useUpdatePolling';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||
@@ -219,41 +219,7 @@ export const MainLayout: React.FC = () => {
|
||||
}
|
||||
}, [isMobile, isSettingsDialogOpen, isRightSidebarOpen, setMobileSessionPanelOpen, setRightSidebarOpen]);
|
||||
|
||||
// Trigger initial update check shortly after mount, then repeat using server-suggested cadence.
|
||||
const checkForUpdates = useUpdateStore((state) => state.checkForUpdates);
|
||||
React.useEffect(() => {
|
||||
const initialDelayMs = 3000;
|
||||
const defaultIntervalMs = 60 * 60 * 1000;
|
||||
const minIntervalMs = 5 * 60 * 1000;
|
||||
const maxIntervalMs = 24 * 60 * 60 * 1000;
|
||||
let disposed = false;
|
||||
let timer: number | null = null;
|
||||
|
||||
const clampIntervalMs = (seconds: number): number => {
|
||||
const ms = Math.round(seconds * 1000);
|
||||
return Math.max(minIntervalMs, Math.min(maxIntervalMs, ms));
|
||||
};
|
||||
|
||||
const scheduleNext = (delayMs: number) => {
|
||||
if (disposed) return;
|
||||
timer = window.setTimeout(async () => {
|
||||
const suggestedSec = await checkForUpdates();
|
||||
const nextDelay = typeof suggestedSec === 'number' && Number.isFinite(suggestedSec)
|
||||
? clampIntervalMs(suggestedSec)
|
||||
: defaultIntervalMs;
|
||||
scheduleNext(nextDelay);
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
scheduleNext(initialDelayMs);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (timer !== null) {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
}, [checkForUpdates]);
|
||||
useUpdatePolling();
|
||||
|
||||
React.useEffect(() => {
|
||||
const previous = useUIStore.getState().isMobile;
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useUpdatePolling } from '@/hooks/useUpdatePolling';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { toast } from '@/components/ui';
|
||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||
@@ -31,7 +32,6 @@ import { PaceIndicator } from '@/components/sections/usage/PaceIndicator';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { formatQuotaValueLabel, formatQuotaResetLabel, formatWindowLabel, QUOTA_PROVIDERS, calculatePace, calculateExpectedUsagePercent } from '@/lib/quota';
|
||||
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
|
||||
import { useUpdateStore } from '@/stores/useUpdateStore';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { formatTimeForPreference } from '@/lib/timeFormat';
|
||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||
@@ -73,41 +73,7 @@ type VSCodeView = 'sessions' | 'chat' | 'settings';
|
||||
export const VSCodeLayout: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const runtimeApis = useRuntimeAPIs();
|
||||
const checkForUpdates = useUpdateStore((state) => state.checkForUpdates);
|
||||
|
||||
React.useEffect(() => {
|
||||
const initialDelayMs = 3000;
|
||||
const defaultIntervalMs = 60 * 60 * 1000;
|
||||
const minIntervalMs = 5 * 60 * 1000;
|
||||
const maxIntervalMs = 24 * 60 * 60 * 1000;
|
||||
let disposed = false;
|
||||
let timer: number | null = null;
|
||||
|
||||
const clampIntervalMs = (seconds: number): number => {
|
||||
const ms = Math.round(seconds * 1000);
|
||||
return Math.max(minIntervalMs, Math.min(maxIntervalMs, ms));
|
||||
};
|
||||
|
||||
const scheduleNext = (delayMs: number) => {
|
||||
if (disposed) return;
|
||||
timer = window.setTimeout(async () => {
|
||||
const suggestedSec = await checkForUpdates();
|
||||
const nextDelay = typeof suggestedSec === 'number' && Number.isFinite(suggestedSec)
|
||||
? clampIntervalMs(suggestedSec)
|
||||
: defaultIntervalMs;
|
||||
scheduleNext(nextDelay);
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
scheduleNext(initialDelayMs);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (timer !== null) {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
}, [checkForUpdates]);
|
||||
useUpdatePolling();
|
||||
|
||||
const viewMode = React.useMemo<'sidebar' | 'editor'>(() => {
|
||||
const configured =
|
||||
|
||||
@@ -62,9 +62,13 @@ interface McpDropdownProps {
|
||||
interface McpDropdownContentProps {
|
||||
active: boolean;
|
||||
className?: string;
|
||||
headerAction?: React.ReactNode;
|
||||
listClassName?: string;
|
||||
hideHeader?: boolean;
|
||||
mobileListDensity?: boolean;
|
||||
}
|
||||
|
||||
export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active, className }) => {
|
||||
export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active, className, headerAction, listClassName, hideHeader = false, mobileListDensity = false }) => {
|
||||
const { t } = useI18n();
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
const directory = currentDirectory ?? null;
|
||||
@@ -115,7 +119,7 @@ export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active,
|
||||
|
||||
return (
|
||||
<div className={cn('w-full', className)}>
|
||||
<div className="border-b border-[var(--interactive-border)]">
|
||||
{!hideHeader ? <div className="border-b border-[var(--interactive-border)]">
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-2.5">
|
||||
<div className="min-w-0 flex items-baseline gap-2">
|
||||
<div className="typography-ui-header font-semibold text-foreground">{t('mcpDropdown.title')}</div>
|
||||
@@ -125,19 +129,22 @@ export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active,
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
disabled={isSpinning}
|
||||
onClick={handleRefresh}
|
||||
aria-label={t('mcpDropdown.actions.refreshAria')}
|
||||
>
|
||||
<Icon name="refresh" className={cn('h-4 w-4', isSpinning && 'animate-spin')} />
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{headerAction}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
disabled={isSpinning}
|
||||
onClick={handleRefresh}
|
||||
aria-label={t('mcpDropdown.actions.refreshAria')}
|
||||
>
|
||||
<Icon name="refresh" className={cn('h-4 w-4', isSpinning && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> : null}
|
||||
|
||||
<div className="max-h-64 overflow-y-auto py-2">
|
||||
<div className={cn('max-h-64 overflow-y-auto py-2', mobileListDensity && 'space-y-1 py-3', listClassName)}>
|
||||
{sortedNames.map((serverName) => {
|
||||
const serverStatus = status[serverName];
|
||||
const tone = statusTone(serverStatus);
|
||||
@@ -148,7 +155,10 @@ export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active,
|
||||
return (
|
||||
<div
|
||||
key={serverName}
|
||||
className="flex items-center justify-between gap-2 px-4 py-1.5 rounded-lg hover:bg-interactive-hover/50"
|
||||
className={cn(
|
||||
'flex items-center justify-between rounded-lg hover:bg-interactive-hover/50',
|
||||
mobileListDensity ? 'gap-3 px-4 py-3' : 'gap-2 px-4 py-1.5',
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
@@ -156,7 +166,8 @@ export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active,
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
'h-2 w-2 rounded-full flex-shrink-0',
|
||||
'rounded-full flex-shrink-0',
|
||||
mobileListDensity ? 'h-2.5 w-2.5' : 'h-2 w-2',
|
||||
tone === 'success' && 'bg-status-success',
|
||||
tone === 'error' && 'bg-status-error',
|
||||
tone === 'warning' && 'bg-status-warning',
|
||||
@@ -169,7 +180,9 @@ export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active,
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="typography-ui-label truncate">{serverName}</span>
|
||||
<span className={cn('truncate', mobileListDensity ? 'text-[17px] leading-6 font-medium' : 'typography-ui-label')}>
|
||||
{serverName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SIDEBAR_SECTION_CONFIG_MAP, SIDEBAR_SECTION_DESCRIPTIONS } from '@/cons
|
||||
import type { SidebarSection } from '@/constants/sidebar';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { McpIcon } from '@/components/icons/McpIcon';
|
||||
|
||||
interface SectionPlaceholderProps {
|
||||
sectionId: SidebarSection;
|
||||
@@ -18,7 +19,7 @@ export const SectionPlaceholder: React.FC<SectionPlaceholderProps> = ({ sectionI
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<div className="rounded-full bg-accent/40 p-3 text-muted-foreground">
|
||||
<Icon name={icon} className="h-5 w-5" />
|
||||
{icon === 'mcp-custom' ? <McpIcon className="h-5 w-5" /> : <Icon name={icon} className="h-5 w-5" />}
|
||||
</div>
|
||||
<h3 className="typography-ui-label font-semibold text-foreground">{config.label}</h3>
|
||||
<p className="typography-meta max-w-xs text-muted-foreground">
|
||||
@@ -31,7 +32,7 @@ export const SectionPlaceholder: React.FC<SectionPlaceholderProps> = ({ sectionI
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 px-6 text-center">
|
||||
<div className="rounded-full bg-accent/40 p-4 text-muted-foreground">
|
||||
<Icon name={icon} className="h-8 w-8" />
|
||||
{icon === 'mcp-custom' ? <McpIcon className="h-8 w-8" /> : <Icon name={icon} className="h-8 w-8" />}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="typography-h2 font-semibold text-foreground">{config.label}</h2>
|
||||
|
||||
@@ -4,20 +4,27 @@ import { useShallow } from 'zustand/react/shallow';
|
||||
import { UpdateDialog } from '@/components/ui/UpdateDialog';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { toast } from '@/components/ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
const GITHUB_URL = 'https://github.com/btriapitsyn/openchamber';
|
||||
const GITHUB_URL = 'https://github.com/openchamber/openchamber';
|
||||
const DISCORD_URL = 'https://discord.gg/ZYRSdnwwKA';
|
||||
const X_URL = 'https://x.com/openchamber_dev';
|
||||
|
||||
const MIN_CHECKING_DURATION = 800; // ms
|
||||
|
||||
export const AboutSettings: React.FC = () => {
|
||||
type AboutSettingsProps = {
|
||||
initialUpdateDialogOpen?: boolean;
|
||||
};
|
||||
|
||||
export const AboutSettings: React.FC<AboutSettingsProps> = ({ initialUpdateDialogOpen = false }) => {
|
||||
const { t } = useI18n();
|
||||
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false);
|
||||
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(initialUpdateDialogOpen);
|
||||
const [showChecking, setShowChecking] = React.useState(false);
|
||||
const [openChamberVersion, setOpenChamberVersion] = React.useState<string | null>(null);
|
||||
const [openCodeVersion, setOpenCodeVersion] = React.useState<string | null>(null);
|
||||
const updateStore = useUpdateStore(useShallow((s) => ({
|
||||
info: s.info,
|
||||
@@ -34,21 +41,48 @@ export const AboutSettings: React.FC = () => {
|
||||
})));
|
||||
const { isMobile } = useDeviceInfo();
|
||||
|
||||
const currentVersion = updateStore.info?.currentVersion || 'unknown';
|
||||
const currentVersion = openChamberVersion || updateStore.info?.currentVersion || 'unknown';
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const loadOpenChamberVersion = async () => {
|
||||
try {
|
||||
const response = await runtimeFetch('/api/system/info', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const data = await response.json().catch(() => null) as { openchamberVersion?: unknown } | null;
|
||||
const version = typeof data?.openchamberVersion === 'string' && data.openchamberVersion.trim().length > 0
|
||||
? data.openchamberVersion.trim()
|
||||
: null;
|
||||
if (!cancelled) setOpenChamberVersion(version);
|
||||
} catch {
|
||||
if (!cancelled) setOpenChamberVersion(null);
|
||||
}
|
||||
};
|
||||
|
||||
void loadOpenChamberVersion();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const loadOpenCodeVersion = async () => {
|
||||
try {
|
||||
const response = await runtimeFetch('/api/opencode/version', {
|
||||
const response = await runtimeFetch('/api/opencode/upgrade-status', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const data = await response.json().catch(() => null) as { version?: unknown } | null;
|
||||
const version = typeof data?.version === 'string' && data.version.trim().length > 0
|
||||
? data.version.trim()
|
||||
const data = await response.json().catch(() => null) as { currentVersion?: unknown } | null;
|
||||
const version = typeof data?.currentVersion === 'string' && data.currentVersion.trim().length > 0
|
||||
? data.currentVersion.trim()
|
||||
: null;
|
||||
if (!cancelled) setOpenCodeVersion(version);
|
||||
} catch {
|
||||
@@ -86,82 +120,91 @@ export const AboutSettings: React.FC = () => {
|
||||
|
||||
const isChecking = updateStore.checking || showChecking;
|
||||
|
||||
// Compact mobile layout for sidebar footer
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="w-full space-y-2">
|
||||
{/* Version row with update status */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
v{currentVersion}
|
||||
</span>
|
||||
<div className="w-full space-y-6 pb-2">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<OpenChamberLogo width={72} height={72} />
|
||||
<h2 className="mt-4 typography-ui-header font-semibold text-foreground">OpenChamber</h2>
|
||||
<div className="mt-2 space-y-1 typography-ui text-muted-foreground">
|
||||
<p>{t('aboutDialog.openChamberVersionLabel', { version: currentVersion })}</p>
|
||||
<p>{t('aboutDialog.openCodeVersionLabel', { version: openCodeVersion || t('settings.openchamber.about.state.unknown') })}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
{!updateStore.available && !updateStore.error && (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => updateStore.checkForUpdates()}
|
||||
disabled={isChecking}
|
||||
className={cn(
|
||||
'typography-meta text-muted-foreground/60 hover:text-muted-foreground disabled:cursor-default',
|
||||
isChecking && 'animate-pulse [animation-duration:1s]'
|
||||
)}
|
||||
className="h-10 w-auto justify-center gap-2 rounded-xl px-4"
|
||||
>
|
||||
{t('settings.openchamber.about.actions.checkUpdates')}
|
||||
</button>
|
||||
{isChecking ? <Icon name="loader" className="size-4 animate-spin" /> : <Icon name="refresh" className="size-4" />}
|
||||
{isChecking ? t('settings.openchamber.about.state.checking') : t('settings.openchamber.about.actions.checkForUpdates')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isChecking && updateStore.available && (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => setUpdateDialogOpen(true)}
|
||||
className="flex items-center gap-1 typography-meta text-[var(--primary-base)] hover:underline"
|
||||
className="h-10 w-auto justify-center gap-2 rounded-xl px-4"
|
||||
>
|
||||
<Icon name="download" className="h-3.5 w-3.5" />
|
||||
{t('settings.openchamber.about.actions.update')}
|
||||
</button>
|
||||
<Icon name="download" className="size-4" />
|
||||
{t('settings.openchamber.about.actions.updateToVersion', { version: updateStore.info?.version || '' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.openchamber.about.field.openCodeVersion')}</span>
|
||||
<span className="typography-meta text-muted-foreground font-mono">{openCodeVersion || t('settings.openchamber.about.state.unknown')}</span>
|
||||
</div>
|
||||
|
||||
{updateStore.error && (
|
||||
<p className="typography-micro text-[var(--status-error)] truncate">{updateStore.error}</p>
|
||||
<p className="rounded-xl border border-[var(--status-error-border)] bg-[var(--status-error-background)] px-3 py-2 typography-meta text-[var(--status-error)]">
|
||||
{updateStore.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Links row */}
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href={GITHUB_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 typography-meta text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Icon name="github-fill" className="h-3.5 w-3.5" />
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<div className="flex items-center justify-center gap-5">
|
||||
<a
|
||||
href={GITHUB_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 typography-ui-label text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<Icon name="github-fill" className="size-5" />
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={DISCORD_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 typography-ui-label text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<Icon name="discord-fill" className="size-5" />
|
||||
<span>Discord</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="https://discord.gg/ZYRSdnwwKA"
|
||||
href={X_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 typography-meta text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="flex items-center gap-1.5 typography-ui-label text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<Icon name="discord-fill" className="h-3.5 w-3.5" />
|
||||
<span>Discord</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://x.com/btriapitsyn"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 typography-meta text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Icon name="twitter-xfill" className="h-3.5 w-3.5" />
|
||||
<span>@btriapitsyn</span>
|
||||
<Icon name="twitter-xfill" className="size-5" />
|
||||
<span>@openchamber_dev</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p className="text-center typography-ui text-muted-foreground/60">
|
||||
{t('aboutDialog.footerNote')}
|
||||
</p>
|
||||
|
||||
<UpdateDialog
|
||||
open={updateDialogOpen}
|
||||
onOpenChange={setUpdateDialogOpen}
|
||||
@@ -247,15 +290,15 @@ export const AboutSettings: React.FC = () => {
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://x.com/btriapitsyn"
|
||||
<a
|
||||
href={X_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground typography-meta transition-colors"
|
||||
>
|
||||
<Icon name="twitter-xfill" className="h-4 w-4" />
|
||||
<span>@btriapitsyn</span>
|
||||
</a>
|
||||
<span>@openchamber_dev</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ const VisualSectionContent: React.FC = () => {
|
||||
|
||||
// Chat section: User message rendering, Diff layout, Mobile status bar, Show reasoning traces, Queue mode, Persist draft
|
||||
const ChatSectionContent: React.FC = () => {
|
||||
return <OpenChamberVisualSettings visibleSettings={['chatRenderMode', 'messageTransport', 'activityRenderMode', 'userMessageRendering', 'mermaidRendering', 'reasoning', 'showToolFileIcons', 'showTurnChangedFiles', 'expandedTools', 'stickyUserHeader', 'wideChatLayout', 'splitAssistantMessageActions', 'diffLayout', 'mobileStatusBar', 'dotfiles', 'fileViewerPreview', 'queueMode', 'persistDraft', 'inputSpellcheck']} />;
|
||||
return <OpenChamberVisualSettings visibleSettings={['chatRenderMode', 'messageTransport', 'activityRenderMode', 'userMessageRendering', 'mermaidRendering', 'reasoning', 'showToolFileIcons', 'showTurnChangedFiles', 'expandedTools', 'stickyUserHeader', 'wideChatLayout', 'splitAssistantMessageActions', 'diffLayout', 'dotfiles', 'fileViewerPreview', 'queueMode', 'persistDraft', 'inputSpellcheck']} />;
|
||||
};
|
||||
|
||||
// Sessions section: Default model & agent, Session retention
|
||||
|
||||
@@ -319,8 +319,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
const setWeekStartPreference = useUIStore(state => state.setWeekStartPreference);
|
||||
const showSplitAssistantMessageActions = useUIStore(state => state.showSplitAssistantMessageActions);
|
||||
const setShowSplitAssistantMessageActions = useUIStore(state => state.setShowSplitAssistantMessageActions);
|
||||
const showMobileSessionStatusBar = useUIStore(state => state.showMobileSessionStatusBar);
|
||||
const setShowMobileSessionStatusBar = useUIStore(state => state.setShowMobileSessionStatusBar);
|
||||
const messageStreamTransport = useConfigStore((state) => state.settingsMessageStreamTransport);
|
||||
const setMessageStreamTransport = useConfigStore((state) => state.setSettingsMessageStreamTransport);
|
||||
const settingsDefaultFileViewerPreview = useConfigStore((state) => state.settingsDefaultFileViewerPreview);
|
||||
@@ -538,7 +536,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
|| shouldShow('wideChatLayout')
|
||||
|| shouldShow('splitAssistantMessageActions')
|
||||
|| shouldShow('diffLayout')
|
||||
|| (shouldShow('mobileStatusBar') && isMobile)
|
||||
|| shouldShow('dotfiles')
|
||||
|| shouldShow('fileViewerPreview')
|
||||
|| shouldShow('reasoning')
|
||||
@@ -1698,7 +1695,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || (shouldShow('mobileStatusBar') && isMobile) || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('queueMode') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && (
|
||||
{(shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('queueMode') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && (
|
||||
<section className="p-2 space-y-0.5">
|
||||
{shouldShow('reasoning') && (
|
||||
<div
|
||||
@@ -1871,29 +1868,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShow('mobileStatusBar') && isMobile && (
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2 py-0.5"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={showMobileSessionStatusBar}
|
||||
onClick={() => setShowMobileSessionStatusBar(!showMobileSessionStatusBar)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
setShowMobileSessionStatusBar(!showMobileSessionStatusBar);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={showMobileSessionStatusBar}
|
||||
onChange={setShowMobileSessionStatusBar}
|
||||
ariaLabel={t('settings.openchamber.visual.field.showMobileStatusBarAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.showMobileStatusBar')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShow('dotfiles') && !isVSCodeRuntime() && (
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2 py-0.5"
|
||||
|
||||
@@ -35,6 +35,7 @@ import { canUseElectronDesktopIPC, invokeDesktop, isDesktopShell, isVSCodeRuntim
|
||||
import { SETTINGS_PAGE_METADATA, type SettingsRuntimeContext } from '@/lib/settings/metadata';
|
||||
import { getSettingsNavIcon } from '@/components/views/SettingsView';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { McpIcon } from '@/components/icons/McpIcon';
|
||||
import { scoreByFuzzyQuery } from '@/lib/search/fuzzySearch';
|
||||
import { truncatePathMiddle } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -277,7 +278,9 @@ export const CommandPalette: React.FC = () => {
|
||||
return {
|
||||
id: `settings:${page.slug}`,
|
||||
title: page.title,
|
||||
icon: <Icon name={iconName} className="mr-2 h-4 w-4" />,
|
||||
icon: page.slug === 'mcp'
|
||||
? <McpIcon className="mr-2 h-4 w-4" />
|
||||
: <Icon name={iconName} className="mr-2 h-4 w-4" />,
|
||||
searchText: `${page.title} ${page.group} ${keywords}`,
|
||||
onSelect: run(() => {
|
||||
setSettingsPage(page.slug);
|
||||
|
||||
@@ -31,6 +31,9 @@ export const ProviderLogo: React.FC<ProviderLogoProps> = ({
|
||||
src={src}
|
||||
alt={alt || `${providerId} logo`}
|
||||
className={cn('dark:invert object-contain', className)}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
fetchPriority="high"
|
||||
onError={handleError}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -35,11 +35,13 @@ import { SnippetsPage } from '@/components/sections/snippets/SnippetsPage';
|
||||
import { GitPage } from '@/components/sections/git-identities/GitPage';
|
||||
import type { OpenChamberSection } from '@/components/sections/openchamber/types';
|
||||
import { OpenChamberPage } from '@/components/sections/openchamber/OpenChamberPage';
|
||||
import { AboutSettings } from '@/components/sections/openchamber/AboutSettings';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import type { IconName } from "@/components/icon/icons";
|
||||
import { McpIcon } from '@/components/icons/McpIcon';
|
||||
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
|
||||
import {
|
||||
SETTINGS_PAGE_METADATA,
|
||||
@@ -74,6 +76,7 @@ interface SettingsViewProps {
|
||||
isWindowed?: boolean;
|
||||
/** Restrict top-level settings navigation to a specific product surface. */
|
||||
visiblePageSlugs?: SettingsPageSlug[];
|
||||
initialMobileStage?: MobileStage;
|
||||
}
|
||||
|
||||
const pageOrder: SettingsPageSlug[] = [
|
||||
@@ -98,6 +101,7 @@ const pageOrder: SettingsPageSlug[] = [
|
||||
'skills.catalog',
|
||||
'voice',
|
||||
'tunnel',
|
||||
'about',
|
||||
];
|
||||
|
||||
const SNIPPETS_SETTINGS_ICON = { icon: 'chat-thread' } as const;
|
||||
@@ -177,7 +181,7 @@ export function getSettingsNavIcon(slug: SettingsPageSlug): IconName | null {
|
||||
case 'commands':
|
||||
return 'slash-commands-2';
|
||||
case 'mcp':
|
||||
return 'plug-2';
|
||||
return null;
|
||||
case 'plugins':
|
||||
return 'code-box';
|
||||
|
||||
@@ -195,6 +199,8 @@ export function getSettingsNavIcon(slug: SettingsPageSlug): IconName | null {
|
||||
return 'mic';
|
||||
case 'tunnel':
|
||||
return 'global';
|
||||
case 'about':
|
||||
return 'information';
|
||||
case 'home':
|
||||
return null;
|
||||
default:
|
||||
@@ -278,7 +284,7 @@ const SettingsHome: React.FC<{ onOpen: (slug: SettingsPageSlug) => void }> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile, isWindowed, visiblePageSlugs }) => {
|
||||
export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile, isWindowed, visiblePageSlugs, initialMobileStage = 'nav' }) => {
|
||||
const { t } = useI18n();
|
||||
const deviceInfo = useDeviceInfo();
|
||||
const isMobile = forceMobile ?? deviceInfo.isMobile;
|
||||
@@ -288,7 +294,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
||||
const settingsSlug = resolveSettingsSlug(settingsPageRaw);
|
||||
|
||||
const [mobileStage, setMobileStage] = React.useState<MobileStage>('nav');
|
||||
const [mobileStage, setMobileStage] = React.useState<MobileStage>(initialMobileStage);
|
||||
const autoNavSlugRef = React.useRef<string | null>(null);
|
||||
|
||||
const [navWidth, setNavWidth] = React.useState(216);
|
||||
@@ -492,6 +498,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
return t('settings.page.voice.title');
|
||||
case 'tunnel':
|
||||
return t('settings.page.tunnel.title');
|
||||
case 'about':
|
||||
return t('settings.page.about.title');
|
||||
case 'home':
|
||||
default:
|
||||
return t('settings.view.home.title');
|
||||
@@ -567,6 +575,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
return <ProvidersPage />;
|
||||
case 'usage':
|
||||
return <UsagePage />;
|
||||
case 'about':
|
||||
return <div className="h-full overflow-auto px-5 py-6"><AboutSettings /></div>;
|
||||
case 'magic-prompts':
|
||||
return <MagicPromptsPage />;
|
||||
case 'snippets':
|
||||
@@ -703,7 +713,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
{sortedFilteredPages.map((page) => {
|
||||
const selected = settingsSlug === page.slug;
|
||||
const iconName = getSettingsNavIcon(page.slug);
|
||||
if (!iconName) return null;
|
||||
if (!iconName && page.slug !== 'mcp') return null;
|
||||
|
||||
return (
|
||||
<Tooltip key={page.slug}>
|
||||
@@ -719,7 +729,9 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
: 'text-foreground hover:bg-interactive-hover'
|
||||
)}
|
||||
>
|
||||
<Icon name={iconName} className="h-4 w-4 shrink-0" />
|
||||
{page.slug === 'mcp'
|
||||
? <McpIcon className="h-4 w-4 shrink-0" />
|
||||
: <Icon name={iconName!} className="h-4 w-4 shrink-0" />}
|
||||
<span className="flex items-center gap-1.5 whitespace-nowrap overflow-hidden transition-opacity duration-150 opacity-100">
|
||||
<span className="typography-ui-label font-normal truncate">{getPageTitle(page.slug)}</span>
|
||||
{(page.slug === 'voice' || page.slug === 'tunnel') && (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -14,6 +14,7 @@ const localLogoModules = import.meta.glob<string>('../assets/provider-logos/*.sv
|
||||
});
|
||||
|
||||
const LOCAL_PROVIDER_LOGO_MAP = new Map<string, string>();
|
||||
const PRELOADED_LOGO_SRCS = new Set<string>();
|
||||
|
||||
const LOGO_ALIAS = new Map<string, string>([
|
||||
['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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -33,9 +33,15 @@ export const dict: Record<I18nKey, string> = {
|
||||
"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<I18nKey, string> = {
|
||||
"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.",
|
||||
|
||||
@@ -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': '기본으로 메시지 대기열 사용',
|
||||
|
||||
@@ -33,9 +33,15 @@ export const dict: Record<I18nKey, string> = {
|
||||
'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<I18nKey, string> = {
|
||||
'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}분 후 다시 시도하세요.',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -34,9 +34,15 @@ export const dict: Record<I18nKey, string> = {
|
||||
'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<I18nKey, string> = {
|
||||
'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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -33,9 +33,15 @@ export const dict: Record<I18nKey, string> = {
|
||||
"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<I18nKey, string> = {
|
||||
"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.",
|
||||
|
||||
@@ -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": "Ставити повідомлення в чергу за замовчуванням",
|
||||
|
||||
@@ -33,9 +33,15 @@ export const dict: Record<I18nKey, string> = {
|
||||
"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<I18nKey, string> = {
|
||||
"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} хвилину перед повторною спробою.",
|
||||
|
||||
@@ -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': '默认排队消息',
|
||||
|
||||
@@ -33,9 +33,15 @@ export const dict: Record<I18nKey, string> = {
|
||||
'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<I18nKey, string> = {
|
||||
'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} 分钟后再试。',
|
||||
|
||||
@@ -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': '預設排隊訊息',
|
||||
|
||||
@@ -33,9 +33,15 @@ export const dict: Record<I18nKey, string> = {
|
||||
'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<I18nKey, string> = {
|
||||
'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} 分鐘後再試。',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<SidebarSection, SettingsPageSlug> = {
|
||||
|
||||
@@ -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<UIStore>()(
|
||||
userMessageRenderingMode: 'markdown',
|
||||
stickyUserHeader: false,
|
||||
showSplitAssistantMessageActions: false,
|
||||
showMobileSessionStatusBar: false,
|
||||
isMobileSessionStatusBarCollapsed: false,
|
||||
mobileSessionPanelOpen: false,
|
||||
mobileSessionFilterProjectId: null,
|
||||
@@ -2010,9 +2007,6 @@ export const useUIStore = create<UIStore>()(
|
||||
setShowSplitAssistantMessageActions: (value) => {
|
||||
set({ showSplitAssistantMessageActions: value });
|
||||
},
|
||||
setShowMobileSessionStatusBar: (value) => {
|
||||
set({ showMobileSessionStatusBar: value });
|
||||
},
|
||||
setIsMobileSessionStatusBarCollapsed: (value) => {
|
||||
set({ isMobileSessionStatusBarCollapsed: value });
|
||||
},
|
||||
@@ -2240,7 +2234,6 @@ export const useUIStore = create<UIStore>()(
|
||||
userMessageRenderingMode: state.userMessageRenderingMode,
|
||||
stickyUserHeader: state.stickyUserHeader,
|
||||
showSplitAssistantMessageActions: state.showSplitAssistantMessageActions,
|
||||
showMobileSessionStatusBar: state.showMobileSessionStatusBar,
|
||||
isMobileSessionStatusBarCollapsed: state.isMobileSessionStatusBarCollapsed,
|
||||
mobileSessionFilterProjectId: state.mobileSessionFilterProjectId,
|
||||
shortcutOverrides: state.shortcutOverrides,
|
||||
|
||||