The token breakdown of an assistant message accumulates across every API round-trip inside the turn: each tool call re-reads the whole cached prompt, so input/cache.read add up to several times the context window. Every context-usage surface summed those fields, which is why the meter could read 330% of a 1M window whose real fill was 232,872 tokens (23.3%), and why reopening an older session jumps the readout (#2562). The server reports the final round-trip's window as tokens.total (optional in the message schema; opencode 1.18.18 returns it, verified against its live /session/:id/message API). Prefer it everywhere the window fill is displayed and fall back to summing only when the server did not send it: contextTokensFromBreakdown in tokenUtils now owns that rule, and the context store extractor, sync store getter, work status panel, context sidebar, VS Code layout, mini chat, and mobile metadata all use it instead of their own inline sums. Fixes #2562
352 lines
17 KiB
TypeScript
352 lines
17 KiB
TypeScript
import React from 'react';
|
|
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 { WindowsWindowControls } from '@/components/desktop/WindowsWindowControls';
|
|
import { SessionSwitcherDropdown } from '@/components/session/SessionSwitcherDropdown';
|
|
import { cn } from '@/lib/utils';
|
|
import { useI18n } from '@/lib/i18n';
|
|
import { invokeDesktop, isElectronShell } from '@/lib/desktop';
|
|
import { useDesktopWindowControlsLayout } from '@/hooks/useDesktopWindowControlsLayout';
|
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
|
import { useSessionWorktreeStore } from '@/sync/session-worktree-store';
|
|
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 { Icon } from "@/components/icon/Icon";
|
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
|
import { contextTokensFromBreakdown } from '@/stores/utils/tokenUtils';
|
|
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
|
|
|
|
type MiniChatMode = 'session' | 'draft';
|
|
|
|
type MiniChatLayoutProps = {
|
|
mode: MiniChatMode;
|
|
autoOpenDraft?: boolean;
|
|
unavailable?: boolean;
|
|
};
|
|
|
|
const compactPath = (value: string | null | undefined): string => {
|
|
const path = typeof value === 'string' ? value.trim() : '';
|
|
if (!path) return '';
|
|
const home = typeof window !== 'undefined' ? window.__OPENCHAMBER_HOME__ : '';
|
|
if (home && path === home) return '~';
|
|
if (home && path.startsWith(`${home}/`)) return `~/${path.slice(home.length + 1)}`;
|
|
const segments = path.split('/').filter(Boolean);
|
|
if (segments.length <= 3) return path;
|
|
return `.../${segments.slice(-3).join('/')}`;
|
|
};
|
|
|
|
const normalizePath = (value: string | null | undefined): string => {
|
|
const raw = typeof value === 'string' ? value.trim() : '';
|
|
if (!raw) return '';
|
|
const normalized = raw.replace(/\\/g, '/');
|
|
return normalized === '/' ? '/' : normalized.replace(/\/+$/, '');
|
|
};
|
|
|
|
const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
|
|
const { t } = useI18n();
|
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
|
const draftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
|
const draftProjectId = useSessionUIStore((state) => state.newSessionDraft?.selectedProjectId ?? null);
|
|
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 ?? '' : '');
|
|
const worktreeMetadataBranch = useSessionUIStore((state) => currentSessionId ? state.worktreeMetadata.get(currentSessionId)?.branch?.trim() ?? null : null);
|
|
const worktreeAttachment = useSessionWorktreeStore((state) => currentSessionId ? state.getAttachment(currentSessionId) : undefined);
|
|
const draftDirectory = useSessionUIStore((state) => {
|
|
if (!state.newSessionDraft?.open) return '';
|
|
return normalizePath(state.newSessionDraft.bootstrapPendingDirectory ?? state.newSessionDraft.directoryOverride ?? '');
|
|
});
|
|
const [pinned, setPinned] = React.useState(false);
|
|
const macosMajor = typeof window !== 'undefined' ? window.__OPENCHAMBER_MACOS_MAJOR__ ?? 0 : 0;
|
|
const hasMacTrafficLights = Number.isFinite(macosMajor) && macosMajor > 0;
|
|
const { usesFramelessChrome, side: windowControlsSide } = useDesktopWindowControlsLayout();
|
|
const macosHeaderSizeClass = hasMacTrafficLights
|
|
? macosMajor >= 26
|
|
? 'h-12'
|
|
: macosMajor <= 15
|
|
? 'h-14'
|
|
: ''
|
|
: '';
|
|
|
|
const session = React.useMemo(
|
|
() => currentSessionId ? sessions.find((entry) => entry.id === currentSessionId) ?? null : null,
|
|
[currentSessionId, sessions],
|
|
);
|
|
const sessionWorktreeMetadata = (session as { worktreeMetadata?: { path?: string | null; branch?: string | null; projectDirectory?: string | null } } | null)?.worktreeMetadata ?? null;
|
|
|
|
React.useEffect(() => {
|
|
if (!isElectronShell()) return;
|
|
void invokeDesktop<{ pinned?: boolean }>('desktop_get_window_pinned').then((result) => {
|
|
if (typeof result?.pinned === 'boolean') setPinned(result.pinned);
|
|
});
|
|
}, []);
|
|
|
|
const title = session?.title?.trim()
|
|
|| (draftOpen || mode === 'draft' ? t('miniChat.header.newSession') : t('miniChat.header.session'));
|
|
const sessionDirectory = normalizePath((session as { directory?: string | null } | null)?.directory ?? null);
|
|
const worktreeDirectory = normalizePath(worktreePath || sessionWorktreeMetadata?.path || worktreeAttachment?.cwd || worktreeAttachment?.worktreeRoot || '');
|
|
const currentDirectoryNormalized = normalizePath(currentDirectory);
|
|
const openDirectory = worktreeDirectory || sessionDirectory || draftDirectory || currentDirectoryNormalized;
|
|
const directoryLabel = compactPath(openDirectory);
|
|
const catalogWorktreeBranch = useSessionUIStore((state) => {
|
|
const candidateDirectory = normalizePath(worktreeDirectory || sessionDirectory || '');
|
|
if (!candidateDirectory) return null;
|
|
for (const worktrees of state.availableWorktreesByProject.values()) {
|
|
const match = worktrees.find((worktree) => normalizePath(worktree.path) === candidateDirectory);
|
|
const branch = match?.branch?.trim();
|
|
if (branch) return branch;
|
|
}
|
|
return null;
|
|
});
|
|
React.useEffect(() => {
|
|
if (!openDirectory) return;
|
|
void ensureGitStatus(openDirectory, runtimeApis.git).catch(() => {});
|
|
}, [ensureGitStatus, openDirectory, runtimeApis.git]);
|
|
|
|
const pathMatchedProject = React.useMemo(() => {
|
|
const projectDirectory = normalizePath(sessionWorktreeMetadata?.projectDirectory ?? worktreeAttachment?.worktreeRoot ?? null);
|
|
const candidateDirectory = projectDirectory || openDirectory;
|
|
if (!candidateDirectory) return null;
|
|
return projects
|
|
.map((entry) => ({ ...entry, normalizedPath: normalizePath(entry.path) }))
|
|
.filter((entry) => entry.normalizedPath && (entry.normalizedPath === candidateDirectory || candidateDirectory.startsWith(`${entry.normalizedPath}/`)))
|
|
.sort((left, right) => right.path.length - left.path.length)[0] ?? null;
|
|
}, [openDirectory, projects, sessionWorktreeMetadata?.projectDirectory, worktreeAttachment?.worktreeRoot]);
|
|
const projectLabel = React.useMemo(() => {
|
|
const project = pathMatchedProject ?? activeProject;
|
|
if (!project) return directoryLabel || 'OpenChamber';
|
|
const label = project.label?.trim();
|
|
if (label) return label;
|
|
const segments = project.path.split(/[\\/]/).filter(Boolean);
|
|
return segments.at(-1) ?? project.path;
|
|
}, [activeProject, directoryLabel, pathMatchedProject]);
|
|
const gitBranchForDirectory = useGitBranchLabel(openDirectory || null);
|
|
const rawBranchLabel = gitBranchForDirectory || worktreeMetadataBranch || sessionWorktreeMetadata?.branch?.trim() || worktreeAttachment?.branch?.trim() || catalogWorktreeBranch;
|
|
const branchLabel = rawBranchLabel && rawBranchLabel !== 'HEAD' ? rawBranchLabel : null;
|
|
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 = { total?: number; 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 = contextTokensFromBreakdown(tokens);
|
|
if (total > 0) {
|
|
lastTokens = tokens;
|
|
lastMessageId = message.id;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!lastTokens) {
|
|
return null;
|
|
}
|
|
|
|
const totalTokens = contextTokensFromBreakdown(lastTokens);
|
|
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);
|
|
void invokeDesktop('desktop_set_window_pinned', { pinned: nextPinned }).catch(() => {
|
|
setPinned(!nextPinned);
|
|
});
|
|
}, [pinned]);
|
|
|
|
const handleOpenMainApp = React.useCallback(() => {
|
|
const payload = currentSessionId
|
|
? { sessionId: currentSessionId, directory: (session as { directory?: string | null } | null)?.directory ?? currentDirectory ?? '' }
|
|
: { mode: 'draft', directory: openDirectory || currentDirectory || '', projectId: draftProjectId };
|
|
void invokeDesktop<{ focused?: boolean }>('desktop_focus_main_window', payload)
|
|
.then((result) => {
|
|
if (result?.focused === true) {
|
|
return invokeDesktop('desktop_close_current_window');
|
|
}
|
|
return null;
|
|
});
|
|
}, [currentDirectory, currentSessionId, draftProjectId, openDirectory, session]);
|
|
|
|
return (
|
|
<header
|
|
className={cn(
|
|
'flex items-center gap-3 bg-background pr-3',
|
|
hasMacTrafficLights ? 'pl-[5.5rem]' : 'pl-3',
|
|
usesFramelessChrome ? 'h-12' : macosHeaderSizeClass || 'min-h-14',
|
|
)}
|
|
style={dragRegionStyle}
|
|
>
|
|
{usesFramelessChrome && windowControlsSide === 'left' ? (
|
|
<WindowsWindowControls visible position="left" />
|
|
) : null}
|
|
<SessionSwitcherDropdown>
|
|
<button
|
|
type="button"
|
|
aria-label={t('sessions.switcher.openAria')}
|
|
style={noDragRegionStyle}
|
|
className="flex min-w-0 max-w-full flex-col items-start rounded-md px-1 py-0.5 text-left transition-colors hover:bg-interactive-hover/60 focus-visible:outline-none focus-visible:bg-interactive-hover/60"
|
|
>
|
|
<span className="truncate typography-ui-label text-[14px] font-normal leading-tight text-foreground max-w-full">
|
|
{title}
|
|
</span>
|
|
<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">
|
|
<span className="truncate">{projectLabel}</span>
|
|
{branchLabel ? (
|
|
<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">{branchLabel}</span>
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
</button>
|
|
</SessionSwitcherDropdown>
|
|
<div className="min-w-0 flex-1" />
|
|
{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-4.5 w-4.5"
|
|
/>
|
|
) : null}
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={handleTogglePinned}
|
|
aria-label={pinned ? t('miniChat.actions.unpinAria') : t('miniChat.actions.pinAria')}
|
|
title={pinned ? t('miniChat.actions.unpin') : t('miniChat.actions.pin')}
|
|
style={noDragRegionStyle}
|
|
>
|
|
{pinned ? <Icon name="pushpin-2-fill" className="h-4 w-4" /> : <Icon name="pushpin-2" className="h-4 w-4" />}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={handleOpenMainApp}
|
|
aria-label={t('miniChat.actions.openMainAria')}
|
|
title={t('miniChat.actions.openMain')}
|
|
style={noDragRegionStyle}
|
|
>
|
|
<Icon name="external-link" className="h-4 w-4" />
|
|
</Button>
|
|
<WindowsWindowControls visible={usesFramelessChrome && windowControlsSide === 'right'} position="right" />
|
|
</header>
|
|
);
|
|
};
|
|
|
|
export const MiniChatLayout: React.FC<MiniChatLayoutProps> = ({ mode, autoOpenDraft = false, unavailable = false }) => {
|
|
const { t } = useI18n();
|
|
|
|
return (
|
|
<div className="flex h-full min-h-0 flex-col bg-background text-foreground">
|
|
<MiniChatHeader mode={mode} />
|
|
<main className="min-h-0 flex-1">
|
|
{unavailable ? (
|
|
<div className="flex h-full items-center justify-center px-6 text-center typography-ui-label text-muted-foreground">
|
|
<div className="max-w-sm rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-4 py-3">
|
|
<div className="font-medium text-foreground">{t('miniChat.unavailable.title')}</div>
|
|
<div className="mt-1 typography-small text-muted-foreground">{t('miniChat.unavailable.description')}</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<ChatSurfaceProvider mode="mini-chat">
|
|
<ChatContainer autoOpenDraft={autoOpenDraft} />
|
|
</ChatSurfaceProvider>
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
};
|