Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
69a167b
feat(schema): add Attachment type and Question.attachments (TS)
dylan-savage May 19, 2026
d426fc3
feat(schema): add Attachment type and Question.attachments (Python)
dylan-savage May 19, 2026
9c85a65
style(schema): harmonize Attachment.ts license header with sibling Do…
dylan-savage May 19, 2026
afe19ad
feat(chat): persist and rehydrate Question.attachments on Chat.send (TS)
dylan-savage May 19, 2026
3169ac1
feat(chat): persist and rehydrate Question.attachments on Chat.send (…
dylan-savage May 19, 2026
c2f3e0d
feat(chat-ui): file attachments with chunked upload
dylan-savage May 19, 2026
ddec1c1
feat(agent): propagate Question.attachments through AgentContext
dylan-savage May 19, 2026
213fc6d
feat(llm): provider-shape dispatch + openai-shape attachment translator
dylan-savage May 19, 2026
eea4411
feat(llm): anthropic, gemini, and bedrock attachment translator shapes
dylan-savage May 19, 2026
d7a1614
feat(llm): wire provider_shape + provider_name across 13 llm_* nodes
dylan-savage May 19, 2026
96c9893
feat(rocketlib): pre-resolve format: rocketride-attachment tool inputs
dylan-savage May 19, 2026
8b2211e
feat(nodes): reference multimodal tool (sha256) + dispatcher tests
dylan-savage May 19, 2026
6664094
feat(agent): attachment picker for tool-call forwarding
dylan-savage May 19, 2026
70c0af8
feat(agent_langchain,agent_deepagent): forward attachments to tool calls
dylan-savage May 19, 2026
3b86d00
feat(agent_crewai,agent_rocketride): drop-and-warn multimodal LLM calls
dylan-savage May 19, 2026
84639a3
test(agent): picker + tool_sha256 method contract
dylan-savage May 19, 2026
d31167b
feat(telemetry): emit attachment + tool METRIC log lines
dylan-savage May 19, 2026
5347240
test(chat): multimodal e2e round-trip stub (TDD §14.2)
dylan-savage May 19, 2026
2d34a58
docs(review): multimodal attachment review checklist
dylan-savage May 19, 2026
dbb0774
fix(multimodal): manual-testing fixes + steady-state cleanup
dylan-savage May 28, 2026
d4552c4
chore(multimodal): tidy attachment feature diff for review
dylan-savage May 29, 2026
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
54 changes: 42 additions & 12 deletions apps/chat-ui/src/components/ChatContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
*/

import React, { useCallback, useState, useRef, useEffect } from 'react';
import { RocketRideClient, Chat } from 'rocketride';
import { RocketRideClient, Chat, type Attachment } from 'rocketride';
import { useRocketRideClient } from '../hooks/useRocketRide';
import { useChatMessages } from '../hooks/useChatMessages';
import { ChatHeader } from './ChatHeader';
Expand Down Expand Up @@ -87,6 +87,28 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ authToken, pipelin
// Initialize connection
const { isConnected, client } = useRocketRideClient(handleConnected, handleDisconnected, setStatusMessage);

// Ensure a Chat exists for the current session. Dedupes via creatingChatRef
// so concurrent callers (auto-resume fallback, paperclip upload, send) share
// one in-flight Chat.create.
const ensureChat = useCallback(async (): Promise<Chat | null> => {
if (!persistEnabled || !pipelineId || !client || !authToken) return null;
if (!creatingChatRef.current) {
creatingChatRef.current = (async () => {
try {
const created = await Chat.create({ client, token: authToken, pipelineId });
setCurrentChat(created);
return created;
} catch (err) {
console.warn('ChatContainer: Chat.create failed', err);
return null;
} finally {
creatingChatRef.current = null;
}
})();
}
return creatingChatRef.current;
}, [persistEnabled, client, authToken, pipelineId]);

// When persistence is on, auto-open the most recent chat ONCE per chat-ui
// session — so users land back in their last conversation after a window
// close/refresh. Gated by a ref so subsequent transitions to currentChat=null
Expand All @@ -100,7 +122,11 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ authToken, pipelin
(async () => {
try {
const list = await client.chats.list({ pipelineId });
if (cancelled || list.length === 0) return;
if (cancelled) return;
if (list.length === 0) {
await ensureChat();
return;
}
list.sort((a, b) => (b.updated || '').localeCompare(a.updated || ''));
const recent = list[0];
if (!recent) return;
Expand All @@ -115,7 +141,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ authToken, pipelin
return () => {
cancelled = true;
};
}, [persistEnabled, client, pipelineId, authToken, hydrateFromChat]);
}, [persistEnabled, client, pipelineId, authToken, hydrateFromChat, ensureChat]);

