feat: add chat components from jarvis frontend
- Migrated Chat.tsx with message handling and UI structure - Migrated ChatInput.tsx with character limits and keyboard shortcuts - Migrated MessageList.tsx with thinking/reasoning display - Migrated ConversationSidebar.tsx (simplified placeholder) - Migrated BackendStatusBanner.tsx (simplified placeholder) - Created components/chat/index.ts barrel export - Created app/chat/page.tsx placeholder route These components are adapted from jarvis-fe but not yet fully functional: - API calls placeholder (need to wire up /api/brain/query) - Auth hooks stubbed (need useAuth implementation) - Project/conversation hooks stubbed (need implementation) - Imports changed from @jarvis/* to @mosaic/* Next steps: - Implement missing hooks (useAuth, useProjects, useConversations, useApi) - Wire up backend API endpoints - Add proper TypeScript types - Implement full conversation management
This commit is contained in:
189
apps/web/src/components/chat/ChatInput.tsx
Normal file
189
apps/web/src/components/chat/ChatInput.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState, useEffect, KeyboardEvent, RefObject } from "react";
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (message: string) => void;
|
||||
disabled?: boolean;
|
||||
inputRef?: RefObject<HTMLTextAreaElement | null>;
|
||||
}
|
||||
|
||||
export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps) {
|
||||
const [message, setMessage] = useState("");
|
||||
const [version, setVersion] = useState<string | null>(null);
|
||||
|
||||
// Fetch version from static version.json (generated at build time)
|
||||
useEffect(() => {
|
||||
fetch("/version.json")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.version) {
|
||||
// Format as "version+commit" for full build identification
|
||||
const fullVersion = data.commit
|
||||
? `${data.version}+${data.commit}`
|
||||
: data.version;
|
||||
setVersion(fullVersion);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently fail - version display is non-critical
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (message.trim() && !disabled) {
|
||||
onSend(message);
|
||||
setMessage("");
|
||||
}
|
||||
}, [message, onSend, disabled]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Enter to send (without Shift)
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
// Ctrl/Cmd + Enter to send (alternative)
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit]
|
||||
);
|
||||
|
||||
const characterCount = message.length;
|
||||
const maxCharacters = 4000;
|
||||
const isNearLimit = characterCount > maxCharacters * 0.9;
|
||||
const isOverLimit = characterCount > maxCharacters;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Input Container */}
|
||||
<div
|
||||
className="relative rounded-lg border transition-all duration-150"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--surface-0))",
|
||||
borderColor: disabled
|
||||
? "rgb(var(--border-default))"
|
||||
: "rgb(var(--border-strong))",
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a message..."
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
className="block w-full resize-none bg-transparent px-4 py-3 pr-24 text-sm outline-none placeholder:text-[rgb(var(--text-muted))] disabled:opacity-50"
|
||||
style={{
|
||||
color: "rgb(var(--text-primary))",
|
||||
minHeight: "48px",
|
||||
maxHeight: "200px",
|
||||
}}
|
||||
onInput={(e) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
target.style.height = "auto";
|
||||
target.style.height = Math.min(target.scrollHeight, 200) + "px";
|
||||
}}
|
||||
aria-label="Message input"
|
||||
aria-describedby="input-help"
|
||||
/>
|
||||
|
||||
{/* Send Button */}
|
||||
<div className="absolute bottom-2 right-2 flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={disabled || !message.trim() || isOverLimit}
|
||||
className="btn-primary btn-sm rounded-md"
|
||||
style={{
|
||||
opacity: disabled || !message.trim() || isOverLimit ? 0.5 : 1,
|
||||
}}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Send</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer: Keyboard shortcuts + character count */}
|
||||
<div
|
||||
className="flex items-center justify-between text-xs"
|
||||
style={{ color: "rgb(var(--text-muted))" }}
|
||||
id="input-help"
|
||||
>
|
||||
{/* Keyboard Shortcuts */}
|
||||
<div className="hidden items-center gap-4 sm:flex">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="kbd">Enter</span>
|
||||
<span>to send</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="kbd">Shift</span>
|
||||
<span>+</span>
|
||||
<span className="kbd">Enter</span>
|
||||
<span>for new line</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile hint */}
|
||||
<div className="sm:hidden">
|
||||
Tap send or press Enter
|
||||
</div>
|
||||
|
||||
{/* Character Count */}
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
style={{
|
||||
color: isOverLimit
|
||||
? "rgb(var(--semantic-error))"
|
||||
: isNearLimit
|
||||
? "rgb(var(--semantic-warning))"
|
||||
: "rgb(var(--text-muted))",
|
||||
}}
|
||||
>
|
||||
{characterCount > 0 && (
|
||||
<>
|
||||
<span>
|
||||
{characterCount.toLocaleString()}/{maxCharacters.toLocaleString()}
|
||||
</span>
|
||||
{isOverLimit && (
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Disclaimer & Version */}
|
||||
<div
|
||||
className="flex items-center justify-center gap-2 text-xs"
|
||||
style={{ color: "rgb(var(--text-muted))" }}
|
||||
>
|
||||
<span>AI may produce inaccurate information. Verify important details.</span>
|
||||
{version && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>v{version}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user