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; +}