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 { toast } from 'sonner';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import { useSessionStream } from '@/hooks/useSessionStream';
|
import { useSessionStream } from '@/hooks/useSessionStream';
|
||||||
|
import { useChatContextStats } from '@/hooks/useChatContextStats';
|
||||||
import { MessageList } from '@/components/MessageList';
|
import { MessageList } from '@/components/MessageList';
|
||||||
import { ChatInput } from '@/components/ChatInput';
|
import { ChatInput } from '@/components/ChatInput';
|
||||||
|
import { ChatContextPopover } from '@/components/ChatContextPopover';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -37,6 +39,7 @@ export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props)
|
|||||||
|
|
||||||
const chatMessages = stream.messages.filter((m) => m.chat_id === chatId);
|
const chatMessages = stream.messages.filter((m) => m.chat_id === chatId);
|
||||||
const streaming = chatMessages.some((m) => m.status === 'streaming');
|
const streaming = chatMessages.some((m) => m.status === 'streaming');
|
||||||
|
const contextStats = useChatContextStats(chatId, chatMessages);
|
||||||
|
|
||||||
// Auto-send next queued message when streaming completes
|
// Auto-send next queued message when streaming completes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -162,7 +165,10 @@ export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props)
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<ChatContextPopover stats={contextStats} />
|
||||||
<ChatInput disabled={false} projectId={projectId} onSend={handleSend} onForceSend={streaming ? handleForceSend : undefined} />
|
<ChatInput disabled={false} projectId={projectId} onSend={handleSend} onForceSend={streaming ? handleForceSend : undefined} />
|
||||||
</div>
|
</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