Skip to content
Closed
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
44 changes: 42 additions & 2 deletions frontend/src/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ import { resizeImage } from '../lib/resizeImage';
import { extractImageFiles } from '../lib/paste-images';
import { MAX_IMAGE_ATTACHMENTS } from '../lib/constants';
import { SlashPicker } from './SlashPicker';
import { ContextPicker } from './ContextPicker';
import { MicButton } from './MicButton';
import type { UseVoiceReturn } from '../hooks/useVoice';

interface Props {
onSend: (text: string, images?: ImageAttachment[]) => boolean;
onSend: (text: string, images?: ImageAttachment[], contextBlocks?: string[]) => boolean;
onStop: () => void;
onInterrupt?: (text: string, images?: ImageAttachment[]) => void;
onInterrupt?: (text: string, images?: ImageAttachment[], contextBlocks?: string[]) => void;
running: boolean;
initialText?: string;
cwd?: string;
Expand All @@ -40,6 +41,8 @@ export function ChatInput({
const [text, setText] = useState(initialText || '');
const [images, setImages] = useState<ImageAttachment[]>([]);
const [showSlashPicker, setShowSlashPicker] = useState(false);
const [showContextPicker, setShowContextPicker] = useState(false);
const [contextBlocks, setContextBlocks] = useState<string[]>([]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const initialApplied = useRef(false);
Expand Down Expand Up @@ -93,10 +96,12 @@ export function ChatInput({
const sent = onSend(
trimmed || 'What do you see in this image?',
images.length > 0 ? images : undefined,
contextBlocks.length > 0 ? contextBlocks : undefined,
);
if (sent) {
setText('');
setImages([]);
setContextBlocks([]);
requestAnimationFrame(() => {
autoResize();
textareaRef.current?.focus();
Expand Down Expand Up @@ -179,9 +184,11 @@ export function ChatInput({
onInterrupt(
trimmed || 'What do you see in this image?',
images.length > 0 ? images : undefined,
contextBlocks.length > 0 ? contextBlocks : undefined,
);
setText('');
setImages([]);
setContextBlocks([]);
requestAnimationFrame(() => {
autoResize();
textareaRef.current?.focus();
Expand All @@ -198,6 +205,17 @@ export function ChatInput({
cwd={cwd}
/>
)}
{showContextPicker && (
<ContextPicker
selected={contextBlocks}
onToggle={(name) =>
setContextBlocks((prev) =>
prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name],
)
}
onClose={() => setShowContextPicker(false)}
/>
)}
{images.length > 0 && (
<div className="chat-input-previews">
{images.map((img, i) => (
Expand All @@ -210,6 +228,21 @@ export function ChatInput({
))}
</div>
)}
{contextBlocks.length > 0 && (
<div className="chat-input-context-pills">
{contextBlocks.map((name) => (
<span key={name} className="chat-input-context-pill">
{name}
<button
className="chat-input-context-pill-remove"
onClick={() => setContextBlocks((prev) => prev.filter((n) => n !== name))}
>
&times;
</button>
</span>
))}
</div>
)}
{voice?.recording && voice.partialTranscript && (
<div className="voice-partial">{voice.partialTranscript}</div>
)}
Expand All @@ -233,6 +266,13 @@ export function ChatInput({
>
+
</button>
<button
className={`chat-input-btn chat-input-btn--context${contextBlocks.length > 0 ? ' chat-input-btn--active' : ''}`}
onClick={() => setShowContextPicker((v) => !v)}
title="Attach context"
>
@
</button>
<input
ref={fileInputRef}
type="file"
Expand Down
91 changes: 91 additions & 0 deletions frontend/src/components/ContextPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { useState, useEffect, useRef } from 'react';

export interface ContextBlockEntry {
name: string;
path: string;
sizeBytes: number;
}

interface Props {
/** Currently selected context block names */
selected: string[];
/** Called when a block is toggled on/off */
onToggle: (name: string) => void;
/** Called when the picker should close */
onClose: () => void;
}

function formatSize(bytes: number): string {
if (bytes === 0) return '0 B';
if (bytes < 1024) return `${bytes} B`;
const kb = bytes / 1024;
if (kb < 1024) return `${kb.toFixed(1)} KB`;
return `${(kb / 1024).toFixed(1)} MB`;
}

export function ContextPicker({ selected, onToggle, onClose }: Props) {
const [blocks, setBlocks] = useState<ContextBlockEntry[]>([]);
const [loaded, setLoaded] = useState(false);
const pickerRef = useRef<HTMLDivElement>(null);

// Fetch available context blocks from /api/config
useEffect(() => {
fetch('/api/config', { credentials: 'include' })
.then((r) => r.json())
.then((data: { contextBlocks?: Record<string, { path: string; sizeBytes: number }> }) => {
const entries: ContextBlockEntry[] = [];
if (data.contextBlocks) {
for (const [name, info] of Object.entries(data.contextBlocks)) {
entries.push({ name, path: info.path, sizeBytes: info.sizeBytes });
}
}
setBlocks(entries);
setLoaded(true);
})
.catch(() => {
setLoaded(true);
});
}, []);

// Close on click outside
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [onClose]);

if (!loaded) return null;

if (blocks.length === 0) {
return (
<div className="context-picker" ref={pickerRef}>
<div className="context-picker-empty">No context blocks configured</div>
</div>
);
}

return (
<div className="context-picker" ref={pickerRef}>
<div className="context-picker-list">
{blocks.map((block) => {
const isSelected = selected.includes(block.name);
return (
<button
key={block.name}
className={`context-picker-item${isSelected ? ' context-picker-item--selected' : ''}`}
onClick={() => onToggle(block.name)}
>
<span className="context-picker-check">{isSelected ? '✓' : ''}</span>
<span className="context-picker-name">{block.name}</span>
<span className="context-picker-size">{formatSize(block.sizeBytes)}</span>
</button>
);
})}
</div>
</div>
);
}
6 changes: 5 additions & 1 deletion frontend/src/components/MessageBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@ import type { FinishedMessage } from '../types/chat';
interface UserBubbleProps {
text?: string;
images?: string[];
contextBlocks?: string[];
}

export function UserBubble({ text, images }: UserBubbleProps) {
export function UserBubble({ text, images, contextBlocks }: UserBubbleProps) {
return (
<div className="msg-bubble msg-bubble--user">
{contextBlocks && contextBlocks.length > 0 && (
<div className="msg-bubble-context">@ {contextBlocks.join(', ')}</div>
)}
{images && images.length > 0 && (
<div className="msg-bubble-images">
{images.map((src, i) => (
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/hooks/useChatMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
// Session / UI lifecycle
| { type: 'ERROR'; error: string }
| { type: 'SESSION_INFO'; branch: string; isWorktree: boolean }
| { type: 'USER_SEND'; text: string; images?: string[] }
| { type: 'USER_SEND'; text: string; images?: string[]; contextBlocks?: string[] }
| { type: 'SET_RUNNING'; running: boolean }
| { type: 'CONNECTION_LOST' }
| { type: 'PERMISSION_REQUEST'; payload: PermissionRequest }
Expand Down Expand Up @@ -380,6 +380,7 @@
role: 'user',
blocks: [],
images: action.images,
contextBlocks: action.contextBlocks,
// Store text in a synthetic text block for rendering convenience.
...(action.text
? {
Expand Down Expand Up @@ -639,7 +640,7 @@
}
}
},
[poolKey, onSessionAssigned, onSessionExpired, onMessagesRestored],

Check warning on line 643 in frontend/src/hooks/useChatMessages.ts

View workflow job for this annotation

GitHub Actions / ci

React Hook useCallback has a missing dependency: 'onSessionRenamed'. Either include it or remove the dependency array. If 'onSessionRenamed' changes too often, find the parent component that defines it and wrap that definition in useCallback
);

return {
Expand Down
36 changes: 30 additions & 6 deletions frontend/src/pages/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@
if (distFromBottom <= SCROLL_NEAR_BOTTOM_PX) {
el.scrollTop = el.scrollHeight;
}
}, [msgState.messages, msgState.current]);

Check warning on line 95 in frontend/src/pages/ChatView.tsx

View workflow job for this annotation

GitHub Actions / ci

React Hook useEffect has an unnecessary dependency: 'msgState.current'. Either exclude it or remove the dependency array. Mutable values like 'msgState.current' aren't valid dependencies because mutating them doesn't re-render the component

// Restore messages when navigating to an existing session.
// Fetch from the API (single source of truth — no localStorage cache).
Expand Down Expand Up @@ -154,7 +154,11 @@
return payload;
}

function sendMessage(text: string, images?: ImageAttachment[]): boolean {
function sendMessage(
text: string,
images?: ImageAttachment[],
contextBlocks?: string[],
): boolean {
if (!wsIsOpen(poolKey)) {
dispatch({ type: 'CONNECTION_LOST' });
return false;
Expand All @@ -164,27 +168,40 @@
voice.stopSpeaking();

const payload = buildSendPayload(text, images);
if (contextBlocks?.length) payload.contextBlocks = contextBlocks;
const previews = images?.map((img) => img.preview);

if (msgState.running) {
// Server queues it natively — no client-side stop+re-send needed.
wsSend(poolKey, payload);
dispatch({ type: 'USER_SEND', text, images: previews });
dispatch({ type: 'USER_SEND', text, images: previews, contextBlocks });
forceScrollToBottom();
} else {
wsSetRunning(poolKey, true);
wsSend(poolKey, payload);
dispatch({ type: 'USER_SEND', text, images: previews });
dispatch({ type: 'USER_SEND', text, images: previews, contextBlocks });
forceScrollToBottom();
}

return true;
}

function interruptMessage(text: string, images?: ImageAttachment[]): void {
function interruptMessage(
text: string,
images?: ImageAttachment[],
contextBlocks?: string[],
): void {
if (!wsIsOpen(poolKey) || !msgState.running) return;
const imagePayload = images?.map((img) => ({ data: img.data, mediaType: img.mediaType }));
wsSend(poolKey, { type: 'interrupt', prompt: text, images: imagePayload });
const previews = images?.map((img) => img.preview);
wsSend(poolKey, {
type: 'interrupt',
prompt: text,
images: imagePayload,
...(contextBlocks?.length ? { contextBlocks } : {}),
});
dispatch({ type: 'USER_SEND', text, images: previews, contextBlocks });
forceScrollToBottom();
}

const handleStop = useCallback(() => {
Expand Down Expand Up @@ -285,7 +302,14 @@
{groupedMessages.map(({ msg, grouped }) => {
if (msg.role === 'user') {
const textBlock = msg.blocks.find((b) => b.blockType === 'text');
return <UserBubble key={msg.messageId} text={textBlock?.content} images={msg.images} />;
return (
<UserBubble
key={msg.messageId}
text={textBlock?.content}
images={msg.images}
contextBlocks={msg.contextBlocks}
/>
);
}

// Assistant turn — render grouped blocks
Expand Down
Loading
Loading