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
38 changes: 20 additions & 18 deletions src/components/Chat/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,15 @@ 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';
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';
Expand Down Expand Up @@ -484,14 +485,18 @@ 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',
isFixSend: wasFix,
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(),
Expand All @@ -507,26 +512,31 @@ 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.
// Every kept entry, not just the top few — the report's provenance
// 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;
Expand Down Expand Up @@ -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;
Expand Down
96 changes: 74 additions & 22 deletions src/components/Chat/MessageInput.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -39,7 +45,7 @@ export function MessageInput({
}: MessageInputProps) {
const [input, setInput] = useState('');
const [attachments, setAttachments] = useState<string[]>([]);
const [dragOver, setDragOver] = useState(false);
const [dragOver, setDragOver] = useState<'files' | 'commons' | null>(null);
const dragDepth = useRef(0);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
Expand All @@ -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<Mentionable[] | null>(null);
const [mentionQuery, setMentionQuery] = useState<string | null>(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
Expand All @@ -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)
: [];
Expand Down Expand Up @@ -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);
}

Expand All @@ -251,7 +299,7 @@ export function MessageInput({
{dragOver && (
<div className={`pointer-events-none absolute inset-0 z-10 flex items-center justify-center border-2 border-dashed border-primary bg-primary/10 ${hero ? 'rounded-2xl' : 'rounded-lg'}`}>
<p className="rounded-full bg-background/95 border px-3 py-1.5 text-xs font-medium text-foreground shadow-sm">
Drop images to attach
{dragOver === 'commons' ? 'Drop to build on this commons entry' : 'Drop images to attach'}
</p>
</div>
)}
Expand Down Expand Up @@ -279,7 +327,9 @@ export function MessageInput({
{mentionMatches.length > 0 && (
<div className="mb-2 rounded-lg border bg-popover shadow-md max-w-sm overflow-hidden">
<p className="px-2.5 pt-2 pb-1 text-xs uppercase tracking-wide text-muted-foreground">
Reference one of your apps
{mentionMatches.some(m => m.kind === 'commons')
? 'Reference one of your apps, or the commons'
: 'Reference one of your apps'}
</p>
{mentionMatches.map(m => (
<button
Expand All @@ -289,10 +339,12 @@ export function MessageInput({
>
{m.kind === 'project'
? <FolderOpen className="size-3 text-muted-foreground shrink-0" />
: <Globe className="size-3 text-muted-foreground shrink-0" />}
: m.kind === 'commons'
? <BookOpen className="size-3 text-muted-foreground shrink-0" />
: <Globe className="size-3 text-muted-foreground shrink-0" />}
<span className="truncate">{m.name}</span>
<span className="ml-auto text-xs text-muted-foreground shrink-0">
{m.kind === 'project' ? 'project' : 'live site'}
{m.kind === 'project' ? 'project' : m.kind === 'commons' ? 'commons' : 'live site'}
</span>
</button>
))}
Expand Down
58 changes: 47 additions & 11 deletions src/components/Chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
<div className="flex-1 relative min-h-0">
Expand Down Expand Up @@ -769,18 +782,29 @@ 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 (
<div className="mt-1.5 pl-1 flex flex-wrap items-center gap-1.5">
<span className="text-xs text-muted-foreground/70">Drew on the commons:</span>
{refs.slice(0, 4).map(r => (
<button
key={r.slug}
draggable
onDragStart={e => {
e.dataTransfer.setData(COMMONS_REF_DRAG_TYPE, JSON.stringify(r));
e.dataTransfer.setData('text/plain', mentionToken(r.title));
e.dataTransfer.effectAllowed = 'copy';
}}
onClick={() => openGalleryItem(r.slug)}
className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground hover:text-foreground hover:border-primary/50 hover:bg-primary/5 transition-colors"
title={`Open "${r.title}" in the Commons Gallery`}
className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground hover:text-foreground hover:border-primary/50 hover:bg-primary/5 transition-colors cursor-grab active:cursor-grabbing"
title={`Open "${r.title}" in the Commons Gallery — or drag it into your message to build on it`}
>
<BookOpen className="size-2.5 shrink-0" />
{r.title}
Expand Down Expand Up @@ -844,6 +868,10 @@ export function stripPlanQuestions(content: string): string {
* stage and send together once all are answered (or early via Send answers).
* Only the newest plan's cards are live; older plans keep a quiet transcript
* of what was asked.
*
* The readiness check ("Anything else to change, or ready to build?") comes
* through here like any other question — but its yes is not an answer to
* relay. It starts the build.
*/
function PlanQuestionCards({ message }: { message: DisplayMessage }) {
const questions = useMemo(() => extractPlanQuestions(message.content), [message.content]);
Expand All @@ -865,6 +893,14 @@ function PlanQuestionCards({ message }: { message: DisplayMessage }) {
};

const answer = (i: number, value: string) => {
// "Ready to build" IS the press — sending the words instead would spend a
// whole reply re-offering a button. Only when it stands alone: a yes
// tapped beside another open question would discard that answer.
if (questions.length === 1 && isReadyToBuildOption(value)) {
setSent(true);
startBuildFromPlan();
return;
}
const next = { ...answers, [i]: value };
setAnswers(next);
// One question sends on tap; several send once the last one is answered
Expand Down
Loading