diff --git a/src/components/Chat/ChatPanel.tsx b/src/components/Chat/ChatPanel.tsx index 7380c62..80b9d31 100644 --- a/src/components/Chat/ChatPanel.tsx +++ b/src/components/Chat/ChatPanel.tsx @@ -36,7 +36,7 @@ import { useCloudStore } from '@/store/cloud-store'; import { retrieveCommonsContext, findMentionedResults } from '@/knowledge/retrieval'; import { loadGalleryReferences } from '@/cloud/gallery-references'; import { detectFrames, framesFromSlugs } from '@/knowledge/frames'; -import { buildMentionContext } from '@/knowledge/mentions'; +import { buildMentionContext, pinnedCommonsEntries } from '@/knowledge/mentions'; import { retrieveCivicDataContext } from '@/knowledge/civic-data'; import { runQualityReview, messageProducedFiles } from '@/knowledge/review-pass'; import { requestBuildNotifyPermission, notifyBuildReady } from '@/notify/build-ready'; @@ -44,6 +44,7 @@ import { adoptDraftedProjectName } from '@/project/drafted-name'; import { recordBuildEvent, useBuildLogStore } from '@/report/build-log'; import { resetSubmitTracking } from '@/report/friction'; import { BuildReportCard } from './BuildReportCard'; +import { startBuildFromPlan } from './build-from-plan'; import { MessageList } from './MessageList'; import { MessageInput } from './MessageInput'; import { CHUNK_MARKER, FILE_REQUEST_MARKER } from './display'; @@ -484,7 +485,7 @@ export function ChatPanel() { // Context enriches the send; it must never break one. The person's // message is already on screen — a retrieval failure falls back to an // uninformed send instead of stranding the composer mid-generation. - const [retrieval, references, galleryReferences, civicData] = await Promise.all([ + const [retrieval, references, pinned, galleryReferences, civicData] = await Promise.all([ retrieveCommonsContext({ message: content, mode: currentMode === 'plan' ? 'plan' : 'build', @@ -492,6 +493,10 @@ export function ChatPanel() { messages: priorMessages, }).catch(() => ({ results: [], query: null, dropped: 0 })), buildMentionContext(content).catch(() => []), + // Commons entries the person named by @[Title] — a chip dragged in + // from an earlier reply. They ride the same section retrieval fills, + // ahead of anything it scored, whatever this turn's search returned + pinnedCommonsEntries(content).catch(() => []), // Connections between entries — cached for the session; lets the AI // say where else a surfaced tool or practice showed up loadGalleryReferences(), @@ -507,7 +512,7 @@ export function ChatPanel() { if (civicData.length > 0) { recordBuildEvent('civic-data', `live city data in context: ${civicData.map(e => e.city).join(', ')}`); } - const commonsResults = retrieval.results; + const retrieved = retrieval.results; if (retrieval.query !== null) { // The eval trail: what was searched, what survived the floor. A // deliberate empty ("kept 0/8") is a finding, not a failure. @@ -515,18 +520,23 @@ export function ChatPanel() { // section can only credit what the log names. recordBuildEvent( 'retrieval', - `"${retrieval.query.replace(/\s+/g, ' ').slice(0, 60)}" · kept ${commonsResults.length}/${commonsResults.length + retrieval.dropped}` + - (commonsResults.length > 0 - ? ` (${commonsResults.map(r => `${r.slug}${r.similarity ? ` ${r.similarity.toFixed(2)}` : ''}`).join(', ')})` + `"${retrieval.query.replace(/\s+/g, ' ').slice(0, 60)}" · kept ${retrieved.length}/${retrieved.length + retrieval.dropped}` + + (retrieved.length > 0 + ? ` (${retrieved.map(r => `${r.slug}${r.similarity ? ` ${r.similarity.toFixed(2)}` : ''}`).join(', ')})` : ''), ); } + if (pinned.length > 0) { + recordBuildEvent('retrieval', `referenced directly (${pinned.map(r => r.slug).join(', ')})`); + } + const pinnedSlugs = new Set(pinned.map(r => r.slug)); + const commonsResults = [...pinned, ...retrieved.filter(r => !pinnedSlugs.has(r.slug))]; // Local TF-IDF over the Studio KB is the fallback for an UNREACHABLE // commons (zero raw hits — the live search always returns candidates). // A reachable search whose hits all fell below the relevance floor is a // deliberate empty: injecting TF-IDF noise instead would undo the floor. const relevant = - retrieval.query !== null && commonsResults.length === 0 && retrieval.dropped === 0 + retrieval.query !== null && retrieved.length === 0 && retrieval.dropped === 0 ? getRelevantContext(content) : null; const envVars = useEnvStore.getState().vars; @@ -1044,17 +1054,9 @@ export function ChatPanel() { handleSend(queuedMessage, attachments.length > 0 ? attachments : undefined); }, [queuedMessage, isGenerating, setMode, handleSend]); - const handleBuildPlan = useCallback(() => { - setMode('build'); - // On an existing project the plan is a delta — build only it. From - // scratch, the plan is the whole first build. - const existing = useProjectStore.getState().getFileCount() > 0; - handleSend( - existing - ? 'Make the changes agreed in the plan above — only those changes, keeping everything else in the app exactly as it is. Generate the complete added or edited files with filename annotations. End by naming, in one line, anything you deliberately left for a later pass.' - : 'Build the first version of the app described in the plan above — the plan\'s First-build features, not its Later ones. Generate complete, working files with filename annotations, following the plan\'s look & feel and data decisions. End by naming, in one line, what you left for the next pass.', - ); - }, [setMode, handleSend]); + // The action under the conversation and the readiness card's "Ready to + // build" are the same press — see build-from-plan.ts + const handleBuildPlan = useCallback(() => startBuildFromPlan(), []); const handleStop = useCallback(() => { const controller = useChatStore.getState().abortController; diff --git a/src/components/Chat/MessageInput.tsx b/src/components/Chat/MessageInput.tsx index b1abbac..870977e 100644 --- a/src/components/Chat/MessageInput.tsx +++ b/src/components/Chat/MessageInput.tsx @@ -1,10 +1,16 @@ 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, BookOpen } from 'lucide-react'; import { useChatStore, type ChatMode } from '@/store/chat-store'; import { useCloudStore } from '@/store/cloud-store'; import { fileToDataUrl, isImageFile } from '@/lib/image'; -import { listMentionables, type Mentionable } from '@/knowledge/mentions'; +import { + listMentionables, + commonsRefFromDrag, + mentionToken, + COMMONS_REF_DRAG_TYPE, + type Mentionable, +} from '@/knowledge/mentions'; import { ModelSelector } from '@/components/ModelSelector'; import { noteSubmit, recordFriction } from '@/report/friction'; @@ -39,7 +45,7 @@ export function MessageInput({ }: MessageInputProps) { const [input, setInput] = useState(''); const [attachments, setAttachments] = useState([]); - const [dragOver, setDragOver] = useState(false); + const [dragOver, setDragOver] = useState<'files' | 'commons' | null>(null); const dragDepth = useRef(0); const textareaRef = useRef(null); const fileInputRef = useRef(null); @@ -56,15 +62,23 @@ export function MessageInput({ }, [mode, hasCollaborators, onModeChange]); const messageMode = mode === 'message' && hasCollaborators; - // @ mentions: candidates load on first @, popover filters as you type + // @ mentions: candidates load each time a @ opens the popover (the commons + // entries the chat has drawn on grow as it goes), popover filters as you type const [mentionables, setMentionables] = useState(null); const [mentionQuery, setMentionQuery] = useState(null); + const listedForOpen = useRef(false); useEffect(() => { - if (mentionQuery !== null && mentionables === null) { - listMentionables().then(setMentionables).catch(() => setMentionables([])); + if (mentionQuery === null) { + listedForOpen.current = false; + return; } - }, [mentionQuery, mentionables]); + if (listedForOpen.current) return; + listedForOpen.current = true; + listMentionables() + .then(setMentionables) + .catch(() => setMentionables(prev => prev ?? [])); + }, [mentionQuery]); function updateMentionState(value: string, caret: number) { // An @ being typed: "@" preceded by start/whitespace, no ] yet, caret at end of it @@ -87,6 +101,27 @@ export function MessageInput({ }, 0); } + // A mention arriving whole (a dragged commons chip) lands at the caret, + // padded so it never fuses with the word beside it + function insertMentionAtCaret(name: string) { + const token = mentionToken(name); + if (input.includes(token)) return; + const el = textareaRef.current; + const caret = el?.selectionStart ?? input.length; + const before = input.slice(0, caret); + const after = input.slice(caret); + const lead = before && !/\s$/.test(before) ? ' ' : ''; + const head = `${before}${lead}${token} `; + setInput(head + after.replace(/^\s+/, '')); + setTimeout(() => { + if (!el) return; + el.focus(); + el.style.height = 'auto'; + el.style.height = Math.min(el.scrollHeight, maxHeight) + 'px'; + el.setSelectionRange(head.length, head.length); + }, 0); + } + const mentionMatches = (mentionQuery !== null && mentionables) ? mentionables.filter(m => m.name.toLowerCase().includes(mentionQuery.toLowerCase())).slice(0, 6) : []; @@ -211,32 +246,45 @@ export function MessageInput({ } // Drag a photo anywhere onto the composer and it attaches — the same - // door the picker and paste use. dragenter/dragleave fire for every - // child crossed, so a depth counter tells "left the composer" from - // "moved over the textarea". - const draggingFiles = (e: React.DragEvent) => e.dataTransfer.types.includes('Files'); + // door the picker and paste use. A "Drew on the commons" chip dragged + // from a reply lands as an @[Title] mention through the same door. + // dragenter/dragleave fire for every child crossed, so a depth counter + // tells "left the composer" from "moved over the textarea". + const dragKind = (e: React.DragEvent): 'files' | 'commons' | null => { + const types = e.dataTransfer.types; + if (types.includes(COMMONS_REF_DRAG_TYPE)) return 'commons'; + if (types.includes('Files')) return 'files'; + return null; + }; function handleDragEnter(e: React.DragEvent) { - if (!draggingFiles(e) || disabled) return; + const kind = dragKind(e); + if (!kind || disabled) return; e.preventDefault(); dragDepth.current++; - setDragOver(true); + setDragOver(kind); } function handleDragOver(e: React.DragEvent) { - if (!draggingFiles(e) || disabled) return; + if (!dragKind(e) || disabled) return; e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; } function handleDragLeave(e: React.DragEvent) { - if (!draggingFiles(e)) return; + if (!dragKind(e)) return; dragDepth.current = Math.max(0, dragDepth.current - 1); - if (dragDepth.current === 0) setDragOver(false); + if (dragDepth.current === 0) setDragOver(null); } function handleDrop(e: React.DragEvent) { dragDepth.current = 0; - setDragOver(false); - if (!draggingFiles(e) || disabled) return; + setDragOver(null); + const kind = dragKind(e); + if (!kind || disabled) return; e.preventDefault(); + if (kind === 'commons') { + const ref = commonsRefFromDrag(e.dataTransfer); + if (ref) insertMentionAtCaret(ref.title); + return; + } addFiles(e.dataTransfer.files); } @@ -251,7 +299,7 @@ export function MessageInput({ {dragOver && (

- Drop images to attach + {dragOver === 'commons' ? 'Drop to build on this commons entry' : 'Drop images to attach'}

)} @@ -279,7 +327,9 @@ export function MessageInput({ {mentionMatches.length > 0 && (

- Reference one of your apps + {mentionMatches.some(m => m.kind === 'commons') + ? 'Reference one of your apps, or the commons' + : 'Reference one of your apps'}

{mentionMatches.map(m => ( ))} diff --git a/src/components/Chat/MessageList.tsx b/src/components/Chat/MessageList.tsx index dbb0e33..2420028 100644 --- a/src/components/Chat/MessageList.tsx +++ b/src/components/Chat/MessageList.tsx @@ -7,6 +7,8 @@ import { useProjectStore } from '@/store/project-store'; import { usePanelStore } from '@/store/panel-store'; import { artifactDisplay } from '@/project/display-name'; import { useUIStore } from '@/store/ui-store'; +import { COMMONS_REF_DRAG_TYPE, mentionToken, type CommonsRef } from '@/knowledge/mentions'; +import { isReadyToBuildOption, startBuildFromPlan } from './build-from-plan'; import { renderableContent } from './display'; import { CodeBlock } from './CodeBlock'; import { ConnectionSuggestion } from './ConnectionSuggestion'; @@ -418,25 +420,36 @@ export function MessageList({ messages, onBuildPlan, isGenerating }: MessageList } }, [lastMessage]); + // From scratch, the action arrives with the drafted plan document — but it + // must not LEAVE with it. Refinements ("also add a lending toggle") come + // back as short conversational replies, and pinning the action to a + // document-shaped last message meant the invitation to build outlived the + // button that does it: the reply said "press Build this plan" and there was + // nothing to press. Once a plan has been drafted, the plan stands until it + // is built, and every settled reply carries the action. Refinements ride + // along with it — the send carries the whole conversation. + const planDrafted = useMemo( + () => messages.some(m => m.role === 'assistant' && m.isPlan && !m.isStreaming && isPlanDocument(m.content)), + [messages], + ); + if (messages.length === 0) { return null; } // The Build/Approve action belongs to a reply with something to approve. - // From scratch that means the drafted plan document — a conversational - // reply (exploring, questions) has nothing to build yet. On an existing - // project even a two-sentence change IS the plan, so any settled reply - // qualifies — except one that just asked questions, which wants answers, - // not approval. + // A reply that just asked questions is the exception either way: it wants + // answers, not approval — and when the question IS the readiness check, its + // "Ready to build" card is the press. const showBuildAction = !isGenerating && !!onBuildPlan && lastMessage?.role === 'assistant' && lastMessage.isPlan && !lastMessage.isStreaming && - (hasProject - ? extractPlanQuestions(lastMessage.content).length === 0 - : isPlanDocument(lastMessage.content)); + extractPlanQuestions(lastMessage.content).length === 0 && + // On an existing project even a two-sentence change IS the plan + (hasProject || planDrafted); return (
@@ -769,8 +782,13 @@ const MessageBubble = memo(function MessageBubble({ message }: { message: Displa * borrowing from the Mutual Aid Pod recipe" is one tap from the recipe * itself. This is the visible half of the commons loop; the build log keeps * the measurable half. + * + * A chip also drags: dropped on the composer it becomes an @[Title] mention, + * so "yes, that one — build on it" is a gesture instead of a retyped name. + * The entry then rides the next turn pinned, not left to retrieval's luck + * (see knowledge/mentions.ts). */ -function CommonsRefChips({ refs }: { refs: { slug: string; title: string; kind: string }[] }) { +function CommonsRefChips({ refs }: { refs: CommonsRef[] }) { const openGalleryItem = useUIStore(s => s.openGalleryItem); return (
@@ -778,9 +796,15 @@ function CommonsRefChips({ refs }: { refs: { slug: string; title: string; kind: {refs.slice(0, 4).map(r => (