wip: context-meter + model-label UI and provider/inference tweaks
Checkpoint of in-flight work so the orchestrator branch can rebase onto a clean main: ContextBar → ContextMeter, model-label helper, model/agent picker + provider-snapshot/registry changes, inference payload + message-columns. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { BottomSheet } from '@/components/BottomSheet';
|
||||
import { useViewport } from '@/hooks/useViewport';
|
||||
import { formatModelLabel } from '@/lib/model-label';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const PREFS_KEY = 'boocode.coder.agent-prefs';
|
||||
@@ -95,9 +96,12 @@ interface PickerProps {
|
||||
icon?: React.ReactNode;
|
||||
/** Mobile: render icon + chevron only (no value label) to save row width. */
|
||||
iconOnly?: boolean;
|
||||
/** Grow to fill the row's free space and render the value brighter — used for
|
||||
* the Model picker so the active model is the most visible control. */
|
||||
flexible?: boolean;
|
||||
}
|
||||
|
||||
function CompactPicker({ label, value, disabled, options, onPick, icon, iconOnly }: PickerProps) {
|
||||
function CompactPicker({ label, value, disabled, options, onPick, icon, iconOnly, flexible }: PickerProps) {
|
||||
const { isMobile } = useViewport();
|
||||
const [open, setOpen] = useState(false);
|
||||
const currentLabel = options.find((o) => o.id === value)?.label ?? (value || label);
|
||||
@@ -129,10 +133,16 @@ function CompactPicker({ label, value, disabled, options, onPick, icon, iconOnly
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label={`${label}: ${currentLabel}`}
|
||||
className="inline-flex items-center gap-1 min-h-[44px] px-1.5 rounded text-xs text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 min-h-[36px] rounded-lg border border-border bg-muted/50 hover:bg-muted text-xs hover:text-foreground disabled:opacity-40',
|
||||
iconOnly ? 'px-1.5' : 'px-2.5',
|
||||
flexible ? 'flex-1 min-w-0 text-foreground' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{!iconOnly && <span className="truncate max-w-[120px]">{currentLabel}</span>}
|
||||
{!iconOnly && (
|
||||
<span className={cn('truncate', flexible ? 'flex-1 text-left' : 'max-w-[120px]')}>{currentLabel}</span>
|
||||
)}
|
||||
<ChevronDown className="size-3 opacity-70 shrink-0" />
|
||||
</button>
|
||||
<BottomSheet open={open} onClose={() => setOpen(false)} title={label}>
|
||||
@@ -148,10 +158,14 @@ function CompactPicker({ label, value, disabled, options, onPick, icon, iconOnly
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted/60 disabled:opacity-40"
|
||||
className={cn(
|
||||
'text-xs hover:text-foreground flex items-center gap-1 py-1 rounded-lg border border-border bg-muted/50 hover:bg-muted disabled:opacity-40',
|
||||
iconOnly ? 'px-1.5' : 'px-2.5',
|
||||
flexible ? 'flex-1 min-w-0 text-foreground' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<span className="truncate max-w-[180px]">{currentLabel}</span>
|
||||
<span className={cn('truncate', flexible ? 'flex-1 text-left' : 'max-w-[180px]')}>{currentLabel}</span>
|
||||
<ChevronDown className="size-3 opacity-70 shrink-0" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -337,31 +351,47 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
||||
|
||||
const providerOptions = entries.map((e) => ({ id: e.name, label: e.label }));
|
||||
const modeOptions = (currentEntry?.modes ?? []).map((m) => ({ id: m.id, label: m.label }));
|
||||
const modelOptions = (currentEntry?.models ?? []).map((m) => ({ id: m.id, label: m.label }));
|
||||
const modelOptions = (currentEntry?.models ?? []).map((m) => ({ id: m.id, label: formatModelLabel(m.label) }));
|
||||
const thinkingOpts = thinkingOptions.map((t) => ({ id: t.id, label: t.label }));
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-2 py-1 border-b border-border bg-muted/20 shrink-0">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 border-b border-border bg-muted/20 shrink-0">
|
||||
<CompactPicker
|
||||
label="Provider"
|
||||
value={value.provider}
|
||||
options={providerOptions}
|
||||
onPick={pickProvider}
|
||||
icon={
|
||||
currentEntry?.status === 'loading'
|
||||
? <Loader2 size={13} className="shrink-0 animate-spin" />
|
||||
: providerIcon(value.provider)
|
||||
<>
|
||||
{/* Status indicator lives inside the agent button, to its left:
|
||||
normalized agent status (external agents only) + WS liveness. */}
|
||||
{agentStatus && value.provider !== 'boocode' && (
|
||||
<AgentStatusDot entry={agentStatus} agent={value.provider} />
|
||||
)}
|
||||
{connected !== undefined && (
|
||||
<span
|
||||
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', connected ? 'bg-green-500' : 'bg-red-500')}
|
||||
title={connected ? 'Connected' : 'Disconnected'}
|
||||
/>
|
||||
)}
|
||||
{currentEntry?.status === 'loading'
|
||||
? <Loader2 size={13} className="shrink-0 animate-spin" />
|
||||
: providerIcon(value.provider)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<CompactPicker
|
||||
label="Mode"
|
||||
value={value.modeId ?? ''}
|
||||
disabled={modeOptions.length === 0}
|
||||
options={modeOptions}
|
||||
onPick={(modeId) => persist({ ...value, modeId })}
|
||||
icon={<Shield className="size-3 shrink-0" />}
|
||||
iconOnly
|
||||
/>
|
||||
{/* Mode (shield) only when the provider actually exposes modes. Native
|
||||
BooCoder has none, so it's hidden rather than shown disabled. */}
|
||||
{modeOptions.length > 0 && (
|
||||
<CompactPicker
|
||||
label="Mode"
|
||||
value={value.modeId ?? ''}
|
||||
options={modeOptions}
|
||||
onPick={(modeId) => persist({ ...value, modeId })}
|
||||
icon={<Shield className="size-3 shrink-0" />}
|
||||
iconOnly
|
||||
/>
|
||||
)}
|
||||
<CompactPicker
|
||||
label="Model"
|
||||
value={value.model}
|
||||
@@ -369,6 +399,7 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
||||
options={modelOptions}
|
||||
onPick={pickModel}
|
||||
icon={<Bot size={13} className="shrink-0" />}
|
||||
flexible
|
||||
/>
|
||||
{thinkingOpts.length > 0 && (
|
||||
<CompactPicker
|
||||
@@ -377,26 +408,16 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
||||
options={thinkingOpts}
|
||||
onPick={(thinkingOptionId) => persist({ ...value, thinkingOptionId })}
|
||||
icon={<Brain className="size-3 shrink-0" />}
|
||||
iconOnly
|
||||
/>
|
||||
)}
|
||||
{/* Status dot + refresh — pinned right (ml-auto), never on its own line. */}
|
||||
<div className="ml-auto flex items-center gap-1 shrink-0">
|
||||
{/* #10: normalized agent status — only for an external agent with a
|
||||
live status frame. Distinct from the WS-liveness dot that follows. */}
|
||||
{agentStatus && value.provider !== 'boocode' && (
|
||||
<AgentStatusDot entry={agentStatus} agent={value.provider} />
|
||||
)}
|
||||
{connected !== undefined && (
|
||||
<span
|
||||
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', connected ? 'bg-green-500' : 'bg-red-500')}
|
||||
title={connected ? 'Connected' : 'Disconnected'}
|
||||
/>
|
||||
)}
|
||||
{/* Refresh — pinned right. Status now lives in the agent button (left). */}
|
||||
<div className="ml-auto flex items-center shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRefresh()}
|
||||
disabled={refreshing}
|
||||
className="inline-flex items-center justify-center size-7 max-md:min-h-[44px] max-md:min-w-[44px] rounded text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||
className="inline-flex items-center justify-center size-9 max-md:min-h-[36px] max-md:min-w-[44px] rounded-lg border border-border bg-muted/50 hover:bg-muted text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||
aria-label="Refresh provider list"
|
||||
title="Refresh providers"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { formatAgo } from '@/lib/format';
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
import { Bot, Check, ChevronDown } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import type { Agent, AgentParseError, ToolCostStat } from '@/api/types';
|
||||
@@ -90,11 +90,14 @@ export function AgentPicker({ projectId, value, onChange }: Props) {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted/60"
|
||||
title={selectedAgent?.description ?? undefined}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-border px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground max-md:min-h-[36px] max-md:min-w-[36px]"
|
||||
title={selectedAgent?.name ? `Agent: ${selectedAgent.name}` : 'No agent'}
|
||||
aria-label={`Agent: ${triggerLabel}`}
|
||||
>
|
||||
<span className="truncate max-w-[160px]">{triggerLabel}</span>
|
||||
<ChevronDown className="size-3 opacity-70" />
|
||||
<Bot className="size-3.5 shrink-0" />
|
||||
{/* Mobile = icon only; desktop keeps the agent name + chevron. */}
|
||||
<span className="truncate max-w-[160px] max-md:hidden">{triggerLabel}</span>
|
||||
<ChevronDown className="size-3 opacity-70 shrink-0 max-md:hidden" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-80 overflow-y-auto w-96">
|
||||
|
||||
@@ -16,7 +16,7 @@ import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal';
|
||||
import { FileMentionPopover } from '@/components/FileMentionPopover';
|
||||
import { DropOverlay } from '@/components/DropOverlay';
|
||||
import { AgentPicker } from '@/components/AgentPicker';
|
||||
import { ContextBar } from '@/components/ContextBar';
|
||||
import { ContextMeter } from '@/components/ContextMeter';
|
||||
import { SlashCommandPicker, type SlashCommandGroup } from '@/components/SlashCommandPicker';
|
||||
import { isSlashCommandToken, parseSlashInput, slashQuery } from '@/lib/slash-command';
|
||||
import { api } from '@/api/client';
|
||||
@@ -600,31 +600,6 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Batch 9 toolbar — agent picker + quick-toggle menu. v1.11.5.1
|
||||
inlines ContextBar in the same row so the bar lives next to the
|
||||
picker rather than as a separate header above it. The row renders
|
||||
when ANY of {picker, quick-toggle, ContextBar} is wanted. */}
|
||||
{(onAgentChange || sessionId || messages !== undefined) && (
|
||||
<div className="px-4 pt-2 flex items-center gap-1.5">
|
||||
{onAgentChange && (
|
||||
<AgentPicker
|
||||
projectId={projectId}
|
||||
value={agentId ?? null}
|
||||
onChange={onAgentChange}
|
||||
/>
|
||||
)}
|
||||
{/* BooCode 2.0: the web-search toggle moved out of this top toolbar
|
||||
into the composer box's bottom controls row (the Web pill below),
|
||||
leaving the top row as just the agent picker + context bar. */}
|
||||
{/* v1.11.5.1: ContextBar fills the remaining horizontal space.
|
||||
`flex-1 min-w-0` is set inside the component. Mounts only when
|
||||
the caller passes `messages` so older call sites (without the
|
||||
prop) keep their original layout. */}
|
||||
{messages !== undefined && (
|
||||
<ContextBar messages={messages} modelContextLimit={modelContextLimit} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* BooCode 2.0 composer: textarea + a bottom controls row live INSIDE one
|
||||
bordered, focus-ringed message box (Refreshed direction). */}
|
||||
<div className="px-4 py-3">
|
||||
@@ -647,6 +622,13 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
||||
{/* bottom controls row: attach + slash chip + Web on the left, Send/Stop on the right */}
|
||||
<div className="flex items-center gap-1.5 px-2 pb-2 pt-0.5">
|
||||
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={onPickFiles} />
|
||||
{onAgentChange && (
|
||||
<AgentPicker
|
||||
projectId={projectId}
|
||||
value={agentId ?? null}
|
||||
onChange={onAgentChange}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
@@ -686,17 +668,21 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
||||
}}
|
||||
aria-pressed={webSearchEnabled === true}
|
||||
title="Web search & fetch"
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors max-md:min-h-[36px] ${
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors max-md:min-h-[36px] max-md:min-w-[36px] ${
|
||||
webSearchEnabled === true
|
||||
? 'border-primary/40 bg-primary/10 text-primary'
|
||||
: 'border-border text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Globe className="size-3.5" />
|
||||
Web
|
||||
{/* Mobile = icon only; desktop keeps the "Web" label. */}
|
||||
<span className="max-md:hidden">Web</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{messages !== undefined && (
|
||||
<ContextMeter messages={messages} modelContextLimit={modelContextLimit} />
|
||||
)}
|
||||
{(() => {
|
||||
const hasContent = value.trim().length > 0 || attachments.length > 0;
|
||||
// While generating with an empty draft, the button stops generation.
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import type { Message } from '@/api/types';
|
||||
|
||||
interface Props {
|
||||
messages: Message[];
|
||||
// v1.11.5: model's full context window from chat.model_context_limit
|
||||
// (server-side getModelContext lookup). Lets us render a meaningful
|
||||
// zero-state (0 / max, muted) before any assistant message has run.
|
||||
// null/undefined means lookup failed — bar still renders, but with an
|
||||
// "Context — / —" placeholder rather than misleading 0/0 math.
|
||||
modelContextLimit?: number | null;
|
||||
}
|
||||
|
||||
// v1.11.5.1: inline persistent context-usage indicator. Lives in the same
|
||||
// horizontal row as the agent picker (was a separate row above; user
|
||||
// pointed at the empty space next to "Code Reviewer ▾ +" and asked for
|
||||
// the bar there). Caller wraps in a flex container and ContextBar takes
|
||||
// the remaining width via `flex-1 min-w-0`. Color tiers fire against
|
||||
// (max - 20k compaction reserve) so the bar warns amber/orange/red at
|
||||
// the same boundaries the server's auto-compaction triggers.
|
||||
const COMPACTION_BUFFER = 20_000;
|
||||
|
||||
// Take the latest ctx_used and the latest ctx_max INDEPENDENTLY (newest-first).
|
||||
// They needn't be on the same message: ctx_max is the model's context window — a
|
||||
// constant per model — while some agents report it only intermittently (the claude
|
||||
// SDK populates modelUsage.contextWindow on some turns, not all) yet report
|
||||
// ctx_used every turn. Pairing the latest of each gives a correct used/max even
|
||||
// when the most recent turn omitted the window. Native BooChat sets both on the
|
||||
// same assistant message, so this is identical there. Returns null until BOTH a
|
||||
// used and a positive max have been seen at least once.
|
||||
function latestPair(messages: Message[]): { used: number; max: number } | null {
|
||||
let used: number | null = null;
|
||||
let max: number | null = null;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]!;
|
||||
if (used === null && m.ctx_used != null) used = m.ctx_used;
|
||||
if (max === null && m.ctx_max != null && m.ctx_max > 0) max = m.ctx_max;
|
||||
if (used !== null && max !== null) break;
|
||||
}
|
||||
return used !== null && max !== null ? { used, max } : null;
|
||||
}
|
||||
|
||||
interface ColorTier {
|
||||
// Tailwind utility for the label / numbers. Uses literal palette names
|
||||
// rather than design tokens because we want three distinct severities
|
||||
// (amber → orange → red) and BooCode only defines one warning token
|
||||
// (`destructive`). Literal classes keep the gradation explicit.
|
||||
text: string;
|
||||
bar: string;
|
||||
}
|
||||
|
||||
function tierFor(usablePct: number): ColorTier {
|
||||
if (usablePct >= 0.95) return { text: 'text-red-600 dark:text-red-400', bar: 'bg-red-500' };
|
||||
if (usablePct >= 0.80) return { text: 'text-orange-600 dark:text-orange-400', bar: 'bg-orange-500' };
|
||||
if (usablePct >= 0.60) return { text: 'text-amber-600 dark:text-amber-400', bar: 'bg-amber-500' };
|
||||
return { text: 'text-muted-foreground', bar: 'bg-muted-foreground/40' };
|
||||
}
|
||||
|
||||
export function ContextBar({ messages, modelContextLimit }: Props) {
|
||||
// Resolve which of the three render branches applies:
|
||||
// 1. real pair — actual usage from the latest assistant message
|
||||
// 2. zero-state — no usage yet but we know the model's limit
|
||||
// 3. unknown — neither usage nor limit; render placeholder
|
||||
// The component NEVER returns null per v1.11.5 spec — the bar is
|
||||
// persistent so the user knows where it lives.
|
||||
const pair = latestPair(messages);
|
||||
const usable: number | null = pair
|
||||
? Math.max(0, pair.max - COMPACTION_BUFFER)
|
||||
: modelContextLimit && modelContextLimit > 0
|
||||
? Math.max(0, modelContextLimit - COMPACTION_BUFFER)
|
||||
: null;
|
||||
|
||||
const used = pair?.used ?? 0;
|
||||
const max = pair?.max ?? (modelContextLimit && modelContextLimit > 0 ? modelContextLimit : null);
|
||||
|
||||
// pct/usablePct only meaningful when max is known. The unknown branch
|
||||
// sets fill width to 0 and tier to muted regardless.
|
||||
const pct = max ? used / max : 0;
|
||||
const usablePct = usable && usable > 0 ? used / usable : 0;
|
||||
const tier = tierFor(usablePct);
|
||||
|
||||
// Bar fill clamped to [0, 100]. Over-budget cases (usable < used) still
|
||||
// show the bar at 100% red rather than overflowing the track visually.
|
||||
const fillPct = Math.min(100, Math.max(0, pct * 100));
|
||||
const compactionThresholdPct =
|
||||
max && usable && usable > 0 ? Math.round((usable / max) * 100) : null;
|
||||
const tooltipText =
|
||||
compactionThresholdPct !== null
|
||||
? `Auto-compaction at ~${compactionThresholdPct}%`
|
||||
: 'Model context unknown.';
|
||||
|
||||
// `flex-1 min-w-0` lets the bar consume the remaining width inside the
|
||||
// picker row's flex container while preventing the numbers (whitespace-
|
||||
// nowrap) from pushing the bar out of bounds. Two-element row: track on
|
||||
// the left, numbers on the right.
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden min-w-0">
|
||||
<div
|
||||
className={`h-full ${tier.bar} transition-[width] duration-300`}
|
||||
style={{ width: `${fillPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={`${tier.text} text-[10px] font-mono whitespace-nowrap shrink-0`}
|
||||
title={tooltipText}
|
||||
>
|
||||
{max !== null ? (
|
||||
<>
|
||||
{/* Absolute counts hidden on very narrow viewports so the
|
||||
percentage always has room. Tooltip carries full detail. */}
|
||||
<span className="max-[480px]:hidden">
|
||||
{used.toLocaleString()} / {max.toLocaleString()}{' '}
|
||||
</span>
|
||||
({Math.round(pct * 100)}%)
|
||||
</>
|
||||
) : (
|
||||
<>— / —</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
apps/web/src/components/ContextMeter.tsx
Normal file
146
apps/web/src/components/ContextMeter.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { Message } from '@/api/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Circular context-window meter — a small SVG ring (Paseo-style) that lives in
|
||||
// the composer footer beside the send button. Tap/click toggles a popover with
|
||||
// the full detail (% used, used/max tokens, optional session cost). Replaces the
|
||||
// old inline ContextBar (a horizontal bar in the toolbar row above the box).
|
||||
|
||||
interface Props {
|
||||
messages: Message[];
|
||||
// Zero-state fallback: the model's full context window from
|
||||
// chat.model_context_limit (server getModelContext lookup). Lets the ring
|
||||
// render a meaningful 0% before any assistant turn has reported usage.
|
||||
modelContextLimit?: number | null;
|
||||
// Optional session cost (USD). Omitted today (local llama-swap is free); the
|
||||
// popover line only shows when a positive number is passed.
|
||||
sessionCostUsd?: number | null;
|
||||
}
|
||||
|
||||
const SIZE = 18;
|
||||
const CENTER = SIZE / 2;
|
||||
const RADIUS = 7;
|
||||
const STROKE = 2.25;
|
||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
||||
|
||||
// Take the latest ctx_used and ctx_max INDEPENDENTLY (newest-first) — they need
|
||||
// not be on the same message (some agents report the window only intermittently
|
||||
// while reporting usage every turn). Mirrors the old ContextBar logic.
|
||||
function latestPair(messages: Message[]): { used: number; max: number } | null {
|
||||
let used: number | null = null;
|
||||
let max: number | null = null;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]!;
|
||||
if (used === null && m.ctx_used != null) used = m.ctx_used;
|
||||
if (max === null && m.ctx_max != null && m.ctx_max > 0) max = m.ctx_max;
|
||||
if (used !== null && max !== null) break;
|
||||
}
|
||||
return used !== null && max !== null ? { used, max } : null;
|
||||
}
|
||||
|
||||
function formatTokens(v: number): string {
|
||||
if (v >= 1_000_000) return `${Math.round(v / 1_000_000)}m`;
|
||||
if (v >= 1_000) return `${Math.round(v / 1_000)}k`;
|
||||
return `${Math.round(v)}`;
|
||||
}
|
||||
|
||||
function formatCost(v: number): string {
|
||||
return v < 0.01 ? `$${v.toFixed(4)}` : `$${v.toFixed(2)}`;
|
||||
}
|
||||
|
||||
export function ContextMeter({ messages, modelContextLimit, sessionCostUsd }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent | TouchEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('touchstart', onDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('touchstart', onDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const pair = latestPair(messages);
|
||||
const max = pair?.max ?? (modelContextLimit && modelContextLimit > 0 ? modelContextLimit : null);
|
||||
const used = pair?.used ?? 0;
|
||||
|
||||
const ratio = max ? Math.max(0, Math.min(1, used / max)) : 0;
|
||||
const rounded = max ? Math.round((used / max) * 100) : null;
|
||||
const offset = CIRCUMFERENCE - ratio * CIRCUMFERENCE;
|
||||
|
||||
// Paseo thresholds on raw usage: muted < 70%, amber 70–90%, red > 90%.
|
||||
const progressClass =
|
||||
rounded === null
|
||||
? 'stroke-muted-foreground/40'
|
||||
: rounded > 90
|
||||
? 'stroke-red-500'
|
||||
: rounded >= 70
|
||||
? 'stroke-amber-500'
|
||||
: 'stroke-muted-foreground';
|
||||
const labelClass =
|
||||
rounded === null
|
||||
? 'text-muted-foreground'
|
||||
: rounded > 90
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: rounded >= 70
|
||||
? 'text-amber-600 dark:text-amber-400'
|
||||
: 'text-muted-foreground';
|
||||
|
||||
const cost =
|
||||
typeof sessionCostUsd === 'number' && sessionCostUsd > 0 ? formatCost(sessionCostUsd) : null;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-label={`Context window ${rounded ?? 0}% used`}
|
||||
title={rounded === null ? 'Model context unknown' : `Context window ${rounded}% used`}
|
||||
className="inline-flex items-center gap-1 rounded-full px-1 text-muted-foreground hover:text-foreground max-md:min-h-[36px]"
|
||||
>
|
||||
<svg width={SIZE} height={SIZE} viewBox={`0 0 ${SIZE} ${SIZE}`} className="-rotate-90 shrink-0">
|
||||
<circle
|
||||
cx={CENTER}
|
||||
cy={CENTER}
|
||||
r={RADIUS}
|
||||
fill="none"
|
||||
className="stroke-muted"
|
||||
strokeWidth={STROKE}
|
||||
/>
|
||||
<circle
|
||||
cx={CENTER}
|
||||
cy={CENTER}
|
||||
r={RADIUS}
|
||||
fill="none"
|
||||
className={cn('transition-all duration-300', progressClass)}
|
||||
strokeWidth={STROKE}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={CIRCUMFERENCE}
|
||||
strokeDashoffset={offset}
|
||||
/>
|
||||
</svg>
|
||||
<span className={cn('text-[11px] font-mono tabular-nums', labelClass)}>
|
||||
{rounded === null ? '—' : `${rounded}%`}
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute bottom-full right-0 z-50 mb-2 w-max rounded-lg border border-border bg-popover px-3 py-2 text-left shadow-md">
|
||||
<p className="text-sm text-foreground">Context window</p>
|
||||
<p className="text-sm text-foreground">{rounded === null ? 'Unknown' : `${rounded}% used`}</p>
|
||||
{max !== null && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatTokens(used)} / {formatTokens(max)} tokens
|
||||
</p>
|
||||
)}
|
||||
{cost && <p className="text-xs text-muted-foreground">Session cost {cost}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -198,7 +198,7 @@ export function MobileTabSwitcher({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPillClick}
|
||||
className="flex-1 w-full inline-flex items-center gap-1.5 min-h-[44px] px-3 text-sm rounded-full bg-muted/40 hover:bg-muted/70 text-foreground min-w-0 relative"
|
||||
className="flex-1 w-full inline-flex items-center gap-1.5 min-h-[36px] px-3 text-sm rounded-full bg-muted/40 hover:bg-muted/70 text-foreground min-w-0 relative"
|
||||
aria-label="Switch pane"
|
||||
style={{
|
||||
transform: `translateX(${dragX}px)`,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { BottomSheet } from '@/components/BottomSheet';
|
||||
import { useViewport } from '@/hooks/useViewport';
|
||||
import { formatModelLabel } from '@/lib/model-label';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
@@ -45,7 +46,7 @@ function ModelList({
|
||||
className="w-full text-left flex items-center gap-2 font-mono text-xs px-2 py-1.5 hover:bg-accent rounded"
|
||||
>
|
||||
<Check className={`size-3 ${m.id === value ? 'opacity-100' : 'opacity-0'}`} />
|
||||
<span className="truncate">{m.id}</span>
|
||||
<span className="truncate">{formatModelLabel(m.id)}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
@@ -83,7 +84,7 @@ export function ModelPicker({ value, onChange }: Props) {
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label={`Model: ${value}`}
|
||||
title={value}
|
||||
className="inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded text-muted-foreground hover:text-foreground"
|
||||
className="inline-flex items-center justify-center min-h-[36px] min-w-[44px] rounded text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Cpu className="size-4" />
|
||||
</button>
|
||||
@@ -103,7 +104,7 @@ export function ModelPicker({ value, onChange }: Props) {
|
||||
type="button"
|
||||
className="text-xs font-mono text-muted-foreground hover:text-foreground flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted/60"
|
||||
>
|
||||
{value}
|
||||
{formatModelLabel(value)}
|
||||
<ChevronDown className="size-3 opacity-70" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -123,7 +124,7 @@ export function ModelPicker({ value, onChange }: Props) {
|
||||
<Check
|
||||
className={`size-3 ${m.id === value ? 'opacity-100' : 'opacity-0'}`}
|
||||
/>
|
||||
{m.id}
|
||||
{formatModelLabel(m.id)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -21,7 +21,7 @@ export function NewPaneMenu({ onAddPane, disabled }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded-full bg-muted/40 hover:bg-muted/70 text-foreground disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
|
||||
className="inline-flex items-center justify-center min-h-[36px] min-w-[44px] rounded-full bg-muted/40 hover:bg-muted/70 text-foreground disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
|
||||
aria-label="New pane"
|
||||
>
|
||||
<Plus size={16} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Archive, Code, MessageSquare, RotateCcw, Terminal, Trash2 } from 'lucide-react';
|
||||
import { Archive, ChevronLeft, Code, MessageSquare, RotateCcw, Terminal, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import mascot from '@/assets/brand/banner-mascot.png';
|
||||
import { ChatInput } from '@/components/ChatInput';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -33,6 +34,10 @@ interface Props {
|
||||
onUnarchiveChat: (chatId: string) => Promise<void>;
|
||||
onArchiveChat: (chatId: string) => Promise<void>;
|
||||
onDeleteChat: (chatId: string) => Promise<void>;
|
||||
// Controlled session-history view: the new-chat hero is the default; the
|
||||
// header's "Session history" button flips this on (and Back flips it off).
|
||||
openHistory: boolean;
|
||||
onCloseHistory: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +68,8 @@ export function SessionLandingPage({
|
||||
onUnarchiveChat,
|
||||
onArchiveChat,
|
||||
onDeleteChat,
|
||||
openHistory,
|
||||
onCloseHistory,
|
||||
}: Props) {
|
||||
const [chatId, setChatId] = useState<string | null>(null);
|
||||
const [archived, setArchived] = useState<Chat[]>([]);
|
||||
@@ -129,13 +136,41 @@ export function SessionLandingPage({
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="max-w-[760px] mx-auto w-full px-4 py-4">
|
||||
{isEmpty ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No conversations yet. Send a message to start.
|
||||
</p>
|
||||
<div className="max-w-[760px] mx-auto w-full px-4 py-4 h-full">
|
||||
{!openHistory ? (
|
||||
/* Landing hero — BooCode mascot + prompt, vertically centered to
|
||||
match the coder pane's empty state (a flex-1 fill).
|
||||
Session history lives in the header (see Session.tsx row 2). */
|
||||
<div className="flex flex-col items-center justify-center text-center gap-4 h-full">
|
||||
<img
|
||||
src={mascot}
|
||||
alt="BooCode"
|
||||
draggable={false}
|
||||
className="w-20 h-auto select-none"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Send a message to start a new conversation
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCloseHistory}
|
||||
className="inline-flex items-center gap-1 -ml-1 px-2 min-h-[44px] rounded text-sm text-muted-foreground hover:text-foreground hover:bg-muted/60"
|
||||
aria-label="Back to new conversation"
|
||||
>
|
||||
<ChevronLeft size={16} className="shrink-0" />
|
||||
Back
|
||||
</button>
|
||||
<h2 className="text-sm font-medium ml-auto mr-1">Session history</h2>
|
||||
</div>
|
||||
{isEmpty ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No conversations yet. Send a message to start.
|
||||
</p>
|
||||
) : (<>
|
||||
{openChats.length > 0 && (
|
||||
<>
|
||||
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground px-1 mb-1.5">
|
||||
@@ -165,7 +200,7 @@ export function SessionLandingPage({
|
||||
{formatRelative(c.updated_at)}
|
||||
</span>
|
||||
</button>
|
||||
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover/row:opacity-100 focus-within:opacity-100 transition-opacity">
|
||||
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover/row:opacity-100 focus-within:opacity-100 max-md:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); void onArchiveChat(c.id); }}
|
||||
@@ -213,13 +248,13 @@ export function SessionLandingPage({
|
||||
<span className="shrink-0 text-xs">{formatRelative(c.updated_at)}</span>
|
||||
<RotateCcw
|
||||
size={13}
|
||||
className="shrink-0 opacity-0 group-hover/arch:opacity-100"
|
||||
className="shrink-0 opacity-0 group-hover/arch:opacity-100 max-md:opacity-100"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); setDeleteConfirm({ id: c.id, name: c.name }); }}
|
||||
className="shrink-0 inline-flex items-center justify-center size-7 rounded hover:bg-destructive/20 hover:text-destructive max-md:size-9 opacity-0 group-hover/arch:opacity-100 focus-within:opacity-100 transition-opacity"
|
||||
className="shrink-0 inline-flex items-center justify-center size-7 rounded hover:bg-destructive/20 hover:text-destructive max-md:size-9 opacity-0 group-hover/arch:opacity-100 focus-within:opacity-100 max-md:opacity-100 transition-opacity"
|
||||
aria-label="Delete chat"
|
||||
title="Delete"
|
||||
>
|
||||
@@ -230,6 +265,7 @@ export function SessionLandingPage({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -56,7 +56,9 @@ export function Workspace({
|
||||
closeOtherTabs,
|
||||
closeTabsToRight,
|
||||
closeAllTabs,
|
||||
showLandingPage,
|
||||
historyPaneId,
|
||||
openSessionHistory,
|
||||
closeSessionHistory,
|
||||
addSplitPane,
|
||||
createCoderTab,
|
||||
removePane,
|
||||
@@ -206,7 +208,7 @@ export function Workspace({
|
||||
onNewTab={isCoder ? () => void createCoderTab(idx) : () => void createChat(idx)}
|
||||
onSplitPane={(kind) => onAddPane(kind)}
|
||||
onReopenPane={hasClosedPanes ? reopenPane : undefined}
|
||||
onShowHistory={() => showLandingPage(idx)}
|
||||
onShowHistory={() => openSessionHistory(idx)}
|
||||
onRename={renameChat}
|
||||
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
||||
/>
|
||||
@@ -238,7 +240,7 @@ export function Workspace({
|
||||
<PaneHeaderActions
|
||||
onSplitPane={onAddPane}
|
||||
onReopenPane={hasClosedPanes ? reopenPane : undefined}
|
||||
onShowHistory={() => showLandingPage(idx)}
|
||||
onShowHistory={() => openSessionHistory(idx)}
|
||||
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
||||
/>
|
||||
</div>
|
||||
@@ -307,6 +309,8 @@ export function Workspace({
|
||||
onUnarchiveChat={unarchiveChat}
|
||||
onArchiveChat={archiveChat}
|
||||
onDeleteChat={deleteChat}
|
||||
openHistory={historyPaneId === pane.id}
|
||||
onCloseHistory={closeSessionHistory}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,16 +8,26 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Bird, Dog, Terminal as TermIcon } from 'lucide-react';
|
||||
import { ClaudeIcon, OpenCodeIcon } from '@/components/icons/ProviderIcons';
|
||||
import mascot from '@/assets/brand/banner-mascot.png';
|
||||
|
||||
/**
|
||||
* Glyph for a provider/agent name. Mirrors AgentComposerBar's original
|
||||
* `providerIcon` switch verbatim — `boocode` (native) falls through to the
|
||||
* neutral dog like any unmapped name, preserving the composer's prior look.
|
||||
* Sized to match the picker (13px) by default; pass a different size for
|
||||
* inline badges.
|
||||
* Glyph for a provider/agent name. `boocode` (native) is the BooCode westie
|
||||
* mascot; unmapped names fall through to a neutral dog. Sized to match the
|
||||
* picker (13px) by default; pass a different size for inline badges.
|
||||
*/
|
||||
export function providerIcon(name: string | null, size = 13): ReactNode {
|
||||
switch (name) {
|
||||
case 'boocode':
|
||||
return (
|
||||
<img
|
||||
src={mascot}
|
||||
alt=""
|
||||
width={size}
|
||||
height={size}
|
||||
draggable={false}
|
||||
className="shrink-0 object-contain select-none"
|
||||
/>
|
||||
);
|
||||
case 'claude':
|
||||
return <ClaudeIcon size={size} className="shrink-0" />;
|
||||
case 'opencode':
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
// WS: /api/coder/ws/sessions/:id (Vite dev proxies to :9502).
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Code, Check, X, RefreshCw, Terminal, Puzzle, Sparkles } from 'lucide-react';
|
||||
import { Check, X, RefreshCw, Terminal, Puzzle, Sparkles } from 'lucide-react';
|
||||
import mascot from '@/assets/brand/banner-mascot.png';
|
||||
import { AgentComposerBar } from '@/components/AgentComposerBar';
|
||||
import { PermissionCard } from '@/components/PermissionCard';
|
||||
import { ChatInput } from '@/components/ChatInput';
|
||||
@@ -1085,8 +1086,8 @@ export function CoderPane({
|
||||
{/* Chat area — BooChat-style timeline (text + tool runs as siblings) */}
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center flex-1 text-sm text-muted-foreground gap-2">
|
||||
<Code size={32} className="opacity-40" />
|
||||
<div className="flex flex-col items-center justify-center flex-1 text-sm text-muted-foreground gap-3">
|
||||
<img src={mascot} alt="BooCode" draggable={false} className="w-20 h-auto select-none" />
|
||||
<p>{chatPending || !chatId ? 'Preparing pane chat…' : 'Send a message to start coding'}</p>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user