From 53b5422c7cdca342f2323df0525aa8f0c5b4968a Mon Sep 17 00:00:00 2001 From: agentforce314 <273884145+agentforce314@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:41:10 -0700 Subject: [PATCH] feat(nano): nano badge in the web client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser client now shows the backend's nano mode (clawcodex web --nano, docs/nano.md) on three surfaces, all driven by the gateway wire fields the previous commit added: * the composer row: a green 'nano' fact chip after the effort picker — the TUI status line's `model effort nano` order. A span, not a control: nano is a launch flag, there is nothing to toggle. * the run-stats line: the chip rides the model segment (RunStatsBar), so whatever narrows the bar can never shed the mode without also shedding the model it describes — the TUI stats-line contract. * the session details panel: a 'Harness: nano' fact row, rendered only when the session IS nano (default mode is not a fact worth a row). Sourcing mirrors the composer's model/approval fallbacks: the session's own session.info.nano once one exists, else $backendNano — a new boot fact read from /api/status, which is what lets the welcome screen say what the first prompt will run on before it is sent. Strict === true everywhere, so older backends without the field never grow a badge. Tests: 8 TS vitest in NanoBadge.test.tsx (composer chip render/absence/ not-a-button, stats-bar ride-along + absence + no-model case, details row presence/absence) + a transcript merge pin (nano survives a session.info republish that omits it — matching the backend, where _install_provider keeps the nano registry). ui-web npm run check: 418 tests green, typecheck clean; vite build clean. Verified live against `clawcodex web --nano` (deepseek): hero chip from /api/status before any session; a real fix-the-bug turn on the six-tool surface (Read → Edit → Bash verify); mid-session model switch keeps chip, details row, and registry (second turn green); default-mode control run shows no chip anywhere and nano:false on every wire field. Co-Authored-By: Claude Fable 5 --- docs/nano.md | 6 + ui-web/src/conversation/ConversationRoot.tsx | 11 ++ ui-web/src/conversation/InputBar.tsx | 15 ++ ui-web/src/conversation/NanoBadge.test.tsx | 137 +++++++++++++++++++ ui-web/src/conversation/Pickers.module.css | 23 ++++ ui-web/src/details/DetailsPanel.tsx | 10 ++ ui-web/src/gateway/protocol.ts | 7 + ui-web/src/state/actions.ts | 21 +-- ui-web/src/state/store.ts | 11 ++ ui-web/src/state/transcript.test.ts | 13 ++ ui-web/src/trajectory/RunStatsBar.module.css | 14 ++ ui-web/src/trajectory/RunStatsBar.tsx | 8 +- 12 files changed, 266 insertions(+), 10 deletions(-) create mode 100644 ui-web/src/conversation/NanoBadge.test.tsx diff --git a/docs/nano.md b/docs/nano.md index f83040ca..b523926f 100644 --- a/docs/nano.md +++ b/docs/nano.md @@ -23,6 +23,12 @@ usual — nano changes what the *model* sees, not the UI. A mid-session a `nano` chip beside the model name (e.g. `deepseek-v4-flash nano`), driven by the backend's `system/init` frame. +The browser client (`clawcodex web --nano`) shows the same fact on three +surfaces — a green `nano` chip beside the composer's model picker (from +the welcome screen on, via `/api/status`), the chip riding the model +segment of the run-stats line, and a `Harness: nano` row in the session +details panel — all driven by the gateway's `session.info.nano`. + ## What nano sends | | nano | default | diff --git a/ui-web/src/conversation/ConversationRoot.tsx b/ui-web/src/conversation/ConversationRoot.tsx index 5622e1c2..6c130530 100644 --- a/ui-web/src/conversation/ConversationRoot.tsx +++ b/ui-web/src/conversation/ConversationRoot.tsx @@ -18,6 +18,7 @@ import { submitPrompt, } from '../state/actions.ts' import { + $backendNano, $commands, $connection, $contextUsage, @@ -80,9 +81,16 @@ export function ConversationRoot() { const loading = useStore($sessionLoading) const tab = useStore($conversationTab) const trajectory = useStore($trajectory) + const backendNano = useStore($backendNano) const stats = useMemo(() => trajectoryStats(trajectory), [trajectory]) const todos = useMemo(() => currentTodos(transcript.nodes), [transcript.nodes]) + // The session's own truth once session.info reported it, else the backend's + // process-wide fact (/api/status) — same shape as the approval mode and + // model fallbacks in the composer below. The two can only disagree across a + // backend restart, and the session's word wins. + const nano = transcript.info.nano ?? backendNano + const [draft, setDraft] = useState('') // null while in flight, so the panel can say "loading" rather than "no plan". const [plan, setPlan] = useState(null) @@ -209,6 +217,7 @@ export function ConversationRoot() { effort={effort} hero={hero} models={models} + nano={nano === true} onApprovalModeChange={mode => { void setApprovalMode(mode) }} @@ -374,6 +383,7 @@ export function ConversationRoot() { {seatPanel} @@ -427,6 +437,7 @@ export function ConversationRoot() { {seatPanel} diff --git a/ui-web/src/conversation/InputBar.tsx b/ui-web/src/conversation/InputBar.tsx index 3c7c185d..ff53717b 100644 --- a/ui-web/src/conversation/InputBar.tsx +++ b/ui-web/src/conversation/InputBar.tsx @@ -29,12 +29,15 @@ import { EffortSelect } from './EffortSelect.tsx' import { ModelSelect } from './ModelSelect.tsx' import { PermissionSelect, type ApprovalMode } from './PermissionSelect.tsx' import css from './InputBar.module.css' +import pickerCss from './Pickers.module.css' export interface InputBarProps { approvalMode?: ApprovalMode draft: string effort: EffortOptionsResult hero?: boolean + /** Nano mode (docs/nano.md) — renders a chip beside the model, like the TUI's. */ + nano?: boolean /** False when the session's model would 400 on an image. */ vision?: boolean models: ModelOptionsResult @@ -66,6 +69,7 @@ export function InputBar({ effort, hero = false, models, + nano = false, onApprovalModeChange, onDraftChange, onEffortChange, @@ -529,6 +533,17 @@ export function InputBar({ sessionProvider={sessionProvider} /> + {/* After effort, matching the TUI's status-line order + (`model effort nano`). A fact chip, not a control: nano is a + launch flag, so there is nothing to open or toggle here. */} + {nano && ( + + nano + + )}
diff --git a/ui-web/src/conversation/NanoBadge.test.tsx b/ui-web/src/conversation/NanoBadge.test.tsx new file mode 100644 index 00000000..ecf0cd1e --- /dev/null +++ b/ui-web/src/conversation/NanoBadge.test.tsx @@ -0,0 +1,137 @@ +/** + * The nano chip (backend `--nano`, docs/nano.md) on its three surfaces: + * the composer row, the run-stats line, and the session details panel. + * + * The rule under every case: the chip is driven by an explicit `true` and + * nothing else — a backend that never says nano must never grow a badge. + */ + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { DetailsPanel } from '../details/DetailsPanel.tsx' +import { $contextUsage, $sessionId, $transcript, $workspace } from '../state/store.ts' +import { emptyTranscript } from '../state/transcript.ts' +import type { TrajectoryStats } from '../state/trajectory.ts' +import { RunStatsBar } from '../trajectory/RunStatsBar.tsx' +import { InputBar } from './InputBar.tsx' + +afterEach(() => { + cleanup() + $transcript.set(emptyTranscript()) + $workspace.set('') + $sessionId.set(null) + $contextUsage.set(null) +}) + +function renderBar(nano?: boolean) { + render( + , + ) +} + +describe('InputBar nano chip', () => { + it('renders the chip when the session is nano', () => { + renderBar(true) + + expect(screen.getByText('nano')).toBeTruthy() + }) + + it('renders nothing by default', () => { + // Absent on older backends must stay absent here — a chip with no flag + // behind it would claim a mode the session is not in. + renderBar() + + expect(screen.queryByText('nano')).toBeNull() + }) + + it('is a fact, not a control — no button role', () => { + renderBar(true) + + const chip = screen.getByText('nano') + + expect(chip.tagName).toBe('SPAN') + expect(chip.getAttribute('role')).toBeNull() + }) +}) + +const NO_RUN: TrajectoryStats = { + cacheHitRatio: null, + inputTokens: 0, + llmMs: 0, + outputTokens: 0, + steps: 0, + throughput: null, + toolMs: 0, + ttftMs: null, + turns: 0, +} + +describe('RunStatsBar nano chip', () => { + it('rides the model segment', () => { + render( + , + ) + + const model = screen.getByText('deepseek:deepseek-v4-flash') + const chip = screen.getByText('nano') + + // Same group: whatever narrows the bar cannot shed the mode without also + // shedding the model it describes (the TUI stats-line contract). + expect(model.parentElement).toBe(chip.parentElement) + }) + + it('shows no chip without the flag', () => { + render() + + expect(screen.queryByText('nano')).toBeNull() + }) + + it('shows no chip with no model to describe', () => { + // The chip rides the model segment; with nothing to ride it stays off + // rather than floating as a lone token in an otherwise empty bar. + const { container } = render() + + expect(container.firstChild).toBeNull() + }) +}) + +describe('DetailsPanel harness row', () => { + it('names the harness when the session is nano', () => { + $transcript.set({ + ...emptyTranscript(), + info: { model: 'deepseek-v4-flash', nano: true, provider: 'deepseek' }, + }) + + render() + + expect(screen.getByText('Harness')).toBeTruthy() + expect(screen.getByText('nano')).toBeTruthy() + }) + + it('shows no harness row for a default session', () => { + // Default mode is not a fact worth a row — and strict === true keeps a + // backend that never reported the field silent too. + $transcript.set({ + ...emptyTranscript(), + info: { model: 'deepseek-v4-flash', provider: 'deepseek' }, + }) + + render() + + expect(screen.queryByText('Harness')).toBeNull() + }) +}) diff --git a/ui-web/src/conversation/Pickers.module.css b/ui-web/src/conversation/Pickers.module.css index abb38200..7056b3ff 100644 --- a/ui-web/src/conversation/Pickers.module.css +++ b/ui-web/src/conversation/Pickers.module.css @@ -76,3 +76,26 @@ display: none; } } + +/* Nano mode fact chip (docs/nano.md). Sits among the picker triggers but is + not one: nano is a launch flag, so it has no menu, no hover, nothing to + toggle. Green because nano is the slim/eco profile — a fact, not a warning. + Deliberately NOT shed by the narrow-composer query above: like the TUI's + stats line, the mode must survive every narrowing. */ +.nanoBadge { + flex: none; + display: inline-flex; + align-items: center; + height: 20px; + padding: 0 8px; + border-radius: 10px; + background: var(--cc-alias-state-success-tertiary); + color: var(--cc-alias-state-success-primary); + font-size: 11px; + line-height: 20px; + font-weight: 600; + letter-spacing: 0.02em; + white-space: nowrap; + cursor: default; + user-select: none; +} diff --git a/ui-web/src/details/DetailsPanel.tsx b/ui-web/src/details/DetailsPanel.tsx index ed03a154..d8b856db 100644 --- a/ui-web/src/details/DetailsPanel.tsx +++ b/ui-web/src/details/DetailsPanel.tsx @@ -88,6 +88,16 @@ export function DetailsPanel() {
{info.model ?? '—'}
Provider
{info.provider ?? '—'}
+ {/* Only when the session IS nano: default mode is not a fact worth + a row, and strict === true keeps older backends silent. */} + {info.nano === true && ( + <> +
Harness
+
+ nano +
+ + )} {info.reasoning_effort !== undefined && ( <>
Effort
diff --git a/ui-web/src/gateway/protocol.ts b/ui-web/src/gateway/protocol.ts index 2d1c4d30..a6501fc2 100644 --- a/ui-web/src/gateway/protocol.ts +++ b/ui-web/src/gateway/protocol.ts @@ -26,6 +26,13 @@ export interface SessionInfoPayload { cwd?: string desktop_contract?: number model?: string + /** + * Nano mode (backend `--nano`, docs/nano.md): six-tool pi-style minimal + * harness. Rendered as a chip beside the model name, like the TUI's. + * Compared strict `=== true` everywhere — absent on an older backend must + * stay falsy, never render a stale badge. + */ + nano?: boolean provider?: string reasoning_effort?: string /** Whether the session's model accepts image input; false hides the attach control. */ diff --git a/ui-web/src/state/actions.ts b/ui-web/src/state/actions.ts index 8f1982d8..c5890480 100644 --- a/ui-web/src/state/actions.ts +++ b/ui-web/src/state/actions.ts @@ -27,6 +27,7 @@ import type { SlashResult, } from '../gateway/protocol.ts' import { + $backendNano, $bootError, $bootPhase, $commands, @@ -133,23 +134,25 @@ export async function start(): Promise { } /** - * The workspace the backend was started in. + * The backend facts the hero needs before any session exists. * - * It is the directory the first session will run in, so the hero has to name - * it *before* that session exists — and only REST knows it that early - * (`session.info` carries a cwd, but not until a session is created). + * The workspace is the directory the first session will run in, and nano is + * whether that session will be a nano one — both have to be named *before* + * the session exists, and only REST knows them that early (`session.info` + * carries both, but not until a session is created). */ async function seedWorkspace(target: BackendTarget): Promise { - if ($workspace.get() !== '') return - try { - const status = await apiGet<{ workspace?: string }>(target, '/status') + const status = await apiGet<{ nano?: boolean; workspace?: string }>(target, '/status') - if (typeof status.workspace === 'string' && status.workspace !== '') { + if ($workspace.get() === '' && typeof status.workspace === 'string' && status.workspace !== '') { $workspace.set(status.workspace) } + + // Strict === true: an older backend without the field must stay falsy. + $backendNano.set(status.nano === true) } catch { - /* the hero simply omits the workspace chip */ + /* the hero simply omits the workspace chip and the nano badge */ } } diff --git a/ui-web/src/state/store.ts b/ui-web/src/state/store.ts index 84814ae6..c3341e61 100644 --- a/ui-web/src/state/store.ts +++ b/ui-web/src/state/store.ts @@ -63,6 +63,17 @@ export const $projects = atom([]) export const $projectsLoading = atom(false) export const $workspace = atom('') +/** + * Whether this backend runs in nano mode (`clawcodex web --nano`). + * + * Nano is process-global on the backend — every session it hosts is nano — so + * this is a boot-time fact from `/api/status`, not session state. It exists + * for the welcome screen: `session.info` also carries `nano`, but only once a + * session does, and the composer should say what the first prompt will run on + * before it is sent. + */ +export const $backendNano = atom(false) + export const $models = atom({}) /** Effort ladder for the running model; `supported: false` hides the chip. */ export const $effort = atom({ supported: false }) diff --git a/ui-web/src/state/transcript.test.ts b/ui-web/src/state/transcript.test.ts index a03f9c9c..6b6a3454 100644 --- a/ui-web/src/state/transcript.test.ts +++ b/ui-web/src/state/transcript.test.ts @@ -200,6 +200,19 @@ describe('turn state', () => { expect(state.info).toMatchObject({ approval_mode: 'manual', model: 'm', provider: 'p' }) }) + it('keeps the nano flag across a session.info that omits it', () => { + // A mid-session model switch republishes session.info; the nano chip must + // survive it (matching the backend, where _install_provider keeps the + // nano registry) even if that republish carries no nano field. + const state = fold([ + event('session.info', { model: 'm', nano: true }), + event('session.info', { model: 'm2', provider: 'p2' }), + ]) + + expect(state.info.nano).toBe(true) + expect(state.info.model).toBe('m2') + }) + it('accumulates usage across turns', () => { const state = fold([ event('message.complete', { diff --git a/ui-web/src/trajectory/RunStatsBar.module.css b/ui-web/src/trajectory/RunStatsBar.module.css index a1c328f2..573b443d 100644 --- a/ui-web/src/trajectory/RunStatsBar.module.css +++ b/ui-web/src/trajectory/RunStatsBar.module.css @@ -47,3 +47,17 @@ color: var(--cc-alias-label-caption); white-space: nowrap; } + +/* Nano mode chip, riding the model segment (docs/nano.md). Same green as the + composer's badge so the two surfaces read as one fact. */ +.nano { + flex: none; + padding: 0 6px; + border-radius: 8px; + background: var(--cc-alias-state-success-tertiary); + color: var(--cc-alias-state-success-primary); + font-size: 10px; + line-height: 15px; + font-weight: 600; + letter-spacing: 0.02em; +} diff --git a/ui-web/src/trajectory/RunStatsBar.tsx b/ui-web/src/trajectory/RunStatsBar.tsx index 8442c142..1172cca3 100644 --- a/ui-web/src/trajectory/RunStatsBar.tsx +++ b/ui-web/src/trajectory/RunStatsBar.tsx @@ -4,6 +4,8 @@ import css from './RunStatsBar.module.css' export interface RunStatsBarProps { model?: string + /** Nano mode chip (session.info.nano) — rides the model segment. */ + nano?: boolean provider?: string stats: TrajectoryStats } @@ -21,7 +23,7 @@ export interface RunStatsBarProps { * to go look at. Figures that were never measured are omitted rather than * printed as zero. */ -export function RunStatsBar({ model, provider, stats }: RunStatsBarProps) { +export function RunStatsBar({ model, nano = false, provider, stats }: RunStatsBarProps) { const named = model !== undefined && model !== '' // Before the first turn there are no figures, and a bar holding nothing but @@ -33,6 +35,10 @@ export function RunStatsBar({ model, provider, stats }: RunStatsBarProps) { {named && ( {qualifiedModel(model ?? '', provider)} + {/* The chip rides the model segment (like the TUI's stats line), so + whatever narrows this bar can never shed the mode without also + shedding the model it describes. */} + {nano && nano} )}