feat: persistent context-window tracker in ChatPane
Adds a floating popover above the chat input showing current context-window usage. Modeled on Paseo's tracker. - New hook useChatContextStats(chatId, messages) finds the latest assistant message in the chat with both ctx_used and ctx_max set, computes percent, and returns null when data unavailable. - New component ChatContextPopover renders a small card with the "Context window" label, big percent, and "used / max tokens" subline. Hidden when stats is null. - Color thresholds: <60% muted, 60-85 amber, >85 destructive. - Not a portal — absolutely positioned inside a new relative wrapper around ChatInput in ChatPane.tsx, so it's pane-local (multi-pane safe). - Live updates via the existing messages-array dependency. - No API / schema / WS changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
55
apps/web/src/components/ChatContextPopover.tsx
Normal file
55
apps/web/src/components/ChatContextPopover.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { ChatContextStats } from '@/hooks/useChatContextStats';
|
||||
|
||||
interface Props {
|
||||
stats: ChatContextStats | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a token count into a compact k/m-suffix string.
|
||||
* - < 1_000 → raw integer (e.g. "42")
|
||||
* - 1_000–999_999 → "Nk" or "N.Nk" (e.g. "30k", "12.5k", "100k")
|
||||
* - >= 1_000_000 → "Nm" or "N.Nm" (e.g. "1m", "1.5m", "100m")
|
||||
*
|
||||
* Drops a trailing ".0" so we get "30k" instead of "30.0k".
|
||||
*/
|
||||
function formatTokens(n: number): string {
|
||||
if (n < 1000) return String(n);
|
||||
if (n < 1_000_000) {
|
||||
const k = n / 1000;
|
||||
return k >= 100 ? `${Math.round(k)}k` : `${k.toFixed(1).replace(/\.0$/, '')}k`;
|
||||
}
|
||||
const m = n / 1_000_000;
|
||||
return m >= 100 ? `${Math.round(m)}m` : `${m.toFixed(1).replace(/\.0$/, '')}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Color thresholds:
|
||||
* - > 85% → text-destructive
|
||||
* - >= 60% → text-amber-500
|
||||
* - else → text-muted-foreground
|
||||
* (85% itself falls into the amber band.)
|
||||
*/
|
||||
function percentColorClass(percent: number): string {
|
||||
if (percent > 85) return 'text-destructive';
|
||||
if (percent >= 60) return 'text-amber-500';
|
||||
return 'text-muted-foreground';
|
||||
}
|
||||
|
||||
export function ChatContextPopover({ stats }: Props) {
|
||||
if (!stats) return null;
|
||||
return (
|
||||
<div className="absolute bottom-full right-4 mb-4 z-20 pointer-events-none">
|
||||
<div className="rounded-md border border-border bg-card text-card-foreground shadow-sm px-3 py-2 text-xs min-w-[140px]">
|
||||
<div className="text-muted-foreground/80 text-[10px] uppercase tracking-wide mb-0.5">
|
||||
Context window
|
||||
</div>
|
||||
<div className={`text-base font-medium ${percentColorClass(stats.percent)}`}>
|
||||
{stats.percent}% used
|
||||
</div>
|
||||
<div className="text-muted-foreground text-[10px] font-mono">
|
||||
{formatTokens(stats.used)} / {formatTokens(stats.max)} tokens
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,10 @@ import { ChevronDown, Square, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import { useSessionStream } from '@/hooks/useSessionStream';
|
||||
import { useChatContextStats } from '@/hooks/useChatContextStats';
|
||||
import { MessageList } from '@/components/MessageList';
|
||||
import { ChatInput } from '@/components/ChatInput';
|
||||
import { ChatContextPopover } from '@/components/ChatContextPopover';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -37,6 +39,7 @@ export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props)
|
||||
|
||||
const chatMessages = stream.messages.filter((m) => m.chat_id === chatId);
|
||||
const streaming = chatMessages.some((m) => m.status === 'streaming');
|
||||
const contextStats = useChatContextStats(chatId, chatMessages);
|
||||
|
||||
// Auto-send next queued message when streaming completes
|
||||
useEffect(() => {
|
||||
@@ -162,7 +165,10 @@ export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props)
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ChatInput disabled={false} projectId={projectId} onSend={handleSend} onForceSend={streaming ? handleForceSend : undefined} />
|
||||
<div className="relative">
|
||||
<ChatContextPopover stats={contextStats} />
|
||||
<ChatInput disabled={false} projectId={projectId} onSend={handleSend} onForceSend={streaming ? handleForceSend : undefined} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
37
apps/web/src/hooks/useChatContextStats.ts
Normal file
37
apps/web/src/hooks/useChatContextStats.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { Message } from '@/api/types';
|
||||
|
||||
export interface ChatContextStats {
|
||||
used: number;
|
||||
max: number;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the latest context-window usage for the given chat, derived from the
|
||||
* assistant message (with both ctx_used and ctx_max populated) having the most
|
||||
* recent created_at. Returns null when no such message exists.
|
||||
*
|
||||
* Re-evaluates whenever the `messages` reference or `chatId` changes, which
|
||||
* matches the cadence of streaming updates from `useSessionStream`.
|
||||
*/
|
||||
export function useChatContextStats(
|
||||
chatId: string,
|
||||
messages: Message[],
|
||||
): ChatContextStats | null {
|
||||
return useMemo(() => {
|
||||
let latest: Message | null = null;
|
||||
for (const m of messages) {
|
||||
if (m.chat_id !== chatId) continue;
|
||||
if (m.role !== 'assistant') continue;
|
||||
if (m.ctx_used == null || m.ctx_max == null) continue;
|
||||
if (!latest || m.created_at > latest.created_at) latest = m;
|
||||
}
|
||||
if (!latest || latest.ctx_used == null || latest.ctx_max == null) return null;
|
||||
const used = latest.ctx_used;
|
||||
const max = latest.ctx_max;
|
||||
if (max <= 0) return null;
|
||||
const percent = Math.round((used / max) * 100);
|
||||
return { used, max, percent };
|
||||
}, [chatId, messages]);
|
||||
}
|
||||
Reference in New Issue
Block a user