Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions src/components/Chat/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
107 changes: 103 additions & 4 deletions src/components/Chat/MessageInput.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<string | null>(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<Mentionable[] | null>(null);
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
Expand Down Expand Up @@ -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')) {
Expand Down Expand Up @@ -323,6 +368,60 @@ export function MessageInput({
</div>
)}

{/* 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 && (
<div
className="mb-2 flex flex-wrap items-center gap-x-2 gap-y-1 rounded-lg border border-border bg-muted/40 px-2.5 py-1.5 text-xs"
role="status"
>
<Leaf className="size-3.5 shrink-0 text-green-700 dark:text-green-500" />
{lighterArmed ? (
<>
<span className="text-foreground">
This message runs on <span className="font-medium">{modelDisplayName(lighterArmed)}</span>
{' '}— the next one goes back to {modelDisplayName(activeModelId)}.
</span>
<button
type="button"
onClick={() => setLighterArmed(null)}
className="ml-auto text-muted-foreground underline decoration-dotted hover:text-foreground"
>
Undo
</button>
</>
) : (
<>
<span className="text-foreground">
Small change? <span className="font-medium">{modelDisplayName(lighter)}</span> can do this
{costLabel ? ` for ${costLabel}` : ' with a lighter footprint'}.
</span>
<span className="ml-auto flex items-center gap-1">
<button
type="button"
onClick={() => setLighterArmed(lighter)}
className="rounded-full border border-primary/50 bg-primary/10 px-2.5 py-0.5 font-medium text-foreground hover:bg-primary/20 transition-colors"
>
Use {modelDisplayName(lighter)} for this one
</button>
<button
type="button"
onClick={() => {
hushLighterModelNudge();
setNudgeHushed(true);
}}
title="Stop suggesting for the rest of this sitting"
className="rounded-full px-2 py-0.5 text-muted-foreground hover:text-foreground transition-colors"
>
Keep {modelDisplayName(activeModelId)}
</button>
</span>
</>
)}
</div>
)}

{/* One quiet container: write on top, act along the bottom */}
<div
className={`rounded-xl border transition-shadow focus-within:ring-1 ${
Expand Down
112 changes: 112 additions & 0 deletions src/components/Chat/lighter-model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { CLAUDE_MODELS } from '@/providers/claude';
import { shortModelName } from '@/providers/model-label';

/**
* The lighter-model nudge.
*
* Nobody needs Opus to rename a title — but nobody remembers to switch the
* model before a two-word change either, and the model menu is the last
* thing on a person's mind mid-thought. So the composer notices when a draft
* looks like a small change on a heavy model and offers the lighter one, for
* that one message, right where they're about to press send.
*
* It is a nudge, not a switch. The person's model stays theirs — pinned or
* default — and nothing moves unless they tap. A miss in either direction is
* cheap: a false "small" is a chip they ignore, a false "big" is no chip at
* all. That is what lets the judgment below stay simple and conservative.
*/

/** The lighter model offered for a small change, per heavy model. Sonnet 5 is
* the one-tier step for everything heavier — Haiku's shorter output cap and
* the SEARCH/REPLACE edit format make it a worse bet for real edits. */
export function lighterModelFor(activeModelId: string): string | null {
if (/opus|fable|mythos/i.test(activeModelId)) return 'claude-sonnet-5';
return null;
}

/**
* Output price per million tokens, first-party rates. Price is the proxy the
* whole app already uses for footprint (the Steward view prices per model at
* these same rates), and a model's size moves cost and energy the same
* direction. Kept here, not fetched — this only ever feeds a rough fraction.
*/
const OUTPUT_USD_PER_MTOK: Record<string, number> = {
'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;
}