feat: implement API key authentication and user session management
Some checks failed
CI / Typecheck & Lint (push) Has been cancelled

This commit is contained in:
nirholas
2026-03-31 12:43:05 +00:00
parent da6c5e1ed7
commit 3a854557e0
145 changed files with 34693 additions and 690 deletions

View File

@@ -118,12 +118,16 @@ export function ChatInput({ conversationId }: ChatInputProps) {
>
<button
className="p-1 text-surface-500 hover:text-surface-300 transition-colors flex-shrink-0 mb-0.5"
title="Attach file"
aria-label="Attach file"
>
<Paperclip className="w-4 h-4" />
<Paperclip className="w-4 h-4" aria-hidden="true" />
</button>
<label htmlFor="chat-input" className="sr-only">
Message
</label>
<textarea
id="chat-input"
ref={textareaRef}
value={input}
onChange={(e) => {
@@ -133,6 +137,7 @@ export function ChatInput({ conversationId }: ChatInputProps) {
onKeyDown={handleKeyDown}
placeholder="Message Claude Code..."
rows={1}
aria-label="Message"
className={cn(
"flex-1 resize-none bg-transparent text-sm text-surface-100",
"placeholder:text-surface-500 focus:outline-none",
@@ -143,24 +148,25 @@ export function ChatInput({ conversationId }: ChatInputProps) {
{isStreaming ? (
<button
onClick={handleStop}
aria-label="Stop generation"
className="p-1.5 rounded-lg bg-surface-700 text-surface-300 hover:bg-surface-600 transition-colors flex-shrink-0"
title="Stop generation"
>
<Square className="w-4 h-4" />
<Square className="w-4 h-4" aria-hidden="true" />
</button>
) : (
<button
onClick={handleSubmit}
disabled={!input.trim()}
aria-label="Send message"
aria-disabled={!input.trim()}
className={cn(
"p-1.5 rounded-lg transition-colors flex-shrink-0",
input.trim()
? "bg-brand-600 text-white hover:bg-brand-700"
: "bg-surface-700 text-surface-500 cursor-not-allowed"
)}
title="Send message"
>
<Send className="w-4 h-4" />
<Send className="w-4 h-4" aria-hidden="true" />
</button>
)}
</div>

View File

@@ -6,6 +6,8 @@ import { Sidebar } from "@/components/layout/Sidebar";
import { Header } from "@/components/layout/Header";
import { ChatWindow } from "./ChatWindow";
import { ChatInput } from "./ChatInput";
import { SkipToContent } from "@/components/a11y/SkipToContent";
import { AnnouncerProvider } from "@/components/a11y/Announcer";
export function ChatLayout() {
const { conversations, createConversation, activeConversationId } = useChatStore();
@@ -17,23 +19,30 @@ export function ChatLayout() {
}, []);
return (
<div className="flex h-screen bg-surface-950 text-surface-100">
<Sidebar />
<div className="flex flex-col flex-1 min-w-0">
<Header />
<main className="flex flex-col flex-1 min-h-0">
{activeConversationId ? (
<>
<ChatWindow conversationId={activeConversationId} />
<ChatInput conversationId={activeConversationId} />
</>
) : (
<div className="flex-1 flex items-center justify-center text-surface-500">
Select or create a conversation
</div>
)}
</main>
<AnnouncerProvider>
<SkipToContent />
<div className="flex h-screen bg-surface-950 text-surface-100">
<Sidebar />
<div className="flex flex-col flex-1 min-w-0">
<Header />
<main
id="main-content"
aria-label="Chat"
className="flex flex-col flex-1 min-h-0"
>
{activeConversationId ? (
<>
<ChatWindow conversationId={activeConversationId} />
<ChatInput conversationId={activeConversationId} />
</>
) : (
<div className="flex-1 flex items-center justify-center text-surface-500">
Select or create a conversation
</div>
)}
</main>
</div>
</div>
</div>
</AnnouncerProvider>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { useChatStore } from "@/lib/store";
import { MessageBubble } from "./MessageBubble";
import { Bot } from "lucide-react";
@@ -15,15 +15,37 @@ export function ChatWindow({ conversationId }: ChatWindowProps) {
const conversation = conversations.find((c) => c.id === conversationId);
const messages = conversation?.messages ?? [];
const isStreaming = messages.some((m) => m.status === "streaming");
// Announce the last completed assistant message to screen readers
const [announcement, setAnnouncement] = useState("");
const prevLengthRef = useRef(messages.length);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages.length]);
const lastMsg = messages[messages.length - 1];
if (
messages.length > prevLengthRef.current &&
lastMsg?.role === "assistant" &&
lastMsg.status === "complete"
) {
// Announce a short preview so screen reader users know a reply arrived
const preview = lastMsg.content.slice(0, 100);
setAnnouncement("");
setTimeout(() => setAnnouncement(`Claude replied: ${preview}`), 50);
}
prevLengthRef.current = messages.length;
}, [messages.length, messages]);
if (messages.length === 0) {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-4 text-center px-6">
<div className="w-12 h-12 rounded-full bg-brand-600/20 flex items-center justify-center">
<Bot className="w-6 h-6 text-brand-400" />
<div
className="w-12 h-12 rounded-full bg-brand-600/20 flex items-center justify-center"
aria-hidden="true"
>
<Bot className="w-6 h-6 text-brand-400" aria-hidden="true" />
</div>
<div>
<h2 className="text-lg font-semibold text-surface-100">How can I help?</h2>
@@ -36,12 +58,36 @@ export function ChatWindow({ conversationId }: ChatWindowProps) {
}
return (
<div className="flex-1 overflow-y-auto">
<div
className="flex-1 overflow-y-auto"
aria-busy={isStreaming}
aria-label="Conversation"
>
{/* Polite live region — announces when Claude finishes a reply */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
style={{
position: "absolute",
width: "1px",
height: "1px",
padding: 0,
margin: "-1px",
overflow: "hidden",
clip: "rect(0,0,0,0)",
whiteSpace: "nowrap",
borderWidth: 0,
}}
>
{announcement}
</div>
<div className="max-w-3xl mx-auto py-6 px-4 space-y-6">
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
<div ref={bottomRef} />
<div ref={bottomRef} aria-hidden="true" />
</div>
</div>
);

View File

@@ -15,14 +15,16 @@ export function MessageBubble({ message }: MessageBubbleProps) {
const text = extractTextContent(message.content);
return (
<div
<article
className={cn(
"flex gap-3 animate-fade-in",
isUser && "flex-row-reverse"
)}
aria-label={isUser ? "You" : isError ? "Error from Claude" : "Claude"}
>
{/* Avatar */}
{/* Avatar — purely decorative, role conveyed by article label */}
<div
aria-hidden="true"
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5",
isUser
@@ -33,11 +35,11 @@ export function MessageBubble({ message }: MessageBubbleProps) {
)}
>
{isUser ? (
<User className="w-4 h-4" />
<User className="w-4 h-4" aria-hidden="true" />
) : isError ? (
<AlertCircle className="w-4 h-4" />
<AlertCircle className="w-4 h-4" aria-hidden="true" />
) : (
<Bot className="w-4 h-4" />
<Bot className="w-4 h-4" aria-hidden="true" />
)}
</div>
@@ -64,10 +66,13 @@ export function MessageBubble({ message }: MessageBubbleProps) {
<MarkdownContent content={text} />
)}
{message.status === "streaming" && (
<span className="inline-block w-1.5 h-4 bg-current ml-0.5 animate-pulse-soft" />
<span
aria-hidden="true"
className="inline-block w-1.5 h-4 bg-current ml-0.5 animate-pulse-soft"
/>
)}
</div>
</div>
</div>
</article>
);
}

View File

@@ -0,0 +1,114 @@
"use client";
import { useRef, useEffect, useCallback } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import type { Message } from "@/lib/types";
import { MessageBubble } from "./MessageBubble";
/**
* Estimated heights used for initial layout. The virtualizer measures actual
* heights after render and updates scroll positions accordingly.
*/
const ESTIMATED_HEIGHT = {
short: 80, // typical user message
medium: 160, // short assistant reply
tall: 320, // code blocks / long replies
};
function estimateMessageHeight(message: Message): number {
const text =
typeof message.content === "string"
? message.content
: message.content
.filter((b): b is { type: "text"; text: string } => b.type === "text")
.map((b) => b.text)
.join("");
if (text.length < 100) return ESTIMATED_HEIGHT.short;
if (text.length < 500 || text.includes("```")) return ESTIMATED_HEIGHT.medium;
return ESTIMATED_HEIGHT.tall;
}
interface VirtualMessageListProps {
messages: Message[];
/** Whether streaming is in progress — suppresses smooth-scroll so the
* autoscroll keeps up with incoming tokens. */
isStreaming: boolean;
}
export function VirtualMessageList({ messages, isStreaming }: VirtualMessageListProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const isAtBottomRef = useRef(true);
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => scrollRef.current,
estimateSize: (index) => estimateMessageHeight(messages[index]),
overscan: 5,
});
// Track whether the user has scrolled away from the bottom
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
isAtBottomRef.current = distanceFromBottom < 80;
}, []);
// Auto-scroll to bottom when new messages arrive (if already at bottom)
useEffect(() => {
if (!isAtBottomRef.current) return;
const el = scrollRef.current;
if (!el) return;
if (isStreaming) {
// Instant scroll during streaming to keep up with tokens
el.scrollTop = el.scrollHeight;
} else {
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
}
}, [messages.length, isStreaming]);
// Also scroll when the last streaming message content changes
useEffect(() => {
if (!isStreaming || !isAtBottomRef.current) return;
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
});
const items = virtualizer.getVirtualItems();
return (
<div
ref={scrollRef}
className="flex-1 overflow-y-auto"
onScroll={handleScroll}
>
{/* Spacer that gives the virtualizer its total height */}
<div
style={{ height: virtualizer.getTotalSize(), position: "relative" }}
className="max-w-3xl mx-auto px-4 py-6"
>
{items.map((virtualItem) => {
const message = messages[virtualItem.index];
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
transform: `translateY(${virtualItem.start}px)`,
}}
className="pb-6"
>
<MessageBubble message={message} />
</div>
);
})}
</div>
</div>
);
}