const handleSelectChat = useCallback(
async (chatId: string) => {
Expand All @@ -132,13 +158,13 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ authToken, pipelin
);

const handleNewChat = useCallback(() => {
// Defer creating the on-disk chat until the first send.
setCurrentChat(null);
setMessages([]);
hasWelcomedRef.current = false;
addSystemMessage("Hello! I'm your RocketRide assistant. How can I help you today?");
hasWelcomedRef.current = true;
}, [addSystemMessage, setMessages]);
void ensureChat();
}, [addSystemMessage, setMessages, ensureChat]);

const handleRenameChat = useCallback(
async (chatId: string, title: string) => {
Expand All @@ -163,18 +189,19 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ authToken, pipelin
if (currentChat?.id === chatId) {
setCurrentChat(null);
setMessages([]);
void ensureChat();
}
setListRefreshKey((k) => k + 1);
} catch (err) {
console.warn('ChatContainer: delete failed', err);
}
},
[client, authToken, currentChat, setMessages]
[client, authToken, currentChat, setMessages, ensureChat]
);

// Send message handler — creates a Chat lazily when persistence is on.
const handleSendMessage = useCallback(
async (text: string) => {
async (text: string, attachments?: Attachment[]) => {
if (!client || !authToken) {
addSystemMessage('Not connected. Please wait...');
return;
Expand All @@ -197,7 +224,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ authToken, pipelin
}
chat = await creatingChatRef.current;
}
await sendMessage(text, client, authToken, chat);
await sendMessage(text, client, authToken, chat, attachments);
if (chat) setListRefreshKey((k) => k + 1);
},
[client, authToken, sendMessage, addSystemMessage, persistEnabled, pipelineId, currentChat]
Expand All @@ -206,8 +233,11 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ authToken, pipelin
const handleClearChat = useCallback(() => {
clearMessages();
// When persistence is on, "clear" should start a fresh chat, not nuke the prior on-disk turns.
if (persistEnabled) setCurrentChat(null);
}, [clearMessages, persistEnabled]);
if (persistEnabled) {
setCurrentChat(null);
void ensureChat();
}
}, [clearMessages, persistEnabled, ensureChat]);

// Show error panel when disconnected and we have an error (after 5 attempts) or a specific error message (e.g. auth failed)
if (!isConnected && (statusMessage === 'CONNECTION_FAILED' || connectionErrorMessage)) {
Expand Down Expand Up @@ -239,7 +269,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ authToken, pipelin
</div>
</div>
</div>
<ChatInput onSend={handleSendMessage} disabled={true} />
<ChatInput onSend={handleSendMessage} disabled={true} {...(client ? { client } : {})} {...(currentChat?.id ? { chatId: currentChat.id } : {})} />
</div>
);
}
Expand All @@ -252,7 +282,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ authToken, pipelin
<div style={showSidebar ? { flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 } : undefined}>
<ChatHeader isConnected={isConnected} onClearChat={handleClearChat} />
<ChatMessages messages={messages} isTyping={isTyping} statusMessage={statusMessage} />
<ChatInput onSend={handleSendMessage} disabled={!isConnected} />
<ChatInput onSend={handleSendMessage} disabled={!isConnected} {...(client ? { client } : {})} {...(currentChat?.id ? { chatId: currentChat.id } : {})} />
</div>
</div>
);
Expand Down
127 changes: 115 additions & 12 deletions apps/chat-ui/src/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,29 +23,51 @@
*/

import React, { useState, useRef, useEffect } from 'react';
import { Send } from 'lucide-react';
import { Send, Paperclip, X } from 'lucide-react';
import type { Attachment } from '../types/Attachment';
import { uploadAttachment, type UploadClient } from '../utils/uploadAttachment';

interface ChatInputProps {
onSend: (message: string) => Promise<void>;
/**
* Send the user's message. The `attachments` arg is optional for
* backwards compatibility with parents that have not yet been updated
* to forward attachments (activation-gated).
*/
onSend: (message: string, attachments?: Attachment[]) => Promise<void>;
disabled: boolean;
/**
* RocketRide client used to upload attachments via the chunked filestore
* protocol. Optional — when undefined the 📎 button is hidden so the
* component stays usable in non-persistent / legacy contexts.
*/
client?: UploadClient;
/**
* Current chat id. Required for upload path construction; when missing,
* the 📎 button is hidden.
*/
chatId?: string;
}

