From 782c2b183dfa19444a2afa4087b3f48e743c95cf Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Sat, 16 May 2026 04:36:08 +0000 Subject: [PATCH] feat: persistent context-window tracker in ChatPane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../web/src/components/ChatContextPopover.tsx | 55 +++++++++++++++++++ apps/web/src/components/panes/ChatPane.tsx | 8 ++- apps/web/src/hooks/useChatContextStats.ts | 37 +++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/ChatContextPopover.tsx create mode 100644 apps/web/src/hooks/useChatContextStats.ts diff --git a/apps/web/src/components/ChatContextPopover.tsx b/apps/web/src/components/ChatContextPopover.tsx new file mode 100644 index 0000000..a08cc9d --- /dev/null +++ b/apps/web/src/components/ChatContextPopover.tsx @@ -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 ( +
+
+
+ Context window +
+
+ {stats.percent}% used +
+
+ {formatTokens(stats.used)} / {formatTokens(stats.max)} tokens +
+
+
+ ); +} diff --git a/apps/web/src/components/panes/ChatPane.tsx b/apps/web/src/components/panes/ChatPane.tsx index d0ba490..b858954 100644 --- a/apps/web/src/components/panes/ChatPane.tsx +++ b/apps/web/src/components/panes/ChatPane.tsx @@ -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) )} - +
+ + +
); } diff --git a/apps/web/src/hooks/useChatContextStats.ts b/apps/web/src/hooks/useChatContextStats.ts new file mode 100644 index 0000000..a386584 --- /dev/null +++ b/apps/web/src/hooks/useChatContextStats.ts @@ -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]); +}