gadget/gadget-code/frontend/src/components/SessionPanel.tsx
Rob Colbert 00969cf9bc feat: token economics — real API token extraction, stats propagation, and context window fuel gauge
Phase 1: OpenAI API Token Extraction
- Add stream_options: { include_usage: true } to all streaming API calls
- Capture chunk.usage from final streaming chunks and response.usage from non-streaming
- Extend OpenAiChatIterationResult with optional usage field
- Update buildStats() to accept and return real token counts from usage data
- Wire iteration.usage through chat() to both buildStats() call sites

Phase 2: Agent Loop Stats Propagation
- Rename inputTokens/outputTokens to masterInputTokens/masterOutputTokens, add masterThinkingTokens
- Accumulate response.stats.tokenCounts after each master AI call
- Delete all Math.ceil(length/4) crude approximations (master and subagent loops)
- Track startTime/durationMs and emit IWorkOrderCompleteStats with workOrderComplete
- Subagent loop uses response.stats?.tokenCounts instead of Math.ceil

Phase 3: Database Model Changes
- Add contextWindowUsage field to IChatSession, ChatSessionSchema, and frontend ChatSession
- Initialize contextWindowUsage: 0 on session creation

Phase 4: Persist Stats on Turn Completion
- drone-session: accept IWorkOrderCompleteStats, persist turn stats, walk subagent records for aggregate
- drone-session: $inc session stats and contextWindowUsage, add formatDurationLabel() helper
- code-session: accept and forward stats, update in-memory session stats
- message-queue: Redis replay handles 4th stats arg
- Update WorkOrderCompleteMessage type in @gadget/api to accept stats parameter

Phase 5: UI — Context Window Fuel Gauge
- Add contextWindowUsage prop to SessionPanel
- Add fuel gauge bar with E→F labels, green/yellow/red zones, token count display
- Visible for ALL provider types (not gated by apiType)

Phase 6: Frontend Streaming State
- Add IWorkOrderCompleteStats interface to frontend api.ts
- handleWorkOrderComplete accepts stats, updates turn stats and session contextWindowUsage
- Pass contextWindowUsage prop to SessionPanel
2026-05-18 14:36:36 -04:00

329 lines
15 KiB
TypeScript

