diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 7506c80b..41e91ed6 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -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 = ({ }); }, [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 = ({ }).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 = ({ ); const desktopSidebarActionsInline = !isRightSidebarOpen || !desktopRightSidebarActionsHost; + const showMiniChatHeaderAction = hasElectronDesktopIPC && (isNewSessionDraftOpen || Boolean(currentSessionId)); const renderDesktop = () => (
= ({ ) : null} - {hasElectronDesktopIPC && !isLeftSidebarOpen ? ( - - - - - -

{t('header.actions.newMiniChat')}

-
-
- ) : null} {projectActionsContext && ( = ({
- {showDesktopHeaderContextUsage && stableDesktopContextUsage ? ( = ({ 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} + {desktopSidebarActionsInline ? desktopSidebarActions : null} {!desktopSidebarActionsInline && desktopRightSidebarActionsHost ? createPortal(desktopSidebarActions, desktopRightSidebarActionsHost) diff --git a/packages/ui/src/components/mini-chat/MiniChatLayout.tsx b/packages/ui/src/components/mini-chat/MiniChatLayout.tsx index 9654ee33..fb8ec216 100644 --- a/packages/ui/src/components/mini-chat/MiniChatLayout.tsx +++ b/packages/ui/src/components/mini-chat/MiniChatLayout.tsx @@ -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) + : 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(() => { + 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(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}
+ {stableContextUsage && stableContextUsage.totalTokens > 0 ? ( + + ) : null}