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]);
+}