From abcfe064725057543d903c4a5b299fe7e50e5c03 Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Mon, 25 May 2026 15:54:35 +0530 Subject: [PATCH 1/7] Add session context tab with metrics and breakdown - SessionContextTab component displays token usage, distribution by role, and raw messages - Utilities for extracting metrics from thread state and estimating token breakdown - Wire context tab into chat UI via onOpenContextTab callback --- apps/web/src/components/ChatView.tsx | 3 + apps/web/src/components/chat/ChatComposer.tsx | 11 +- .../components/chat/ContextWindowMeter.tsx | 58 +++- .../src/components/chat/SessionContextTab.tsx | 320 ++++++++++++++++++ .../chat/sessionContextBreakdown.test.ts | 152 +++++++++ .../chat/sessionContextBreakdown.ts | 113 +++++++ .../components/chat/sessionContextFormat.ts | 39 +++ .../chat/sessionContextMetrics.test.ts | 199 +++++++++++ .../components/chat/sessionContextMetrics.ts | 88 +++++ apps/web/src/diffRouteSearch.ts | 13 +- .../routes/_chat.$environmentId.$threadId.tsx | 125 ++++++- 11 files changed, 1105 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/components/chat/SessionContextTab.tsx create mode 100644 apps/web/src/components/chat/sessionContextBreakdown.test.ts create mode 100644 apps/web/src/components/chat/sessionContextBreakdown.ts create mode 100644 apps/web/src/components/chat/sessionContextFormat.ts create mode 100644 apps/web/src/components/chat/sessionContextMetrics.test.ts create mode 100644 apps/web/src/components/chat/sessionContextMetrics.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d66d2487ce3..aaa701e2ef0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -340,6 +340,7 @@ type ChatViewProps = environmentId: EnvironmentId; threadId: ThreadId; onDiffPanelOpen?: () => void; + onOpenContextTab?: () => void; reserveTitleBarControlInset?: boolean; routeKind: "server"; draftId?: never; @@ -348,6 +349,7 @@ type ChatViewProps = environmentId: EnvironmentId; threadId: ThreadId; onDiffPanelOpen?: () => void; + onOpenContextTab?: () => void; reserveTitleBarControlInset?: boolean; routeKind: "draft"; draftId: DraftId; @@ -3676,6 +3678,7 @@ export default function ChatView(props: ChatViewProps) { scheduleComposerFocus={scheduleComposerFocus} setThreadError={setThreadError} onExpandImage={onExpandTimelineImage} + {...(props.onOpenContextTab ? { onOpenContextTab: props.onOpenContextTab } : {})} /> diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b19e1240364..a0c4e290a23 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -306,10 +306,16 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; + onOpenContextTab?: () => void; }) { return ( <> - {props.activeContextWindow ? : null} + {props.activeContextWindow ? ( + + ) : null} {props.isPreparingWorktree ? ( Preparing worktree... ) : null} @@ -483,6 +489,7 @@ export interface ChatComposerProps { scheduleComposerFocus: () => void; setThreadError: (threadId: ThreadId | null, error: string | null) => void; onExpandImage: (preview: ExpandedImagePreview) => void; + onOpenContextTab?: () => void; } // -------------------------------------------------------------------------- @@ -556,6 +563,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) scheduleComposerFocus, setThreadError, onExpandImage, + onOpenContextTab, } = props; // ------------------------------------------------------------------ @@ -2406,6 +2414,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} + {...(onOpenContextTab ? { onOpenContextTab } : {})} /> diff --git a/apps/web/src/components/chat/ContextWindowMeter.tsx b/apps/web/src/components/chat/ContextWindowMeter.tsx index 280c262cf23..abdba32f3cb 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.tsx +++ b/apps/web/src/components/chat/ContextWindowMeter.tsx @@ -12,14 +12,68 @@ function formatPercentage(value: number | null): string | null { return `${Math.round(value)}%`; } -export function ContextWindowMeter(props: { usage: ContextWindowSnapshot }) { - const { usage } = props; +export function ContextWindowMeter(props: { usage: ContextWindowSnapshot; onOpenContextTab?: () => void }) { + const { usage, onOpenContextTab } = props; const usedPercentage = formatPercentage(usage.usedPercentage); const normalizedPercentage = Math.max(0, Math.min(100, usage.usedPercentage ?? 0)); const radius = 9.75; const circumference = 2 * Math.PI * radius; const dashOffset = circumference - (normalizedPercentage / 100) * circumference; + if (onOpenContextTab) { + return ( + + ); + } + return ( = { + system: "bg-amber-500", + user: "bg-sky-500", + assistant: "bg-emerald-500", + tool: "bg-violet-500", + other: "bg-muted-foreground/40", +}; + +const SEGMENT_LABELS: Record = { + system: "System", + user: "User", + assistant: "Assistant", + tool: "Tool", + other: "Other", +}; + +const scrollPositionByKey = new Map(); + +interface SessionContextTabProps { + environmentId: EnvironmentId; + threadId: ThreadId; + onClose: () => void; +} + +export function SessionContextTab({ + environmentId, + threadId, + onClose, +}: SessionContextTabProps) { + const threadRef = useMemo( + () => ({ environmentId, threadId }), + [environmentId, threadId], + ); + const thread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); + const providers = useServerProviders(); + + const formatter = useMemo(() => createSessionContextFormatter(), []); + + const metrics = useMemo( + () => (thread ? getSessionContextMetrics(thread, providers) : null), + [thread, providers], + ); + + const breakdown = useMemo( + () => + thread + ? estimateSessionContextBreakdown({ + messages: thread.messages, + activities: thread.activities, + input: metrics?.input ?? null, + }) + : [], + [thread, metrics?.input], + ); + + const scrollKey = `${environmentId}:${threadId}`; + const scrollContainerRef = useRef(null); + const rafHandleRef = useRef(null); + + useEffect(() => { + const node = scrollContainerRef.current; + if (!node) return; + const saved = scrollPositionByKey.get(scrollKey) ?? 0; + node.scrollTop = saved; + }, [scrollKey]); + + useEffect(() => { + return () => { + if (rafHandleRef.current !== null) { + cancelAnimationFrame(rafHandleRef.current); + rafHandleRef.current = null; + } + }; + }, []); + + const handleScroll = useCallback(() => { + if (rafHandleRef.current !== null) return; + rafHandleRef.current = window.requestAnimationFrame(() => { + rafHandleRef.current = null; + const node = scrollContainerRef.current; + if (!node) return; + scrollPositionByKey.set(scrollKey, node.scrollTop); + }); + }, [scrollKey]); + + return ( +
+ +
+ {!thread || !metrics ? ( +
+ Thread is not loaded. +
+ ) : ( +
+ + + +
+ )} +
+
+ ); +} + +function SessionContextTabHeader({ onClose }: { onClose: () => void }) { + return ( +
+
+ + Context +
+ +
+ ); +} + +function StatsGrid({ + metrics, + formatter, +}: { + metrics: SessionContextMetrics; + formatter: ReturnType; +}) { + const entries: Array<{ label: string; value: string }> = [ + { label: "Session", value: metrics.sessionTitle || "—" }, + { label: "Messages", value: formatter.number(metrics.messageCount) }, + { label: "Provider", value: metrics.providerLabel }, + { label: "Model", value: metrics.modelLabel }, + { label: "Context Limit", value: formatter.number(metrics.limit) }, + { label: "Total Tokens", value: formatter.number(metrics.total) }, + { label: "Usage", value: formatter.percent(metrics.usage) }, + { label: "Input", value: formatter.number(metrics.input) }, + { label: "Output", value: formatter.number(metrics.output) }, + { label: "Reasoning", value: formatter.number(metrics.reasoning) }, + { label: "Cache Read", value: formatter.number(metrics.cacheRead) }, + { label: "Cache Write", value: formatter.number(metrics.cacheWrite) }, + { label: "User Messages", value: formatter.number(metrics.userMessageCount) }, + { label: "Assistant Messages", value: formatter.number(metrics.assistantMessageCount) }, + { label: "Session Created", value: formatter.time(metrics.sessionCreatedAt) }, + { label: "Last Activity", value: formatter.time(metrics.lastActivityAt) }, + ]; + + return ( +
+
+ {entries.map((entry) => ( +
+ + {entry.label} + + + {entry.value} + +
+ ))} +
+
+ ); +} + +function BreakdownSection({ + segments, + formatter, +}: { + segments: SessionContextBreakdownSegment[]; + formatter: ReturnType; +}) { + return ( +
+

+ Breakdown +

+ {segments.length === 0 ? ( +

+ Not enough data to compute a breakdown yet. +

+ ) : ( + <> +
+ {segments.map((segment) => ( +
+ ))} +
+
+ {segments.map((segment) => ( +
+ + {SEGMENT_LABELS[segment.key]} + + {segment.percent.toLocaleString()}% + + + {formatter.number(segment.tokens)} + +
+ ))} +
+ + )} +
+ ); +} + +function RawMessagesSection({ + thread, + formatter, +}: { + thread: Thread; + formatter: ReturnType; +}) { + return ( +
+

+ Raw messages +

+ {thread.messages.length === 0 ? ( +

No messages yet.

+ ) : ( +
+ {thread.messages.map((message) => { + const idSuffix = String(message.id).slice(-8); + const json = JSON.stringify( + { ...message, attachments: message.attachments ?? [] }, + null, + 2, + ); + return ( + + + + + {message.role} + + · + {idSuffix} + + {formatter.time(message.createdAt)} + + + } + /> + +
+                    {json}
+                  
+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/chat/sessionContextBreakdown.test.ts b/apps/web/src/components/chat/sessionContextBreakdown.test.ts new file mode 100644 index 00000000000..90524faf7ef --- /dev/null +++ b/apps/web/src/components/chat/sessionContextBreakdown.test.ts @@ -0,0 +1,152 @@ +import { EventId, MessageId, type OrchestrationThreadActivity } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import type { ChatMessage } from "~/types"; +import { estimateSessionContextBreakdown } from "./sessionContextBreakdown"; + +let idCounter = 0; +function makeMessage(role: ChatMessage["role"], text: string): ChatMessage { + idCounter += 1; + return { + id: MessageId.make(`msg-${idCounter}`), + role, + text, + createdAt: "2026-01-01T00:00:00Z", + streaming: false, + }; +} + +function makeToolActivity( + kind: "tool.started" | "tool.updated" | "tool.completed", + payload: unknown, + id = `act-${idCounter++}`, +): OrchestrationThreadActivity { + return { + id: EventId.make(id), + tone: "tool", + kind, + summary: kind, + payload, + turnId: null, + createdAt: "2026-01-01T00:00:00Z", + }; +} + +describe("sessionContextBreakdown", () => { + it("estimates token counts from character lengths per role", () => { + const breakdown = estimateSessionContextBreakdown({ + messages: [makeMessage("user", "a".repeat(400)), makeMessage("assistant", "b".repeat(800))], + activities: [], + input: 1000, + }); + const user = breakdown.find((s) => s.key === "user"); + const assistant = breakdown.find((s) => s.key === "assistant"); + expect(user?.tokens).toBe(100); + expect(assistant?.tokens).toBe(200); + }); + + it("includes the system prompt length in the system segment", () => { + const breakdown = estimateSessionContextBreakdown({ + messages: [], + activities: [], + systemPrompt: "x".repeat(40), + input: 1000, + }); + const system = breakdown.find((s) => s.key === "system"); + expect(system?.tokens).toBe(10); + }); + + it("counts tool activities by JSON payload length", () => { + const payload = { name: "edit", args: "y".repeat(396) }; + const activities = [makeToolActivity("tool.started", payload)]; + const expected = Math.ceil(JSON.stringify(payload).length / 4); + const breakdown = estimateSessionContextBreakdown({ + messages: [], + activities, + input: 5000, + }); + expect(breakdown.find((s) => s.key === "tool")?.tokens).toBe(expected); + }); + + it("ignores non-tool activities for the tool segment", () => { + const activities: OrchestrationThreadActivity[] = [ + { + id: EventId.make("act-other"), + tone: "info", + kind: "context-window.updated", + summary: "ctx", + payload: { usedTokens: 100 }, + turnId: null, + createdAt: "2026-01-01T00:00:00Z", + }, + ]; + const breakdown = estimateSessionContextBreakdown({ + messages: [], + activities, + input: 1000, + }); + expect(breakdown.find((s) => s.key === "tool")).toBeUndefined(); + }); + + it("fills remainder into the other segment when estimated tokens fit", () => { + const breakdown = estimateSessionContextBreakdown({ + messages: [makeMessage("user", "hello")], + activities: [], + input: 1000, + }); + const other = breakdown.find((s) => s.key === "other"); + expect(other).toBeDefined(); + const totalTokens = breakdown.reduce((sum, s) => sum + s.tokens, 0); + expect(totalTokens).toBe(1000); + }); + + it("scales segments proportionally when estimated exceeds input", () => { + const breakdown = estimateSessionContextBreakdown({ + messages: [ + makeMessage("user", "a".repeat(10_000)), + makeMessage("assistant", "b".repeat(10_000)), + ], + activities: [], + input: 1000, + }); + const total = breakdown.reduce((sum, s) => sum + s.tokens, 0); + expect(total).toBe(1000); + expect(breakdown.find((s) => s.key === "user")?.tokens ?? 0).toBeGreaterThan(0); + expect(breakdown.find((s) => s.key === "assistant")?.tokens ?? 0).toBeGreaterThan(0); + }); + + it("filters out zero-token segments", () => { + const breakdown = estimateSessionContextBreakdown({ + messages: [makeMessage("user", "hi")], + activities: [], + input: 1000, + }); + expect(breakdown.every((s) => s.tokens > 0)).toBe(true); + expect(breakdown.find((s) => s.key === "assistant")).toBeUndefined(); + }); + + it("returns empty array when there is no data and no input budget", () => { + const breakdown = estimateSessionContextBreakdown({ + messages: [], + activities: [], + input: null, + }); + expect(breakdown).toEqual([]); + }); + + it("emits widths matching the per-segment ratios", () => { + const breakdown = estimateSessionContextBreakdown({ + messages: [ + makeMessage("user", "a".repeat(400)), + makeMessage("assistant", "b".repeat(400)), + ], + activities: [], + input: 1000, + }); + const total = breakdown.reduce((sum, s) => sum + s.tokens, 0); + for (const segment of breakdown) { + const expected = (segment.tokens / total) * 100; + expect(Math.abs(segment.width - expected)).toBeLessThan(1e-6); + } + }); +}); diff --git a/apps/web/src/components/chat/sessionContextBreakdown.ts b/apps/web/src/components/chat/sessionContextBreakdown.ts new file mode 100644 index 00000000000..2d92abec1e5 --- /dev/null +++ b/apps/web/src/components/chat/sessionContextBreakdown.ts @@ -0,0 +1,113 @@ +import type { OrchestrationThreadActivity } from "@t3tools/contracts"; + +import type { ChatMessage } from "~/types"; + +export type SessionContextBreakdownKey = + | "system" + | "user" + | "assistant" + | "tool" + | "other"; + +export interface SessionContextBreakdownSegment { + key: SessionContextBreakdownKey; + tokens: number; + width: number; + percent: number; +} + +const CHARS_PER_TOKEN = 4; +const ORDERED_KEYS: SessionContextBreakdownKey[] = [ + "system", + "user", + "assistant", + "tool", + "other", +]; + +function charsToTokens(chars: number): number { + if (chars <= 0) return 0; + return Math.ceil(chars / CHARS_PER_TOKEN); +} + +function safeJsonLength(value: unknown): number { + try { + return JSON.stringify(value).length; + } catch { + return 0; + } +} + +export function estimateSessionContextBreakdown(input: { + messages: ReadonlyArray; + activities: ReadonlyArray; + systemPrompt?: string | null; + input: number | null; +}): SessionContextBreakdownSegment[] { + const { messages, activities, systemPrompt, input: inputTokens } = input; + + let systemChars = systemPrompt?.length ?? 0; + let userChars = 0; + let assistantChars = 0; + for (const message of messages) { + const length = message.text?.length ?? 0; + if (message.role === "user") userChars += length; + else if (message.role === "assistant") assistantChars += length; + else if (message.role === "system") systemChars += length; + } + + let toolChars = 0; + for (const activity of activities) { + if ( + activity.kind === "tool.started" || + activity.kind === "tool.updated" || + activity.kind === "tool.completed" + ) { + toolChars += safeJsonLength(activity.payload); + } + } + + const tokens: Record = { + system: charsToTokens(systemChars), + user: charsToTokens(userChars), + assistant: charsToTokens(assistantChars), + tool: charsToTokens(toolChars), + other: 0, + }; + + const estimatedTotal = tokens.system + tokens.user + tokens.assistant + tokens.tool; + + if (inputTokens !== null && inputTokens > 0) { + if (estimatedTotal <= inputTokens) { + tokens.other = inputTokens - estimatedTotal; + } else { + const scale = inputTokens / estimatedTotal; + tokens.system = Math.floor(tokens.system * scale); + tokens.user = Math.floor(tokens.user * scale); + tokens.assistant = Math.floor(tokens.assistant * scale); + tokens.tool = Math.floor(tokens.tool * scale); + const sum = tokens.system + tokens.user + tokens.assistant + tokens.tool; + tokens.other = Math.max(0, inputTokens - sum); + } + } + + const total = ORDERED_KEYS.reduce((sum, key) => sum + tokens[key], 0); + if (total <= 0) { + return []; + } + + const segments: SessionContextBreakdownSegment[] = []; + for (const key of ORDERED_KEYS) { + const count = tokens[key]; + if (count <= 0) continue; + const ratio = count / total; + const percent = Math.round(ratio * 1000) / 10; + segments.push({ + key, + tokens: count, + width: ratio * 100, + percent, + }); + } + return segments; +} diff --git a/apps/web/src/components/chat/sessionContextFormat.ts b/apps/web/src/components/chat/sessionContextFormat.ts new file mode 100644 index 00000000000..706cf4d6aec --- /dev/null +++ b/apps/web/src/components/chat/sessionContextFormat.ts @@ -0,0 +1,39 @@ +export interface SessionContextFormatter { + number(value: number | null): string; + percent(value: number | null): string; + time(value: string | null | undefined): string; +} + +const FALLBACK = "—"; + +export function createSessionContextFormatter( + locale?: string | ReadonlyArray, +): SessionContextFormatter { + const localeArg = locale === undefined ? undefined : (locale as string | string[]); + const numberFormatter = new Intl.NumberFormat(localeArg); + const dateTimeFormatter = new Intl.DateTimeFormat(localeArg, { + dateStyle: "medium", + timeStyle: "short", + }); + const percentFormatter = new Intl.NumberFormat(localeArg, { + minimumFractionDigits: 0, + maximumFractionDigits: 1, + }); + + return { + number(value) { + if (value === null || !Number.isFinite(value)) return FALLBACK; + return numberFormatter.format(value); + }, + percent(value) { + if (value === null || !Number.isFinite(value)) return FALLBACK; + return `${percentFormatter.format(value)}%`; + }, + time(value) { + if (!value) return FALLBACK; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return FALLBACK; + return dateTimeFormatter.format(date); + }, + }; +} diff --git a/apps/web/src/components/chat/sessionContextMetrics.test.ts b/apps/web/src/components/chat/sessionContextMetrics.test.ts new file mode 100644 index 00000000000..5b17bb1fae5 --- /dev/null +++ b/apps/web/src/components/chat/sessionContextMetrics.test.ts @@ -0,0 +1,199 @@ +import { + EnvironmentId, + EventId, + MessageId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type OrchestrationThreadActivity, + type ServerProvider, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatMessage, type Thread } from "~/types"; +import { getSessionContextMetrics } from "./sessionContextMetrics"; + +const environmentId = EnvironmentId.make("env-1"); +const instanceId = ProviderInstanceId.make("codex"); + +function makeMessage(role: ChatMessage["role"], text: string, suffix: string): ChatMessage { + return { + id: MessageId.make(`msg-${suffix}`), + role, + text, + createdAt: `2026-01-01T00:00:${suffix.padStart(2, "0")}Z`, + streaming: false, + }; +} + +function makeContextWindowActivity( + payload: Record, + createdAt = "2026-01-01T00:00:05Z", + id = "activity-1", +): OrchestrationThreadActivity { + return { + id: EventId.make(id), + tone: "info", + kind: "context-window.updated", + summary: "context", + payload, + turnId: null, + createdAt, + }; +} + +function makeThread(overrides: Partial = {}): Thread { + return { + id: ThreadId.make("thread-1"), + environmentId, + codexThreadId: null, + projectId: ProjectId.make("project-1"), + title: "Test Thread", + modelSelection: { + instanceId, + model: "gpt-5-codex", + }, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_INTERACTION_MODE, + session: null, + messages: [], + proposedPlans: [], + error: null, + createdAt: "2026-01-01T00:00:00Z", + archivedAt: null, + latestTurn: null, + branch: null, + worktreePath: null, + turnDiffSummaries: [], + activities: [], + ...overrides, + }; +} + +function makeProvider(overrides: Partial = {}): ServerProvider { + return { + instanceId, + driver: ProviderDriverKind.make("codex"), + displayName: "Codex Primary", + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "unknown" }, + checkedAt: "2026-01-01T00:00:00Z", + models: [ + { + slug: "gpt-5-codex", + name: "GPT-5 Codex", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + ...overrides, + } as ServerProvider; +} + +describe("sessionContextMetrics", () => { + it("extracts metrics from the latest assistant turn snapshot", () => { + const thread = makeThread({ + messages: [ + makeMessage("user", "hi", "1"), + makeMessage("assistant", "hello", "2"), + makeMessage("user", "ok", "3"), + ], + activities: [ + makeContextWindowActivity( + { + usedTokens: 50, + maxTokens: 1000, + lastInputTokens: 10, + lastOutputTokens: 20, + lastReasoningOutputTokens: 5, + lastCachedInputTokens: 7, + }, + "2026-01-01T00:00:04Z", + "activity-old", + ), + makeContextWindowActivity( + { + usedTokens: 1500, + maxTokens: 10_000, + lastInputTokens: 500, + lastOutputTokens: 300, + lastReasoningOutputTokens: 50, + lastCachedInputTokens: 100, + }, + "2026-01-01T00:00:10Z", + "activity-latest", + ), + ], + }); + + const metrics = getSessionContextMetrics(thread, [makeProvider()]); + + expect(metrics.input).toBe(500); + expect(metrics.output).toBe(300); + expect(metrics.reasoning).toBe(50); + expect(metrics.cacheRead).toBe(100); + expect(metrics.cacheWrite).toBeNull(); + expect(metrics.total).toBe(950); + expect(metrics.limit).toBe(10_000); + expect(metrics.usage).toBe(15); + expect(metrics.lastActivityAt).toBe("2026-01-01T00:00:10Z"); + }); + + it("returns null token fields when no snapshot exists", () => { + const thread = makeThread(); + const metrics = getSessionContextMetrics(thread, [makeProvider()]); + expect(metrics.input).toBeNull(); + expect(metrics.output).toBeNull(); + expect(metrics.total).toBeNull(); + expect(metrics.limit).toBeNull(); + expect(metrics.usage).toBeNull(); + }); + + it("falls back to slug when provider/model cannot be resolved", () => { + const thread = makeThread({ + modelSelection: { + instanceId: ProviderInstanceId.make("unknown-instance"), + model: "mystery-model", + }, + }); + const metrics = getSessionContextMetrics(thread, []); + expect(metrics.providerLabel).toBe("unknown-instance"); + expect(metrics.modelLabel).toBe("mystery-model"); + }); + + it("returns null usage when maxTokens is missing", () => { + const thread = makeThread({ + activities: [ + makeContextWindowActivity({ + usedTokens: 1000, + lastInputTokens: 500, + }), + ], + }); + const metrics = getSessionContextMetrics(thread, [makeProvider()]); + expect(metrics.usage).toBeNull(); + expect(metrics.limit).toBeNull(); + }); + + it("splits message counts by role", () => { + const thread = makeThread({ + messages: [ + makeMessage("user", "1", "1"), + makeMessage("assistant", "2", "2"), + makeMessage("user", "3", "3"), + makeMessage("assistant", "4", "4"), + makeMessage("assistant", "5", "5"), + ], + }); + const metrics = getSessionContextMetrics(thread, [makeProvider()]); + expect(metrics.userMessageCount).toBe(2); + expect(metrics.assistantMessageCount).toBe(3); + expect(metrics.messageCount).toBe(5); + }); +}); diff --git a/apps/web/src/components/chat/sessionContextMetrics.ts b/apps/web/src/components/chat/sessionContextMetrics.ts new file mode 100644 index 00000000000..df5a25309a7 --- /dev/null +++ b/apps/web/src/components/chat/sessionContextMetrics.ts @@ -0,0 +1,88 @@ +import type { ServerProvider } from "@t3tools/contracts"; +import { deriveLatestContextWindowSnapshot } from "~/lib/contextWindow"; +import type { Thread } from "~/types"; + +export interface SessionContextMetrics { + sessionTitle: string; + providerLabel: string; + modelLabel: string; + limit: number | null; + input: number | null; + output: number | null; + reasoning: number | null; + cacheRead: number | null; + cacheWrite: number | null; + total: number | null; + usage: number | null; + userMessageCount: number; + assistantMessageCount: number; + messageCount: number; + sessionCreatedAt: string; + lastActivityAt: string | null; +} + +export function getSessionContextMetrics( + thread: Thread, + providers: ReadonlyArray, +): SessionContextMetrics { + const snapshot = deriveLatestContextWindowSnapshot(thread.activities); + + const provider = providers.find( + (candidate) => candidate.instanceId === thread.modelSelection.instanceId, + ); + const modelSlug = thread.modelSelection.model; + const model = provider?.models.find((candidate) => candidate.slug === modelSlug); + + const providerLabel = + provider?.displayName?.trim() || provider?.driver || thread.modelSelection.instanceId; + const modelLabel = model?.name?.trim() || modelSlug; + + const input = snapshot?.lastInputTokens ?? null; + const output = snapshot?.lastOutputTokens ?? null; + const reasoning = snapshot?.lastReasoningOutputTokens ?? null; + const cacheRead = snapshot?.lastCachedInputTokens ?? null; + const cacheWrite: number | null = null; + + const hasAnyToken = + input !== null || output !== null || reasoning !== null || cacheRead !== null; + const total = hasAnyToken + ? (input ?? 0) + (output ?? 0) + (reasoning ?? 0) + (cacheRead ?? 0) + : null; + + const limit = snapshot?.maxTokens ?? null; + const usedTokens = snapshot?.usedTokens ?? null; + const usage = + limit !== null && limit > 0 && usedTokens !== null && usedTokens > 0 + ? Math.round((usedTokens / limit) * 100) + : null; + + let userMessageCount = 0; + let assistantMessageCount = 0; + for (const message of thread.messages) { + if (message.role === "user") userMessageCount += 1; + else if (message.role === "assistant") assistantMessageCount += 1; + } + + const lastActivity = thread.activities[thread.activities.length - 1]; + const lastMessage = thread.messages[thread.messages.length - 1]; + const lastActivityAt = lastActivity?.createdAt ?? lastMessage?.createdAt ?? null; + + return { + sessionTitle: thread.title, + providerLabel, + modelLabel, + limit, + input, + output, + reasoning, + cacheRead, + cacheWrite, + total, + usage, + userMessageCount, + assistantMessageCount, + messageCount: thread.messages.length, + sessionCreatedAt: thread.createdAt, + lastActivityAt, + }; +} diff --git a/apps/web/src/diffRouteSearch.ts b/apps/web/src/diffRouteSearch.ts index d9b072f28e1..b1dd4ac0179 100644 --- a/apps/web/src/diffRouteSearch.ts +++ b/apps/web/src/diffRouteSearch.ts @@ -4,6 +4,7 @@ export interface DiffRouteSearch { diff?: "1" | undefined; diffTurnId?: TurnId | undefined; diffFilePath?: string | undefined; + tab?: "context" | undefined; } function isDiffOpenValue(value: unknown): boolean { @@ -20,12 +21,18 @@ function normalizeSearchString(value: unknown): string | undefined { export function stripDiffSearchParams>( params: T, -): Omit { - const { diff: _diff, diffTurnId: _diffTurnId, diffFilePath: _diffFilePath, ...rest } = params; - return rest as Omit; +): Omit { + const { diff: _diff, diffTurnId: _diffTurnId, diffFilePath: _diffFilePath, tab: _tab, ...rest } = params; + return rest as Omit; } export function parseDiffRouteSearch(search: Record): DiffRouteSearch { + const tab = search.tab === "context" ? "context" : undefined; + + if (tab === "context") { + return { tab }; + } + const diff = isDiffOpenValue(search.diff) ? "1" : undefined; const diffTurnIdRaw = diff ? normalizeSearchString(search.diffTurnId) : undefined; const diffTurnId = diffTurnIdRaw ? TurnId.make(diffTurnIdRaw) : undefined; diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index 1877fee6f7d..27d69dbb60d 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -23,6 +23,7 @@ import { createThreadSelectorByRef } from "../storeSelectors"; import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes"; import { RightPanelSheet } from "../components/RightPanelSheet"; import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; +import { SessionContextTab } from "../components/chat/SessionContextTab"; const DiffPanel = lazy(() => import("../components/DiffPanel")); const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width"; @@ -30,6 +31,10 @@ const DIFF_INLINE_DEFAULT_WIDTH = "clamp(24rem,34vw,36rem)"; const DIFF_INLINE_SIDEBAR_MIN_WIDTH = 22 * 16; const DIFF_INLINE_SIDEBAR_MAX_WIDTH = 256 * 16; const COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX = 208; +const CONTEXT_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_context_sidebar_width"; +const CONTEXT_INLINE_DEFAULT_WIDTH = "clamp(22rem,30vw,32rem)"; +const CONTEXT_INLINE_SIDEBAR_MIN_WIDTH = 20 * 16; +const CONTEXT_INLINE_SIDEBAR_MAX_WIDTH = 256 * 16; const DiffLoadingFallback = (props: { mode: DiffPanelMode }) => { return ( @@ -138,6 +143,56 @@ const DiffPanelInlineSidebar = (props: { ); }; +const ContextPanelInlineSidebar = (props: { + contextOpen: boolean; + onCloseContext: () => void; + onOpenContext: () => void; + environmentId: import("@t3tools/contracts").EnvironmentId; + threadId: import("@t3tools/contracts").ThreadId; +}) => { + const { contextOpen, onCloseContext, onOpenContext, environmentId, threadId } = props; + const onOpenChange = useCallback( + (open: boolean) => { + if (open) { + onOpenContext(); + return; + } + onCloseContext(); + }, + [onCloseContext, onOpenContext], + ); + + return ( + + + {contextOpen ? ( + + ) : null} + + + + ); +}; + function ChatThreadRouteView() { const navigate = useNavigate(); const threadRef = Route.useParams({ @@ -168,6 +223,7 @@ function ChatThreadRouteView() { const serverThreadStarted = threadHasStarted(serverThread); const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; const diffOpen = search.diff === "1"; + const contextOpen = search.tab === "context"; const shouldUseDiffSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); const currentThreadKey = threadRef ? `${threadRef.environmentId}:${threadRef.threadId}` : null; const [diffPanelMountState, setDiffPanelMountState] = useState(() => ({ @@ -213,6 +269,32 @@ function ChatThreadRouteView() { }, }); }, [markDiffOpened, navigate, threadRef]); + const closeContext = useCallback(() => { + if (!threadRef) { + return; + } + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + search: (previous) => { + const rest = stripDiffSearchParams(previous); + return { ...rest, tab: undefined }; + }, + }); + }, [navigate, threadRef]); + const openContext = useCallback(() => { + if (!threadRef) { + return; + } + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + search: (previous) => { + const rest = stripDiffSearchParams(previous); + return { ...rest, tab: "context" }; + }, + }); + }, [navigate, threadRef]); useEffect(() => { if (!threadRef || !bootstrapComplete) { @@ -245,16 +327,27 @@ function ChatThreadRouteView() { environmentId={threadRef.environmentId} threadId={threadRef.threadId} onDiffPanelOpen={markDiffOpened} - reserveTitleBarControlInset={!diffOpen} + onOpenContextTab={openContext} + reserveTitleBarControlInset={!diffOpen && !contextOpen} routeKind="server" /> - + {contextOpen ? ( + + ) : ( + + )} ); } @@ -266,11 +359,23 @@ function ChatThreadRouteView() { environmentId={threadRef.environmentId} threadId={threadRef.threadId} onDiffPanelOpen={markDiffOpened} + onOpenContextTab={openContext} routeKind="server" /> - - {shouldRenderDiffContent ? : null} + + {contextOpen ? ( + + ) : shouldRenderDiffContent ? ( + + ) : null} ); @@ -279,7 +384,7 @@ function ChatThreadRouteView() { export const Route = createFileRoute("/_chat/$environmentId/$threadId")({ validateSearch: (search) => parseDiffRouteSearch(search), search: { - middlewares: [retainSearchParams(["diff"])], + middlewares: [retainSearchParams(["diff", "tab"])], }, component: ChatThreadRouteView, }); From c7c995183b9e2f186da0a2428eba340e420e43f7 Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Mon, 25 May 2026 16:23:27 +0530 Subject: [PATCH 2/7] Improve context metrics with fallbacks and empty filtering - Add fallback to cumulative token fields (input, output, etc) - Prefer usedTokens for total calculation - Filter empty, zero, and dash values from metrics display - Enhance button accessibility with focus-visible styles - Remove cacheWrite field from metrics --- .../components/chat/ContextWindowMeter.tsx | 6 ++--- .../src/components/chat/SessionContextTab.tsx | 11 ++++++--- .../chat/sessionContextMetrics.test.ts | 23 +++++++++++++++++-- .../components/chat/sessionContextMetrics.ts | 22 ++++++++---------- 4 files changed, 41 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/chat/ContextWindowMeter.tsx b/apps/web/src/components/chat/ContextWindowMeter.tsx index abdba32f3cb..30877600c14 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.tsx +++ b/apps/web/src/components/chat/ContextWindowMeter.tsx @@ -25,7 +25,7 @@ export function ContextWindowMeter(props: { usage: ContextWindowSnapshot; onOpen - ); - } - return ( - - - - - - {usage.usedPercentage !== null - ? Math.round(usage.usedPercentage) - : formatContextWindowTokens(usage.usedTokens)} - - - - } - /> - -
-
- Context window -
- {usage.maxTokens !== null && usedPercentage ? ( -
- {usedPercentage} - - {formatContextWindowTokens(usage.usedTokens)} - / - {formatContextWindowTokens(usage.maxTokens ?? null)} context used -
- ) : ( -
- {formatContextWindowTokens(usage.usedTokens)} tokens used so far -
+
-
-
+ > + {usage.usedPercentage !== null + ? Math.round(usage.usedPercentage) + : formatContextWindowTokens(usage.usedTokens)} + + + ); } diff --git a/apps/web/src/components/chat/SessionContextTab.tsx b/apps/web/src/components/chat/SessionContextTab.tsx index db746e75a31..1a11282c576 100644 --- a/apps/web/src/components/chat/SessionContextTab.tsx +++ b/apps/web/src/components/chat/SessionContextTab.tsx @@ -2,11 +2,7 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { BarChart3, ChevronDown, X } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef } from "react"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "~/components/ui/collapsible"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "~/components/ui/collapsible"; import { useServerProviders } from "~/rpc/serverState"; import { useStore } from "~/store"; import { createThreadSelectorByRef } from "~/storeSelectors"; @@ -19,10 +15,7 @@ import { type SessionContextBreakdownSegment, } from "./sessionContextBreakdown"; import { createSessionContextFormatter } from "./sessionContextFormat"; -import { - getSessionContextMetrics, - type SessionContextMetrics, -} from "./sessionContextMetrics"; +import { getSessionContextMetrics, type SessionContextMetrics } from "./sessionContextMetrics"; const SEGMENT_COLORS: Record = { system: "bg-amber-500", @@ -48,15 +41,8 @@ interface SessionContextTabProps { onClose: () => void; } -export function SessionContextTab({ - environmentId, - threadId, - onClose, -}: SessionContextTabProps) { - const threadRef = useMemo( - () => ({ environmentId, threadId }), - [environmentId, threadId], - ); +export function SessionContextTab({ environmentId, threadId, onClose }: SessionContextTabProps) { + const threadRef = useMemo(() => ({ environmentId, threadId }), [environmentId, threadId]); const thread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); const providers = useServerProviders(); @@ -138,7 +124,7 @@ function SessionContextTabHeader({ onClose }: { onClose: () => void }) {
- Context + Context Usage
))} + {metrics.compactsAutomatically ? ( +

+ Automatically compacts its context when needed. +

+ ) : null} ); } @@ -220,9 +209,7 @@ function BreakdownSection({ Breakdown {segments.length === 0 ? ( -

- Not enough data to compute a breakdown yet. -

+

Not enough data to compute a breakdown yet.

) : ( <>
Date: Mon, 25 May 2026 16:49:04 +0530 Subject: [PATCH 5/7] Clear tab state when toggling diff panel view --- apps/web/src/components/ChatView.tsx | 8 +++++--- apps/web/src/routes/_chat.$environmentId.$threadId.tsx | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index aaa701e2ef0..55e738ecda7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1716,7 +1716,9 @@ export default function ChatView(props: ChatViewProps) { replace: true, search: (previous) => { const rest = stripDiffSearchParams(previous); - return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; + return diffOpen + ? { ...rest, diff: undefined, tab: undefined } + : { ...rest, diff: "1", tab: undefined }; }, }); }, [diffOpen, environmentId, isServerThread, navigate, onDiffPanelOpen, threadId]); @@ -3472,8 +3474,8 @@ export default function ChatView(props: ChatViewProps) { search: (previous) => { const rest = stripDiffSearchParams(previous); return filePath - ? { ...rest, diff: "1", diffTurnId: turnId, diffFilePath: filePath } - : { ...rest, diff: "1", diffTurnId: turnId }; + ? { ...rest, diff: "1", diffTurnId: turnId, diffFilePath: filePath, tab: undefined } + : { ...rest, diff: "1", diffTurnId: turnId, tab: undefined }; }, }); }, diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index 27d69dbb60d..ed262ffcfa0 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -265,7 +265,7 @@ function ChatThreadRouteView() { params: buildThreadRouteParams(threadRef), search: (previous) => { const rest = stripDiffSearchParams(previous); - return { ...rest, diff: "1" }; + return { ...rest, diff: "1", tab: undefined }; }, }); }, [markDiffOpened, navigate, threadRef]); From b1ea15f55ea474792ff182fb064908256c586026 Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Mon, 25 May 2026 17:01:20 +0530 Subject: [PATCH 6/7] Standardize code formatting across session context files --- .../chat/sessionContextBreakdown.test.ts | 5 +---- .../components/chat/sessionContextBreakdown.ts | 15 ++------------- .../components/chat/sessionContextMetrics.test.ts | 7 ++++++- .../src/components/chat/sessionContextMetrics.ts | 3 +-- apps/web/src/diffRouteSearch.ts | 8 +++++++- 5 files changed, 17 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/chat/sessionContextBreakdown.test.ts b/apps/web/src/components/chat/sessionContextBreakdown.test.ts index 90524faf7ef..17e760772d0 100644 --- a/apps/web/src/components/chat/sessionContextBreakdown.test.ts +++ b/apps/web/src/components/chat/sessionContextBreakdown.test.ts @@ -136,10 +136,7 @@ describe("sessionContextBreakdown", () => { it("emits widths matching the per-segment ratios", () => { const breakdown = estimateSessionContextBreakdown({ - messages: [ - makeMessage("user", "a".repeat(400)), - makeMessage("assistant", "b".repeat(400)), - ], + messages: [makeMessage("user", "a".repeat(400)), makeMessage("assistant", "b".repeat(400))], activities: [], input: 1000, }); diff --git a/apps/web/src/components/chat/sessionContextBreakdown.ts b/apps/web/src/components/chat/sessionContextBreakdown.ts index 2d92abec1e5..81262ca12c3 100644 --- a/apps/web/src/components/chat/sessionContextBreakdown.ts +++ b/apps/web/src/components/chat/sessionContextBreakdown.ts @@ -2,12 +2,7 @@ import type { OrchestrationThreadActivity } from "@t3tools/contracts"; import type { ChatMessage } from "~/types"; -export type SessionContextBreakdownKey = - | "system" - | "user" - | "assistant" - | "tool" - | "other"; +export type SessionContextBreakdownKey = "system" | "user" | "assistant" | "tool" | "other"; export interface SessionContextBreakdownSegment { key: SessionContextBreakdownKey; @@ -17,13 +12,7 @@ export interface SessionContextBreakdownSegment { } const CHARS_PER_TOKEN = 4; -const ORDERED_KEYS: SessionContextBreakdownKey[] = [ - "system", - "user", - "assistant", - "tool", - "other", -]; +const ORDERED_KEYS: SessionContextBreakdownKey[] = ["system", "user", "assistant", "tool", "other"]; function charsToTokens(chars: number): number { if (chars <= 0) return 0; diff --git a/apps/web/src/components/chat/sessionContextMetrics.test.ts b/apps/web/src/components/chat/sessionContextMetrics.test.ts index 79c2eca6e82..9d2a89727b1 100644 --- a/apps/web/src/components/chat/sessionContextMetrics.test.ts +++ b/apps/web/src/components/chat/sessionContextMetrics.test.ts @@ -11,7 +11,12 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; -import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatMessage, type Thread } from "~/types"; +import { + DEFAULT_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + type ChatMessage, + type Thread, +} from "~/types"; import { getSessionContextMetrics } from "./sessionContextMetrics"; const environmentId = EnvironmentId.make("env-1"); diff --git a/apps/web/src/components/chat/sessionContextMetrics.ts b/apps/web/src/components/chat/sessionContextMetrics.ts index 09073e10653..96bea7ddedb 100644 --- a/apps/web/src/components/chat/sessionContextMetrics.ts +++ b/apps/web/src/components/chat/sessionContextMetrics.ts @@ -45,8 +45,7 @@ export function getSessionContextMetrics( const limit = snapshot?.maxTokens ?? null; const usedTokens = snapshot?.usedTokens ?? null; - const hasAnyToken = - input !== null || output !== null || reasoning !== null || cacheRead !== null; + const hasAnyToken = input !== null || output !== null || reasoning !== null || cacheRead !== null; const total = usedTokens ?? (hasAnyToken ? (input ?? 0) + (output ?? 0) + (reasoning ?? 0) + (cacheRead ?? 0) : null); diff --git a/apps/web/src/diffRouteSearch.ts b/apps/web/src/diffRouteSearch.ts index b1dd4ac0179..42065dfc106 100644 --- a/apps/web/src/diffRouteSearch.ts +++ b/apps/web/src/diffRouteSearch.ts @@ -22,7 +22,13 @@ function normalizeSearchString(value: unknown): string | undefined { export function stripDiffSearchParams>( params: T, ): Omit { - const { diff: _diff, diffTurnId: _diffTurnId, diffFilePath: _diffFilePath, tab: _tab, ...rest } = params; + const { + diff: _diff, + diffTurnId: _diffTurnId, + diffFilePath: _diffFilePath, + tab: _tab, + ...rest + } = params; return rest as Omit; } From 6bbad50229e3d0810ba32ab6ada5d835bfd7c8b4 Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Mon, 25 May 2026 17:17:34 +0530 Subject: [PATCH 7/7] updating tailing classes (to trigger failed ci mainly) --- apps/web/src/components/chat/ContextWindowMeter.tsx | 2 +- apps/web/src/components/chat/SessionContextTab.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/ContextWindowMeter.tsx b/apps/web/src/components/chat/ContextWindowMeter.tsx index f69a6d737f6..fdb2bef080b 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.tsx +++ b/apps/web/src/components/chat/ContextWindowMeter.tsx @@ -62,7 +62,7 @@ export function ContextWindowMeter(props: { diff --git a/apps/web/src/components/chat/SessionContextTab.tsx b/apps/web/src/components/chat/SessionContextTab.tsx index 1a11282c576..403c6960748 100644 --- a/apps/web/src/components/chat/SessionContextTab.tsx +++ b/apps/web/src/components/chat/SessionContextTab.tsx @@ -283,7 +283,7 @@ function RawMessagesSection({ className="group flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-xs" >