|
|
|
@@ -47,7 +47,6 @@ import { PaceIndicator } from '@/components/sections/usage/PaceIndicator';
|
|
|
|
|
import { updateDesktopSettings } from '@/lib/persistence';
|
|
|
|
|
import { formatTimeForPreference } from '@/lib/timeFormat';
|
|
|
|
|
import { eventMatchesShortcut, formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
|
|
|
|
|
import type { TimeFormatPreference } from '@/stores/useUIStore';
|
|
|
|
|
import {
|
|
|
|
|
getAllModelFamilies,
|
|
|
|
|
getDisplayModelName,
|
|
|
|
@@ -65,16 +64,21 @@ import type { GitHubAuthStatus } from '@/lib/api/types';
|
|
|
|
|
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
|
|
|
|
|
import { DesktopHostSwitcherDialog } from '@/components/desktop/DesktopHostSwitcher';
|
|
|
|
|
import { OpenInAppButton } from '@/components/desktop/OpenInAppButton';
|
|
|
|
|
import { useTerminalStore } from '@/stores/useTerminalStore';
|
|
|
|
|
import { ProjectActionsButton } from '@/components/layout/ProjectActionsButton';
|
|
|
|
|
import { SessionSwitcherDropdown } from '@/components/session/SessionSwitcherDropdown';
|
|
|
|
|
import { canUseElectronDesktopIPC, invokeDesktop, isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, startDesktopWindowDrag, type UpdateInfo } from '@/lib/desktop';
|
|
|
|
|
import { desktopHostsGet, getDesktopHostApiUrl, locationMatchesHost, redactSensitiveUrl } from '@/lib/desktopHosts';
|
|
|
|
|
import { desktopHostsGet, redactSensitiveUrl } from '@/lib/desktopHosts';
|
|
|
|
|
import {
|
|
|
|
|
LOCAL_HOST_ID,
|
|
|
|
|
buildLocalDesktopHost,
|
|
|
|
|
getLocalDesktopOrigin,
|
|
|
|
|
resolveCurrentDesktopHost,
|
|
|
|
|
} from '@/lib/desktopCurrentHost';
|
|
|
|
|
import { Icon } from "@/components/icon/Icon";
|
|
|
|
|
import { useI18n } from '@/lib/i18n';
|
|
|
|
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
|
|
|
|
import { getRuntimeBearerTokenSync } from '@/lib/runtime-auth';
|
|
|
|
|
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
|
|
|
|
import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
|
|
|
|
|
import { useShallow } from 'zustand/react/shallow';
|
|
|
|
|
import type { IconName } from "@/components/icon/icons";
|
|
|
|
|
import { toast } from '@/components/ui';
|
|
|
|
@@ -269,32 +273,11 @@ type DesktopServicesMenuProps = {
|
|
|
|
|
isDesktopServicesOpen: boolean;
|
|
|
|
|
setIsDesktopServicesOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
|
|
|
|
refreshCurrentInstanceLabel: () => Promise<void>;
|
|
|
|
|
desktopServicesTab: 'instance' | 'usage' | 'mcp';
|
|
|
|
|
setDesktopServicesTab: React.Dispatch<React.SetStateAction<'instance' | 'usage' | 'mcp'>>;
|
|
|
|
|
quotaResultsLength: number;
|
|
|
|
|
fetchAllQuotas: () => Promise<unknown>;
|
|
|
|
|
servicesTabItems: SortableTabsStripItem[];
|
|
|
|
|
quotaLastUpdated: number | null;
|
|
|
|
|
quotaDisplayMode: 'usage' | 'remaining';
|
|
|
|
|
quotaDisplayTabItems: SortableTabsStripItem[];
|
|
|
|
|
handleDisplayModeChange: (mode: 'usage' | 'remaining') => Promise<void>;
|
|
|
|
|
handleUsageRefresh: () => void;
|
|
|
|
|
isQuotaLoading: boolean;
|
|
|
|
|
isUsageRefreshSpinning: boolean;
|
|
|
|
|
hasRateLimits: boolean;
|
|
|
|
|
rateLimitGroups: RateLimitGroup[];
|
|
|
|
|
expandedFamilies: Record<string, string[]>;
|
|
|
|
|
toggleFamilyExpanded: (providerId: string, familyId: string) => void;
|
|
|
|
|
shortcutLabel: (actionId: string) => string;
|
|
|
|
|
showDevShutdown: boolean;
|
|
|
|
|
isDevShutdownInFlight: boolean;
|
|
|
|
|
onDevShutdown: () => Promise<void>;
|
|
|
|
|
remoteUpdateInfo: UpdateInfo | null;
|
|
|
|
|
remoteUpdateChecking: boolean;
|
|
|
|
|
remoteUpdateError: string | null;
|
|
|
|
|
onOpenRemoteUpdate: () => void;
|
|
|
|
|
showPredValues: boolean;
|
|
|
|
|
timeFormatPreference: TimeFormatPreference;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
|
|
|
|
@@ -305,32 +288,11 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
|
|
|
|
|
isDesktopServicesOpen,
|
|
|
|
|
setIsDesktopServicesOpen,
|
|
|
|
|
refreshCurrentInstanceLabel,
|
|
|
|
|
desktopServicesTab,
|
|
|
|
|
setDesktopServicesTab,
|
|
|
|
|
quotaResultsLength,
|
|
|
|
|
fetchAllQuotas,
|
|
|
|
|
servicesTabItems,
|
|
|
|
|
quotaLastUpdated,
|
|
|
|
|
quotaDisplayMode,
|
|
|
|
|
quotaDisplayTabItems,
|
|
|
|
|
handleDisplayModeChange,
|
|
|
|
|
handleUsageRefresh,
|
|
|
|
|
isQuotaLoading,
|
|
|
|
|
isUsageRefreshSpinning,
|
|
|
|
|
hasRateLimits,
|
|
|
|
|
rateLimitGroups,
|
|
|
|
|
expandedFamilies,
|
|
|
|
|
toggleFamilyExpanded,
|
|
|
|
|
shortcutLabel,
|
|
|
|
|
showDevShutdown,
|
|
|
|
|
isDevShutdownInFlight,
|
|
|
|
|
onDevShutdown,
|
|
|
|
|
remoteUpdateInfo,
|
|
|
|
|
remoteUpdateChecking,
|
|
|
|
|
remoteUpdateError,
|
|
|
|
|
onOpenRemoteUpdate,
|
|
|
|
|
showPredValues,
|
|
|
|
|
timeFormatPreference,
|
|
|
|
|
}: DesktopServicesMenuProps) {
|
|
|
|
|
const { t } = useI18n();
|
|
|
|
|
return (
|
|
|
|
@@ -340,9 +302,6 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
|
|
|
|
|
setIsDesktopServicesOpen(open);
|
|
|
|
|
if (open) {
|
|
|
|
|
void refreshCurrentInstanceLabel();
|
|
|
|
|
if (desktopServicesTab === 'usage' && quotaResultsLength === 0) {
|
|
|
|
|
void fetchAllQuotas();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
@@ -359,7 +318,7 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
|
|
|
|
|
isDesktopApp ? 'w-auto max-w-[14rem] justify-start gap-1.5 px-2.5' : 'h-8 w-8'
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<Icon name="stack" className="h-[18px] w-[18px]" />
|
|
|
|
|
<Icon name="server" className="h-[18px] w-[18px]" />
|
|
|
|
|
{isDesktopApp ? (
|
|
|
|
|
<span className="truncate typography-ui-label font-medium text-foreground">{compactCurrentInstanceLabel}</span>
|
|
|
|
|
) : null}
|
|
|
|
@@ -368,16 +327,10 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent>
|
|
|
|
|
<p>
|
|
|
|
|
{isDesktopApp
|
|
|
|
|
? t('header.services.tooltip.currentInstanceWithShortcuts', {
|
|
|
|
|
current: currentInstanceLabel,
|
|
|
|
|
toggle: shortcutLabel('toggle_services_menu'),
|
|
|
|
|
nextTab: shortcutLabel('cycle_services_tab'),
|
|
|
|
|
})
|
|
|
|
|
: t('header.services.tooltip.servicesWithShortcuts', {
|
|
|
|
|
toggle: shortcutLabel('toggle_services_menu'),
|
|
|
|
|
nextTab: shortcutLabel('cycle_services_tab'),
|
|
|
|
|
})}
|
|
|
|
|
{t('header.services.tooltip.currentInstance', {
|
|
|
|
|
current: currentInstanceLabel,
|
|
|
|
|
toggle: shortcutLabel('toggle_services_menu'),
|
|
|
|
|
})}
|
|
|
|
|
</p>
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
@@ -385,28 +338,7 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
|
|
|
|
|
align="end"
|
|
|
|
|
className="w-[min(27rem,calc(100vw-2rem))] max-h-[75vh] overflow-y-auto bg-[var(--surface-elevated)] p-0"
|
|
|
|
|
>
|
|
|
|
|
<div className="sticky top-0 z-20 px-2 pt-1.5 pb-px">
|
|
|
|
|
<div className="h-9">
|
|
|
|
|
<SortableTabsStrip
|
|
|
|
|
items={servicesTabItems}
|
|
|
|
|
activeId={desktopServicesTab}
|
|
|
|
|
onSelect={(tabID) => {
|
|
|
|
|
const value = tabID as 'instance' | 'usage' | 'mcp';
|
|
|
|
|
setDesktopServicesTab(value);
|
|
|
|
|
if (value === 'usage' && quotaResultsLength === 0) {
|
|
|
|
|
void fetchAllQuotas();
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
layoutMode="fit"
|
|
|
|
|
variant="active-pill"
|
|
|
|
|
activePillInsetClassName="gap-0.5 px-px py-0"
|
|
|
|
|
activePillButtonClassName="h-8"
|
|
|
|
|
className="h-full"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{isDesktopApp && desktopServicesTab === 'instance' ? (
|
|
|
|
|
{isDesktopApp ? (
|
|
|
|
|
<div>
|
|
|
|
|
{!currentInstanceIsLocal ? (
|
|
|
|
|
<div className="border-b border-[var(--interactive-border)] px-4 py-2.5">
|
|
|
|
@@ -435,185 +367,13 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
|
|
|
|
|
) : null}
|
|
|
|
|
<DesktopHostSwitcherDialog
|
|
|
|
|
embedded
|
|
|
|
|
open={isDesktopServicesOpen && desktopServicesTab === 'instance'}
|
|
|
|
|
open={isDesktopServicesOpen}
|
|
|
|
|
onOpenChange={() => {}}
|
|
|
|
|
onHostSwitched={() => setIsDesktopServicesOpen(false)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{desktopServicesTab === 'mcp' ? (
|
|
|
|
|
<McpDropdownContent active={isDesktopServicesOpen && desktopServicesTab === 'mcp'} />
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{desktopServicesTab === 'usage' ? (
|
|
|
|
|
<div className="overflow-x-hidden">
|
|
|
|
|
<div className="flex items-center justify-between gap-3 border-b border-[var(--interactive-border)] px-4 py-2.5">
|
|
|
|
|
<div className="flex min-w-0 items-baseline gap-2">
|
|
|
|
|
<span className="typography-ui-header font-semibold text-foreground">{t('header.services.rateLimits')}</span>
|
|
|
|
|
<span className="truncate typography-micro text-muted-foreground">{formatTime(quotaLastUpdated, timeFormatPreference)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
|
|
|
<div className="h-7 w-[10.5rem]">
|
|
|
|
|
<SortableTabsStrip
|
|
|
|
|
items={quotaDisplayTabItems}
|
|
|
|
|
activeId={quotaDisplayMode}
|
|
|
|
|
onSelect={(tabID) => void handleDisplayModeChange(tabID as 'usage' | 'remaining')}
|
|
|
|
|
layoutMode="fit"
|
|
|
|
|
variant="active-pill"
|
|
|
|
|
activePillInsetClassName="gap-0.5 px-px py-0"
|
|
|
|
|
className="h-full"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className={cn(
|
|
|
|
|
'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'
|
|
|
|
|
)}
|
|
|
|
|
onClick={handleUsageRefresh}
|
|
|
|
|
disabled={isQuotaLoading || isUsageRefreshSpinning}
|
|
|
|
|
aria-label={t('header.services.refreshRateLimitsAria')}
|
|
|
|
|
>
|
|
|
|
|
<Icon name="refresh" className={cn('h-4 w-4', isUsageRefreshSpinning && 'animate-spin')} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{!hasRateLimits ? (
|
|
|
|
|
<div className="px-4 py-5 text-center">
|
|
|
|
|
<span className="typography-ui-label text-muted-foreground">{t('header.services.noRateLimits')}</span>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{/* One elevated card per provider (same card language as the mobile
|
|
|
|
|
usage popover) instead of a flat run of divider-separated rows. */}
|
|
|
|
|
<div className="space-y-2 px-3 py-2.5">
|
|
|
|
|
{rateLimitGroups.map((group) => {
|
|
|
|
|
const providerExpandedFamilies = expandedFamilies[group.providerId] ?? [];
|
|
|
|
|
return (
|
|
|
|
|
<div key={group.providerId} className="min-w-0 rounded-xl bg-[var(--surface-muted)] p-3">
|
|
|
|
|
<div className="flex items-center gap-2 pb-2">
|
|
|
|
|
<ProviderLogo providerId={group.providerId} className="h-4 w-4" />
|
|
|
|
|
<span className="typography-ui-label font-medium text-foreground">{group.providerName}</span>
|
|
|
|
|
</div>
|
|
|
|
|
{group.entries.length === 0 && (!group.modelFamilies || group.modelFamilies.length === 0) ? (
|
|
|
|
|
<div>
|
|
|
|
|
<span className="typography-ui-label text-muted-foreground">{group.error ?? t('header.services.noRateLimitsReported')}</span>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{group.entries.map(([label, window]) => {
|
|
|
|
|
const displayPercent = quotaDisplayMode === 'remaining' ? window.remainingPercent : window.usedPercent;
|
|
|
|
|
const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds, label);
|
|
|
|
|
const expectedMarker = paceInfo?.dailyAllocationPercent != null
|
|
|
|
|
? (quotaDisplayMode === 'remaining'
|
|
|
|
|
? 100 - calculateExpectedUsagePercent(paceInfo.elapsedRatio)
|
|
|
|
|
: calculateExpectedUsagePercent(paceInfo.elapsedRatio))
|
|
|
|
|
: null;
|
|
|
|
|
const metricLabel = formatQuotaValueLabel(window.valueLabel, displayPercent);
|
|
|
|
|
const resetLabel = formatQuotaResetLabel(window.resetAt, window.resetAfterFormatted ?? window.resetAtFormatted, timeFormatPreference);
|
|
|
|
|
return (
|
|
|
|
|
<div key={`${group.providerId}-${label}`} className="flex flex-col gap-1.5">
|
|
|
|
|
<div className="flex min-w-0 items-center justify-between gap-3">
|
|
|
|
|
<div className="min-w-0 flex items-center gap-2">
|
|
|
|
|
<span className="truncate typography-ui-label text-foreground">{formatWindowLabel(label)}</span>
|
|
|
|
|
{resetLabel ? (
|
|
|
|
|
<span className="truncate typography-micro text-muted-foreground">
|
|
|
|
|
{resetLabel}
|
|
|
|
|
</span>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
<span className="typography-ui-label tabular-nums text-foreground">
|
|
|
|
|
{metricLabel === '-' ? '' : metricLabel}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<UsageProgressBar
|
|
|
|
|
percent={displayPercent}
|
|
|
|
|
tonePercent={window.usedPercent}
|
|
|
|
|
className="h-1.5"
|
|
|
|
|
expectedMarkerPercent={expectedMarker}
|
|
|
|
|
/>
|
|
|
|
|
{paceInfo && showPredValues ? <PaceIndicator paceInfo={paceInfo} compact /> : null}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
{group.modelFamilies && group.modelFamilies.length > 0 ? (
|
|
|
|
|
<div className="space-y-0.5">
|
|
|
|
|
{group.modelFamilies.map((family) => {
|
|
|
|
|
const familyKey = family.familyId ?? 'other';
|
|
|
|
|
const isExpanded = providerExpandedFamilies.includes(familyKey);
|
|
|
|
|
return (
|
|
|
|
|
<Collapsible
|
|
|
|
|
key={familyKey}
|
|
|
|
|
open={isExpanded}
|
|
|
|
|
onOpenChange={() => toggleFamilyExpanded(group.providerId, familyKey)}
|
|
|
|
|
>
|
|
|
|
|
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-md px-1 py-1.5 text-left hover:bg-[var(--interactive-hover)]/50 transition-colors">
|
|
|
|
|
<span className="typography-ui-label font-medium text-foreground">{family.familyLabel}</span>
|
|
|
|
|
{isExpanded ? <Icon name="arrow-down-s" className="h-4 w-4 text-muted-foreground" /> : <Icon name="arrow-right-s" className="h-4 w-4 text-muted-foreground" />}
|
|
|
|
|
</CollapsibleTrigger>
|
|
|
|
|
<CollapsibleContent>
|
|
|
|
|
<div className="space-y-2.5 pb-1 pl-1 pt-1">
|
|
|
|
|
{family.models.map(([modelName, window]) => {
|
|
|
|
|
const displayPercent = quotaDisplayMode === 'remaining' ? window.remainingPercent : window.usedPercent;
|
|
|
|
|
const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds);
|
|
|
|
|
const expectedMarker = paceInfo?.dailyAllocationPercent != null
|
|
|
|
|
? (quotaDisplayMode === 'remaining'
|
|
|
|
|
? 100 - calculateExpectedUsagePercent(paceInfo.elapsedRatio)
|
|
|
|
|
: calculateExpectedUsagePercent(paceInfo.elapsedRatio))
|
|
|
|
|
: null;
|
|
|
|
|
const metricLabel = formatQuotaValueLabel(window.valueLabel, displayPercent);
|
|
|
|
|
return (
|
|
|
|
|
<div key={`${group.providerId}-${modelName}`} className="flex flex-col gap-1.5">
|
|
|
|
|
<div className="flex min-w-0 items-center justify-between gap-3">
|
|
|
|
|
<span className="truncate typography-micro text-muted-foreground">{getDisplayModelName(modelName)}</span>
|
|
|
|
|
<span className="typography-ui-label tabular-nums text-foreground">
|
|
|
|
|
{metricLabel === '-' ? '' : metricLabel}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<UsageProgressBar
|
|
|
|
|
percent={displayPercent}
|
|
|
|
|
tonePercent={window.usedPercent}
|
|
|
|
|
className="h-1.5"
|
|
|
|
|
expectedMarkerPercent={expectedMarker}
|
|
|
|
|
/>
|
|
|
|
|
{paceInfo && showPredValues ? <PaceIndicator paceInfo={paceInfo} compact /> : null}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</CollapsibleContent>
|
|
|
|
|
</Collapsible>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{showDevShutdown ? (
|
|
|
|
|
<>
|
|
|
|
|
<div className="mx-4 my-2 border-t border-[var(--interactive-border)]" />
|
|
|
|
|
<div className="px-2 pb-2">
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
disabled={isDevShutdownInFlight}
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
void onDevShutdown();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{t('header.services.shutdownDev')}
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
) : null}
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
);
|
|
|
|
@@ -740,7 +500,6 @@ export const Header: React.FC<HeaderProps> = ({
|
|
|
|
|
|
|
|
|
|
const getCurrentModel = useConfigStore((state) => state.getCurrentModel);
|
|
|
|
|
const runtimeApis = useRuntimeAPIs();
|
|
|
|
|
const [isDevShutdownInFlight, setIsDevShutdownInFlight] = React.useState(false);
|
|
|
|
|
|
|
|
|
|
const getContextUsage = useSessionUIStore((state) => state.getContextUsage);
|
|
|
|
|
const isNewSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
|
|
|
@@ -894,18 +653,38 @@ export const Header: React.FC<HeaderProps> = ({
|
|
|
|
|
const [remoteUpdateChecking, setRemoteUpdateChecking] = React.useState(false);
|
|
|
|
|
const [remoteUpdateError, setRemoteUpdateError] = React.useState<string | null>(null);
|
|
|
|
|
const compactCurrentInstanceLabel = React.useMemo(() => formatCompactHeaderLabel(currentInstanceLabel), [currentInstanceLabel]);
|
|
|
|
|
const [desktopServicesTab, setDesktopServicesTab] = React.useState<'instance' | 'usage' | 'mcp'>(
|
|
|
|
|
isDesktopApp ? 'instance' : 'usage'
|
|
|
|
|
);
|
|
|
|
|
const [mobileServicesTab, setMobileServicesTab] = React.useState<'usage' | 'mcp'>('usage');
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!isDesktopApp && desktopServicesTab === 'instance') {
|
|
|
|
|
setDesktopServicesTab('usage');
|
|
|
|
|
}
|
|
|
|
|
}, [desktopServicesTab, isDesktopApp]);
|
|
|
|
|
|
|
|
|
|
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
|
|
|
|
const showDesktopHeaderContextUsage = !isVSCode && activeMainTab === 'chat' && !!stableDesktopContextUsage && stableDesktopContextUsage.totalTokens > 0;
|
|
|
|
|
// While the work-status panel is on screen it already reports the project,
|
|
|
|
|
// the branch and the context fill — three paces away in the same window.
|
|
|
|
|
// These yield to it rather than saying the same thing twice, and return the
|
|
|
|
|
// moment the panel is switched off or squeezed out by a narrow chat.
|
|
|
|
|
const workStatusPanelVisible = useUIStore((state) => state.workStatusPanelVisible);
|
|
|
|
|
const workStatusPanelEnabled = useUIStore((state) => state.workStatusPanelEnabled);
|
|
|
|
|
const setWorkStatusPanelEnabled = useUIStore((state) => state.setWorkStatusPanelEnabled);
|
|
|
|
|
const workStatusPanelFits = useUIStore((state) => state.workStatusPanelFits);
|
|
|
|
|
const workStatusOverlayOpen = useUIStore((state) => state.workStatusOverlayOpen);
|
|
|
|
|
const setWorkStatusOverlayOpen = useUIStore((state) => state.setWorkStatusOverlayOpen);
|
|
|
|
|
|
|
|
|
|
// Two meanings for one button. With room beside the chat it switches the
|
|
|
|
|
// panel on and off. Without room it cannot be shown inline at all, so it
|
|
|
|
|
// reads as off and opens the panel over the chat instead — the stored
|
|
|
|
|
// preference is left alone, so the panel comes back on its own once the
|
|
|
|
|
// window is wide enough again.
|
|
|
|
|
const workStatusPanelShownInline = workStatusPanelEnabled && workStatusPanelFits;
|
|
|
|
|
const workStatusToggleActive = workStatusPanelShownInline || workStatusOverlayOpen;
|
|
|
|
|
const handleWorkStatusToggle = React.useCallback(() => {
|
|
|
|
|
if (workStatusPanelEnabled && !workStatusPanelFits) {
|
|
|
|
|
setWorkStatusOverlayOpen(!workStatusOverlayOpen);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setWorkStatusPanelEnabled(!workStatusPanelEnabled);
|
|
|
|
|
}, [setWorkStatusOverlayOpen, setWorkStatusPanelEnabled, workStatusOverlayOpen, workStatusPanelEnabled, workStatusPanelFits]);
|
|
|
|
|
const showDesktopHeaderContextUsage = !isVSCode
|
|
|
|
|
&& !workStatusPanelVisible
|
|
|
|
|
&& activeMainTab === 'chat'
|
|
|
|
|
&& !!stableDesktopContextUsage
|
|
|
|
|
&& stableDesktopContextUsage.totalTokens > 0;
|
|
|
|
|
const desktopHeaderDisplayPercentage = stableDesktopContextUsage && stableDesktopContextUsage.contextLimit > 0
|
|
|
|
|
? Math.min(999, (stableDesktopContextUsage.totalTokens / stableDesktopContextUsage.contextLimit) * 100)
|
|
|
|
|
: 0;
|
|
|
|
@@ -923,26 +702,19 @@ export const Header: React.FC<HeaderProps> = ({
|
|
|
|
|
}
|
|
|
|
|
setCurrentInstanceIsLocal(false);
|
|
|
|
|
|
|
|
|
|
// Same resolution the host switcher's own header uses, so the button and
|
|
|
|
|
// the panel it opens can never disagree about which instance this is.
|
|
|
|
|
const cfg = await desktopHostsGet();
|
|
|
|
|
const localOrigin = window.__OPENCHAMBER_LOCAL_ORIGIN__ || window.location.origin;
|
|
|
|
|
const runtimeApiBaseUrl = getRuntimeApiBaseUrl();
|
|
|
|
|
const localOrigin = getLocalDesktopOrigin();
|
|
|
|
|
const resolved = resolveCurrentDesktopHost([buildLocalDesktopHost(localOrigin), ...cfg.hosts]);
|
|
|
|
|
|
|
|
|
|
if (runtimeApiBaseUrl && locationMatchesHost(runtimeApiBaseUrl, localOrigin)) {
|
|
|
|
|
if (resolved.id === LOCAL_HOST_ID) {
|
|
|
|
|
setCurrentInstanceLabel('Local');
|
|
|
|
|
setCurrentInstanceIsLocal(true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const match = cfg.hosts.find((host) => {
|
|
|
|
|
return runtimeApiBaseUrl ? locationMatchesHost(runtimeApiBaseUrl, getDesktopHostApiUrl(host)) : false;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (match?.label?.trim()) {
|
|
|
|
|
setCurrentInstanceLabel(redactSensitiveUrl(match.label.trim()));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setCurrentInstanceLabel('Instance');
|
|
|
|
|
setCurrentInstanceLabel(redactSensitiveUrl(resolved.label.trim() || 'Instance'));
|
|
|
|
|
} catch {
|
|
|
|
|
setCurrentInstanceLabel('Local');
|
|
|
|
|
setCurrentInstanceIsLocal(true);
|
|
|
|
@@ -951,6 +723,11 @@ export const Header: React.FC<HeaderProps> = ({
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
void refreshCurrentInstanceLabel();
|
|
|
|
|
// Switching instances does not remount the header, so without this the
|
|
|
|
|
// button would keep naming the instance the window left behind.
|
|
|
|
|
return subscribeRuntimeEndpointChanged(() => {
|
|
|
|
|
void refreshCurrentInstanceLabel();
|
|
|
|
|
});
|
|
|
|
|
}, [refreshCurrentInstanceLabel]);
|
|
|
|
|
|
|
|
|
|
const checkRemoteInstanceUpdate = React.useCallback(async () => {
|
|
|
|
@@ -1306,6 +1083,12 @@ export const Header: React.FC<HeaderProps> = ({
|
|
|
|
|
const gitBranchForDirectory = useGitBranchLabel(openDirectory || null);
|
|
|
|
|
const currentBranchLabel = gitBranchForDirectory || currentSessionWorktreeBranch || catalogWorktreeBranch;
|
|
|
|
|
|
|
|
|
|
// Whether the title carries a second line under it. Hoisted because the
|
|
|
|
|
// session menu's vertical alignment depends on the same answer.
|
|
|
|
|
const showHeaderMetaRow = !workStatusPanelVisible
|
|
|
|
|
&& Boolean(activeProjectLabel || currentBranchLabel || (!isNewSessionDraftOpen && worktreeBadgeKind));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const currentSessionTitle = React.useMemo(() => {
|
|
|
|
|
if (!currentSessionId) {
|
|
|
|
|
return activeProjectLabel ?? 'OpenChamber';
|
|
|
|
@@ -1948,93 +1731,17 @@ export const Header: React.FC<HeaderProps> = ({
|
|
|
|
|
}
|
|
|
|
|
}, [activeMainTab, isMobile, setActiveMainTab]);
|
|
|
|
|
|
|
|
|
|
// Desktop keeps instances only: quota and MCP now live in the work-status
|
|
|
|
|
// panel, which reports them per session rather than per window. The mobile
|
|
|
|
|
// menu below is untouched — it has no panel to defer to.
|
|
|
|
|
const servicesTabs = React.useMemo(() => {
|
|
|
|
|
const base: Array<{ value: 'instance' | 'usage' | 'mcp'; label: string; icon: React.ReactNode }> = [];
|
|
|
|
|
if (isDesktopApp) {
|
|
|
|
|
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: <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]);
|
|
|
|
|
|
|
|
|
|
const servicesTabItems = React.useMemo(() => {
|
|
|
|
|
return servicesTabs.map((tab) => ({
|
|
|
|
|
id: tab.value,
|
|
|
|
|
label: tab.label,
|
|
|
|
|
icon: tab.icon,
|
|
|
|
|
}));
|
|
|
|
|
}, [servicesTabs]);
|
|
|
|
|
|
|
|
|
|
const showDevShutdown = React.useMemo(() => {
|
|
|
|
|
if (typeof window === 'undefined') return false;
|
|
|
|
|
if (isDesktopApp) return false;
|
|
|
|
|
if (isVSCode) return false;
|
|
|
|
|
const host = window.location.hostname;
|
|
|
|
|
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
|
|
|
|
}, [isDesktopApp, isVSCode]);
|
|
|
|
|
|
|
|
|
|
const handleDevShutdown = React.useCallback(async () => {
|
|
|
|
|
if (isDevShutdownInFlight) return;
|
|
|
|
|
setIsDevShutdownInFlight(true);
|
|
|
|
|
setIsDesktopServicesOpen(false);
|
|
|
|
|
|
|
|
|
|
const previewUrls: string[] = [];
|
|
|
|
|
let shutdownRequested = false;
|
|
|
|
|
try {
|
|
|
|
|
try {
|
|
|
|
|
for (const [, dirState] of useTerminalStore.getState().sessions.entries()) {
|
|
|
|
|
for (const tab of dirState.tabs) {
|
|
|
|
|
if (tab.previewUrl) {
|
|
|
|
|
previewUrls.push(tab.previewUrl);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Ensure preview/dev terminals don't linger.
|
|
|
|
|
await runtimeApis.terminal.forceKill?.({});
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const devRes = await runtimeFetch('/api/system/dev-shutdown', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ previewUrls }),
|
|
|
|
|
});
|
|
|
|
|
if (devRes.ok) {
|
|
|
|
|
shutdownRequested = true;
|
|
|
|
|
} else {
|
|
|
|
|
const shutdownRes = await runtimeFetch('/api/system/shutdown', { method: 'POST' });
|
|
|
|
|
shutdownRequested = shutdownRes.ok;
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
if (!shutdownRequested) {
|
|
|
|
|
setIsDevShutdownInFlight(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, [isDevShutdownInFlight, runtimeApis.terminal, setIsDesktopServicesOpen]);
|
|
|
|
|
|
|
|
|
|
const quotaDisplayTabs = React.useMemo(() => {
|
|
|
|
|
return [
|
|
|
|
|
{ value: 'usage' as const, label: t('header.services.used') },
|
|
|
|
|
{ value: 'remaining' as const, label: t('header.services.remaining') },
|
|
|
|
|
];
|
|
|
|
|
}, [t]);
|
|
|
|
|
|
|
|
|
|
const quotaDisplayTabItems = React.useMemo(() => {
|
|
|
|
|
return quotaDisplayTabs.map((tab) => ({ id: tab.value, label: tab.label }));
|
|
|
|
|
}, [quotaDisplayTabs]);
|
|
|
|
|
|
|
|
|
|
const mobileServicesTabItems = React.useMemo<SortableTabsStripItem[]>(() => {
|
|
|
|
|
return [
|
|
|
|
@@ -2072,31 +1779,19 @@ export const Header: React.FC<HeaderProps> = ({
|
|
|
|
|
} else {
|
|
|
|
|
setIsDesktopServicesOpen(true);
|
|
|
|
|
void refreshCurrentInstanceLabel();
|
|
|
|
|
if (desktopServicesTab === 'usage' && quotaResults.length === 0) {
|
|
|
|
|
void fetchAllQuotas();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The desktop menu holds one destination now, so this shortcut opens it
|
|
|
|
|
// rather than cycling. The binding is kept: it is user-configurable and
|
|
|
|
|
// silently dropping it would break existing setups.
|
|
|
|
|
const cycleServicesCombo = getEffectiveShortcutCombo('cycle_services_tab', shortcutOverrides);
|
|
|
|
|
if (eventMatchesShortcut(e, cycleServicesCombo)) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
|
|
const tabValues = servicesTabs.map((tab) => tab.value) as Array<'instance' | 'usage' | 'mcp'>;
|
|
|
|
|
if (tabValues.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const currentIndex = tabValues.indexOf(desktopServicesTab);
|
|
|
|
|
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % tabValues.length;
|
|
|
|
|
const nextTab = tabValues[nextIndex];
|
|
|
|
|
setDesktopServicesTab(nextTab);
|
|
|
|
|
if (servicesTabs.length === 0) return;
|
|
|
|
|
setIsDesktopServicesOpen(true);
|
|
|
|
|
void refreshCurrentInstanceLabel();
|
|
|
|
|
if (nextTab === 'usage' && quotaResults.length === 0) {
|
|
|
|
|
void fetchAllQuotas();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -2112,7 +1807,6 @@ export const Header: React.FC<HeaderProps> = ({
|
|
|
|
|
}, [
|
|
|
|
|
shortcutOverrides,
|
|
|
|
|
isDesktopServicesOpen,
|
|
|
|
|
desktopServicesTab,
|
|
|
|
|
servicesTabs,
|
|
|
|
|
quotaResults.length,
|
|
|
|
|
fetchAllQuotas,
|
|
|
|
@@ -2172,6 +1866,10 @@ export const Header: React.FC<HeaderProps> = ({
|
|
|
|
|
const desktopSidebarActions = (
|
|
|
|
|
<>
|
|
|
|
|
<OpenInAppButton directory={actionDirectory} className="mr-1" />
|
|
|
|
|
{/* Instances only exist in the desktop app. On web the menu was left
|
|
|
|
|
holding a single dev-only shutdown action, which is not a reason to
|
|
|
|
|
keep a dropdown in the header. */}
|
|
|
|
|
{isDesktopApp ? (
|
|
|
|
|
<DesktopServicesMenu
|
|
|
|
|
isDesktopApp={isDesktopApp}
|
|
|
|
|
currentInstanceLabel={currentInstanceLabel}
|
|
|
|
@@ -2180,33 +1878,13 @@ export const Header: React.FC<HeaderProps> = ({
|
|
|
|
|
isDesktopServicesOpen={isDesktopServicesOpen}
|
|
|
|
|
setIsDesktopServicesOpen={setIsDesktopServicesOpen}
|
|
|
|
|
refreshCurrentInstanceLabel={refreshCurrentInstanceLabel}
|
|
|
|
|
desktopServicesTab={desktopServicesTab}
|
|
|
|
|
setDesktopServicesTab={setDesktopServicesTab}
|
|
|
|
|
quotaResultsLength={quotaResults.length}
|
|
|
|
|
fetchAllQuotas={fetchAllQuotas}
|
|
|
|
|
servicesTabItems={servicesTabItems}
|
|
|
|
|
quotaLastUpdated={quotaLastUpdated}
|
|
|
|
|
quotaDisplayMode={quotaDisplayMode}
|
|
|
|
|
showPredValues={showPredValues}
|
|
|
|
|
quotaDisplayTabItems={quotaDisplayTabItems}
|
|
|
|
|
handleDisplayModeChange={handleDisplayModeChange}
|
|
|
|
|
handleUsageRefresh={handleUsageRefresh}
|
|
|
|
|
isQuotaLoading={isQuotaLoading}
|
|
|
|
|
isUsageRefreshSpinning={isUsageRefreshSpinning}
|
|
|
|
|
hasRateLimits={hasRateLimits}
|
|
|
|
|
rateLimitGroups={rateLimitGroups}
|
|
|
|
|
expandedFamilies={expandedFamilies}
|
|
|
|
|
toggleFamilyExpanded={toggleFamilyExpanded}
|
|
|
|
|
shortcutLabel={shortcutLabel}
|
|
|
|
|
showDevShutdown={showDevShutdown}
|
|
|
|
|
isDevShutdownInFlight={isDevShutdownInFlight}
|
|
|
|
|
onDevShutdown={handleDevShutdown}
|
|
|
|
|
remoteUpdateInfo={remoteUpdateInfo}
|
|
|
|
|
remoteUpdateChecking={remoteUpdateChecking}
|
|
|
|
|
remoteUpdateError={remoteUpdateError}
|
|
|
|
|
onOpenRemoteUpdate={openRemoteInstanceUpdate}
|
|
|
|
|
timeFormatPreference={timeFormatPreference}
|
|
|
|
|
/>
|
|
|
|
|
) : null}
|
|
|
|
|
<DesktopGitHubControl
|
|
|
|
|
isMobile={isMobile}
|
|
|
|
|
githubAuthStatus={githubAuthStatus}
|
|
|
|
@@ -2321,7 +1999,7 @@ export const Header: React.FC<HeaderProps> = ({
|
|
|
|
|
{isNewSessionDraftOpen ? t('sessions.switcher.draftTitle') : currentSessionTitle}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{(activeProjectLabel || currentBranchLabel || (!isNewSessionDraftOpen && worktreeBadgeKind)) ? (
|
|
|
|
|
{showHeaderMetaRow ? (
|
|
|
|
|
<span className="flex min-w-0 max-w-full items-center gap-1.5 truncate typography-micro text-[10.5px] font-normal leading-tight text-muted-foreground/75">
|
|
|
|
|
{activeProjectLabel ? <span className="truncate">{activeProjectLabel}</span> : null}
|
|
|
|
|
{currentBranchLabel ? (
|
|
|
|
@@ -2342,7 +2020,12 @@ export const Header: React.FC<HeaderProps> = ({
|
|
|
|
|
</span>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex h-[18px] shrink-0 items-center justify-center self-start">
|
|
|
|
|
<div className={cn(
|
|
|
|
|
'flex h-[18px] shrink-0 items-center justify-center',
|
|
|
|
|
// Top-aligned only when the title has a metadata line under it;
|
|
|
|
|
// alone, the title is centred and the button must follow.
|
|
|
|
|
showHeaderMetaRow ? 'self-start' : 'self-center',
|
|
|
|
|
)}>
|
|
|
|
|
{currentSessionId && !isNewSessionDraftOpen && !isRenamingHeaderSession ? (
|
|
|
|
|
<DropdownMenu
|
|
|
|
|
open={isHeaderSessionMenuOpen}
|
|
|
|
@@ -2431,6 +2114,7 @@ export const Header: React.FC<HeaderProps> = ({
|
|
|
|
|
percentIconClassName="h-4.5 w-4.5"
|
|
|
|
|
/>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<HeaderIconActionButton
|
|
|
|
|
visible={showMiniChatHeaderAction}
|
|
|
|
|
title={isNewSessionDraftOpen ? t('header.actions.newMiniChat') : t('header.actions.openSessionMiniChat')}
|
|
|
|
@@ -2439,6 +2123,40 @@ export const Header: React.FC<HeaderProps> = ({
|
|
|
|
|
className={cn(desktopHeaderIconButtonClass, 'mr-1')}
|
|
|
|
|
Icon={'picture-in-picture-2'}
|
|
|
|
|
/>
|
|
|
|
|
{activeMainTab === 'chat' && !isVSCode ? (
|
|
|
|
|
<Tooltip>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
data-work-status-toggle="true"
|
|
|
|
|
aria-pressed={workStatusToggleActive}
|
|
|
|
|
aria-label={t('header.workStatusPanel.toggleAria')}
|
|
|
|
|
onClick={handleWorkStatusToggle}
|
|
|
|
|
className={cn(
|
|
|
|
|
DESKTOP_HEADER_ICON_BUTTON_CLASS,
|
|
|
|
|
// Trailing gap before the sidebar actions; it moved here
|
|
|
|
|
// with the button when this took the last position.
|
|
|
|
|
'mr-1',
|
|
|
|
|
// On is the resting state and carries no chrome; off is the
|
|
|
|
|
// one worth signalling, so it dims instead of filling.
|
|
|
|
|
workStatusToggleActive ? 'text-foreground' : 'text-muted-foreground/50',
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<Icon name="list-indefinite" className="h-[18px] w-[18px]" />
|
|
|
|
|
</button>
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent side="bottom">
|
|
|
|
|
{workStatusPanelEnabled && !workStatusPanelFits
|
|
|
|
|
? (workStatusOverlayOpen
|
|
|
|
|
? t('header.workStatusPanel.hide')
|
|
|
|
|
: t('header.workStatusPanel.showOverlay'))
|
|
|
|
|
: workStatusPanelEnabled
|
|
|
|
|
? t('header.workStatusPanel.hide')
|
|
|
|
|
: t('header.workStatusPanel.show')}
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{desktopSidebarActions}
|
|
|
|
|
<WindowsWindowControls visible={usesFramelessChrome && windowControlsSide === 'right'} position="right" />
|
|
|
|
|
</div>
|
|
|
|
|