mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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)
This commit is contained in:
+34
-3
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
// Default empty suggestion handler
|
||||
const defaultSuggestionHandler = async (): Promise<Suggestion[]> => []
|
||||
|
||||
// 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<ChatInputHandle, ChatInputProps>(function ChatInput(props, ref) {
|
||||
const {
|
||||
disabled = false,
|
||||
@@ -74,6 +160,9 @@ export const ChatInput = memo(forwardRef<ChatInputHandle, ChatInputProps>(functi
|
||||
permissionMode = 'default',
|
||||
modelMode = 'default',
|
||||
active = true,
|
||||
thinking = false,
|
||||
agentState,
|
||||
contextSize,
|
||||
onPermissionModeChange,
|
||||
onModelModeChange,
|
||||
onAbort,
|
||||
@@ -81,6 +170,18 @@ export const ChatInput = memo(forwardRef<ChatInputHandle, ChatInputProps>(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<TextInputState>({
|
||||
@@ -408,8 +509,27 @@ export const ChatInput = memo(forwardRef<ChatInputHandle, ChatInputProps>(functi
|
||||
)}
|
||||
|
||||
{/* Status bar */}
|
||||
{(permissionMode && permissionMode !== 'default') && (
|
||||
<div className="flex items-center justify-end px-2 pb-1">
|
||||
<div className="flex items-center justify-between px-2 pb-1">
|
||||
{/* Left side: connection status and context */}
|
||||
<div className="flex items-baseline gap-3">
|
||||
{/* Connection status */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${connectionStatus.dotColor} ${connectionStatus.isPulsing ? 'animate-pulse' : ''}`}
|
||||
/>
|
||||
<span className={`text-xs ${connectionStatus.color}`}>
|
||||
{connectionStatus.text}
|
||||
</span>
|
||||
</div>
|
||||
{/* Context warning */}
|
||||
{contextWarning && (
|
||||
<span className={`text-[10px] ${contextWarning.color}`}>
|
||||
{contextWarning.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Right side: permission mode */}
|
||||
{(permissionMode && permissionMode !== 'default') && (
|
||||
<span className={`text-xs ${
|
||||
permissionMode === 'acceptEdits' ? 'text-amber-500' :
|
||||
permissionMode === 'bypassPermissions' ? 'text-red-500' :
|
||||
@@ -418,8 +538,8 @@ export const ChatInput = memo(forwardRef<ChatInputHandle, ChatInputProps>(functi
|
||||
}`}>
|
||||
{PERMISSION_MODE_LABELS[permissionMode]}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Unified panel */}
|
||||
<div className="overflow-hidden rounded-[20px] bg-[var(--app-secondary-bg)]">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -44,21 +44,9 @@ export function SessionHeader(props: {
|
||||
{/* Session info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className={props.session.active ? 'h-2.5 w-2.5 rounded-full bg-emerald-500' : 'h-2.5 w-2.5 rounded-full bg-gray-400'}
|
||||
title={props.session.active ? 'active' : 'offline'}
|
||||
aria-label={props.session.active ? 'active' : 'offline'}
|
||||
/>
|
||||
<div className="truncate font-semibold">
|
||||
{title}
|
||||
</div>
|
||||
{props.session.thinking ? (
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full bg-amber-400 animate-pulse"
|
||||
title="thinking"
|
||||
aria-label="thinking"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--app-hint)] truncate">
|
||||
{props.session.metadata?.host ? `Host: ${props.session.metadata.host}` : props.session.id}
|
||||
|
||||
Reference in New Issue
Block a user