import { ChatSessionMode, type AiProvider, type ChatSession, type AiModel } from '../lib/api';
import { WorkspaceMode } from '../lib/types';
import WorkspaceModeIndicator from './WorkspaceModeIndicator';
interface SessionPanelProps {
session: ChatSession | null;
workspaceMode: WorkspaceMode;
sessionLocked: boolean;
providers: AiProvider[];
selectedProviderId: string;
selectedModelId: string;
sessionReasoningEffort: string;
sessionNumCtx: number;
isEditingProvider: boolean;
editProviderId: string;
editModelId: string;
isUpdatingProvider: boolean;
isEditingName: boolean;
editName: string;
isUpdatingName: boolean;
isUpdatingMode: boolean;
isUpdatingReasoning: boolean;
isUpdatingNumCtx: boolean;
onWorkspaceModeChange: (mode: WorkspaceMode) => void;
onModeChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
onReasoningChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
onNumCtxChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onNumCtxCommit: (e: React.MouseEvent<HTMLInputElement> | React.TouchEvent<HTMLInputElement>) => void;
onStartEditProvider: () => void;
onSaveProvider: () => void;
onCancelEditProvider: () => void;
onEditProviderIdChange: (id: string) => void;
onEditModelIdChange: (id: string) => void;
onStartEditName: () => void;
onSaveName: () => void;
onCancelEditName: () => void;
onEditNameChange: (name: string) => void;
getProviderName: (id: string) => string;
getModelName: (providerId: string, modelId: string) => string;
getSortedModels: (providerId: string) => AiProvider['models'];
truncateModelName: (name: string, maxLength?: number) => string;
getSelectedModelCapabilities: () => { hasThinking: boolean } | null;
getSelectedModel: () => AiModel | null;
contextWindowUsage: number;
}
export default function SessionPanel({
session,
workspaceMode,
sessionLocked,
providers,
selectedProviderId,
selectedModelId,
sessionReasoningEffort,
sessionNumCtx,
isEditingProvider,
editProviderId,
editModelId,
isUpdatingProvider,
isEditingName,
editName,
isUpdatingName,
isUpdatingMode,
isUpdatingReasoning,
isUpdatingNumCtx,
onWorkspaceModeChange,
onModeChange,
onReasoningChange,
onNumCtxChange,
onNumCtxCommit,
onStartEditProvider,
onSaveProvider,
onCancelEditProvider,
onEditProviderIdChange,
onEditModelIdChange,
onStartEditName,
onSaveName,
onCancelEditName,
onEditNameChange,
getProviderName,
getModelName,
getSortedModels,
truncateModelName,
getSelectedModelCapabilities,
getSelectedModel,
contextWindowUsage,
}: SessionPanelProps) {
const usagePercent = sessionNumCtx > 0 ? (contextWindowUsage / sessionNumCtx) * 100 : 0;
return (
<div className="border-b border-border-subtle shrink-0">
<div className="flex items-center justify-between px-4 py-2 bg-bg-tertiary">
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
Session
</h3>
<WorkspaceModeIndicator
mode={workspaceMode}
onChange={onWorkspaceModeChange}
disabled={!sessionLocked || isEditingProvider}
/>
</div>
<div className="p-4 space-y-3">
<div>
<div className="text-xs text-text-muted">Name</div>
{isEditingName ? (
<div className="flex gap-2 mt-1">
<input
type="text"
value={editName}
onChange={(e) => onEditNameChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); onSaveName(); }
if (e.key === 'Escape') { e.preventDefault(); onCancelEditName(); }
}}
className="flex-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none"
autoFocus
/>
<button
title="Save"
onClick={onSaveName}
disabled={!editName.trim() || isUpdatingName}
className="text-green-500 hover:text-green-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors self-center"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</button>
<button
title="Cancel"
onClick={onCancelEditName}
disabled={isUpdatingName}
className="text-red-500 hover:text-red-400 disabled:opacity-30 transition-colors self-center"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
) : (
<div className="flex gap-2 items-center mt-1">
<div className="text-text-primary flex-1">{session?.name}</div>
<button
title="Edit name"
onClick={onStartEditName}
className="text-text-muted hover:text-text-primary transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</div>
)}
</div>
{isEditingProvider ? (
<div className="flex gap-2">
<div className="flex-1">
<div className="text-xs text-text-muted">Provider</div>
<select
value={editProviderId || ''}
onChange={(e) => { onEditProviderIdChange(e.target.value); onEditModelIdChange(''); }}
className="w-full mt-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none"
>
<option value="">-- Select Provider --</option>
{providers.map((provider) => (
<option key={provider._id} value={provider._id}>
{provider.name}
</option>
))}
</select>
</div>
<div className="flex-1">
<div className="text-xs text-text-muted">Model</div>
<select
value={editModelId || ''}
onChange={(e) => onEditModelIdChange(e.target.value)}
disabled={!editProviderId}
className="w-full mt-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed truncate"
>
{!editProviderId ? (
<option value="">Select provider first</option>
) : (
<>
<option value="">-- Select Model --</option>
{getSortedModels(editProviderId).map((model) => (
<option key={model.id} value={model.id}>
{truncateModelName(model.name)}
</option>
))}
</>
)}
</select>
</div>
<div className="flex items-end gap-1 pb-1">
<button
title="Save"
onClick={onSaveProvider}
disabled={!editProviderId || !editModelId || isUpdatingProvider}
className="text-green-500 hover:text-green-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</button>
<button
title="Cancel"
onClick={onCancelEditProvider}
disabled={isUpdatingProvider}
className="text-red-500 hover:text-red-400 disabled:opacity-30 transition-colors"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
) : (
<div className="flex gap-2">
<div className="flex-1">
<div className="text-xs text-text-muted">Provider</div>
<div className="text-text-primary text-sm mt-1">{getProviderName(selectedProviderId)}</div>
</div>
<div className="flex-1">
<div className="text-xs text-text-muted">Model</div>
<div
className="text-text-primary text-sm mt-1 truncate"
title={getModelName(selectedProviderId, selectedModelId)}
>
{getModelName(selectedProviderId, selectedModelId)}
</div>
</div>
<button
title="Change provider/model..."
onClick={onStartEditProvider}
className="self-end pb-1 text-text-muted hover:text-text-primary transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</div>
)}
<div>
<div className="text-xs text-text-muted">Mode</div>
<select
value={session?.mode || ChatSessionMode.Build}
onChange={onModeChange}
disabled={isUpdatingMode}
className="w-full mt-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
>
{Object.values(ChatSessionMode).map((mode) => (
<option key={mode} value={mode}>
{mode.charAt(0).toUpperCase() + mode.slice(1)}
</option>
))}
</select>
</div>
<div>
<div className="text-xs text-text-muted">Reasoning</div>
<select
value={sessionReasoningEffort}
onChange={onReasoningChange}
disabled={isUpdatingReasoning || !getSelectedModelCapabilities()?.hasThinking}
className="w-full mt-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
title={
!getSelectedModelCapabilities()?.hasThinking
? "Selected model does not support reasoning"
: "Controls how much the model thinks before responding"
}
>
<option value="off">Off</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
{providers.find(p => p._id === selectedProviderId)?.apiType !== 'openai' && (
<div>
<div className="text-xs text-text-muted">Context Window</div>
<div className="flex items-center gap-2 mt-1">
<input
type="range"
min={16384}
max={getSelectedModel()?.contextWindow || 131072}
step={8192}
value={sessionNumCtx}
onChange={onNumCtxChange}
onMouseUp={onNumCtxCommit}
onTouchEnd={onNumCtxCommit}
className="flex-1 h-2 bg-bg-tertiary rounded appearance-none cursor-pointer accent-brand"
/>
<span className="text-xs text-text-secondary w-16 text-right tabular-nums">
{sessionNumCtx.toLocaleString()}
</span>
</div>
</div>
)}
<div>
<div className="text-xs text-text-muted">Context Window Usage</div>
<div className="mt-1">
<div className="flex items-center gap-1.5">
<span className="text-[10px] font-mono text-text-muted w-3">E</span>
<div className="flex-1 h-3 bg-bg-tertiary rounded-full overflow-hidden border border-border-subtle">
<div
className={`h-full rounded-full transition-all duration-300 ${
usagePercent >= 80 ? 'bg-red-500' :
usagePercent >= 50 ? 'bg-yellow-500' :
'bg-green-500'
}`}
style={{ width: `${Math.min(usagePercent, 100)}%` }}
/>
</div>
<span className="text-[10px] font-mono text-text-muted w-3">F</span>
</div>
<div className="flex justify-between mt-0.5">
<span className="text-[10px] text-text-muted tabular-nums">
{contextWindowUsage.toLocaleString()} tokens
</span>
<span className="text-[10px] text-text-muted tabular-nums">
{sessionNumCtx.toLocaleString()} max
</span>
</div>
</div>
</div>
</div>
</div>
);
}