From a3c13324d39bb9aad49785f66380f93f56d0694b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 20:34:44 +0000 Subject: [PATCH] Offer the lighter model for a small change, right where you press send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nobody needs Opus to rename a title. Nobody remembers to switch the model before a two-word change either — the model menu is the last thing on a person's mind mid-thought, and it stays wherever the last big change left it. So the heavy model ran every tiny edit, quietly, for everyone who never thought to look at the picker. The composer now notices. When a draft on a heavy model reads like a small change — a rename, a color, a typo, the preview's own "Copy change" and photo-swap messages — a quiet line appears above the composer: "Small change? Sonnet 5 can do this for about two-fifths of the cost", with one tap to use it for this message and one to keep what they have. It shows at the moment people forget: right before send, reading the draft as they type. It never fires on a plan, a first build, the home composer, or a model that is already light. It is a nudge, not a switch. The heavy model stays the default unless they tap; the one-shot ride never pins or moves the picker, and the very next message runs on whatever it would have. "Keep Opus 5" hushes it for the rest of the sitting — in memory on purpose, so a preference can never quietly persist forever. Armed, the chip becomes a plain statement of what is about to happen, with an undo. The judgment is deliberately conservative: the preview's mechanical messages, or a short single ask that talks like a tweak and never names anything with shape (a page, a feature, a login, a calendar, "everything"). A miss in either direction is cheap — a false small is a chip you ignore, a false big is no chip — which is what lets it stay simple. The cost fraction comes from first-party output prices (the proxy the Steward view already prices with), rounded to a fraction a person can hold, never a percentage with false precision. Driven in Chromium against the dev server with model requests captured off the wire: fourteen drafts judged as intended; the chip appears on a small draft, disappears on a big one, and the armed send goes out as claude-sonnet-5 while the picker stays on Opus, unpinned; the next send goes back to claude-opus-5; undo returns to the offer and keep hushes the sitting; no chip in plan mode, on a first build, or already on Sonnet; on Fable the fraction reads "a fifth". Zero page errors. tsc -b and vite build clean; lint at the same three pre-existing MessageInput problems, none in the new code. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RFnFvBZPsVQjc5Xwj7H9nZ --- src/components/Chat/ChatPanel.tsx | 11 ++- src/components/Chat/MessageInput.tsx | 107 ++++++++++++++++++++++++- src/components/Chat/lighter-model.ts | 112 +++++++++++++++++++++++++++ 3 files changed, 224 insertions(+), 6 deletions(-) create mode 100644 src/components/Chat/lighter-model.ts diff --git a/src/components/Chat/ChatPanel.tsx b/src/components/Chat/ChatPanel.tsx index 82a3ea4..c4bc473 100644 --- a/src/components/Chat/ChatPanel.tsx +++ b/src/components/Chat/ChatPanel.tsx @@ -45,7 +45,7 @@ import { recordBuildEvent, useBuildLogStore } from '@/report/build-log'; import { resetSubmitTracking } from '@/report/friction'; import { BuildReportCard } from './BuildReportCard'; import { MessageList } from './MessageList'; -import { MessageInput } from './MessageInput'; +import { MessageInput, type SendOptions } from './MessageInput'; import { CHUNK_MARKER, FILE_REQUEST_MARKER } from './display'; import { markFilesRequested } from '@/knowledge/snapshot-split'; import { useNeedsKey, NeedsKeyHint } from './composer-gate'; @@ -375,7 +375,7 @@ export function ChatPanel() { // asks the same question through the same hook (composer-gate.tsx) const needsKey = useNeedsKey(); - const handleSend = useCallback(async (content: string, attachments?: string[]) => { + const handleSend = useCallback(async (content: string, attachments?: string[], opts?: SendOptions) => { // Mode is read fresh from the store: "Build this plan" flips it right before sending const currentMode = useChatStore.getState().mode; // Message mode is human-to-human: the note joins the conversation (and @@ -451,6 +451,13 @@ export function ChatPanel() { } } + // A lighter model the person chose for this one message — the composer's + // nudge on a small change. One send only: the picker stays where it was, + // nothing pins, and the next message runs on whatever it would have. It + // was chosen in plain view, so no model note; the build log's gen_start + // still records what actually ran. + if (opts?.model && !wasFix) modelForSend = opts.model; + // A project's initial build starts a fresh build log — the timeline that // becomes a shareable report if the builder opts in when it's done. The // knowledge already gathered while planning carries over: the commons diff --git a/src/components/Chat/MessageInput.tsx b/src/components/Chat/MessageInput.tsx index b1abbac..99ddc46 100644 --- a/src/components/Chat/MessageInput.tsx +++ b/src/components/Chat/MessageInput.tsx @@ -1,19 +1,36 @@ import { useState, useRef, useCallback, useEffect } from 'react'; import { Button } from '@/components/ui/button'; -import { SendHorizontal, Square, Map, Hammer, ImagePlus, X, FolderOpen, Globe, Clock, MessagesSquare } from 'lucide-react'; +import { SendHorizontal, Square, Map, Hammer, ImagePlus, X, FolderOpen, Globe, Clock, MessagesSquare, Leaf } from 'lucide-react'; import { useChatStore, type ChatMode } from '@/store/chat-store'; import { useCloudStore } from '@/store/cloud-store'; +import { useProjectStore } from '@/store/project-store'; +import { useProviderStore } from '@/store/provider-store'; import { fileToDataUrl, isImageFile } from '@/lib/image'; import { listMentionables, type Mentionable } from '@/knowledge/mentions'; import { ModelSelector } from '@/components/ModelSelector'; import { noteSubmit, recordFriction } from '@/report/friction'; +import { + lighterModelFor, + looksLikeSmallChange, + relativeCostLabel, + modelDisplayName, + hushLighterModelNudge, + lighterModelNudgeHushed, +} from './lighter-model'; + +/** Per-send options a composer can attach to what it sends */ +export interface SendOptions { + /** Run this one message on a specific model — the lighter-model nudge. + * Never pins or changes the picker; the next message is unaffected. */ + model?: string; +} // Room for two seeded reference screenshots (gallery remixes) plus the // person's own images const MAX_ATTACHMENTS = 4; interface MessageInputProps { - onSend: (message: string, attachments?: string[]) => void; + onSend: (message: string, attachments?: string[], opts?: SendOptions) => void; onStop: () => void; isGenerating: boolean; disabled?: boolean; @@ -56,6 +73,29 @@ export function MessageInput({ }, [mode, hasCollaborators, onModeChange]); const messageMode = mode === 'message' && hasCollaborators; + // The lighter-model nudge: a small change drafted on a heavy model gets a + // quiet offer of the lighter one, for this message only. It reads the + // draft, so it shows up at the moment people forget — right before send — + // and it never fires on a first build, a plan, or the home composer. + // Arming is sticky until send or undo (their choice, even if the draft + // grows); the unarmed suggestion follows the draft. + const fileCount = useProjectStore(s => s.getFileCount()); + const activeModelId = useProviderStore(s => s.activeModelId); + const activeProviderId = useProviderStore(s => s.activeProviderId); + const [lighterArmed, setLighterArmed] = useState(null); + const [nudgeHushed, setNudgeHushed] = useState(lighterModelNudgeHushed); + const lighter = activeProviderId === 'claude' ? lighterModelFor(activeModelId) : null; + const showLighterNudge = + !!lighter && + !hero && + mode === 'build' && + fileCount > 0 && + !isGenerating && + !disabled && + !nudgeHushed && + (lighterArmed !== null || looksLikeSmallChange(input)); + const costLabel = lighter ? relativeCostLabel(activeModelId, lighter) : null; + // @ mentions: candidates load on first @, popover filters as you type const [mentionables, setMentionables] = useState(null); const [mentionQuery, setMentionQuery] = useState(null); @@ -151,14 +191,19 @@ export function MessageInput({ if (textareaRef.current) textareaRef.current.style.height = 'auto'; return; } - onSend(trimmed || 'Here’s an image for reference.', attachments); + onSend( + trimmed || 'Here’s an image for reference.', + attachments, + lighterArmed ? { model: lighterArmed } : undefined, + ); setInput(''); setAttachments([]); + setLighterArmed(null); // Reset textarea height if (textareaRef.current) { textareaRef.current.style.height = 'auto'; } - }, [input, attachments, disabled, isGenerating, mode, onSend]); + }, [input, attachments, disabled, isGenerating, mode, onSend, lighterArmed]); function handleKeyDown(e: React.KeyboardEvent) { if (mentionMatches.length > 0 && (e.key === 'Enter' || e.key === 'Tab')) { @@ -323,6 +368,60 @@ export function MessageInput({ )} + {/* The lighter-model nudge. Quiet, one line, two answers — and the + heavy model stays the default unless they tap. Armed, it turns + into a plain statement of what's about to happen, with an undo. */} + {showLighterNudge && lighter && ( +
+ + {lighterArmed ? ( + <> + + This message runs on {modelDisplayName(lighterArmed)} + {' '}— the next one goes back to {modelDisplayName(activeModelId)}. + + + + ) : ( + <> + + Small change? {modelDisplayName(lighter)} can do this + {costLabel ? ` for ${costLabel}` : ' with a lighter footprint'}. + + + + + + + )} +
+ )} + {/* One quiet container: write on top, act along the bottom */}
= { + 'claude-fable-5-1': 50, + 'claude-opus-5': 25, + 'claude-opus-4-8': 25, + 'claude-sonnet-5': 10, + 'claude-haiku-4-5': 5, +}; + +/** "about two-fifths of the cost" — a fraction a person can hold, never a + * percentage with false precision. Null when either price is unknown. */ +export function relativeCostLabel(fromModelId: string, toModelId: string): string | null { + const from = OUTPUT_USD_PER_MTOK[fromModelId]; + const to = OUTPUT_USD_PER_MTOK[toModelId]; + if (!from || !to || to >= from) return null; + const ratio = to / from; + const fraction = + ratio <= 0.22 ? 'a fifth' : + ratio <= 0.28 ? 'a quarter' : + ratio <= 0.36 ? 'a third' : + ratio <= 0.45 ? 'two-fifths' : + 'half'; + return `about ${fraction} of the cost`; +} + +/** Named the way the model menu names them — "Sonnet 5", not "Claude Sonnet 5" */ +export function modelDisplayName(id: string): string { + return shortModelName(CLAUDE_MODELS.find(m => m.id === id)?.name ?? id); +} + +// The preview's point-at-it messages: a quoted string and its replacement, a +// photo swap. Mechanical by construction — the surest "small" there is. +const MACHINE_SMALL_RE = + /^(Copy change — this text in the preview:|Swap this photo in the preview|About this element in the preview:)/; + +// Words that describe a tweak to something that already exists +const TWEAK_RE = + /\b(rename|retitle|title|heading|headline|subtitle|tagline|text|copy|wording|word|typo|spell|caption|label|placeholder|say|says|read|reads|color|colour|font|bold|italic|bigger|smaller|larger|tiny|huge|padding|spacing|margin|gap|align|center|centre|left|right|darker|lighter|rounded|border|shadow|icon|swap|replace|move|hide|show|remove|delete|tweak|adjust|nudge|shorten|capitali[sz]e|lowercase|uppercase|date|time|name|link|url|button text|change)\b/i; + +// Words that mean the ask has shape beyond a tweak: new surface, new +// capability, new data, or "all of it" +const SCOPE_RE = + /\b(page|screen|tab|view|feature|section|form|flow|wizard|onboarding|login|log in|sign ?in|sign ?up|account|profile|user|admin|database|backend|server|api|integrat\w*|connect\w*|payment|checkout|email|notif\w*|calendar|map|chart|graph|search|filter|sort|pagination|upload|export|import|sync|deploy|publish|redesign|rework|rewrite|refactor|restructure|rebuild|overhaul|everything|all of|whole|entire|each|every|multiple|several|another|also|as well|and then|plus|dark mode|responsive|mobile|accessib\w*|animation|component|table|list of|dashboard)\b/i; + +const SMALL_MAX_CHARS = 160; + +/** + * Does this draft read like a small change — the kind of ask that doesn't + * need the heaviest model in the room? Deliberately conservative: it says + * "small" only for the preview's mechanical messages and for short, single + * asks that talk like a tweak and never mention anything with shape. + */ +export function looksLikeSmallChange(draft: string): boolean { + const text = draft.trim(); + if (!text) return false; + if (MACHINE_SMALL_RE.test(text)) return true; + if (text.length > SMALL_MAX_CHARS) return false; + // Two or more sentences is usually two or more asks + if ((text.match(/[.!?](\s|$)/g) ?? []).length > 1) return false; + if (text.includes('\n')) return false; + if (SCOPE_RE.test(text)) return false; + return TWEAK_RE.test(text); +} + +// "Keep Opus 5" means keep it — for the rest of this sitting, not just this +// draft. In memory on purpose: a reload is a fresh sitting, and a preference +// that quietly persisted forever would be the nudge you can never get back. +let hushed = false; + +export function hushLighterModelNudge(): void { + hushed = true; +} + +export function lighterModelNudgeHushed(): boolean { + return hushed; +} + +/** Test seam — a session-level flag has to be resettable between scenarios */ +export function resetLighterModelNudge(): void { + hushed = false; +}