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

@@ -47,17 +47,18 @@ export const PROVIDERS: ProviderDef[] = [
label: 'Claude Code',
transport: 'pty',
modelSource: 'static',
// Passed verbatim to `claude --model <id>` (PTY dispatch). The CLI accepts a
// latest-alias ('opus'/'sonnet'/'haiku') or a pinned full name
// ('claude-opus-4-8'). Aliases never go stale; pinned IDs let you select an
// exact version. Extend/replace per-install via data/coder-providers.json
// Passed verbatim to `claude --model <id>` (PTY dispatch). Pinned full
// names; the `[1m]` suffix selects the 1M-context variant of that model
// (e.g. `claude --model claude-opus-4-8[1m]`). First entry is the default
// (the snapshot carries no isDefault, so the frontend falls back to
// models[0]). Extend/replace per-install via data/coder-providers.json
// (models / additionalModels) without a code change.
staticModels: [
{ id: 'opus', label: 'Opus (latest)' },
{ id: 'claude-opus-4-8[1m]', label: 'Opus 4.8 1M' },
{ id: 'claude-opus-4-8', label: 'Opus 4.8' },
{ id: 'sonnet', label: 'Sonnet (latest)' },
{ id: 'claude-sonnet-4-6[1m]', label: 'Sonnet 4.6 1M' },
{ id: 'claude-sonnet-4-6', label: 'Sonnet 4.6' },
{ id: 'haiku', label: 'Haiku (latest)' },
{ id: 'claude-haiku-4-5-20251001[1m]', label: 'Haiku 4.5 1M' },
{ id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5' },
],
},

View File

@@ -34,7 +34,14 @@ export async function fetchLlamaSwapModels(config: Config): Promise<ProviderMode
const res = await fetch(`${config.LLAMA_SWAP_URL}/v1/models`);
if (!res.ok) return [];
const parsed = (await res.json()) as { data?: Array<{ id: string }> };
return (parsed.data ?? []).map((m) => ({ id: m.id, label: m.id }));
const models = (parsed.data ?? []).map((m) => ({ id: m.id, label: m.id }));
// Hoist the configured DEFAULT_MODEL to the front so the BooCoder picker —
// which defaults to models[0] (no isDefault on llama-swap entries) — selects
// the same model the dispatcher falls back to. Rest keep llama-swap's order.
const def = config.DEFAULT_MODEL;
const i = models.findIndex((m) => m.id === def);
if (i > 0) models.unshift(models.splice(i, 1)[0]!);
return models;
} catch {
return [];
}

View File

