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:
2026-06-03 14:55:38 +00:00
parent 5f4c7a9050
commit 163b5b86f7
21 changed files with 471 additions and 233 deletions

View File

@@ -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"
>

View File

@@ -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">

View File

@@ -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.

View File

@@ -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>
);
}

View 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 7090%, 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>
);
}

View File

@@ -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)`,

View File

@@ -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>

View File

@@ -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} />

View File

@@ -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>

View File

@@ -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>

View File

@@ -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':

View File

@@ -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>
) : (