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:
Bohdan Triapitsyn
2026-08-09 19:30:25 +03:00
parent 493a618efc
commit f4743ea060
69 changed files with 5777 additions and 894 deletions
@@ -120,7 +120,14 @@ const ContextPanelRailItem: React.FC<RailItemProps> = ({
) : displayBadgeCount ? (
<span
aria-hidden="true"
className="absolute right-0 top-0 flex h-4 min-w-4 items-center justify-center rounded-full bg-surface-muted px-1 text-[0.625rem] font-medium leading-none text-muted-foreground"
// Muted digits on the muted surface sat at almost the same
// luminance as the glyph they overlap. The count is a live
// signal, so it takes the info tone on its own opaque chip.
className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[0.625rem] font-semibold leading-none"
style={{
backgroundColor: 'var(--status-info-background)',
color: 'var(--status-info)',
}}
>
{displayBadgeCount}
</span>
@@ -152,6 +159,7 @@ export const ContextPanelRail: React.FC = () => {
const directoryKey = effectiveDirectory ? normalizeContextPanelDirectoryKey(effectiveDirectory) : '';
const panelState = useUIStore((state) => (directoryKey ? state.contextPanelByDirectory[directoryKey] : undefined));
const workStatusPanelVisible = useUIStore((state) => state.workStatusPanelVisible);
const contextRailOrder = useUIStore((state) => state.contextRailOrder);
const setContextRailOrder = useUIStore((state) => state.setContextRailOrder);
const openContextSurface = useUIStore((state) => state.openContextSurface);
@@ -286,7 +294,9 @@ export const ContextPanelRail: React.FC = () => {
const label = t(surface.labelKey);
// Git shows a numeric badge instead of the old activity dot.
// Other surfaces never inherit git's changed-files signal.
const gitChangedCount = surface.id === 'git' ? changedFilesCount : 0;
// The work-status panel reports the same count in words a few
// pixels away; two live counts for one fact is one too many.
const gitChangedCount = surface.id === 'git' && !workStatusPanelVisible ? changedFilesCount : 0;
const badgeCount = gitChangedCount > 0 ? gitChangedCount : null;
return (
<ContextPanelRailItem
+116 -398
View File
@@ -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>
@@ -437,7 +437,11 @@ export const MainLayout: React.FC = () => {
<div className="relative flex flex-1 min-h-0 overflow-hidden bg-background" data-page-scroll-lock="true">
<div className="relative flex flex-1 min-w-0 flex-col overflow-hidden border-t border-border bg-background" data-page-scroll-lock="true">
<div className="flex flex-1 min-h-0 overflow-hidden" data-page-scroll-lock="true">
<div className="relative flex flex-1 min-h-0 min-w-0 overflow-hidden" data-page-scroll-lock="true">
{/* Holds the chat and the context panel together, so its
width does not move when the context panel opens. The
work-status panel measures this rather than the chat,
which the context panel animates. */}
<div className="relative flex flex-1 min-h-0 min-w-0 overflow-hidden" data-page-scroll-lock="true" data-chat-area="true">
<main className="flex-1 overflow-hidden bg-background relative" data-page-scroll-lock="true">
<div className={cn('absolute inset-0', (!isChatActive || isSurfacePageOpen) && 'invisible')}>
<ErrorBoundary><ChatView active={isChatActive && !isSettingsDialogOpen && !isSurfacePageOpen} /></ErrorBoundary>
@@ -128,7 +128,6 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children, cl
className={cn(
'relative flex h-full overflow-hidden border-r border-border will-change-[width] motion-reduce:transition-none',
'bg-sidebar oc-vibrancy-surface',
isOpen && 'shadow-[inset_-2px_0_10px_-2px_rgb(0_0_0_/_0.06)]',
!isOpen && 'border-r-0',
className,
)}
@@ -144,6 +143,12 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children, cl
}}
aria-hidden={!isOpen || appliedWidth === 0}
>
{isOpen && (
<div
className="pointer-events-none absolute inset-0 z-30 shadow-[inset_-2px_0_10px_-2px_rgb(0_0_0_/_0.06)]"
aria-hidden="true"
/>
)}
{isOpen && (
<div
className={cn(