/**
* Chat input component with send button
* Chat input component with send button and optional file attachments.
*
* Features:
* - Auto-expanding multi-line text input
* - Enter to send, Shift+Enter for new line
* - Clipboard paste support in VSCode webview
* - Disabled state when not connected
* - Auto-focus on mount
*
* @param onSend - Callback function to send message
* @param disabled - Whether input should be disabled
* - 📎 attach files (when `client` + `chatId` are provided) with chunked
* upload; attachment pills render above the textarea while pending
*/
export const ChatInput: React.FC<ChatInputProps> = ({ onSend, disabled }) => {
export const ChatInput: React.FC<ChatInputProps> = ({ onSend, disabled, client, chatId }) => {
const [inputText, setInputText] = useState('');
const [pending, setPending] = useState<Attachment[]>([]);
const [uploading, setUploading] = useState<number>(0);
const inputRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);

const attachEnabled = !!client && !!chatId;

/**
* Focus input on mount and listen for paste messages from VSCode parent
Expand Down Expand Up @@ -76,23 +98,55 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onSend, disabled }) => {
return () => window.removeEventListener('message', handleMessage);
}, []);

/**
* Upload selected files in parallel via the chunked filestore helper.
* Each successful upload pushes a pill onto the pending list; failures
* are surfaced via `console.error` and skipped (the user can retry).
*/
const handleFiles = async (files: FileList) => {
if (!client || !chatId) return;
const list = Array.from(files);
setUploading(n => n + list.length);
try {
const results = await Promise.allSettled(
list.map(file => uploadAttachment({ client, chatId, file }))
);
const uploaded: Attachment[] = [];
for (const r of results) {
if (r.status === 'fulfilled') uploaded.push(r.value);
else console.error('[chat-ui] attachment upload failed:', r.reason);
}
if (uploaded.length) setPending(prev => [...prev, ...uploaded]);
} finally {
setUploading(n => n - list.length);
}
};

const removePending = (id: string) => {
setPending(prev => prev.filter(a => a.attachment_id !== id));
};

/**
* Handles send button click or Enter key press
*/
const handleSend = async () => {
if (!inputText.trim() || disabled) return;
if (disabled) return;
if (!inputText.trim() && pending.length === 0) return;
if (uploading > 0) return;

const message = inputText;
const atts = pending;
setInputText('');
setPending([]);
if (inputRef.current) {
inputRef.current.style.height = 'auto';
}
await onSend(message);
await onSend(message, atts);
};

/**
* Handles keyboard input
*
*
* Enter: Send message
* Shift+Enter: New line
*/
Expand All @@ -114,7 +168,56 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onSend, disabled }) => {
return (
<div className="input-container">
<div className="input-content">
{(pending.length > 0 || uploading > 0) && (
<div className="attachment-pills attachment-pills--input">
{pending.map(a => (
<span key={a.attachment_id} className="attachment-pill" title={`${a.mime} · ${a.size_bytes} bytes`}>
<span className="attachment-pill-name">{a.filename}</span>
<button
type="button"
className="attachment-pill-remove"
onClick={() => removePending(a.attachment_id)}
title="Remove attachment"
>
<X className="w-3 h-3" />
</button>
</span>
))}
{uploading > 0 && (
<span className="attachment-pill attachment-pill--uploading">
Uploading {uploading}…
</span>
)}
</div>
)}
<div className="input-wrapper">
{attachEnabled && (
<>
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={(e) => {
if (e.target.files && e.target.files.length > 0) {
handleFiles(e.target.files);
}
// Reset so selecting the same file again re-fires onChange.
e.target.value = '';
}}
disabled={disabled}
/>
<button
type="button"
className="attach-btn"
title="Attach file"
onClick={() => fileInputRef.current?.click()}
disabled={disabled}
>
<Paperclip className="w-5 h-5" />
</button>
</>
)}
<div className="input-field-wrapper">
<textarea
ref={inputRef}
Expand All @@ -140,7 +243,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onSend, disabled }) => {

<button
onClick={handleSend}
disabled={!inputText.trim() || disabled}
disabled={disabled || uploading > 0 || (!inputText.trim() && pending.length === 0)}
className="send-btn"
title="Send message"
type="button"
Expand All @@ -151,4 +254,4 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onSend, disabled }) => {
</div>
</div>
);
};
};
16 changes: 15 additions & 1 deletion apps/chat-ui/src/components/Message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,25 @@ export const Message: React.FC<MessageProps> = ({ message }) => {
}

// User message
const attachments = message.attachments ?? [];
return (
<div className="message-wrapper user">
<div className="message-bubble user">
<div className="user-bubble-content">
<p>{message.text}</p>
{message.text && <p>{message.text}</p>}
{attachments.length > 0 && (
<div className="attachment-pills attachment-pills--bubble">
{attachments.map(a => (
<span
key={a.attachment_id}
className="attachment-pill"
title={`${a.mime} · ${a.size_bytes} bytes`}
>
<span className="attachment-pill-name">{a.filename}</span>
</span>
))}
</div>
)}
</div>
<div className="message-timestamp">
{message.timestamp}
Expand Down
Loading
Loading