From 163b5b86f7cd7ccbf9fbffb32358c270df2a1881 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Wed, 3 Jun 2026 14:55:38 +0000 Subject: [PATCH] wip: context-meter + model-label UI and provider/inference tweaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/coder/src/services/provider-registry.ts | 15 +- apps/coder/src/services/provider-snapshot.ts | 9 +- .../src/services/__tests__/inference.test.ts | 36 +++++ apps/server/src/services/inference/payload.ts | 24 ++- apps/server/src/services/message-columns.ts | 7 +- apps/server/src/types/api.ts | 5 + apps/web/src/components/AgentComposerBar.tsx | 87 +++++++---- apps/web/src/components/AgentPicker.tsx | 13 +- apps/web/src/components/ChatInput.tsx | 42 ++--- apps/web/src/components/ContextBar.tsx | 122 --------------- apps/web/src/components/ContextMeter.tsx | 146 ++++++++++++++++++ apps/web/src/components/MobileTabSwitcher.tsx | 2 +- apps/web/src/components/ModelPicker.tsx | 9 +- apps/web/src/components/NewPaneMenu.tsx | 2 +- .../web/src/components/SessionLandingPage.tsx | 54 +++++-- apps/web/src/components/Workspace.tsx | 10 +- .../src/components/coder/providerIcons.tsx | 20 ++- apps/web/src/components/panes/CoderPane.tsx | 7 +- apps/web/src/hooks/useWorkspacePanes.ts | 21 +++ apps/web/src/lib/model-label.ts | 50 ++++++ apps/web/src/pages/Session.tsx | 23 ++- 21 files changed, 471 insertions(+), 233 deletions(-) delete mode 100644 apps/web/src/components/ContextBar.tsx create mode 100644 apps/web/src/components/ContextMeter.tsx create mode 100644 apps/web/src/lib/model-label.ts diff --git a/apps/coder/src/services/provider-registry.ts b/apps/coder/src/services/provider-registry.ts index 816a6a5..68df116 100644 --- a/apps/coder/src/services/provider-registry.ts +++ b/apps/coder/src/services/provider-registry.ts @@ -47,17 +47,18 @@ export const PROVIDERS: ProviderDef[] = [ label: 'Claude Code', transport: 'pty', modelSource: 'static', - // Passed verbatim to `claude --model ` (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 ` (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' }, ], }, diff --git a/apps/coder/src/services/provider-snapshot.ts b/apps/coder/src/services/provider-snapshot.ts index e9e6138..8d60490 100644 --- a/apps/coder/src/services/provider-snapshot.ts +++ b/apps/coder/src/services/provider-snapshot.ts @@ -34,7 +34,14 @@ export async function fetchLlamaSwapModels(config: Config): Promise }; - 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 []; } diff --git a/apps/server/src/services/__tests__/inference.test.ts b/apps/server/src/services/__tests__/inference.test.ts index 31e2840..9101890 100644 --- a/apps/server/src/services/__tests__/inference.test.ts +++ b/apps/server/src/services/__tests__/inference.test.ts @@ -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(); diff --git a/apps/server/src/services/inference/payload.ts b/apps/server/src/services/inference/payload.ts index 7778e83..d90e467 100644 --- a/apps/server/src/services/inference/payload.ts +++ b/apps/server/src/services/inference/payload.ts @@ -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(); + 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)) { diff --git a/apps/server/src/services/message-columns.ts b/apps/server/src/services/message-columns.ts index 46b503e..33a4cac 100644 --- a/apps/server/src/services/message-columns.ts +++ b/apps/server/src/services/message-columns.ts @@ -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'; diff --git a/apps/server/src/types/api.ts b/apps/server/src/types/api.ts index 5c48d3b..0146343 100644 --- a/apps/server/src/types/api.ts +++ b/apps/server/src/types/api.ts @@ -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 { diff --git a/apps/web/src/components/AgentComposerBar.tsx b/apps/web/src/components/AgentComposerBar.tsx index 1784539..a0b6f85 100644 --- a/apps/web/src/components/AgentComposerBar.tsx +++ b/apps/web/src/components/AgentComposerBar.tsx @@ -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 && {currentLabel}} + {!iconOnly && ( + {currentLabel} + )} setOpen(false)} title={label}> @@ -148,10 +158,14 @@ function CompactPicker({ label, value, disabled, options, onPick, icon, iconOnly @@ -337,31 +351,47 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma const providerOptions = entries.map((e) => ({ id: e.name, label: e.label })); const modeOptions = (currentEntry?.modes ?? []).map((m) => ({ id: m.id, label: m.label })); - const modelOptions = (currentEntry?.models ?? []).map((m) => ({ id: m.id, label: m.label })); + const modelOptions = (currentEntry?.models ?? []).map((m) => ({ id: m.id, label: formatModelLabel(m.label) })); const thinkingOpts = thinkingOptions.map((t) => ({ id: t.id, label: t.label })); return ( -
+
- : providerIcon(value.provider) + <> + {/* Status indicator lives inside the agent button, to its left: + normalized agent status (external agents only) + WS liveness. */} + {agentStatus && value.provider !== 'boocode' && ( + + )} + {connected !== undefined && ( + + )} + {currentEntry?.status === 'loading' + ? + : providerIcon(value.provider)} + } /> - persist({ ...value, modeId })} - icon={} - iconOnly - /> + {/* Mode (shield) only when the provider actually exposes modes. Native + BooCoder has none, so it's hidden rather than shown disabled. */} + {modeOptions.length > 0 && ( + persist({ ...value, modeId })} + icon={} + iconOnly + /> + )} } + flexible /> {thinkingOpts.length > 0 && ( persist({ ...value, thinkingOptionId })} icon={} + iconOnly /> )} - {/* Status dot + refresh — pinned right (ml-auto), never on its own line. */} -
- {/* #10: normalized agent status — only for an external agent with a - live status frame. Distinct from the WS-liveness dot that follows. */} - {agentStatus && value.provider !== 'boocode' && ( - - )} - {connected !== undefined && ( - - )} + {/* Refresh — pinned right. Status now lives in the agent button (left). */} +
diff --git a/apps/web/src/components/ChatInput.tsx b/apps/web/src/components/ChatInput.tsx index 74d4759..1fe20d6 100644 --- a/apps/web/src/components/ChatInput.tsx +++ b/apps/web/src/components/ChatInput.tsx @@ -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 ))}
)} - {/* 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) && ( -
- {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 && ( - - )} -
- )} {/* BooCode 2.0 composer: textarea + a bottom controls row live INSIDE one bordered, focus-ringed message box (Refreshed direction). */}
@@ -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 */}
+ {onAgentChange && ( + + )} )}
+ {messages !== undefined && ( + + )} {(() => { const hasContent = value.trim().length > 0 || attachments.length > 0; // While generating with an empty draft, the button stops generation. diff --git a/apps/web/src/components/ContextBar.tsx b/apps/web/src/components/ContextBar.tsx deleted file mode 100644 index db2f3b9..0000000 --- a/apps/web/src/components/ContextBar.tsx +++ /dev/null @@ -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 ( -
-
-
-
- - {max !== null ? ( - <> - {/* Absolute counts hidden on very narrow viewports so the - percentage always has room. Tooltip carries full detail. */} - - {used.toLocaleString()} / {max.toLocaleString()}{' '} - - ({Math.round(pct * 100)}%) - - ) : ( - <>— / — - )} - -
- ); -} diff --git a/apps/web/src/components/ContextMeter.tsx b/apps/web/src/components/ContextMeter.tsx new file mode 100644 index 0000000..49787cd --- /dev/null +++ b/apps/web/src/components/ContextMeter.tsx @@ -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(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 ( +
+ + {open && ( +
+

Context window

+

{rounded === null ? 'Unknown' : `${rounded}% used`}

+ {max !== null && ( +

+ {formatTokens(used)} / {formatTokens(max)} tokens +

+ )} + {cost &&

Session cost {cost}

} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/MobileTabSwitcher.tsx b/apps/web/src/components/MobileTabSwitcher.tsx index e797744..3dd27ca 100644 --- a/apps/web/src/components/MobileTabSwitcher.tsx +++ b/apps/web/src/components/MobileTabSwitcher.tsx @@ -198,7 +198,7 @@ export function MobileTabSwitcher({ ))} @@ -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" > @@ -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)} @@ -123,7 +124,7 @@ export function ModelPicker({ value, onChange }: Props) { - {m.id} + {formatModelLabel(m.id)} ))} diff --git a/apps/web/src/components/NewPaneMenu.tsx b/apps/web/src/components/NewPaneMenu.tsx index 5b9c5d7..3a4ba4d 100644 --- a/apps/web/src/components/NewPaneMenu.tsx +++ b/apps/web/src/components/NewPaneMenu.tsx @@ -21,7 +21,7 @@ export function NewPaneMenu({ onAddPane, disabled }: Props) { +

Session history

+
+ {isEmpty ? ( +

+ No conversations yet. Send a message to start. +

+ ) : (<> {openChats.length > 0 && ( <>

@@ -165,7 +200,7 @@ export function SessionLandingPage({ {formatRelative(c.updated_at)} -
+
)} + )} )}
diff --git a/apps/web/src/components/Workspace.tsx b/apps/web/src/components/Workspace.tsx index 29253b0..b1a2f96 100644 --- a/apps/web/src/components/Workspace.tsx +++ b/apps/web/src/components/Workspace.tsx @@ -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({ showLandingPage(idx)} + onShowHistory={() => openSessionHistory(idx)} onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined} />

@@ -307,6 +309,8 @@ export function Workspace({ onUnarchiveChat={unarchiveChat} onArchiveChat={archiveChat} onDeleteChat={deleteChat} + openHistory={historyPaneId === pane.id} + onCloseHistory={closeSessionHistory} /> )}
diff --git a/apps/web/src/components/coder/providerIcons.tsx b/apps/web/src/components/coder/providerIcons.tsx index 40b7519..be5cf38 100644 --- a/apps/web/src/components/coder/providerIcons.tsx +++ b/apps/web/src/components/coder/providerIcons.tsx @@ -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 ( + + ); case 'claude': return ; case 'opencode': diff --git a/apps/web/src/components/panes/CoderPane.tsx b/apps/web/src/components/panes/CoderPane.tsx index ace8aa0..d8083ce 100644 --- a/apps/web/src/components/panes/CoderPane.tsx +++ b/apps/web/src/components/panes/CoderPane.tsx @@ -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) */}
{messages.length === 0 ? ( -
- +
+ BooCode

{chatPending || !chatId ? 'Preparing pane chat…' : 'Send a message to start coding'}

) : ( diff --git a/apps/web/src/hooks/useWorkspacePanes.ts b/apps/web/src/hooks/useWorkspacePanes.ts index d8a6999..0abf824 100644 --- a/apps/web/src/hooks/useWorkspacePanes.ts +++ b/apps/web/src/hooks/useWorkspacePanes.ts @@ -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([]); const draggingIdxRef = useRef(null); const [dragOverIdx, setDragOverIdx] = useState(null); + const [historyPaneId, setHistoryPaneId] = useState(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, diff --git a/apps/web/src/lib/model-label.ts b/apps/web/src/lib/model-label.ts new file mode 100644 index 0000000..48f6331 --- /dev/null +++ b/apps/web/src/lib/model-label.ts @@ -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 `--…` 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(' '); +} diff --git a/apps/web/src/pages/Session.tsx b/apps/web/src/pages/Session.tsx index 2cfb5cb..9dd557a 100644 --- a/apps/web/src/pages/Session.tsx +++ b/apps/web/src/pages/Session.tsx @@ -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 }) { )} +
) : (