Files
openchamber/packages/ui/src/components/layout/Header.tsx
T
Bohdan Triapitsyn c47c7190f4 feat(header): session tab tooltips, status dots, cursor context menu
Right-click now opens the session menu under the cursor (the sidebar's
context-menu pattern; the same header-supplied items back both the
"..." dropdown and the context menu via injected menu primitives) and
still never changes the active tab. Hovering a tab shows the sidebar's
session tooltip — title, last-activity time, project, branch and PR
status — after a delay, suppressed while a menu is open or a drag is in
flight. Each tab carries the sidebar's status dot at its end (accent
while the session runs, info-blue for unread), hidden while the hover
controls overlay it. Tab titles fade out instead of ending in "...",
and while the active tab is renaming its hover controls stay hidden so
only the rename controls show.
2026-08-24 20:02:59 +03:00

1988 lines
82 KiB
TypeScript

import React, { useEffect } from 'react';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useUIStore, type ContextPanelMode } from '@/stores/useUIStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessionWorktreeStore } from '@/sync/session-worktree-store';
import { formatSessionWorktreeBadge } from '@/sync/session-worktree-contract';
import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSessionMessagesResolved } from '@/sync/sync-context';
import { useDirectoryStore as useAppDirectoryStore } from '@/stores/useDirectoryStore';
import { isChatDirectoryForHome } from '@/lib/chatDirectories';
import { useSync } from '@/sync/use-sync';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
import { useGitBranchLabel } from '@/stores/useGitStore';
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { streamPerfCount } from '@/stores/utils/streamDebug';
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useDesktopWindowControlsLayout } from '@/hooks/useDesktopWindowControlsLayout';
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
import { WindowsWindowControls } from '@/components/desktop/WindowsWindowControls';
import { UpdateDialog } from '@/components/ui/UpdateDialog';
import { useDeviceInfo, useTabletStandalonePwaRuntime } from '@/lib/device';
import { cn } from '@/lib/utils';
import { eventMatchesShortcut, formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
import {
} from '@/lib/quota/model-families';
import {
} from '@/components/ui/collapsible';
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 { SessionSwitcherDropdown } from '@/components/session/SessionSwitcherDropdown';
import { SessionTabsStrip, type SessionTabMenuArgs } from './SessionTabsStrip';
import { canUseElectronDesktopIPC, invokeDesktop, isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, startDesktopWindowDrag, type UpdateInfo } from '@/lib/desktop';
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, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
import { useShallow } from 'zustand/react/shallow';
import type { IconName } from "@/components/icon/icons";
import { toast } from '@/components/ui';
import { copyTextToClipboard } from '@/lib/clipboard';
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { startSessionTreeWorktreeMove, useIsSessionWorktreeMovePending } from '@/lib/worktrees/sessionWorktreeMove';
const DESKTOP_HEADER_ICON_BUTTON_CLASS = 'app-region-no-drag inline-flex h-8 w-8 items-center justify-center gap-2 rounded-md typography-ui-label font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:bg-interactive-hover transition-colors';
type HeaderIconActionButtonProps = {
visible?: boolean;
title: string;
ariaLabel: string;
onClick: React.MouseEventHandler<HTMLButtonElement>;
className?: string;
Icon: IconName;
iconClassName?: string;
pressed?: boolean;
};
const HeaderIconActionButton = React.memo(function HeaderIconActionButton({
visible = true,
title,
ariaLabel,
onClick,
className,
Icon: iconName,
iconClassName,
pressed = false,
}: HeaderIconActionButtonProps) {
if (!visible) {
return null;
}
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onClick}
aria-label={ariaLabel}
aria-pressed={pressed}
className={cn(
className ?? DESKTOP_HEADER_ICON_BUTTON_CLASS,
pressed && 'bg-interactive-selection text-interactive-selection-foreground'
)}
>
<Icon name={iconName} className={iconClassName ?? 'h-[18px] w-[18px]'} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>{title}</p>
</TooltipContent>
</Tooltip>
);
});
type DesktopGitHubControlProps = {
isMobile: boolean;
githubAuthStatus: GitHubAuthStatus | null;
githubAccounts: Array<NonNullable<GitHubAuthStatus['accounts']>[number]>;
githubAvatarUrl: string | null;
githubLogin: string | null;
isSwitchingGitHubAccount: boolean;
handleGitHubAccountSwitch: (accountId: string) => Promise<void>;
};
const DesktopGitHubControl = React.memo(function DesktopGitHubControl({
isMobile,
githubAuthStatus,
githubAccounts,
githubAvatarUrl,
githubLogin,
isSwitchingGitHubAccount,
handleGitHubAccountSwitch,
}: DesktopGitHubControlProps) {
const { t } = useI18n();
if (!githubAuthStatus?.connected || isMobile) {
return null;
}
if (githubAccounts.length > 1) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
DESKTOP_HEADER_ICON_BUTTON_CLASS,
'h-7 w-7 overflow-hidden rounded-full border border-border/60 bg-muted/80 p-0'
)}
title={githubLogin ? t('header.github.connectedWithLogin', { login: githubLogin }) : t('header.github.connected')}
disabled={isSwitchingGitHubAccount}
>
{githubAvatarUrl ? (
<img
src={githubAvatarUrl}
alt={githubLogin ? t('header.github.avatarWithLogin', { login: githubLogin }) : t('header.github.avatar')}
className="h-full w-full object-cover"
loading="lazy"
referrerPolicy="no-referrer"
/>
) : (
<Icon name="github-fill" className="h-3.5 w-3.5 text-foreground" />
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel className="typography-ui-header font-semibold text-foreground">
{t('header.github.accountsTitle')}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{githubAccounts.map((account) => {
const accountUser = account.user;
const isCurrent = Boolean(account.current);
const sourceLabel = account.source === 'gh-cli'
? t('header.github.accountSource.cli')
: t('header.github.accountSource.oauth');
return (
<DropdownMenuItem
key={account.id}
className="gap-2"
disabled={isSwitchingGitHubAccount}
onSelect={() => {
if (!isCurrent) {
void handleGitHubAccountSwitch(account.id);
}
}}
>
{accountUser?.avatarUrl ? (
<img
src={accountUser.avatarUrl}
alt={accountUser.login ? t('header.github.avatarWithLogin', { login: accountUser.login }) : t('header.github.avatar')}
className="h-6 w-6 rounded-full border border-border/60 bg-muted object-cover"
loading="lazy"
referrerPolicy="no-referrer"
/>
) : (
<div className="flex h-6 w-6 items-center justify-center rounded-full border border-border/60 bg-muted">
<Icon name="github-fill" className="h-3 w-3 text-muted-foreground" />
</div>
)}
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate typography-ui-label text-foreground">
{accountUser?.name?.trim() || accountUser?.login || 'GitHub'}
</span>
{accountUser?.login ? (
<span className="truncate typography-micro text-muted-foreground">
<span className="font-mono">{accountUser.login}</span>
<span className="mx-1 opacity-50">·</span>
<span>{sourceLabel}</span>
</span>
) : null}
</span>
{isCurrent ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
return (
<div
className="app-region-no-drag flex h-7 w-7 items-center justify-center overflow-hidden rounded-full border border-border/60 bg-muted/80"
title={githubLogin ? t('header.github.connectedWithLogin', { login: githubLogin }) : t('header.github.connected')}
>
{githubAvatarUrl ? (
<img
src={githubAvatarUrl}
alt={githubLogin ? t('header.github.avatarWithLogin', { login: githubLogin }) : t('header.github.avatar')}
className="h-full w-full object-cover"
loading="lazy"
referrerPolicy="no-referrer"
/>
) : (
<Icon name="github-fill" className="h-3.5 w-3.5 text-foreground" />
)}
</div>
);
});
type DesktopServicesMenuProps = {
isDesktopApp: boolean;
currentInstanceLabel: string;
compactCurrentInstanceLabel: string;
currentInstanceIsLocal: boolean;
isDesktopServicesOpen: boolean;
setIsDesktopServicesOpen: React.Dispatch<React.SetStateAction<boolean>>;
refreshCurrentInstanceLabel: () => Promise<void>;
shortcutLabel: (actionId: string) => string;
remoteUpdateInfo: UpdateInfo | null;
remoteUpdateChecking: boolean;
remoteUpdateError: string | null;
onOpenRemoteUpdate: () => void;
};
const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
isDesktopApp,
currentInstanceLabel,
compactCurrentInstanceLabel,
currentInstanceIsLocal,
isDesktopServicesOpen,
setIsDesktopServicesOpen,
refreshCurrentInstanceLabel,
shortcutLabel,
remoteUpdateInfo,
remoteUpdateChecking,
remoteUpdateError,
onOpenRemoteUpdate,
}: DesktopServicesMenuProps) {
const { t } = useI18n();
return (
<DropdownMenu
open={isDesktopServicesOpen}
onOpenChange={(open) => {
setIsDesktopServicesOpen(open);
if (open) {
void refreshCurrentInstanceLabel();
}
}}
>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={isDesktopApp
? t('header.services.openWithCurrent', { current: currentInstanceLabel })
: t('header.services.open')}
className={cn(
DESKTOP_HEADER_ICON_BUTTON_CLASS,
isDesktopApp ? 'w-auto max-w-[14rem] justify-start gap-1.5 px-2.5' : 'h-8 w-8'
)}
>
<Icon name="server" className="h-[18px] w-[18px]" />
{isDesktopApp ? (
<span className="truncate typography-ui-label font-medium text-foreground">{compactCurrentInstanceLabel}</span>
) : null}
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>
<p>
{t('header.services.tooltip.currentInstance', {
current: currentInstanceLabel,
toggle: shortcutLabel('toggle_services_menu'),
})}
</p>
</TooltipContent>
</Tooltip>
<DropdownMenuContent
align="end"
className="w-[min(27rem,calc(100vw-2rem))] max-h-[75vh] overflow-y-auto p-0"
>
{isDesktopApp ? (
<div>
{!currentInstanceIsLocal ? (
<div className="border-b border-[var(--interactive-border)] px-4 py-2.5">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="typography-ui-label font-medium text-foreground">{t('header.services.remoteUpdate.title')}</div>
<div className="typography-micro text-muted-foreground">
{remoteUpdateInfo?.available
? t('header.services.remoteUpdate.available', { version: remoteUpdateInfo.version || '' })
: remoteUpdateChecking
? t('header.services.remoteUpdate.checking')
: remoteUpdateError || t('header.services.remoteUpdate.upToDate')}
</div>
</div>
{remoteUpdateInfo?.available ? (
<button
type="button"
className="shrink-0 rounded-md bg-[var(--primary-base)] px-3 py-1.5 typography-ui-label font-medium text-[var(--primary-foreground)] hover:opacity-90"
onClick={onOpenRemoteUpdate}
>
{t('header.services.remoteUpdate.actions.open')}
</button>
) : null}
</div>
</div>
) : null}
<DesktopHostSwitcherDialog
embedded
open={isDesktopServicesOpen}
onOpenChange={() => {}}
onHostSwitched={() => setIsDesktopServicesOpen(false)}
/>
</div>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
});
const isSameContextUsage = (
a: SessionContextUsage | null,
b: SessionContextUsage | null,
): boolean => {
if (a === b) return true;
if (!a || !b) return false;
return a.totalTokens === b.totalTokens
&& a.percentage === b.percentage
&& a.contextLimit === b.contextLimit
&& (a.outputLimit ?? 0) === (b.outputLimit ?? 0)
&& (a.normalizedOutput ?? 0) === (b.normalizedOutput ?? 0)
&& a.thresholdLimit === b.thresholdLimit
&& (a.lastMessageId ?? '') === (b.lastMessageId ?? '');
};
const formatCompactHeaderLabel = (value: string): string => {
const trimmed = value.trim();
if (!trimmed) {
return '';
}
const words = trimmed.split(/\s+/).filter(Boolean);
if (words.length >= 2) {
const first = words[0];
const second = words[1].slice(0, 3);
const shortTwoWord = `${first} ${second}`.trim();
if (words.length > 2 || shortTwoWord.length < trimmed.length) {
return `${shortTwoWord}...`;
}
return shortTwoWord;
}
return trimmed.length > 12 ? `${trimmed.slice(0, 9).trimEnd()}...` : trimmed;
};
const normalize = (value: string): string => {
if (!value) return '';
const replaced = value.replace(/\\/g, '/');
return replaced === '/' ? '/' : replaced.replace(/\/+$/, '');
};
const getActiveContextMode = (panelState: {
isOpen: boolean;
activeTabId: string | null;
tabs: Array<{ id: string; mode: ContextPanelMode }>;
} | undefined): ContextPanelMode | null => {
if (!panelState?.isOpen || !Array.isArray(panelState.tabs) || panelState.tabs.length === 0) {
return null;
}
const activeTab = panelState.tabs.find((tab) => tab.id === panelState.activeTabId) ?? panelState.tabs[panelState.tabs.length - 1];
return activeTab?.mode ?? null;
};
type HeaderSessionSnapshot = {
title: string | null;
directory: string | null;
created: number | null;
slug: string | null;
shareUrl: string | null;
parentId: string | null;
};
export const Header: React.FC = () => {
streamPerfCount('ui.header.render');
const { t } = useI18n();
const isSidebarOpen = useUIStore((state) => state.isSidebarOpen);
const openContextOverview = useUIStore((state) => state.openContextOverview);
const openContextPlan = useUIStore((state) => state.openContextPlan);
const closeContextPanel = useUIStore((state) => state.closeContextPanel);
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
const getCurrentModel = useConfigStore((state) => state.getCurrentModel);
const runtimeApis = useRuntimeAPIs();
const getContextUsage = useSessionUIStore((state) => state.getContextUsage);
const isNewSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const currentSessionMessagesResolved = useSessionMessagesResolved(currentSessionId ?? '');
const currentSessionStatus = useGlobalSessionStatus(currentSessionId ?? '');
const isCurrentSessionMovingToWorktree = useIsSessionWorktreeMovePending(currentSessionId ?? '');
const currentGlobalSession = useGlobalSessionsStore(useShallow(React.useCallback(
(state): HeaderSessionSnapshot | null => {
if (!currentSessionId) return null;
const session = [...state.activeSessions, ...state.archivedSessions]
.find((candidate) => candidate.id === currentSessionId);
if (!session) return null;
const record = session as typeof session & { directory?: string | null; slug?: string | null };
return {
title: session.title ?? null,
directory: record.directory ?? null,
created: session.time?.created ?? null,
slug: record.slug ?? null,
shareUrl: session.share?.url ?? null,
parentId: session.parentID ?? null,
};
},
[currentSessionId],
)));
const activeProject = useProjectsStore(useShallow((state) => {
if (!state.activeProjectId) {
return null;
}
const project = state.projects.find((candidate) => candidate.id === state.activeProjectId);
return project ? { id: project.id, path: project.path, label: project.label } : null;
}));
const activeProjectLabel = React.useMemo(() => {
if (!activeProject) {
return null;
}
const trimmedLabel = activeProject.label?.trim();
if (trimmedLabel) {
return trimmedLabel;
}
const pathSegments = activeProject.path.split(/[\\/]/).filter(Boolean);
return pathSegments[pathSegments.length - 1] ?? null;
}, [activeProject]);
const quotaResults = useQuotaStore((state) => state.results);
const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas);
const loadQuotaSettings = useQuotaStore((state) => state.loadSettings);
const { isMobile } = useDeviceInfo();
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const setGitHubAuthStatus = useGitHubAuthStore((state) => state.setStatus);
const headerRef = React.useRef<HTMLElement | null>(null);
const [isDesktopApp, setIsDesktopApp] = React.useState<boolean>(() => {
if (typeof window === 'undefined') {
return false;
}
return isDesktopShell();
});
const hasElectronDesktopIPC = React.useMemo(() => canUseElectronDesktopIPC(), []);
const isTabletStandalonePwa = useTabletStandalonePwaRuntime();
const [isDesktopWindowFullscreen, setIsDesktopWindowFullscreen] = React.useState(false);
const isMacPlatform = React.useMemo(() => {
if (typeof navigator === 'undefined') {
return false;
}
return /Macintosh|Mac OS X/.test(navigator.userAgent || '');
}, []);
const { usesFramelessChrome, side: windowControlsSide } = useDesktopWindowControlsLayout();
const macosMajorVersion = React.useMemo(() => {
if (typeof window === 'undefined') {
return null;
}
const injected = (window as unknown as { __OPENCHAMBER_MACOS_MAJOR__?: unknown }).__OPENCHAMBER_MACOS_MAJOR__;
if (typeof injected === 'number' && Number.isFinite(injected) && injected > 0) {
return injected;
}
// Fallback: WebKit reports "Mac OS X 10_15_7" format where 10 is legacy prefix
if (typeof navigator === 'undefined') {
return null;
}
const match = (navigator.userAgent || '').match(/Mac OS X (\d+)[._](\d+)/);
if (!match) {
return null;
}
const first = Number.parseInt(match[1], 10);
const second = Number.parseInt(match[2], 10);
if (Number.isNaN(first)) {
return null;
}
return first === 10 ? second : first;
}, []);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
setIsDesktopApp(isDesktopShell());
}, []);
const currentModel = getCurrentModel();
const limit = currentModel && typeof currentModel.limit === 'object' && currentModel.limit !== null
? (currentModel.limit as Record<string, unknown>)
: null;
const contextLimit = (limit && typeof limit.context === 'number' ? limit.context : 0);
const outputLimit = (limit && typeof limit.output === 'number' ? limit.output : 0);
const contextUsage = getContextUsage(contextLimit, outputLimit);
const [stableDesktopContextUsage, setStableDesktopContextUsage] = React.useState<SessionContextUsage | null>(null);
const isContextUsageResolvedForSession = !currentSessionId || currentSessionMessagesResolved;
useEffect(() => {
if (!currentSessionId) {
setStableDesktopContextUsage((prev) => (prev === null ? prev : null));
return;
}
if (contextUsage && contextUsage.totalTokens > 0) {
setStableDesktopContextUsage((prev) => (isSameContextUsage(prev, contextUsage) ? prev : contextUsage));
return;
}
if (isContextUsageResolvedForSession) {
setStableDesktopContextUsage((prev) => (prev === null ? prev : null));
}
}, [contextUsage, currentSessionId, isContextUsageResolvedForSession]);
const githubAvatarUrl = githubAuthStatus?.connected ? (githubAuthStatus.user?.avatarUrl ?? null) : null;
const githubLogin = githubAuthStatus?.connected ? (githubAuthStatus.user?.login ?? null) : null;
const githubAccounts = githubAuthStatus?.accounts ?? [];
const [isSwitchingGitHubAccount, setIsSwitchingGitHubAccount] = React.useState(false);
const [isDesktopServicesOpen, setIsDesktopServicesOpen] = React.useState(false);
const [currentInstanceLabel, setCurrentInstanceLabel] = React.useState('Local');
const [currentInstanceIsLocal, setCurrentInstanceIsLocal] = React.useState(true);
const [remoteUpdateDialogOpen, setRemoteUpdateDialogOpen] = React.useState(false);
const [remoteUpdateInfo, setRemoteUpdateInfo] = React.useState<UpdateInfo | null>(null);
const [remoteUpdateChecking, setRemoteUpdateChecking] = React.useState(false);
const [remoteUpdateError, setRemoteUpdateError] = React.useState<string | null>(null);
const compactCurrentInstanceLabel = React.useMemo(() => formatCompactHeaderLabel(currentInstanceLabel), [currentInstanceLabel]);
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
// 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
&& !!stableDesktopContextUsage
&& stableDesktopContextUsage.totalTokens > 0;
const desktopHeaderDisplayPercentage = stableDesktopContextUsage && stableDesktopContextUsage.contextLimit > 0
? Math.min(999, (stableDesktopContextUsage.totalTokens / stableDesktopContextUsage.contextLimit) * 100)
: 0;
const refreshCurrentInstanceLabel = React.useCallback(async () => {
if (typeof window === 'undefined' || !isDesktopApp) {
return;
}
try {
if (isDesktopLocalOriginActive()) {
setCurrentInstanceLabel('Local');
setCurrentInstanceIsLocal(true);
return;
}
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 = getLocalDesktopOrigin();
const resolved = resolveCurrentDesktopHost([buildLocalDesktopHost(localOrigin), ...cfg.hosts]);
if (resolved.id === LOCAL_HOST_ID) {
setCurrentInstanceLabel('Local');
setCurrentInstanceIsLocal(true);
return;
}
setCurrentInstanceLabel(redactSensitiveUrl(resolved.label.trim() || 'Instance'));
} catch {
setCurrentInstanceLabel('Local');
setCurrentInstanceIsLocal(true);
}
}, [isDesktopApp]);
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 () => {
if (currentInstanceIsLocal) {
setRemoteUpdateInfo(null);
setRemoteUpdateError(null);
return;
}
setRemoteUpdateChecking(true);
setRemoteUpdateError(null);
try {
// Status-only poll: must not count as usage on the remote server's install id.
const params = new URLSearchParams({ appType: 'web', instanceMode: 'remote', reportUsage: 'false' });
const response = await runtimeFetch(`/api/openchamber/update-check?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error(`Server responded with ${response.status}`);
}
const data = await response.json();
setRemoteUpdateInfo({
available: data.available ?? false,
version: data.version,
currentVersion: data.currentVersion ?? 'unknown',
body: data.body,
nextSuggestedCheckInSec: typeof data.nextSuggestedCheckInSec === 'number' ? data.nextSuggestedCheckInSec : undefined,
packageManager: data.packageManager,
updateCommand: data.updateCommand,
});
} catch (error) {
setRemoteUpdateInfo(null);
setRemoteUpdateError(error instanceof Error ? error.message : t('header.services.remoteUpdate.error'));
} finally {
setRemoteUpdateChecking(false);
}
}, [currentInstanceIsLocal, t]);
React.useEffect(() => {
setRemoteUpdateInfo(null);
setRemoteUpdateError(null);
setRemoteUpdateDialogOpen(false);
}, [currentInstanceIsLocal, currentInstanceLabel]);
React.useEffect(() => {
if (!isDesktopApp || currentInstanceIsLocal) {
return;
}
const initialDelayMs = 3000;
const intervalMs = 60 * 60 * 1000;
let disposed = false;
let timer: number | null = null;
const schedule = (delayMs: number) => {
timer = window.setTimeout(() => {
if (disposed || (typeof document !== 'undefined' && document.visibilityState !== 'visible')) {
schedule(intervalMs);
return;
}
void checkRemoteInstanceUpdate().finally(() => {
if (!disposed) {
schedule(intervalMs);
}
});
}, delayMs);
};
schedule(initialDelayMs);
return () => {
disposed = true;
if (timer !== null) {
window.clearTimeout(timer);
}
};
}, [checkRemoteInstanceUpdate, currentInstanceIsLocal, currentInstanceLabel, isDesktopApp]);
const openRemoteInstanceUpdate = React.useCallback(() => {
if (remoteUpdateInfo?.available) {
setRemoteUpdateDialogOpen(true);
return;
}
void checkRemoteInstanceUpdate();
}, [checkRemoteInstanceUpdate, remoteUpdateInfo?.available]);
useQuotaAutoRefresh();
React.useEffect(() => {
void loadQuotaSettings();
}, [loadQuotaSettings]);
const currentSessionSnapshot = currentSessionId
? currentGlobalSession ?? null
: null;
const lastResolvedSessionRef = React.useRef<{
sessionId: string;
session: HeaderSessionSnapshot;
expiresAt: number;
} | null>(null);
const [sessionFallbackVersion, setSessionFallbackVersion] = React.useState(0);
React.useEffect(() => {
if (!currentSessionId) {
if (lastResolvedSessionRef.current) {
lastResolvedSessionRef.current = null;
setSessionFallbackVersion((value) => value + 1);
}
return;
}
if (currentSessionSnapshot) {
lastResolvedSessionRef.current = {
sessionId: currentSessionId,
session: currentSessionSnapshot,
expiresAt: Date.now() + 2000,
};
return;
}
const cached = lastResolvedSessionRef.current;
if (!cached || cached.sessionId !== currentSessionId) {
return;
}
const remainingMs = cached.expiresAt - Date.now();
if (remainingMs <= 0) {
lastResolvedSessionRef.current = null;
setSessionFallbackVersion((value) => value + 1);
return;
}
const timeoutId = window.setTimeout(() => {
if (lastResolvedSessionRef.current?.sessionId === currentSessionId) {
lastResolvedSessionRef.current = null;
}
setSessionFallbackVersion((value) => value + 1);
}, remainingMs);
return () => {
window.clearTimeout(timeoutId);
};
}, [currentSessionId, currentSessionSnapshot]);
void sessionFallbackVersion;
const currentSession = (() => {
if (currentSessionSnapshot) {
return currentSessionSnapshot;
}
if (!currentSessionId) {
return null;
}
const cached = lastResolvedSessionRef.current;
if (cached && cached.sessionId === currentSessionId && cached.expiresAt > Date.now()) {
return cached.session;
}
return null;
})();
const worktreePath = useSessionUIStore((state) => {
if (!currentSessionId) return '';
return state.worktreeMetadata.get(currentSessionId)?.path ?? '';
});
const currentSessionWorktreeBranch = useSessionUIStore((state) => {
if (!currentSessionId) return null;
return state.worktreeMetadata.get(currentSessionId)?.branch?.trim() ?? null;
});
// Authoritative session↔worktree attachment from session-worktree-store
const worktreeAttachment = useSessionWorktreeStore((state) =>
currentSessionId ? state.getAttachment(currentSessionId) : undefined
);
const worktreeBadge = React.useMemo(() => {
if (!worktreeAttachment) return null;
return formatSessionWorktreeBadge(worktreeAttachment, {
pending: t('gitView.empty.worktreeSetupInProgress'),
});
}, [t, worktreeAttachment]);
const worktreeBadgeKind = React.useMemo(() => {
if (!worktreeAttachment) return null;
if (worktreeAttachment.legacy) return 'legacy';
if (worktreeAttachment.degraded) return 'degraded';
if (worktreeAttachment.worktreeStatus === 'pending') return 'pending';
if (worktreeAttachment.worktreeStatus === 'missing') return 'missing';
if (worktreeAttachment.worktreeStatus === 'invalid') return 'invalid';
if (worktreeAttachment.attentionReason) return 'attention';
return null;
}, [worktreeAttachment]);
const worktreeDirectory = React.useMemo(() => {
return normalize(worktreePath || '');
}, [worktreePath]);
const sessionDirectory = React.useMemo(() => {
const raw = typeof currentSession?.directory === 'string' ? currentSession.directory : '';
return normalize(raw || '');
}, [currentSession?.directory]);
const draftDirectory = useSessionUIStore((state) => {
if (!state.newSessionDraft?.open) {
return '';
}
return normalize(state.newSessionDraft.bootstrapPendingDirectory ?? state.newSessionDraft.directoryOverride ?? '');
});
const draftTarget = useSessionUIStore((state) => state.newSessionDraft.target);
const draftProjectId = useSessionUIStore((state) => state.newSessionDraft.selectedProjectId);
const selectedSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
const homeDirectory = useAppDirectoryStore((state) => state.homeDirectory);
const openDirectory = React.useMemo(() => {
return worktreeDirectory || sessionDirectory || draftDirectory;
}, [draftDirectory, sessionDirectory, worktreeDirectory]);
const activeContextMode = useUIStore(React.useCallback((state) => {
const directory = normalize(openDirectory || '');
return directory ? getActiveContextMode(state.contextPanelByDirectory[directory]) : null;
}, [openDirectory]));
const catalogWorktreeBranch = useSessionUIStore((state) => {
const candidateDirectory = normalize(worktreeDirectory || sessionDirectory || '');
if (!candidateDirectory) {
return null;
}
for (const worktrees of state.availableWorktreesByProject.values()) {
const match = worktrees.find((worktree) => normalize(worktree.path) === candidateDirectory);
const branch = match?.branch?.trim();
if (branch) {
return branch;
}
}
return null;
});
const gitBranchForDirectory = useGitBranchLabel(openDirectory || null);
const currentBranchLabel = gitBranchForDirectory || currentSessionWorktreeBranch || catalogWorktreeBranch;
const isChatContext = isNewSessionDraftOpen
? draftTarget === 'chat'
: isChatDirectoryForHome(sessionDirectory || selectedSessionDirectory, homeDirectory);
// Whether the title carries a second line under it. Hoisted because the
// session menu's vertical alignment depends on the same answer.
const showHeaderMetaRow = !isChatContext && !workStatusPanelVisible
&& Boolean(activeProjectLabel || currentBranchLabel || (!isNewSessionDraftOpen && worktreeBadgeKind));
const currentSessionTitle = React.useMemo(() => {
if (!currentSessionId) {
return activeProjectLabel ?? 'OpenChamber';
}
const trimmedTitle = currentSession?.title?.trim();
return trimmedTitle && trimmedTitle.length > 0 ? trimmedTitle : 'Untitled Session';
}, [activeProjectLabel, currentSession?.title, currentSessionId]);
const headerDirectoryStore = useDirectoryStore(openDirectory || undefined, { bootstrap: false });
const sync = useSync();
const updateSessionTitle = useSessionUIStore((state) => state.updateSessionTitle);
const shareSession = useSessionUIStore((state) => state.shareSession);
const unshareSession = useSessionUIStore((state) => state.unshareSession);
const archiveSessions = useSessionUIStore((state) => state.archiveSessions);
const deleteSessions = useSessionUIStore((state) => state.deleteSessions);
const [isRenamingHeaderSession, setIsRenamingHeaderSession] = React.useState(false);
const [isHeaderSessionMenuOpen, setIsHeaderSessionMenuOpen] = React.useState(false);
const pendingHeaderRenameRef = React.useRef(false);
const [headerSessionTitleDraft, setHeaderSessionTitleDraft] = React.useState('');
const [pendingHeaderRetentionAction, setPendingHeaderRetentionAction] = React.useState<{ action: 'archive' | 'delete'; sessionId: string } | null>(null);
const headerRenameFormRef = React.useRef<HTMLFormElement | null>(null);
React.useEffect(() => {
pendingHeaderRenameRef.current = false;
setIsHeaderSessionMenuOpen(false);
setIsRenamingHeaderSession(false);
setHeaderSessionTitleDraft('');
setPendingHeaderRetentionAction(null);
}, [currentSessionId]);
const beginHeaderSessionRename = React.useCallback(() => {
if (!currentSessionId) return;
setHeaderSessionTitleDraft(currentSession?.title?.trim() || currentSessionTitle);
setIsRenamingHeaderSession(true);
}, [currentSession?.title, currentSessionId, currentSessionTitle]);
const saveHeaderSessionRename = React.useCallback(async () => {
if (!currentSessionId) return;
const title = headerSessionTitleDraft.trim();
if (title && title !== currentSession?.title?.trim()) {
await updateSessionTitle(currentSessionId, title);
}
setIsRenamingHeaderSession(false);
}, [currentSession?.title, currentSessionId, headerSessionTitleDraft, updateSessionTitle]);
React.useEffect(() => {
if (!isRenamingHeaderSession) return;
const handleDocumentMouseDown = (event: MouseEvent) => {
const target = event.target as Node | null;
if (!target || !headerRenameFormRef.current?.contains(target)) {
void saveHeaderSessionRename();
}
};
document.addEventListener('mousedown', handleDocumentMouseDown);
return () => document.removeEventListener('mousedown', handleDocumentMouseDown);
}, [isRenamingHeaderSession, saveHeaderSessionRename]);
const copySessionIdFor = React.useCallback((sessionId: string) => {
if (!sessionId) return;
void copyTextToClipboard(sessionId).then((result) => {
toast[result.ok ? 'success' : 'error'](t(result.ok
? 'sessions.sidebar.session.copyId.success'
: 'sessions.sidebar.session.copyId.error'));
}).catch(() => toast.error(t('sessions.sidebar.session.copyId.error')));
}, [t]);
const shareSessionFor = React.useCallback(async (sessionId: string) => {
if (!sessionId) return;
const result = await shareSession(sessionId);
if (result?.share?.url) {
const copied = await copyTextToClipboard(result.share.url);
toast[copied.ok ? 'success' : 'warning'](t('sessions.sidebar.session.share.successTitle'), {
description: t(copied.ok
? 'sessions.sidebar.session.share.successDescription'
: 'sessions.sidebar.session.share.copyUrlError'),
});
return;
}
toast.error(t('sessions.sidebar.session.share.error'));
}, [shareSession, t]);
const copySessionShareUrl = React.useCallback((shareUrl: string | null | undefined) => {
if (!shareUrl) return;
void copyTextToClipboard(shareUrl).then((result) => {
toast[result.ok ? 'success' : 'error'](t(result.ok
? 'sessions.sidebar.session.menu.copied'
: 'sessions.sidebar.session.share.copyUrlError'));
}).catch(() => toast.error(t('sessions.sidebar.session.share.copyUrlError')));
}, [t]);
const unshareSessionFor = React.useCallback(async (sessionId: string) => {
if (!sessionId) return;
const result = await unshareSession(sessionId);
toast[result ? 'success' : 'error'](t(result
? 'sessions.sidebar.session.unshare.success'
: 'sessions.sidebar.session.unshare.error'));
}, [t, unshareSession]);
const exportCurrentSession = React.useCallback(async () => {
if (!currentSessionId || !openDirectory) {
toast.error(t('sessions.sidebar.session.export.nothingToExport'));
return;
}
try {
await sync.loadCompleteHistory(currentSessionId, openDirectory);
} catch {
toast.error(t('sessions.sidebar.session.export.failedLoadHistory'));
return;
}
const records = buildSessionMessageRecordsSnapshot(headerDirectoryStore.getState(), currentSessionId).list;
if (records.length === 0) {
toast.error(t('sessions.sidebar.session.export.nothingToExport'));
return;
}
const markdown = formatSessionAsMarkdown(records, currentSession?.title ?? null);
const filename = buildExportFilename(currentSession?.title ?? null);
const savedPath = await saveAsMarkdownDesktop(markdown, filename);
if (!savedPath) downloadAsMarkdown(markdown, filename);
toast.success(t('sessions.sidebar.session.export.success'));
}, [currentSession?.title, currentSessionId, headerDirectoryStore, openDirectory, sync, t]);
const isCurrentSessionActive = currentSessionStatus?.type === 'busy' || currentSessionStatus?.type === 'retry';
const moveCurrentSessionToWorktree = React.useCallback(() => {
if (!currentSessionId || !sessionDirectory || isCurrentSessionActive || isCurrentSessionMovingToWorktree) return;
const sessions = useGlobalSessionsStore.getState().activeSessions;
const root = sessions.find((session) => session.id === currentSessionId);
if (!root) return;
const descendants: typeof sessions = [];
const pendingParentIds = [currentSessionId];
for (let index = 0; index < pendingParentIds.length; index += 1) {
const parentId = pendingParentIds[index];
for (const session of sessions) {
if (session.parentID !== parentId) continue;
descendants.push(session);
pendingParentIds.push(session.id);
}
}
startSessionTreeWorktreeMove({
root,
descendants,
sourceDirectory: sessionDirectory,
successMessage: t('sessions.sidebar.session.moveToWorktree.success'),
failureMessage: t('sessions.sidebar.session.moveToWorktree.failed'),
});
}, [currentSessionId, isCurrentSessionActive, isCurrentSessionMovingToWorktree, sessionDirectory, t]);
const confirmHeaderRetentionAction = React.useCallback(async () => {
if (!pendingHeaderRetentionAction) return;
const sessions = useGlobalSessionsStore.getState().activeSessions;
const ids = [pendingHeaderRetentionAction.sessionId];
for (let index = 0; index < ids.length; index += 1) {
const parentId = ids[index];
for (const session of sessions) {
if ((session as typeof session & { parentID?: string | null }).parentID === parentId && !ids.includes(session.id)) {
ids.push(session.id);
}
}
}
const action = pendingHeaderRetentionAction.action;
setPendingHeaderRetentionAction(null);
const result = action === 'archive' ? await archiveSessions(ids) : await deleteSessions(ids);
const failedIds = result.failedIds;
if (failedIds.length > 0) {
toast.error(t(action === 'archive'
? 'sessions.sidebar.session.archive.error'
: 'sessions.sidebar.session.delete.error'));
return;
}
toast.success(t(action === 'archive'
? 'sessions.sidebar.session.archive.success'
: 'sessions.sidebar.session.delete.success'));
}, [archiveSessions, deleteSessions, pendingHeaderRetentionAction, t]);
// Full-page surfaces (Scheduled, Archive, Worktrees, Multi-run) replace the
// chat area; while one is open the header shows the surface identity
// instead of the session switcher.
const isScheduledSurfaceOpen = useUIStore((state) => state.isScheduledTasksDialogOpen);
const isArchiveSurfaceOpen = useUIStore((state) => state.isArchivePageOpen);
const worktreesSurfaceProjectId = useUIStore((state) => state.worktreesPageProjectId);
const isMultiRunSurfaceOpen = useUIStore((state) => state.isMultiRunLauncherOpen);
const worktreesSurfaceProjectLabel = useProjectsStore((state) => {
if (!worktreesSurfaceProjectId) return null;
const project = state.projects.find((entry) => entry.id === worktreesSurfaceProjectId);
return project?.label?.trim() || project?.path?.split('/').pop() || null;
});
const activeSurfaceHeader = React.useMemo<{ title: string; subtitle: string | null } | null>(() => {
if (isScheduledSurfaceOpen) {
return { title: t('sessions.scheduledTasks.dialog.title'), subtitle: null };
}
if (isArchiveSurfaceOpen) {
return { title: t('sessions.archivePage.title'), subtitle: null };
}
if (worktreesSurfaceProjectId) {
return {
title: t('sessions.worktreesPage.title', { project: worktreesSurfaceProjectLabel ?? '' }),
subtitle: null,
};
}
if (isMultiRunSurfaceOpen) {
return { title: t('sessions.sidebar.header.actions.newMultiRun'), subtitle: null };
}
return null;
}, [isArchiveSurfaceOpen, isMultiRunSurfaceOpen, isScheduledSurfaceOpen, t, worktreesSurfaceProjectId, worktreesSurfaceProjectLabel]);
const actionDirectory = React.useMemo(() => {
return normalize(openDirectory || activeProject?.path || '');
}, [activeProject?.path, openDirectory]);
const activeProjectRef = React.useMemo(() => {
if (!activeProject) {
return null;
}
return { id: activeProject.id, path: activeProject.path };
}, [activeProject]);
const lastProjectActionsContextRef = React.useRef<{
projectRef: { id: string; path: string };
directory: string;
} | null>(null);
React.useEffect(() => {
if (!activeProjectRef || !actionDirectory) {
return;
}
lastProjectActionsContextRef.current = {
projectRef: activeProjectRef,
directory: actionDirectory,
};
}, [actionDirectory, activeProjectRef]);
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
const isSessionPlanAvailable = useSessionUIStore((state) => state.isSessionPlanAvailable);
const planTabAvailable = planModeEnabled && currentSessionId ? isSessionPlanAvailable(currentSessionId) : false;
const lastPlanSessionKeyRef = React.useRef<string>('');
// Reset plan tab availability when session changes
React.useEffect(() => {
if (!planModeEnabled) {
return;
}
if (!currentSessionId) return;
const sessionKey = `${currentSessionId || 'none'}:${sessionDirectory || 'none'}:${currentSession?.created || 0}:${currentSession?.slug || 'none'}`;
if (lastPlanSessionKeyRef.current !== sessionKey) {
lastPlanSessionKeyRef.current = sessionKey;
}
}, [
planModeEnabled,
planTabAvailable,
currentSession?.slug,
currentSession?.created,
currentSessionId,
sessionDirectory,
]);
const handleGitHubAccountSwitch = React.useCallback(async (accountId: string) => {
if (!accountId || isSwitchingGitHubAccount) return;
setIsSwitchingGitHubAccount(true);
try {
const payload = runtimeApis.github
? await runtimeApis.github.authActivate(accountId)
: await (async () => {
const response = await runtimeFetch('/api/github/auth/activate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ accountId }),
});
const body = (await response.json().catch(() => null)) as
| (GitHubAuthStatus & { error?: string })
| null;
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText);
}
return body;
})();
setGitHubAuthStatus(payload);
} catch (error) {
console.error('Failed to switch GitHub account:', error);
} finally {
setIsSwitchingGitHubAccount(false);
}
}, [isSwitchingGitHubAccount, runtimeApis.github, setGitHubAuthStatus]);
const handleOpenDraftMiniChat = React.useCallback(() => {
void invokeDesktop('desktop_open_draft_mini_chat_window', {
directory: isChatContext ? '' : draftDirectory,
projectId: isChatContext ? null : draftProjectId,
apiBaseUrl: getRuntimeApiBaseUrl(),
clientToken: getRuntimeBearerTokenSync(),
}).catch((error) => {
console.warn('[header] failed to open draft mini chat window', error);
});
}, [draftDirectory, draftProjectId, isChatContext]);
const handleOpenCurrentMiniChat = React.useCallback(() => {
if (isNewSessionDraftOpen) {
handleOpenDraftMiniChat();
return;
}
if (!currentSessionId) {
return;
}
void invokeDesktop('desktop_open_session_mini_chat_window', {
sessionId: currentSessionId,
directory: sessionDirectory || normalize(selectedSessionDirectory || '') || worktreeDirectory,
apiBaseUrl: getRuntimeApiBaseUrl(),
clientToken: getRuntimeBearerTokenSync(),
}).catch((error) => {
console.warn('[header] failed to open session mini chat window', error);
});
}, [currentSessionId, handleOpenDraftMiniChat, isNewSessionDraftOpen, selectedSessionDirectory, sessionDirectory, worktreeDirectory]);
const handleOpenContextPanel = React.useCallback(() => {
const directory = normalize(openDirectory || '');
if (!directory) {
return;
}
const panelState = useUIStore.getState().contextPanelByDirectory[directory];
if (getActiveContextMode(panelState) === 'context') {
closeContextPanel(directory);
return;
}
openContextOverview(directory);
}, [closeContextPanel, openContextOverview, openDirectory]);
const isContextPanelActive = activeContextMode === 'context';
const handleOpenContextPlan = React.useCallback(() => {
const directory = normalize(openDirectory || '');
if (!directory) {
return;
}
const panelState = useUIStore.getState().contextPanelByDirectory[directory];
if (getActiveContextMode(panelState) === 'plan') {
closeContextPanel(directory);
return;
}
openContextPlan(directory);
}, [closeContextPanel, openContextPlan, openDirectory]);
const desktopHeaderIconButtonClass = DESKTOP_HEADER_ICON_BUTTON_CLASS;
// Left padding the header needs to clear the OS window controls (macOS
// traffic lights / window-controls-overlay). When the sidebar is open this
// space is owned by the sidebar's top strip instead, so the header drops back
// to its normal content padding. The full value is published as
// `--oc-titlebar-left-inset` so the sidebar strip can mirror it.
const titlebarLeftInset = React.useMemo(() => {
if (isDesktopApp && isMacPlatform && !isDesktopWindowFullscreen) {
return '5.5rem';
}
if (isTabletStandalonePwa) {
return 'max(calc(0.75rem + var(--oc-wco-left-inset, 0px)), 5.5rem)';
}
if ((!isDesktopApp || usesFramelessChrome) && !isVSCode) {
return 'calc(0.75rem + var(--oc-wco-left-inset, 0px))';
}
return '0.75rem';
}, [isDesktopApp, isDesktopWindowFullscreen, isMacPlatform, isTabletStandalonePwa, isVSCode, usesFramelessChrome]);
useEffect(() => {
if (typeof document === 'undefined') {
return;
}
document.documentElement.style.setProperty('--oc-titlebar-left-inset', titlebarLeftInset);
}, [titlebarLeftInset]);
// Space reserved on the header's left for the persistent overlay when the
// sidebar is collapsed (the overlay sits over the header then). Split into two
// spacers so the strip stays a window drag area while the buttons stay
// clickable: a drag region for the window-controls inset (traffic lights) and
// a no-drag carve under the control cluster. Both animate so the session title
// slides in/out in lockstep with the sidebar. When the sidebar is open the
// overlay is over the sidebar, so the header only keeps normal content padding.
const headerInsetSpacerWidth = isSidebarOpen ? '0.75rem' : 'var(--oc-titlebar-left-inset, 0.75rem)';
const headerControlsSpacerWidth = isSidebarOpen
? '0px'
: 'calc(var(--oc-titlebar-controls-width, 5.5rem) + 0.5rem)';
useEffect(() => {
if (!isDesktopApp || !isMacPlatform) {
setIsDesktopWindowFullscreen(false);
return;
}
let disposed = false;
const syncFullscreenState = async () => {
try {
const fullscreen = await invokeDesktop<boolean>('desktop_is_window_fullscreen');
if (!disposed) {
setIsDesktopWindowFullscreen(fullscreen === true);
}
} catch {
if (!disposed) {
setIsDesktopWindowFullscreen(false);
}
}
};
const onResize = () => {
void syncFullscreenState();
};
void syncFullscreenState();
window.addEventListener('openchamber:window-resized', onResize);
return () => {
disposed = true;
window.removeEventListener('openchamber:window-resized', onResize);
};
}, [isDesktopApp, isMacPlatform]);
const macosHeaderSizeClass = React.useMemo(() => {
if (!isDesktopApp || !isMacPlatform || macosMajorVersion === null) {
return '';
}
if (macosMajorVersion >= 26) {
return 'h-12';
}
if (macosMajorVersion <= 15) {
return 'h-14';
}
return '';
}, [isDesktopApp, isMacPlatform, macosMajorVersion]);
const webWindowControlsOverlayStyle = React.useMemo<React.CSSProperties | undefined>(() => {
if ((isDesktopApp && !usesFramelessChrome) || isVSCode) {
return undefined;
}
return {
// Left inset is handled by the no-drag spacer (see renderDesktop); only
// the right inset / titlebar height are owned by the window-controls overlay.
paddingRight: 'calc(0.75rem + var(--oc-wco-right-inset, 0px))',
minHeight: 'max(3rem, var(--oc-wco-titlebar-height, 0px))',
height: 'max(3rem, var(--oc-wco-titlebar-height, 0px))',
};
}, [isDesktopApp, isVSCode, usesFramelessChrome]);
const updateHeaderHeight = React.useCallback(() => {
if (typeof document === 'undefined') {
return;
}
const height = headerRef.current?.getBoundingClientRect().height;
if (height) {
document.documentElement.style.setProperty('--oc-header-height', `${height}px`);
}
}, []);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
updateHeaderHeight();
const node = headerRef.current;
if (!node || typeof ResizeObserver === 'undefined') {
return () => { };
}
let rafId = 0;
const scheduleUpdate = () => {
if (rafId) return;
rafId = requestAnimationFrame(() => {
rafId = 0;
updateHeaderHeight();
});
};
const observer = new ResizeObserver(scheduleUpdate);
observer.observe(node);
window.addEventListener('resize', scheduleUpdate);
window.addEventListener('orientationchange', scheduleUpdate);
return () => {
if (rafId) cancelAnimationFrame(rafId);
observer.disconnect();
window.removeEventListener('resize', scheduleUpdate);
window.removeEventListener('orientationchange', scheduleUpdate);
};
}, [updateHeaderHeight]);
useEffect(() => {
updateHeaderHeight();
}, [updateHeaderHeight, isMobile, macosHeaderSizeClass]);
const handleDragStart = React.useCallback(async (e: React.MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest('.app-region-no-drag')) {
return;
}
if (target.closest('button, a, input, select, textarea')) {
return;
}
if (e.button !== 0) {
return;
}
if (isDesktopApp) {
await startDesktopWindowDrag();
}
}, [isDesktopApp]);
const shortcutLabel = React.useCallback((actionId: string) => {
return formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides));
}, [shortcutOverrides]);
// 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" /> });
}
return base;
}, [isDesktopApp, t]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const toggleServicesCombo = getEffectiveShortcutCombo('toggle_services_menu', shortcutOverrides);
if (eventMatchesShortcut(e, toggleServicesCombo)) {
e.preventDefault();
if (isDesktopServicesOpen) {
setIsDesktopServicesOpen(false);
} else {
setIsDesktopServicesOpen(true);
void refreshCurrentInstanceLabel();
}
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();
if (servicesTabs.length === 0) return;
setIsDesktopServicesOpen(true);
void refreshCurrentInstanceLabel();
return;
}
const toggleContextPlanCombo = getEffectiveShortcutCombo('toggle_context_plan', shortcutOverrides);
if (eventMatchesShortcut(e, toggleContextPlanCombo)) {
e.preventDefault();
handleOpenContextPlan();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [
shortcutOverrides,
isDesktopServicesOpen,
servicesTabs,
quotaResults.length,
fetchAllQuotas,
refreshCurrentInstanceLabel,
handleOpenContextPlan,
]);
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}
compactCurrentInstanceLabel={compactCurrentInstanceLabel}
currentInstanceIsLocal={currentInstanceIsLocal}
isDesktopServicesOpen={isDesktopServicesOpen}
setIsDesktopServicesOpen={setIsDesktopServicesOpen}
refreshCurrentInstanceLabel={refreshCurrentInstanceLabel}
shortcutLabel={shortcutLabel}
remoteUpdateInfo={remoteUpdateInfo}
remoteUpdateChecking={remoteUpdateChecking}
remoteUpdateError={remoteUpdateError}
onOpenRemoteUpdate={openRemoteInstanceUpdate}
/>
) : null}
<DesktopGitHubControl
isMobile={isMobile}
githubAuthStatus={githubAuthStatus}
githubAccounts={githubAccounts}
githubAvatarUrl={githubAvatarUrl}
githubLogin={githubLogin}
isSwitchingGitHubAccount={isSwitchingGitHubAccount}
handleGitHubAccountSwitch={handleGitHubAccountSwitch}
/>
</>
);
const showMiniChatHeaderAction = hasElectronDesktopIPC && (isNewSessionDraftOpen || Boolean(currentSessionId));
const renderSessionTabMenu = React.useCallback(({ session, isActive, select, closeOtherTabs, components }: SessionTabMenuArgs) => {
const { Item, Separator } = components;
const shareUrl = session.share?.url ?? null;
const canMoveToWorktree = isActive && !isVSCode && !isChatContext && currentSession && !currentSession.parentId;
return (
<>
<Item onClick={() => { if (!isActive) select(); pendingHeaderRenameRef.current = true; }}>
<Icon name="pencil-ai" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.rename')}
</Item>
<Item onClick={() => copySessionIdFor(session.id)}>
<Icon name="file-copy" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.copyId')}
</Item>
<Separator />
{shareUrl ? (
<>
<Item onClick={() => copySessionShareUrl(shareUrl)}>
<Icon name="file-copy" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.copyLink')}
</Item>
<Item onClick={() => void unshareSessionFor(session.id)}>
<Icon name="link-unlink-m" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.unshare')}
</Item>
</>
) : (
<Item onClick={() => void shareSessionFor(session.id)}>
<Icon name="share-2" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.share')}
</Item>
)}
{isActive ? (
<Item onClick={() => void exportCurrentSession()}>
<Icon name="download" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.exportMarkdown')}
</Item>
) : null}
{canMoveToWorktree ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="block">
<Item
disabled={!sessionDirectory || isCurrentSessionActive || isCurrentSessionMovingToWorktree}
onClick={moveCurrentSessionToWorktree}
className="w-full"
>
<Icon name="folder-shared" className="mr-2 size-4" />
{t('sessions.sidebar.session.menu.moveToWorktree')}
</Item>
</span>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-72">
{isCurrentSessionMovingToWorktree
? t('sessions.sidebar.session.moveToWorktree.tooltipMoving')
: isCurrentSessionActive
? t('sessions.sidebar.session.moveToWorktree.tooltipBusy')
: t('sessions.sidebar.session.moveToWorktree.tooltip')}
</TooltipContent>
</Tooltip>
) : null}
<Separator />
<Item onClick={closeOtherTabs}>
<Icon name="close-circle" className="mr-2 size-4" />{t('header.sessionTabs.closeOtherTabs')}
</Item>
<Separator />
<Item onClick={() => setPendingHeaderRetentionAction({ action: 'archive', sessionId: session.id })}>
<Icon name="inbox-archive" className="mr-2 size-4" />{t('sessions.sidebar.bulkActions.archive')}
</Item>
<Item className="text-destructive focus:text-destructive" onClick={() => setPendingHeaderRetentionAction({ action: 'delete', sessionId: session.id })}>
<Icon name="delete-bin" className="mr-2 size-4" />{t('sessions.sidebar.bulkActions.delete')}
</Item>
</>
);
}, [copySessionIdFor, copySessionShareUrl, currentSession, exportCurrentSession, isChatContext, isCurrentSessionActive, isCurrentSessionMovingToWorktree, isVSCode, moveCurrentSessionToWorktree, sessionDirectory, shareSessionFor, t, unshareSessionFor]);
const renderDesktop = () => (
<div
onMouseDown={handleDragStart}
className={cn(
'app-region-drag relative flex h-12 select-none items-center',
usesFramelessChrome && windowControlsSide === 'right' ? 'pr-0' : 'pr-3',
macosHeaderSizeClass
)}
style={webWindowControlsOverlayStyle}
role="tablist"
aria-label={t('header.navigation.mainAria')}
>
{/* Drag region for the window-controls inset (traffic lights) to the left
of the overlay buttons — stays a window drag area. */}
<div
aria-hidden
className="shrink-0 self-stretch transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
style={{ width: headerInsetSpacerWidth }}
/>
{/* No-drag carve under the persistent TitlebarLeftControls overlay so its
buttons stay clickable. Width animates with the sidebar so the session
title slides in lockstep instead of snapping. */}
<div
aria-hidden
className="app-region-no-drag shrink-0 self-stretch transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
style={{ width: headerControlsSpacerWidth }}
/>
{/* Sidebar toggle + project actions live in the persistent
TitlebarLeftControls overlay; the spacers above reserve its footprint
while the sidebar is closed. */}
<div className="flex min-w-0 flex-1 items-center">
{activeSurfaceHeader ? (
<div className="mr-3 flex min-w-0 flex-col items-start px-1 py-0.5 -my-0.5 text-left">
<span className="truncate typography-ui-label text-[14px] font-normal leading-tight text-foreground max-w-full">
{activeSurfaceHeader.title}
</span>
{activeSurfaceHeader.subtitle ? (
<span className="truncate typography-micro text-[10.5px] font-normal leading-tight text-muted-foreground/75 max-w-full">
{activeSurfaceHeader.subtitle}
</span>
) : null}
</div>
) : isVSCode ? (
<div className="app-region-no-drag mr-3 flex min-w-0 max-w-full items-center gap-0.5 py-0.5 -my-0.5 text-left">
{!isSidebarOpen ? (
<SessionSwitcherDropdown align="start">
<button
type="button"
className={desktopHeaderIconButtonClass}
aria-label={t('sessions.switcher.openAria')}
>
<Icon name="history" className="h-[18px] w-[18px]" />
</button>
</SessionSwitcherDropdown>
) : null}
<div className="flex min-w-0 flex-col justify-center px-1">
{isRenamingHeaderSession ? (
<form
ref={headerRenameFormRef}
className="flex w-full min-w-0 items-center gap-2 leading-tight"
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
void saveHeaderSessionRename();
}}
>
<input
value={headerSessionTitleDraft}
onChange={(event) => setHeaderSessionTitleDraft(event.target.value)}
autoFocus
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === 'Escape') {
setIsRenamingHeaderSession(false);
}
}}
placeholder={t('sessions.sidebar.session.menu.rename')}
className="min-w-0 flex-1 bg-transparent typography-ui-label text-[14px] font-normal leading-tight outline-none placeholder:text-muted-foreground"
/>
<button
type="submit"
aria-label={t('sessions.sidebar.session.rename.save')}
title={t('sessions.sidebar.session.rename.save')}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<Icon name="check" className="size-4" />
</button>
<button
type="button"
onClick={() => setIsRenamingHeaderSession(false)}
aria-label={t('sessions.sidebar.session.rename.cancel')}
title={t('sessions.sidebar.session.rename.cancel')}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<Icon name="close" className="size-4" />
</button>
</form>
) : (
<span className="truncate typography-ui-label text-[14px] font-normal leading-tight text-foreground max-w-full">
{isNewSessionDraftOpen ? t('sessions.switcher.draftTitle') : currentSessionTitle}
</span>
)}
{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 ? (
<span className="inline-flex min-w-0 items-center gap-0.5">
<Icon name="git-branch" className="h-3 w-3 flex-shrink-0 text-muted-foreground/70" />
<span className="truncate">{currentBranchLabel}</span>
</span>
) : null}
{!isNewSessionDraftOpen && worktreeBadgeKind ? (
<span className={cn(
"inline-flex min-w-0 items-center gap-0.5",
worktreeBadgeKind === 'attention' || worktreeBadgeKind === 'invalid' || worktreeBadgeKind === 'missing' ? 'text-status-warning' : 'text-muted-foreground/60'
)}>
<Icon name="alert" className="h-3 w-3 flex-shrink-0" />
<span className="truncate">{worktreeBadge}</span>
</span>
) : null}
</span>
) : null}
</div>
<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}
onOpenChange={setIsHeaderSessionMenuOpen}
onOpenChangeComplete={(open) => {
if (!open && pendingHeaderRenameRef.current) {
pendingHeaderRenameRef.current = false;
beginHeaderSessionRename();
}
}}
>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="xs" className="h-[18px] w-6 px-0 text-muted-foreground hover:bg-transparent hover:text-foreground" aria-label={t('header.sessionActions.openAria')}>
<Icon name="more" className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[190px]">
<DropdownMenuItem onClick={() => { pendingHeaderRenameRef.current = true; }}><Icon name="pencil-ai" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.rename')}</DropdownMenuItem>
<DropdownMenuItem onClick={() => currentSessionId && copySessionIdFor(currentSessionId)}><Icon name="file-copy" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.copyId')}</DropdownMenuItem>
<DropdownMenuSeparator />
{currentSession?.shareUrl ? (
<>
<DropdownMenuItem onClick={() => copySessionShareUrl(currentSession?.shareUrl)}><Icon name="file-copy" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.copyLink')}</DropdownMenuItem>
<DropdownMenuItem onClick={() => { if (currentSessionId) void unshareSessionFor(currentSessionId); }}><Icon name="link-unlink-m" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.unshare')}</DropdownMenuItem>
</>
) : (
<DropdownMenuItem onClick={() => { if (currentSessionId) void shareSessionFor(currentSessionId); }}><Icon name="share-2" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.share')}</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => void exportCurrentSession()}><Icon name="download" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.exportMarkdown')}</DropdownMenuItem>
{!isVSCode && !isChatContext && currentSession && !currentSession.parentId ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="block">
<DropdownMenuItem
disabled={!sessionDirectory || isCurrentSessionActive || isCurrentSessionMovingToWorktree}
onClick={moveCurrentSessionToWorktree}
className="w-full"
>
<Icon name="folder-shared" className="mr-2 size-4" />
{t('sessions.sidebar.session.menu.moveToWorktree')}
</DropdownMenuItem>
</span>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-72">
{isCurrentSessionMovingToWorktree
? t('sessions.sidebar.session.moveToWorktree.tooltipMoving')
: isCurrentSessionActive
? t('sessions.sidebar.session.moveToWorktree.tooltipBusy')
: t('sessions.sidebar.session.moveToWorktree.tooltip')}
</TooltipContent>
</Tooltip>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => { if (currentSessionId) setPendingHeaderRetentionAction({ action: 'archive', sessionId: currentSessionId }); }}><Icon name="inbox-archive" className="mr-2 size-4" />{t('sessions.sidebar.bulkActions.archive')}</DropdownMenuItem>
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={() => { if (currentSessionId) setPendingHeaderRetentionAction({ action: 'delete', sessionId: currentSessionId }); }}><Icon name="delete-bin" className="mr-2 size-4" />{t('sessions.sidebar.bulkActions.delete')}</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</div>
) : (
<div className="app-region-no-drag flex h-full min-w-0 flex-1 items-center gap-0.5 text-left">
{!isSidebarOpen ? (
<SessionSwitcherDropdown align="start">
<button
type="button"
className={desktopHeaderIconButtonClass}
aria-label={t('sessions.switcher.openAria')}
>
<Icon name="history" className="h-[18px] w-[18px]" />
</button>
</SessionSwitcherDropdown>
) : null}
<SessionTabsStrip
renderMenu={renderSessionTabMenu}
suppressActiveTabControls={isRenamingHeaderSession}
onMenuOpenChangeComplete={(open) => {
if (!open && pendingHeaderRenameRef.current) {
pendingHeaderRenameRef.current = false;
beginHeaderSessionRename();
}
}}
>
<div className="flex min-w-0 flex-col justify-center">
{isRenamingHeaderSession ? (
<form
ref={headerRenameFormRef}
className="flex w-full min-w-0 items-center gap-2 leading-tight"
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
void saveHeaderSessionRename();
}}
>
<input
value={headerSessionTitleDraft}
onChange={(event) => setHeaderSessionTitleDraft(event.target.value)}
autoFocus
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === 'Escape') {
setIsRenamingHeaderSession(false);
}
}}
placeholder={t('sessions.sidebar.session.menu.rename')}
className="min-w-0 flex-1 bg-transparent text-[13px] font-medium leading-4 outline-none placeholder:text-muted-foreground"
/>
<button
type="submit"
aria-label={t('sessions.sidebar.session.rename.save')}
title={t('sessions.sidebar.session.rename.save')}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<Icon name="check" className="size-4" />
</button>
<button
type="button"
onClick={() => setIsRenamingHeaderSession(false)}
aria-label={t('sessions.sidebar.session.rename.cancel')}
title={t('sessions.sidebar.session.rename.cancel')}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<Icon name="close" className="size-4" />
</button>
</form>
) : (
<span className="truncate text-[13px] font-medium leading-4 text-foreground max-w-full">
{isNewSessionDraftOpen ? t('sessions.switcher.draftTitle') : currentSessionTitle}
</span>
)}
</div>
</SessionTabsStrip>
</div>
)}
{activeSurfaceHeader || isVSCode ? <div className="flex-1" /> : null}
<div className="flex shrink-0 items-center gap-1">
{showDesktopHeaderContextUsage && stableDesktopContextUsage ? (
<ContextUsageDisplay
totalTokens={stableDesktopContextUsage.totalTokens}
percentage={desktopHeaderDisplayPercentage}
colorPercentage={stableDesktopContextUsage.percentage}
contextLimit={stableDesktopContextUsage.contextLimit}
outputLimit={stableDesktopContextUsage.outputLimit ?? 0}
size="compact"
hideIcon
showPercentIcon
onClick={handleOpenContextPanel}
pressed={isContextPanelActive}
className={!showMiniChatHeaderAction ? 'mr-3.5' : ''}
valueClassName="typography-ui-label font-medium leading-none text-foreground"
percentIconClassName="h-4.5 w-4.5"
/>
) : null}
<HeaderIconActionButton
visible={showMiniChatHeaderAction}
title={isNewSessionDraftOpen ? t('header.actions.newMiniChat') : t('header.actions.openSessionMiniChat')}
ariaLabel={isNewSessionDraftOpen ? t('header.actions.newMiniChatAria') : t('header.actions.openSessionMiniChatAria')}
onClick={handleOpenCurrentMiniChat}
className={cn(desktopHeaderIconButtonClass, 'mr-1')}
Icon={'picture-in-picture-2'}
/>
{!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>
</div>
</div>
);
// The divider lives on the chat content wrapper instead of the header, so it
// doesn't run between the header and the right sidebar (they read as one
// continuous surface).
const headerClassName = 'header-safe-area relative z-10 bg-background';
return (
<>
<header
ref={headerRef}
className={headerClassName}
style={{ ['--padding-scale' as string]: '1' } as React.CSSProperties}
>
{renderDesktop()}
</header>
<Dialog open={pendingHeaderRetentionAction !== null} onOpenChange={(open) => { if (!open) setPendingHeaderRetentionAction(null); }}>
<DialogContent showCloseButton={false} className="max-w-sm gap-5">
<DialogHeader>
<DialogTitle>{pendingHeaderRetentionAction?.action === 'delete'
? t('sessions.sidebar.dialogs.deleteSession.title')
: t('sessions.sidebar.dialogs.archiveSession.title')}</DialogTitle>
<DialogDescription>{pendingHeaderRetentionAction?.action === 'delete'
? t('sessions.sidebar.dialogs.deleteSession.single', { sessionTitle: currentSessionTitle })
: t('sessions.sidebar.dialogs.archiveSession.single', { sessionTitle: currentSessionTitle })}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setPendingHeaderRetentionAction(null)}>
{t('sessions.sidebar.dialogs.cancel')}
</Button>
<Button variant="destructive" size="sm" onClick={() => void confirmHeaderRetentionAction()}>
{pendingHeaderRetentionAction?.action === 'delete'
? t('sessions.sidebar.bulkActions.delete')
: t('sessions.sidebar.bulkActions.archive')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<UpdateDialog
open={remoteUpdateDialogOpen}
onOpenChange={setRemoteUpdateDialogOpen}
info={remoteUpdateInfo}
downloading={false}
downloaded={false}
progress={null}
error={remoteUpdateError}
onDownload={() => {}}
onRestart={() => {}}
runtimeType="web"
/>
</>
);
};