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:
@@ -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' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
) : (
|
||||
|
||||
@@ -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,
|
||||
|
||||
50
apps/web/src/lib/model-label.ts
Normal file
50
apps/web/src/lib/model-label.ts
Normal 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(' ');
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user