From 0a0c8287c6a4668e680d7e2503d96faee150cd8e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 01:46:11 +0000 Subject: [PATCH 1/2] Drag a "Drew on the commons" chip into the message to build on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plan reply that leans on a commons entry shows it as a chip under the reply — "Drew on the commons: Mutual Aid Pod" — and until now the chip did one thing: open the gallery card. Saying "yes, that one, build on it" meant retyping the title and hoping the next turn's retrieval surfaced the same entry again. In the first planning exchanges, where entries are being weighed against each other, that was the whole conversation. The chip now drags. Dropped anywhere on the composer it lands as an @[Title] mention at the caret, padded so it never fuses with the word beside it, with the same dashed drop target the image drop uses and its own copy ("Drop to build on this commons entry"). A text/plain twin rides along, so a drop on any ordinary text field still lands as the token. Typing @ offers the surfaced entries too, alongside your apps, so the same reference is one tap on a phone where nothing drags. The mention is a promise, not a hint. At send time it pins the entry into the same Relevant Knowledge section retrieval fills — first, with the strongest match and its full text (bounded, like retrieval's own deepening) — whatever this turn's search happened to score. Retrieval's copy of the same entry steps aside; the build log records the pin as its own retrieval line; the reply's own chips and provenance count it like any surfaced entry. A short section names the intent ("the builder pointed at this directly") so the model knows it was chosen, not merely found. Surfaced entries are read from the messages themselves rather than a side registry, so a reloaded chat still knows what it drew on. The mention popover re-lists on each open, since the set grows as the chat goes. Driven in Chromium against the dev server: the chip drags and lands as the token, a second chip pads beside typed text, a repeat drag is a no-op, the overlay copy shows mid-drag, the @ popover offers the entry as "commons", and the send-time pin resolves to match 'both', similarity 1, with a 1400-char excerpt; an unknown title or no mention pins nothing. tsc -b and vite build clean; lint at the same six pre-existing problems as an untouched tree, 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 | 23 +++-- src/components/Chat/MessageInput.tsx | 96 ++++++++++++++----- src/components/Chat/MessageList.tsx | 18 +++- src/knowledge/mentions.ts | 137 +++++++++++++++++++++++++-- 4 files changed, 234 insertions(+), 40 deletions(-) diff --git a/src/components/Chat/ChatPanel.tsx b/src/components/Chat/ChatPanel.tsx index 7380c62..e72b3ea 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'; @@ -484,7 +484,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 +492,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 +511,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 +519,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; 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..1b8d7b2 100644 --- a/src/components/Chat/MessageList.tsx +++ b/src/components/Chat/MessageList.tsx @@ -7,6 +7,7 @@ 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 { renderableContent } from './display'; import { CodeBlock } from './CodeBlock'; import { ConnectionSuggestion } from './ConnectionSuggestion'; @@ -769,8 +770,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 +784,15 @@ function CommonsRefChips({ refs }: { refs: { slug: string; title: string; kind: {refs.slice(0, 4).map(r => (