From 031116bfceaee0e32507af062d24edb7adab8801 Mon Sep 17 00:00:00 2001 From: weishu Date: Wed, 17 Dec 2025 09:35:45 +0800 Subject: [PATCH] feat: Add online status display and context usage tracking to ChatInput - Display connection status indicators (online, offline, thinking, permission required) in ChatInput with dynamic vibing messages - Add context size percentage display showing remaining context availability - Track latest usage data in reducer with inputTokens, outputTokens, cache metrics, and contextSize - Calculate contextSize from message usage data and expose via latestUsage in reduceChatBlocks - Pass thinking, agentState, and contextSize props to ChatInput component - Remove redundant status indicators from SessionHeader (moved to ChatInput for unified display) --- web/src/chat/reducer.ts | 37 +++++++- web/src/components/ChatInput.tsx | 132 +++++++++++++++++++++++++-- web/src/components/SessionChat.tsx | 3 + web/src/components/SessionHeader.tsx | 12 --- 4 files changed, 163 insertions(+), 21 deletions(-) diff --git a/web/src/chat/reducer.ts b/web/src/chat/reducer.ts index ef5ae6c5..9d6f8a27 100644 --- a/web/src/chat/reducer.ts +++ b/web/src/chat/reducer.ts @@ -1,7 +1,12 @@ import type { AgentState } from '@/types/api' -import type { AgentEvent, ChatBlock, ChatToolCall, NormalizedMessage, ToolCallBlock, ToolPermission } from '@/chat/types' +import type { AgentEvent, ChatBlock, ChatToolCall, NormalizedMessage, ToolCallBlock, ToolPermission, UsageData } from '@/chat/types' import { traceMessages, type TracedMessage } from '@/chat/tracer' +// Calculate context size from usage data +function calculateContextSize(usage: UsageData): number { + return (usage.cache_creation_input_tokens || 0) + (usage.cache_read_input_tokens || 0) + usage.input_tokens +} + function parseClaudeUsageLimit(text: string): number | null { const match = text.match(/^Claude AI usage limit reached\|(\d+)$/) if (!match) return null @@ -435,10 +440,19 @@ function reduceTimeline( return { blocks, toolBlocksById, hasReadyEvent } } +export type LatestUsage = { + inputTokens: number + outputTokens: number + cacheCreation: number + cacheRead: number + contextSize: number + timestamp: number +} + export function reduceChatBlocks( normalized: NormalizedMessage[], agentState: AgentState | null | undefined -): { blocks: ChatBlock[]; hasReadyEvent: boolean } { +): { blocks: ChatBlock[]; hasReadyEvent: boolean; latestUsage: LatestUsage | null } { const permissionsById = getPermissions(agentState) const toolIdsInMessages = collectToolIdsFromMessages(normalized) const titleChangesByToolUseId = collectTitleChanges(normalized) @@ -508,5 +522,22 @@ export function reduceChatBlocks( } } - return { blocks: dedupeAgentEvents(rootResult.blocks), hasReadyEvent } + // Calculate latest usage from messages (find the most recent message with usage data) + let latestUsage: LatestUsage | null = null + for (let i = normalized.length - 1; i >= 0; i--) { + const msg = normalized[i] + if (msg.usage) { + latestUsage = { + inputTokens: msg.usage.input_tokens, + outputTokens: msg.usage.output_tokens, + cacheCreation: msg.usage.cache_creation_input_tokens ?? 0, + cacheRead: msg.usage.cache_read_input_tokens ?? 0, + contextSize: calculateContextSize(msg.usage), + timestamp: msg.createdAt + } + break + } + } + + return { blocks: dedupeAgentEvents(rootResult.blocks), hasReadyEvent, latestUsage } } diff --git a/web/src/components/ChatInput.tsx b/web/src/components/ChatInput.tsx index 9ce8d005..743b7c32 100644 --- a/web/src/components/ChatInput.tsx +++ b/web/src/components/ChatInput.tsx @@ -5,10 +5,11 @@ import { useEffect, useImperativeHandle, forwardRef, - memo + memo, + useMemo } from 'react' import TextareaAutosize from 'react-textarea-autosize' -import type { ModelMode, PermissionMode } from '@/types/api' +import type { AgentState, ModelMode, PermissionMode } from '@/types/api' import type { Suggestion } from '@/hooks/useActiveSuggestions' import { useActiveWord } from '@/hooks/useActiveWord' import { useActiveSuggestions } from '@/hooks/useActiveSuggestions' @@ -38,6 +39,10 @@ export interface ChatInputProps { permissionMode?: PermissionMode modelMode?: ModelMode active?: boolean + thinking?: boolean + agentState?: AgentState | null + // Usage data for context display + contextSize?: number // Callbacks onPermissionModeChange?: (mode: PermissionMode) => void onModelModeChange?: (mode: ModelMode) => void @@ -67,6 +72,87 @@ const MODEL_MODE_LABELS: Record = { // Default empty suggestion handler const defaultSuggestionHandler = async (): Promise => [] +// Max context size for percentage calculation +const MAX_CONTEXT_SIZE = 190000 + +// Vibing messages for thinking state +const VIBING_MESSAGES = [ + "Accomplishing", "Actioning", "Actualizing", "Baking", "Booping", "Brewing", + "Calculating", "Cerebrating", "Channelling", "Churning", "Clauding", "Coalescing", + "Cogitating", "Computing", "Combobulating", "Concocting", "Conjuring", "Considering", + "Contemplating", "Cooking", "Crafting", "Creating", "Crunching", "Deciphering", + "Deliberating", "Determining", "Discombobulating", "Divining", "Doing", "Effecting", + "Elucidating", "Enchanting", "Envisioning", "Finagling", "Flibbertigibbeting", + "Forging", "Forming", "Frolicking", "Generating", "Germinating", "Hatching", + "Herding", "Honking", "Ideating", "Imagining", "Incubating", "Inferring", + "Manifesting", "Marinating", "Meandering", "Moseying", "Mulling", "Mustering", + "Musing", "Noodling", "Percolating", "Perusing", "Philosophising", "Pontificating", + "Pondering", "Processing", "Puttering", "Puzzling", "Reticulating", "Ruminating", + "Scheming", "Schlepping", "Shimmying", "Simmering", "Smooshing", "Spelunking", + "Spinning", "Stewing", "Sussing", "Synthesizing", "Thinking", "Tinkering", + "Transmuting", "Unfurling", "Unravelling", "Vibing", "Wandering", "Whirring", + "Wibbling", "Wizarding", "Working", "Wrangling" +] + +// Get connection status based on session state +function getConnectionStatus( + active: boolean, + thinking: boolean, + agentState: AgentState | null | undefined +): { text: string; color: string; dotColor: string; isPulsing: boolean } { + const hasPermissions = agentState?.requests && Object.keys(agentState.requests).length > 0 + + if (!active) { + return { + text: 'offline', + color: 'text-[#999]', + dotColor: 'bg-[#999]', + isPulsing: false + } + } + + if (hasPermissions) { + return { + text: 'permission required', + color: 'text-[#FF9500]', + dotColor: 'bg-[#FF9500]', + isPulsing: true + } + } + + if (thinking) { + const vibingMessage = VIBING_MESSAGES[Math.floor(Math.random() * VIBING_MESSAGES.length)].toLowerCase() + '…' + return { + text: vibingMessage, + color: 'text-[#007AFF]', + dotColor: 'bg-[#007AFF]', + isPulsing: true + } + } + + return { + text: 'online', + color: 'text-[#34C759]', + dotColor: 'bg-[#34C759]', + isPulsing: false + } +} + +// Get context warning based on usage +function getContextWarning(contextSize: number): { text: string; color: string } | null { + const percentageUsed = (contextSize / MAX_CONTEXT_SIZE) * 100 + const percentageRemaining = 100 - percentageUsed + + if (percentageRemaining <= 5) { + return { text: `${Math.round(percentageRemaining)}% left`, color: 'text-red-500' } + } else if (percentageRemaining <= 10) { + return { text: `${Math.round(percentageRemaining)}% left`, color: 'text-amber-500' } + } else { + // Always show context percentage + return { text: `${Math.round(percentageRemaining)}% left`, color: 'text-[var(--app-hint)]' } + } +} + export const ChatInput = memo(forwardRef(function ChatInput(props, ref) { const { disabled = false, @@ -74,6 +160,9 @@ export const ChatInput = memo(forwardRef(functi permissionMode = 'default', modelMode = 'default', active = true, + thinking = false, + agentState, + contextSize, onPermissionModeChange, onModelModeChange, onAbort, @@ -81,6 +170,18 @@ export const ChatInput = memo(forwardRef(functi autocompleteSuggestions = defaultSuggestionHandler } = props + // Compute connection status + const connectionStatus = useMemo( + () => getConnectionStatus(active, thinking, agentState), + [active, thinking, agentState] + ) + + // Compute context warning + const contextWarning = useMemo( + () => contextSize !== undefined ? getContextWarning(contextSize) : null, + [contextSize] + ) + // State const [text, setText] = useState('') const [inputState, setInputState] = useState({ @@ -408,8 +509,27 @@ export const ChatInput = memo(forwardRef(functi )} {/* Status bar */} - {(permissionMode && permissionMode !== 'default') && ( -
+
+ {/* Left side: connection status and context */} +
+ {/* Connection status */} +
+ + + {connectionStatus.text} + +
+ {/* Context warning */} + {contextWarning && ( + + {contextWarning.text} + + )} +
+ {/* Right side: permission mode */} + {(permissionMode && permissionMode !== 'default') && ( (functi }`}> {PERMISSION_MODE_LABELS[permissionMode]} -
- )} + )} +
{/* Unified panel */}
diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 63a74d01..45b1bf38 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -192,6 +192,9 @@ export function SessionChat(props: { permissionMode={props.session.permissionMode} modelMode={props.session.modelMode} active={props.session.active} + thinking={props.session.thinking} + agentState={props.session.agentState} + contextSize={reduced.latestUsage?.contextSize} onPermissionModeChange={handlePermissionModeChange} onModelModeChange={handleModelModeChange} onAbort={handleAbort} diff --git a/web/src/components/SessionHeader.tsx b/web/src/components/SessionHeader.tsx index aa93235c..7428fa5c 100644 --- a/web/src/components/SessionHeader.tsx +++ b/web/src/components/SessionHeader.tsx @@ -44,21 +44,9 @@ export function SessionHeader(props: { {/* Session info */}
-
{title}
- {props.session.thinking ? ( - - ) : null}
{props.session.metadata?.host ? `Host: ${props.session.metadata.host}` : props.session.id}