@@ -111,6 +111,42 @@ describe('buildMessagesPayload', async () => {
expect(result[4]).toMatchObject({ role: 'assistant', content: 'great' });
});
it('does NOT annotate models when the chat uses a single model', async () => {
const session = makeSession();
const project = makeProject();
const history: Message[] = [
makeMessage('user', 'hi'),
makeMessage('assistant', 'hello', { model: 'qwen3.6-35b-a3b-mxfp4' }),
makeMessage('user', 'again'),
makeMessage('assistant', 'world', { model: 'qwen3.6-35b-a3b-mxfp4' }),
];
const result = await buildMessagesPayload(session, project, history);
// 1 system + 4 history — no extra attribution system note.
expect(result).toHaveLength(5);
expect(result.filter((m) => m.role === 'system')).toHaveLength(1);
expect(result[2]).toMatchObject({ role: 'assistant', content: 'hello' });
expect(result[4]).toMatchObject({ role: 'assistant', content: 'world' });
});
it('annotates each assistant turn with its model when the chat mixes models', async () => {
const session = makeSession();
const project = makeProject();
const history: Message[] = [
makeMessage('user', 'hi'),
makeMessage('assistant', 'opus reply', { model: 'claude-opus-4-8' }),
makeMessage('user', 'switch'),
makeMessage('assistant', 'qwen reply', { model: 'qwen3.6-35b-a3b-mxfp4' }),
];
const result = await buildMessagesPayload(session, project, history);
// 1 system prompt + 1 attribution note + 4 history rows.
const systems = result.filter((m) => m.role === 'system');
expect(systems).toHaveLength(2);
expect(systems[1]!.content).toContain('square brackets');
const assistants = result.filter((m) => m.role === 'assistant');
expect(assistants[0]!.content).toBe('[claude-opus-4-8] opus reply');
expect(assistants[1]!.content).toBe('[qwen3.6-35b-a3b-mxfp4] qwen reply');
});
it('starts from the latest compact marker, emitting it as a system message', async () => {
const session = makeSession();
const project = makeProject();

View File

@@ -91,6 +91,27 @@ export async function buildMessagesPayload(
}
}
// Per-turn model attribution. When the sent window mixes ≥2 models, prefix
// each prior assistant turn with its model id so the active model can answer
// "what did Opus say". Single-model chats are left byte-identical (no prefix,
// no note) so the common case sees no payload or prefix-cache change.
const sentModels = new Set<string>();
for (let i = startIdx; i < history.length; i++) {
const m = history[i]!;
if (m.role === 'assistant' && m.model && !isAnySentinel(m)) sentModels.add(m.model);
}
const annotateModels = sentModels.size >= 2;
if (annotateModels) {
out.push({
role: 'system',
content:
'This conversation includes replies from more than one AI model. Each prior ' +
'assistant turn below is prefixed with its model id in square brackets, e.g. ' +
'[claude-opus-4-8]. Those prefixes are metadata for your reference (so you can ' +
'tell which model produced which turn) — do not add such a prefix to your own replies.',
});
}
for (let i = startIdx; i < history.length; i++) {
const m = history[i]!;
if (m.kind === 'compact') {
@@ -143,9 +164,10 @@ export async function buildMessagesPayload(
continue;
}
if (m.role === 'assistant') {
const body = m.content && m.content.length > 0 ? m.content : null;
const msg: OpenAiMessage = {
role: 'assistant',
content: m.content && m.content.length > 0 ? m.content : null,
content: body != null && annotateModels && m.model ? `[${m.model}] ${body}` : body,
};
if (m.tool_calls && m.tool_calls.length > 0) {
if (assistantToolCallsArePayloadComplete(history, i)) {

View File

@@ -1,8 +1,9 @@
// Shared column projections for queries against the messages_with_parts view.
// All sites that read the full Message wire shape for route responses use
// MESSAGE_COLUMNS. The inference load path uses INFERENCE_MESSAGE_COLUMNS —
// it adds reasoning_parts but omits the compaction-display fields
// (summary, tail_start_id, compacted_at, model) that only the UI needs.
// it adds reasoning_parts and model (per-turn attribution, used to label prior
// turns when a chat mixes models) but omits the compaction-display fields
// (summary, tail_start_id, compacted_at) that only the UI needs.
export const MESSAGE_COLUMNS =
'id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq, ' +
@@ -12,4 +13,4 @@ export const MESSAGE_COLUMNS =
export const INFERENCE_MESSAGE_COLUMNS =
'id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq, ' +
'tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata, ' +
'reasoning_parts';
'reasoning_parts, model';

View File

@@ -223,6 +223,11 @@ export interface Message {
summary?: boolean;
tail_start_id?: string | null;
compacted_at?: string | null;
// Per-assistant-turn model attribution (the chip). Read into the inference
// payload so the active model can attribute prior turns when a chat mixes
// models ("what did Opus say"). Optional — null/absent for user/tool rows
// and pre-attribution assistant rows.
model?: string | null;
}
export interface ModelInfo {

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,52 +351,20 @@ 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)
}
/>
<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
/>
<CompactPicker
label="Model"
value={value.model}
disabled={modelOptions.length === 0}
options={modelOptions}
onPick={pickModel}
icon={<Bot size={13} className="shrink-0" />}
/>
{thinkingOpts.length > 0 && (
<CompactPicker
label="Thinking"
value={value.thinkingOptionId ?? ''}
options={thinkingOpts}
onPick={(thinkingOptionId) => persist({ ...value, thinkingOptionId })}
icon={<Brain className="size-3 shrink-0" />}
/>
)}
{/* 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. */}
<>
{/* 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} />
)}
@@ -392,11 +374,50 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
title={connected ? 'Connected' : 'Disconnected'}
/>
)}
{currentEntry?.status === 'loading'
? <Loader2 size={13} className="shrink-0 animate-spin" />
: providerIcon(value.provider)}
</>
}
/>
{/* 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}
disabled={modelOptions.length === 0}
options={modelOptions}
onPick={pickModel}
icon={<Bot size={13} className="shrink-0" />}
flexible
/>
{thinkingOpts.length > 0 && (
<CompactPicker
label="Thinking"
value={value.thinkingOptionId ?? ''}
options={thinkingOpts}
onPick={(thinkingOptionId) => persist({ ...value, thinkingOptionId })}
icon={<Brain className="size-3 shrink-0" />}
iconOnly
/>
)}
{/* 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">
<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>
) : (

View File

@@ -183,6 +183,12 @@ export interface UseWorkspacePanesResult {
closeTabsToRight: (paneIdx: number, pivotChatId: string) => void;
closeAllTabs: (paneIdx: number) => void;
showLandingPage: (paneIdx: number) => void;
// Session-history view: which pane (by id) should render its landing in the
// history list instead of the new-chat hero. Shared so the mobile header
// button and the desktop pane-header menu drive the same controlled view.
historyPaneId: string | null;
openSessionHistory: (paneIdx: number) => void;
closeSessionHistory: () => void;
// v1.10.3: returns the new pane's id (or null if the operation was a no-op:
// max panes reached). Callers can use the
// id to update mobile URL state so the URL-sync effect doesn't fight the
@@ -222,6 +228,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
const [closedPaneStack, setClosedPaneStack] = useState<ClosedPaneEntry[]>([]);
const draggingIdxRef = useRef<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
const [historyPaneId, setHistoryPaneId] = useState<string | null>(null);
// v1.12.1: skip PATCH while hydrating from the server. Without this, the
// initial [emptyPane()] would be saved over the server's real state before
// the GET resolves.
@@ -696,6 +703,17 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
});
}, [sessionId]);
// Reveal the session-history list. Mirrors the desktop "Show history" action:
// convert the pane to its landing (showLandingPage) and flag it so the landing
// opens on the history list rather than the new-chat hero.
const openSessionHistory = useCallback((paneIdx: number) => {
const id = panes[paneIdx]?.id ?? null;
showLandingPage(paneIdx);
setHistoryPaneId(id);
}, [panes, showLandingPage]);
const closeSessionHistory = useCallback(() => setHistoryPaneId(null), []);
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'coder'): string | null => {
// Generate the id outside the updater so we can return it deterministically.
// setPanes's updater can be invoked twice in strict mode; using a fixed id
@@ -991,6 +1009,9 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
closeTabsToRight,
closeAllTabs,
showLandingPage,
historyPaneId,
openSessionHistory,
closeSessionHistory,
addSplitPane,
createCoderTab,
toggleSettingsPane,

View File

@@ -0,0 +1,50 @@
// Display-only prettifier for raw llama-swap model ids shown in the model
// pickers (BooChat ModelPicker + BooCode AgentComposerBar). The actual model id
// sent to the backend is never changed — this only affects what's rendered.
//
// qwen3.6-35b-a3b-mxfp4 -> Qwen3.6 35B
// qwopus3.5-9b-coder-mtp -> Qwopus3.5 9B Coder
// qwen3.5-9b-deepseek-v4-mtp -> Qwen3.5 9B Deepseek
// OpenCode Zen/Big Pickle -> Big Pickle
// llama-swap/Qwen 3.6 27B MTP -> Qwen 3.6 27B MTP
//
// OpenCode surfaces models as "Provider Group/Model Name"; we drop the group
// prefix and show just the model name. Conservative otherwise: ids that don't
// look like the `<family><ver>-<size>-…` shape (e.g. "Opus (latest)",
// "nemotron-nano-4b") are returned unchanged, so friendly labels aren't mangled.
// Quant / format / speculative-decoding tags that carry no meaning for a human
// scanning the picker. Dropped from the label.
const DROP_TOKENS = new Set([
'mtp', 'mxfp4', 'fp4', 'fp8', 'fp16', 'bf16',
'q4', 'q5', 'q6', 'q8', 'int4', 'int8',
'awq', 'gptq', 'gguf',
]);
export function formatModelLabel(raw: string): string {
if (!raw) return raw;
// OpenCode-style "Provider Group/Model Name" → keep just the model name.
const slash = raw.lastIndexOf('/');
if (slash >= 0) raw = raw.slice(slash + 1).trim();
if (/\s/.test(raw)) return raw; // already a friendly (spaced) label
const tokens = raw.split('-');
const head = tokens[0] ?? '';
// First token must look like a family+version (letters then a digit), e.g.
// qwen3.6 / qwopus3.5. Otherwise leave the id alone.
if (!/^[a-z]+\d/.test(head)) return raw;
const kept: string[] = [];
tokens.forEach((t, i) => {
if (i === 0) {
kept.push(t.charAt(0).toUpperCase() + t.slice(1)); // qwen3.6 -> Qwen3.6
return;
}
if (/^\d+(\.\d+)?b$/.test(t)) { kept.push(t.toUpperCase()); return; } // size: 9B, 27B, 35B
if (/^v\d+$/.test(t)) return; // variant tag: v1, v2, v4
if (/^a\d+b$/.test(t)) return; // MoE active-params tag: a3b
if (DROP_TOKENS.has(t)) return; // quant / format / decoding tags
kept.push(t.charAt(0).toUpperCase() + t.slice(1)); // descriptive: coder, deepseek
});
return kept.join(' ');
}

View File

@@ -6,7 +6,7 @@ import {
useParams,
useSearchParams,
} from 'react-router-dom';
import { ChevronRight, FolderTree, Menu, X } from 'lucide-react';
import { ChevronRight, FolderTree, History, Menu, X } from 'lucide-react';
import { api } from '@/api/client';
import type { Project, Session as SessionType } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents';
@@ -57,12 +57,12 @@ function SessionInner({ sessionId }: { sessionId: string }) {
activePaneIdxRef,
addSplitPane,
removePane,
openSessionHistory,
removeChatFromPanes,
initializeFirstChatIfEmpty,
validatePanes,
} = panesHook;
const activePane = panes[activePaneIdx];
const activeIsCoder = activePane?.kind === 'coder';
const openChatInActivePane = useCallback(
(chatId: string) => openChatInPane(activePaneIdxRef.current, chatId),
@@ -345,7 +345,7 @@ function SessionInner({ sessionId }: { sessionId: string }) {
className={cn(
'border-b shrink-0 text-sm',
isMobile
? 'flex flex-col gap-1.5 px-3 py-2'
? 'flex flex-col gap-1 px-3 py-1'
: 'flex items-center gap-1.5 px-3 sm:px-4 py-2',
)}
style={{ paddingTop: 'max(0.5rem, env(safe-area-inset-top))' }}
@@ -359,7 +359,7 @@ function SessionInner({ sessionId }: { sessionId: string }) {
<button
type="button"
onClick={() => setDrawerOpen(true)}
className="inline-flex items-center justify-center -ml-1 min-w-[44px] min-h-[44px] rounded text-muted-foreground hover:bg-muted hover:text-foreground shrink-0"
className="inline-flex items-center justify-center -ml-1 min-w-[44px] min-h-[36px] rounded text-muted-foreground hover:bg-muted hover:text-foreground shrink-0"
aria-label="Open sidebar"
>
<Menu className="size-5" />
@@ -396,7 +396,7 @@ function SessionInner({ sessionId }: { sessionId: string }) {
<button
type="button"
onClick={toggleRightRail}
className="relative inline-flex items-center justify-center -mr-1 min-w-[44px] min-h-[44px] rounded text-muted-foreground hover:bg-muted hover:text-foreground shrink-0"
className="relative inline-flex items-center justify-center -mr-1 min-w-[44px] min-h-[36px] rounded text-muted-foreground hover:bg-muted hover:text-foreground shrink-0"
aria-label="Toggle file browser"
>
<FolderTree className="size-5" />
@@ -421,16 +421,25 @@ function SessionInner({ sessionId }: { sessionId: string }) {
onAddPane={addPaneAndSwitch}
disabled={panes.length >= MAX_PANES}
/>
{activeIsCoder && activePane && panes.length > 1 && (
{activePane && panes.length > 1 && (
<button
type="button"
onClick={() => removePane(activePaneIdx)}
className="inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded-full bg-muted/40 hover:bg-muted/70 text-foreground 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 shrink-0"
aria-label="Close pane"
>
<X size={16} />
</button>
)}
<button
type="button"
onClick={() => openSessionHistory(activePaneIdx)}
className="inline-flex items-center justify-center min-h-[36px] min-w-[44px] rounded-full bg-muted/40 hover:bg-muted/70 text-foreground shrink-0"
aria-label="Session history"
title="Session history"
>
<History size={16} />
</button>
</div>
</>
) : (