Skip to content
Merged
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
105 changes: 105 additions & 0 deletions src/components/chat/ApprovalCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* Approval Card Component
*
* Displays approval requests from agents with approve/reject actions.
* Uses organic styling with distinctive visual treatment for pending actions.
*/

import type { ApprovalMessage } from '@thumbcode/state';
import { Pressable, Text, View } from 'react-native';

interface ApprovalCardProps {
message: ApprovalMessage;
onApprove: () => void;
onReject: () => void;
}

/**
* Get action type icon and label
*/
function getActionInfo(actionType: ApprovalMessage['metadata']['actionType']) {
const actionMap: Record<
ApprovalMessage['metadata']['actionType'],
{ icon: string; label: string; color: string }
> = {
commit: { icon: '📝', label: 'Commit Changes', color: 'bg-teal-500' },
push: { icon: '⬆️', label: 'Push to Remote', color: 'bg-coral-500' },
merge: { icon: '🔀', label: 'Merge Branch', color: 'bg-gold-500' },
deploy: { icon: '🚀', label: 'Deploy', color: 'bg-coral-600' },
file_change: { icon: '📄', label: 'File Changes', color: 'bg-teal-600' },
};
return actionMap[actionType] || actionMap.commit;
}

export function ApprovalCard({ message, onApprove, onReject }: ApprovalCardProps) {
const actionInfo = getActionInfo(message.metadata.actionType);
const isPending = message.metadata.approved === undefined;
const wasApproved = message.metadata.approved === true;

return (
<View
className="bg-surface-elevated p-4 max-w-[90%]"
style={{
borderRadius: '16px 12px 16px 14px',
borderLeftWidth: 4,
borderLeftColor: isPending
? '#F5D563' // Gold for pending
: wasApproved
? '#14B8A6' // Teal for approved
: '#FF7059', // Coral for rejected
}}
>
{/* Header */}
<View className="flex-row items-center mb-2">
<Text className="text-lg mr-2">{actionInfo.icon}</Text>
<Text className="font-display text-base text-white flex-1">{actionInfo.label}</Text>
{!isPending && (
<View
className={`px-2 py-0.5 ${wasApproved ? 'bg-teal-600' : 'bg-coral-500'}`}
style={{ borderRadius: '6px 8px 6px 8px' }}
>
<Text className="text-xs font-body text-white">
{wasApproved ? 'Approved' : 'Rejected'}
</Text>
</View>
)}
</View>

{/* Description */}
<Text className="font-body text-sm text-neutral-300 mb-3">
{message.metadata.actionDescription}
</Text>

{/* Action buttons - only shown when pending */}
{isPending && (
<View className="flex-row justify-end space-x-2 pt-2 border-t border-neutral-700">
<Pressable
onPress={onReject}
className="px-4 py-2 bg-neutral-700 active:bg-neutral-600"
style={{ borderRadius: '8px 10px 8px 12px' }}
>
<Text className="font-body text-sm text-neutral-200">Reject</Text>
</Pressable>
<Pressable
onPress={onApprove}
className="px-4 py-2 bg-teal-600 active:bg-teal-700 ml-2"
style={{ borderRadius: '8px 10px 8px 12px' }}
>
<Text className="font-body text-sm text-white font-semibold">Approve</Text>
</Pressable>
</View>
)}

{/* Response timestamp */}
{!isPending && message.metadata.respondedAt && (
<Text className="text-xs text-neutral-500 mt-2">
Responded at{' '}
{new Date(message.metadata.respondedAt).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</Text>
)}
</View>
);
}
84 changes: 84 additions & 0 deletions src/components/chat/ChatInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* Chat Input Component
*
* Input field for sending messages in a chat thread.
* Supports text input with organic daube styling.
*/

import type { MessageSender } from '@thumbcode/state';
import { useCallback, useState } from 'react';
import { Pressable, Text, TextInput, View } from 'react-native';
import { ChatService } from '@/services/chat';

interface ChatInputProps {
threadId: string;
targetAgent?: MessageSender;
placeholder?: string;
disabled?: boolean;
}

export function ChatInput({
threadId,
targetAgent,
placeholder = 'Type a message...',
disabled = false,
}: ChatInputProps) {
const [text, setText] = useState('');
const [isSending, setIsSending] = useState(false);

const handleSend = useCallback(async () => {
const trimmedText = text.trim();
if (!trimmedText || isSending) return;

setIsSending(true);
setText('');

try {
await ChatService.sendMessage({
threadId,
content: trimmedText,
targetAgent,
});
} catch (error) {
console.error('[ChatInput] Failed to send message:', error);
// Restore text on error
setText(trimmedText);
} finally {
setIsSending(false);
}
}, [text, threadId, targetAgent, isSending]);

const canSend = text.trim().length > 0 && !disabled && !isSending;

return (
<View className="flex-row items-end p-3 border-t border-neutral-700 bg-surface">
<TextInput
className="flex-1 bg-neutral-800 text-white font-body px-4 py-3 mr-2"
style={{
borderRadius: '12px 16px 12px 14px',
minHeight: 44,
maxHeight: 120,
}}
value={text}
onChangeText={setText}
placeholder={placeholder}
placeholderTextColor="#6B7280"
multiline
editable={!disabled}
returnKeyType="send"
onSubmitEditing={handleSend}
blurOnSubmit={false}
/>
<Pressable
onPress={handleSend}
disabled={!canSend}
className={`px-4 py-3 ${canSend ? 'bg-coral-500 active:bg-coral-600' : 'bg-neutral-700'}`}
style={{ borderRadius: '12px 14px 10px 16px' }}
>
<Text className={`font-body font-semibold ${canSend ? 'text-white' : 'text-neutral-500'}`}>
{isSending ? '...' : 'Send'}
</Text>
</Pressable>
</View>
);
}
121 changes: 121 additions & 0 deletions src/components/chat/ChatMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* Chat Message Component
*
* Renders individual chat messages with support for different content types.
* Uses organic daube styling per brand guidelines.
*/

import type { ApprovalMessage, Message } from '@thumbcode/state';
import { Text, View } from 'react-native';
import { ApprovalCard } from './ApprovalCard';
import { CodeBlock } from './CodeBlock';

interface ChatMessageProps {
message: Message;
onApprovalResponse?: (messageId: string, approved: boolean) => void;
}

/**
* Get sender display name and color
*/
function getSenderInfo(sender: Message['sender']) {
const senderMap: Record<Message['sender'], { name: string; bgColor: string; textColor: string }> =
{
user: { name: 'You', bgColor: 'bg-teal-600', textColor: 'text-white' },
architect: { name: 'Architect', bgColor: 'bg-coral-500', textColor: 'text-white' },
implementer: { name: 'Implementer', bgColor: 'bg-gold-500', textColor: 'text-charcoal' },
reviewer: { name: 'Reviewer', bgColor: 'bg-teal-500', textColor: 'text-white' },
tester: { name: 'Tester', bgColor: 'bg-neutral-600', textColor: 'text-white' },
system: { name: 'System', bgColor: 'bg-neutral-700', textColor: 'text-neutral-300' },
};
return senderMap[sender] || senderMap.system;
}

/**
* Format timestamp for display
*/
function formatTime(timestamp: string): string {
const date = new Date(timestamp);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}

export function ChatMessage({ message, onApprovalResponse }: ChatMessageProps) {
const isUser = message.sender === 'user';
const senderInfo = getSenderInfo(message.sender);

// Render approval request
if (message.contentType === 'approval_request') {
const approvalMessage = message as ApprovalMessage;
return (
<View className={`mb-3 ${isUser ? 'items-end' : 'items-start'}`}>
<ApprovalCard
message={approvalMessage}
onApprove={() => onApprovalResponse?.(message.id, true)}
onReject={() => onApprovalResponse?.(message.id, false)}
/>
<Text className="text-xs text-neutral-500 mt-1 mx-2">{formatTime(message.timestamp)}</Text>
</View>
);
}

// Render code message
if (message.contentType === 'code') {
return (
<View className={`mb-3 ${isUser ? 'items-end' : 'items-start'}`}>
<View className="max-w-[90%]">
<View className="flex-row items-center mb-1">
<View
className={`px-2 py-0.5 ${senderInfo.bgColor}`}
style={{ borderRadius: '8px 10px 8px 12px' }}
>
<Text className={`text-xs font-body ${senderInfo.textColor}`}>{senderInfo.name}</Text>
</View>
</View>
<CodeBlock
code={message.content}
language={(message.metadata?.language as string) || 'text'}
filename={message.metadata?.filename as string | undefined}
/>
<Text className="text-xs text-neutral-500 mt-1">{formatTime(message.timestamp)}</Text>
</View>
</View>
);
}

// Render text message
return (
<View className={`mb-3 ${isUser ? 'items-end' : 'items-start'}`}>
<View className="max-w-[80%]">
{!isUser && (
<View className="flex-row items-center mb-1">
<View
className={`px-2 py-0.5 ${senderInfo.bgColor}`}
style={{ borderRadius: '8px 10px 8px 12px' }}
>
<Text className={`text-xs font-body ${senderInfo.textColor}`}>{senderInfo.name}</Text>
</View>
</View>
)}
<View
className={`p-3 ${isUser ? 'bg-teal-600' : 'bg-surface-elevated'}`}
style={{
borderRadius: isUser ? '16px 4px 16px 16px' : '4px 16px 16px 16px',
}}
>
<Text className={`font-body ${isUser ? 'text-white' : 'text-neutral-200'}`}>
{message.content}
</Text>
</View>
<View className={`flex-row items-center mt-1 ${isUser ? 'justify-end' : ''}`}>
<Text className="text-xs text-neutral-500">{formatTime(message.timestamp)}</Text>
{message.status === 'sending' && (
<Text className="text-xs text-neutral-500 ml-1">• Sending...</Text>
)}
{message.status === 'failed' && (
<Text className="text-xs text-coral-400 ml-1">• Failed</Text>
)}
</View>
</View>
</View>
);
}
Loading