All checks were successful
ci/woodpecker/push/web Pipeline was successful
- Add model selector chip/dropdown in ChatInput with 4 models (llama3.2, claude-3.5-sonnet, gpt-4o, deepseek-r1) - Add temperature slider (0.0–2.0) and max tokens input (100–32000) via settings popover - Persist model, temperature, and max tokens in localStorage across sessions - Wire model/temperature/maxTokens state up through Chat.tsx to useChat hook - Add ChatEmptyState component with greeting and 4 clickable suggested prompts - Clicking a suggestion pre-fills the ChatInput textarea via externalValue prop - Add Cmd/Ctrl+N and Cmd/Ctrl+L keyboard shortcuts to start new conversation - Add "New conversation" button in ChatOverlay header with Cmd+N tooltip - Show ChatEmptyState when conversation has only the welcome message - Write 63 tests covering model selector, params config, empty state, and keyboard shortcuts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
236 lines
7.6 KiB
TypeScript
236 lines
7.6 KiB
TypeScript
/**
|
|
* @file ChatOverlay.tsx
|
|
* @description Persistent chat overlay component that is accessible from any view
|
|
*/
|
|
|
|
"use client";
|
|
|
|
import { useEffect, useRef } from "react";
|
|
import { useChatOverlay } from "@/hooks/useChatOverlay";
|
|
import { Chat } from "./Chat";
|
|
import type { ChatRef } from "./Chat";
|
|
|
|
export function ChatOverlay(): React.JSX.Element {
|
|
const { isOpen, isMinimized, open, close, minimize, expand, toggle } = useChatOverlay();
|
|
|
|
const chatRef = useRef<ChatRef>(null);
|
|
|
|
// Global keyboard shortcuts
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent): void => {
|
|
// Cmd/Ctrl + Shift + J: Toggle chat panel
|
|
if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === "j" || e.key === "J")) {
|
|
e.preventDefault();
|
|
toggle();
|
|
return;
|
|
}
|
|
|
|
// Cmd/Ctrl + K: Open chat and focus input
|
|
if ((e.ctrlKey || e.metaKey) && (e.key === "k" || e.key === "K")) {
|
|
e.preventDefault();
|
|
if (!isOpen) {
|
|
open();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Escape: Minimize chat (if open and not minimized)
|
|
if (e.key === "Escape" && isOpen && !isMinimized) {
|
|
e.preventDefault();
|
|
minimize();
|
|
return;
|
|
}
|
|
};
|
|
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
return (): void => {
|
|
document.removeEventListener("keydown", handleKeyDown);
|
|
};
|
|
}, [isOpen, isMinimized, open, minimize, toggle]);
|
|
|
|
// Render floating button when closed
|
|
if (!isOpen) {
|
|
return (
|
|
<button
|
|
onClick={open}
|
|
className="fixed bottom-6 right-6 z-50 flex h-14 w-14 items-center justify-center rounded-full shadow-lg transition-all hover:scale-110 focus:outline-none focus:ring-2 focus:ring-offset-2 lg:bottom-8 lg:right-8"
|
|
style={{
|
|
backgroundColor: "rgb(var(--accent-primary))",
|
|
color: "rgb(var(--text-on-accent))",
|
|
}}
|
|
aria-label="Open chat"
|
|
title="Open Jarvis chat (Cmd+Shift+J)"
|
|
>
|
|
<svg
|
|
className="h-6 w-6"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
|
</svg>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// Render minimized header when minimized
|
|
if (isMinimized) {
|
|
return (
|
|
<div
|
|
className="fixed bottom-0 right-0 z-40 w-full sm:w-96"
|
|
style={{
|
|
backgroundColor: "rgb(var(--surface-0))",
|
|
borderColor: "rgb(var(--border-default))",
|
|
}}
|
|
>
|
|
<button
|
|
onClick={expand}
|
|
className="flex w-full items-center justify-between border-t px-4 py-3 text-left transition-colors hover:bg-black/5 focus:outline-none focus:ring-2 focus:ring-inset"
|
|
style={{
|
|
borderColor: "rgb(var(--border-default))",
|
|
backgroundColor: "rgb(var(--surface-0))",
|
|
}}
|
|
aria-label="Expand chat"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<svg
|
|
className="h-5 w-5"
|
|
style={{ color: "rgb(var(--accent-primary))" }}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
|
</svg>
|
|
<span className="text-sm font-medium" style={{ color: "rgb(var(--text-primary))" }}>
|
|
Jarvis
|
|
</span>
|
|
</div>
|
|
<svg
|
|
className="h-4 w-4"
|
|
style={{ color: "rgb(var(--text-secondary))" }}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path d="M5 15l7-7 7 7" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Render full chat overlay when open and expanded
|
|
return (
|
|
<>
|
|
{/* Backdrop for mobile */}
|
|
<div
|
|
className="fixed inset-0 z-30 bg-black/50 lg:hidden"
|
|
onClick={close}
|
|
aria-hidden="true"
|
|
/>
|
|
|
|
{/* Chat Panel */}
|
|
<div
|
|
className="fixed inset-y-0 right-0 z-40 flex w-full flex-col border-l sm:w-96 lg:inset-y-16"
|
|
style={{
|
|
backgroundColor: "rgb(var(--surface-0))",
|
|
borderColor: "rgb(var(--border-default))",
|
|
}}
|
|
>
|
|
{/* Header */}
|
|
<div
|
|
className="flex items-center justify-between border-b px-4 py-3"
|
|
style={{ borderColor: "rgb(var(--border-default))" }}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<svg
|
|
className="h-5 w-5"
|
|
style={{ color: "rgb(var(--accent-primary))" }}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
|
</svg>
|
|
<h2 className="text-base font-semibold" style={{ color: "rgb(var(--text-primary))" }}>
|
|
Jarvis
|
|
</h2>
|
|
</div>
|
|
|
|
{/* Header Controls */}
|
|
<div className="flex items-center gap-1">
|
|
{/* New Conversation Button */}
|
|
<button
|
|
onClick={() => {
|
|
chatRef.current?.startNewConversation(null);
|
|
}}
|
|
className="rounded p-1.5 transition-colors hover:bg-black/5 focus:outline-none focus:ring-2"
|
|
aria-label="New conversation"
|
|
title="New conversation (Cmd+N)"
|
|
>
|
|
<svg
|
|
className="h-4 w-4"
|
|
style={{ color: "rgb(var(--text-secondary))" }}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
</button>
|
|
|
|
{/* Minimize Button */}
|
|
<button
|
|
onClick={minimize}
|
|
className="rounded p-1.5 transition-colors hover:bg-black/5 focus:outline-none focus:ring-2"
|
|
aria-label="Minimize chat"
|
|
title="Minimize (Esc)"
|
|
>
|
|
<svg
|
|
className="h-4 w-4"
|
|
style={{ color: "rgb(var(--text-secondary))" }}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</button>
|
|
|
|
{/* Close Button */}
|
|
<button
|
|
onClick={close}
|
|
className="rounded p-1.5 transition-colors hover:bg-black/5 focus:outline-none focus:ring-2"
|
|
aria-label="Close chat"
|
|
title="Close (Cmd+Shift+J)"
|
|
>
|
|
<svg
|
|
className="h-4 w-4"
|
|
style={{ color: "rgb(var(--text-secondary))" }}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Chat Content */}
|
|
<div className="flex-1 overflow-hidden">
|
|
<Chat ref={chatRef} />
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|