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:
Jason Woltje
2026-01-29 21:47:00 -06:00
parent aa267b56d8
commit d54714ea06
8 changed files with 1463 additions and 0 deletions

View 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>
);
}