feat(chat): work-status panel, and MCP auth and settings fixes (#2776)
Adds a work-status panel beside the transcript. Context fill, model and cost, todos, running subagents and the permission requests blocking them, branch and working-tree state, MCP servers, pinned messages and context sources were scattered across the header, the composer and the context panel — a blocked subagent was reported nowhere at all. The panel reads them from live channels rather than persisted history, and becomes an overlay where the chat is too narrow to seat a column. It is on by default, including for existing installs. Because it now carries these readouts, the desktop header and composer drop the ones it duplicates: todo and changed-files chips, usage and MCP tabs. VS Code and mobile keep theirs — neither hosts the panel. Fixes MCP authorization, which was broken from the panel, invalidated by a directory switch through a redirect URI that encoded the working directory, and left the desktop app in the background because browsers will not follow a custom-protocol link without a user gesture. The settings page no longer asks the user to understand the MCP spec before adding a server: one field takes the command or the link, with the kind inferred and a visible override, and client-registration fields appear only when a server actually asks for its own credentials. Also: skills load from the panel instead of only when the composer's slash autocomplete opens; the header button names the current instance rather than falling through to the word "Instance" for relay hosts. Three new optional UI settings keys, all migrated. No change to stored MCP server configuration.
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user