fix: improve Mini Chat session controls

Removed duplicate Mini Chat launch action from the closed-sidebar header
Enabled opening Mini Chat from draft sessions via the header PiP action
Added read-only context usage info to existing Mini Chat sessions
This commit is contained in:
Bohdan Triapitsyn
2026-05-08 23:58:02 +03:00
parent 15f2179274
commit 3039645096
2 changed files with 131 additions and 30 deletions
+18 -29
View File
@@ -16,7 +16,7 @@ import {
} from '@/components/ui/dropdown-menu';
import { SortableTabsStrip, type SortableTabsStripItem } from '@/components/ui/sortable-tabs-strip';
import { RiArrowLeftSLine, RiChat4Line, RiChatNewLine, RiCheckLine, RiCloseLine, RiCommandLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGithubFill, RiLayoutLeftLine, RiLayoutRightLine, RiPictureInPicture2Line, RiPlayListAddLine, RiRefreshLine, RiServerLine, RiStackLine, RiTerminalBoxLine, RiTimerLine, RiAlertLine, RiWindowLine, type RemixiconComponentType } from '@remixicon/react';
import { RiArrowLeftSLine, RiChat4Line, RiChatNewLine, RiCheckLine, RiCloseLine, RiCommandLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGithubFill, RiLayoutLeftLine, RiLayoutRightLine, RiPictureInPicture2Line, RiPlayListAddLine, RiRefreshLine, RiServerLine, RiStackLine, RiTerminalBoxLine, RiTimerLine, RiAlertLine, type RemixiconComponentType } from '@remixicon/react';
import { DiffIcon } from '@/components/icons/DiffIcon';
import { useUIStore, type MainTab } from '@/stores/useUIStore';
import { useConfigStore } from '@/stores/useConfigStore';
@@ -1289,7 +1289,12 @@ export const Header: React.FC<HeaderProps> = ({
});
}, [activeProject?.id, activeProject?.path, openDirectory]);
const handleOpenCurrentSessionMiniChat = React.useCallback(() => {
const handleOpenCurrentMiniChat = React.useCallback(() => {
if (isNewSessionDraftOpen) {
handleOpenDraftMiniChat();
return;
}
if (!currentSessionId) {
return;
}
@@ -1299,7 +1304,7 @@ export const Header: React.FC<HeaderProps> = ({
}).catch((error) => {
console.warn('[header] failed to open session mini chat window', error);
});
}, [activeProject?.path, currentSessionId, openDirectory]);
}, [activeProject?.path, currentSessionId, handleOpenDraftMiniChat, isNewSessionDraftOpen, openDirectory]);
const handleOpenContextPanel = React.useCallback(() => {
const directory = normalize(openDirectory || '');
@@ -1823,6 +1828,7 @@ export const Header: React.FC<HeaderProps> = ({
);
const desktopSidebarActionsInline = !isRightSidebarOpen || !desktopRightSidebarActionsHost;
const showMiniChatHeaderAction = hasElectronDesktopIPC && (isNewSessionDraftOpen || Boolean(currentSessionId));
const renderDesktop = () => (
<div
@@ -1863,23 +1869,6 @@ export const Header: React.FC<HeaderProps> = ({
</TooltipContent>
</Tooltip>
) : null}
{hasElectronDesktopIPC && !isLeftSidebarOpen ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={t('header.actions.newMiniChatAria')}
onClick={handleOpenDraftMiniChat}
className={cn(desktopHeaderIconButtonClass, 'mr-6 shrink-0')}
>
<RiWindowLine className="h-[18px] w-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>{t('header.actions.newMiniChat')}</p>
</TooltipContent>
</Tooltip>
) : null}
{projectActionsContext && (
<ProjectActionsButton
projectRef={projectActionsContext.projectRef}
@@ -1931,14 +1920,6 @@ export const Header: React.FC<HeaderProps> = ({
<div className="flex-1" />
<div className="flex shrink-0 items-center gap-1">
<HeaderIconActionButton
visible={hasElectronDesktopIPC && !isNewSessionDraftOpen && Boolean(currentSessionId)}
title={t('header.actions.openSessionMiniChat')}
ariaLabel={t('header.actions.openSessionMiniChatAria')}
onClick={handleOpenCurrentSessionMiniChat}
className={`${desktopHeaderIconButtonClass} mr-1`}
Icon={RiPictureInPicture2Line}
/>
{showDesktopHeaderContextUsage && stableDesktopContextUsage ? (
<ContextUsageDisplay
totalTokens={stableDesktopContextUsage.totalTokens}
@@ -1951,11 +1932,19 @@ export const Header: React.FC<HeaderProps> = ({
showPercentIcon
onClick={handleOpenContextPanel}
pressed={isContextPanelActive}
className={desktopSidebarActionsInline ? 'mr-3.5' : ''}
className={desktopSidebarActionsInline && !showMiniChatHeaderAction ? 'mr-3.5' : ''}
valueClassName="typography-ui-label font-medium leading-none text-foreground"
percentIconClassName="h-5 w-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, desktopSidebarActionsInline && showDesktopHeaderContextUsage ? 'mr-3.5' : 'mr-1')}
Icon={RiPictureInPicture2Line}
/>
{desktopSidebarActionsInline ? desktopSidebarActions : null}
{!desktopSidebarActionsInline && desktopRightSidebarActionsHost
? createPortal(desktopSidebarActions, desktopRightSidebarActionsHost)
@@ -3,17 +3,20 @@ import { RiExternalLinkLine, RiGitBranchLine, RiPushpin2Fill, RiPushpin2Line } f
import { Button } from '@/components/ui/button';
import { ChatContainer } from '@/components/chat/ChatContainer';
import { ChatSurfaceProvider } from '@/components/chat/ChatSurfaceContext';
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { invokeDesktop, isElectronShell } from '@/lib/desktop';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessionWorktreeStore } from '@/sync/session-worktree-store';
import { useSessions } from '@/sync/sync-context';
import { useSessionMessages, useSessions } from '@/sync/sync-context';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useGitBranchLabel, useGitStore } from '@/stores/useGitStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { resolveSessionDiffStats } from '@/components/session/sidebar/utils';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
type MiniChatMode = 'session' | 'draft';
@@ -49,7 +52,10 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const projects = useProjectsStore((state) => state.projects);
const activeProject = useProjectsStore((state) => state.getActiveProject());
const getCurrentModel = useConfigStore((state) => state.getCurrentModel);
const providers = useConfigStore((state) => state.providers);
const sessions = useSessions();
const currentSessionMessages = useSessionMessages(currentSessionId ?? '');
const runtimeApis = useRuntimeAPIs();
const ensureGitStatus = useGitStore((state) => state.ensureStatus);
const worktreePath = useSessionUIStore((state) => currentSessionId ? state.worktreeMetadata.get(currentSessionId)?.path ?? '' : '');
@@ -129,9 +135,101 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
}, [session?.summary]);
const changes = diffStats ?? { additions: 0, deletions: 0 };
const hasChanges = changes.additions > 0 || changes.deletions > 0;
const currentModel = getCurrentModel();
const latestAssistantModel = React.useMemo(() => {
for (let i = currentSessionMessages.length - 1; i >= 0; i -= 1) {
const message = currentSessionMessages[i] as { role?: unknown; providerID?: unknown; modelID?: unknown };
if (message.role !== 'assistant') continue;
if (typeof message.providerID !== 'string' || typeof message.modelID !== 'string') continue;
const provider = providers.find((entry) => entry.id === message.providerID);
const model = provider?.models.find((entry) => entry.id === message.modelID);
if (model) return model;
}
return undefined;
}, [currentSessionMessages, providers]);
const modelForLimits = currentModel?.limit ? currentModel : latestAssistantModel;
const limit = modelForLimits && typeof modelForLimits.limit === 'object' && modelForLimits.limit !== null
? (modelForLimits.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 = React.useMemo<SessionContextUsage | null>(() => {
if (!currentSessionId || currentSessionMessages.length === 0) {
return null;
}
type AssistantTokens = { input: number; output: number; reasoning: number; cache: { read: number; write: number } };
let lastTokens: AssistantTokens | undefined;
let lastMessageId: string | undefined;
for (let i = currentSessionMessages.length - 1; i >= 0; i -= 1) {
const message = currentSessionMessages[i];
if (message.role !== 'assistant') continue;
const tokens = (message as { tokens?: AssistantTokens }).tokens;
if (!tokens) continue;
const total = tokens.input + tokens.output + tokens.reasoning + (tokens.cache?.read ?? 0) + (tokens.cache?.write ?? 0);
if (total > 0) {
lastTokens = tokens;
lastMessageId = message.id;
break;
}
}
if (!lastTokens) {
return null;
}
const totalTokens = lastTokens.input + lastTokens.output + lastTokens.reasoning + (lastTokens.cache?.read ?? 0) + (lastTokens.cache?.write ?? 0);
const thresholdLimit = contextLimit > 0 ? contextLimit : 200000;
const percentage = contextLimit > 0 ? Math.round((totalTokens / contextLimit) * 100) : 0;
const normalizedOutput = outputLimit > 0 ? Math.round((lastTokens.output / outputLimit) * 100) : undefined;
return {
totalTokens,
percentage,
contextLimit: contextLimit || 0,
outputLimit: outputLimit || undefined,
normalizedOutput,
thresholdLimit,
lastMessageId,
};
}, [contextLimit, currentSessionId, currentSessionMessages, outputLimit]);
const [stableContextUsage, setStableContextUsage] = React.useState<SessionContextUsage | null>(null);
const dragRegionStyle = { WebkitAppRegion: 'drag' } as React.CSSProperties;
const noDragRegionStyle = { WebkitAppRegion: 'no-drag' } as React.CSSProperties;
React.useEffect(() => {
if (!currentSessionId) {
setStableContextUsage((prev) => (prev === null ? prev : null));
return;
}
if (contextUsage && contextUsage.totalTokens > 0) {
setStableContextUsage((prev) => {
if (
prev
&& prev.totalTokens === contextUsage.totalTokens
&& prev.percentage === contextUsage.percentage
&& prev.contextLimit === contextUsage.contextLimit
&& (prev.outputLimit ?? 0) === (contextUsage.outputLimit ?? 0)
&& (prev.normalizedOutput ?? 0) === (contextUsage.normalizedOutput ?? 0)
&& prev.thresholdLimit === contextUsage.thresholdLimit
&& prev.lastMessageId === contextUsage.lastMessageId
) {
return prev;
}
return contextUsage;
});
return;
}
setStableContextUsage((prev) => (prev === null ? prev : null));
}, [contextUsage, currentSessionId]);
const displayContextPercentage = stableContextUsage && stableContextUsage.contextLimit > 0
? Math.min(999, (stableContextUsage.totalTokens / stableContextUsage.contextLimit) * 100)
: 0;
const handleTogglePinned = React.useCallback(() => {
const nextPinned = !pinned;
setPinned(nextPinned);
@@ -181,6 +279,20 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
) : null}
</div>
</div>
{stableContextUsage && stableContextUsage.totalTokens > 0 ? (
<ContextUsageDisplay
totalTokens={stableContextUsage.totalTokens}
percentage={displayContextPercentage}
colorPercentage={stableContextUsage.percentage}
contextLimit={stableContextUsage.contextLimit}
outputLimit={stableContextUsage.outputLimit ?? 0}
className="h-9 shrink-0 pl-1 pr-1 typography-ui-label"
valueClassName="font-semibold leading-none"
hideIcon
showPercentIcon
percentIconClassName="h-5 w-5"
/>
) : null}
<Button
type="button"
variant="ghost"