mirror of
https://github.com/codeaashu/claude-code.git
synced 2026-04-08 22:28:48 +03:00
claude-code
This commit is contained in:
180
web/components/chat/ChatInput.tsx
Normal file
180
web/components/chat/ChatInput.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { Send, Square, Paperclip } from "lucide-react";
|
||||
import { useChatStore } from "@/lib/store";
|
||||
import { streamChat } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MAX_MESSAGE_LENGTH } from "@/lib/constants";
|
||||
|
||||
interface ChatInputProps {
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
export function ChatInput({ conversationId }: ChatInputProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const { conversations, settings, addMessage, updateMessage } = useChatStore();
|
||||
const conversation = conversations.find((c) => c.id === conversationId);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
if (!text || isStreaming) return;
|
||||
|
||||
setInput("");
|
||||
setIsStreaming(true);
|
||||
|
||||
// Add user message
|
||||
addMessage(conversationId, {
|
||||
role: "user",
|
||||
content: text,
|
||||
status: "complete",
|
||||
});
|
||||
|
||||
// Add placeholder assistant message
|
||||
const assistantId = addMessage(conversationId, {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
status: "streaming",
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const messages = [
|
||||
...(conversation?.messages ?? []).map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
})),
|
||||
{ role: "user" as const, content: text },
|
||||
];
|
||||
|
||||
let fullText = "";
|
||||
|
||||
try {
|
||||
for await (const chunk of streamChat(messages, settings.model, controller.signal)) {
|
||||
if (chunk.type === "text" && chunk.content) {
|
||||
fullText += chunk.content;
|
||||
updateMessage(conversationId, assistantId, {
|
||||
content: fullText,
|
||||
status: "streaming",
|
||||
});
|
||||
} else if (chunk.type === "done") {
|
||||
break;
|
||||
} else if (chunk.type === "error") {
|
||||
updateMessage(conversationId, assistantId, {
|
||||
content: chunk.error ?? "An error occurred",
|
||||
status: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
updateMessage(conversationId, assistantId, { status: "complete" });
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== "AbortError") {
|
||||
updateMessage(conversationId, assistantId, {
|
||||
content: "Request failed. Please try again.",
|
||||
status: "error",
|
||||
});
|
||||
} else {
|
||||
updateMessage(conversationId, assistantId, { status: "complete" });
|
||||
}
|
||||
} finally {
|
||||
setIsStreaming(false);
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, [input, isStreaming, conversationId, conversation, settings.model, addMessage, updateMessage]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
|
||||
const adjustHeight = () => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-surface-800 bg-surface-900/50 backdrop-blur-sm px-4 py-3">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-end gap-2 rounded-xl border bg-surface-800 px-3 py-2",
|
||||
"border-surface-700 focus-within:border-brand-500 transition-colors"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
className="p-1 text-surface-500 hover:text-surface-300 transition-colors flex-shrink-0 mb-0.5"
|
||||
aria-label="Attach file"
|
||||
>
|
||||
<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) => {
|
||||
setInput(e.target.value.slice(0, MAX_MESSAGE_LENGTH));
|
||||
adjustHeight();
|
||||
}}
|
||||
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",
|
||||
"min-h-[24px] max-h-[200px] py-0.5"
|
||||
)}
|
||||
/>
|
||||
|
||||
{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"
|
||||
>
|
||||
<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"
|
||||
)}
|
||||
>
|
||||
<Send className="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-surface-600 text-center mt-2">
|
||||
Claude can make mistakes. Verify important information.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
web/components/chat/ChatLayout.tsx
Normal file
48
web/components/chat/ChatLayout.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useChatStore } from "@/lib/store";
|
||||
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();
|
||||
|
||||
useEffect(() => {
|
||||
if (conversations.length === 0) {
|
||||
createConversation();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<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>
|
||||
</AnnouncerProvider>
|
||||
);
|
||||
}
|
||||
94
web/components/chat/ChatWindow.tsx
Normal file
94
web/components/chat/ChatWindow.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useChatStore } from "@/lib/store";
|
||||
import { MessageBubble } from "./MessageBubble";
|
||||
import { Bot } from "lucide-react";
|
||||
|
||||
interface ChatWindowProps {
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
export function ChatWindow({ conversationId }: ChatWindowProps) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const { conversations } = useChatStore();
|
||||
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" });
|
||||
|
||||
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"
|
||||
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>
|
||||
<p className="text-sm text-surface-400 mt-1">
|
||||
Start a conversation with Claude Code
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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} aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
web/components/chat/MarkdownContent.tsx
Normal file
32
web/components/chat/MarkdownContent.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MarkdownContentProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MarkdownContent({ content, className }: MarkdownContentProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"prose prose-sm prose-invert max-w-none",
|
||||
"prose-p:leading-relaxed prose-p:my-1",
|
||||
"prose-pre:bg-surface-900 prose-pre:border prose-pre:border-surface-700",
|
||||
"prose-code:text-brand-300 prose-code:bg-surface-900 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:text-xs",
|
||||
"prose-pre:code:bg-transparent prose-pre:code:text-surface-100 prose-pre:code:p-0",
|
||||
"prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5",
|
||||
"prose-headings:text-surface-100 prose-headings:font-semibold",
|
||||
"prose-a:text-brand-400 prose-a:no-underline hover:prose-a:underline",
|
||||
"prose-blockquote:border-brand-500 prose-blockquote:text-surface-300",
|
||||
"prose-hr:border-surface-700",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
web/components/chat/MessageBubble.tsx
Normal file
78
web/components/chat/MessageBubble.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { User, Bot, AlertCircle } from "lucide-react";
|
||||
import { cn, extractTextContent } from "@/lib/utils";
|
||||
import type { Message } from "@/lib/types";
|
||||
import { MarkdownContent } from "./MarkdownContent";
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
const isUser = message.role === "user";
|
||||
const isError = message.status === "error";
|
||||
const text = extractTextContent(message.content);
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
"flex gap-3 animate-fade-in",
|
||||
isUser && "flex-row-reverse"
|
||||
)}
|
||||
aria-label={isUser ? "You" : isError ? "Error from Claude" : "Claude"}
|
||||
>
|
||||
{/* 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
|
||||
? "bg-brand-600 text-white"
|
||||
: isError
|
||||
? "bg-red-900 text-red-300"
|
||||
: "bg-surface-700 text-surface-300"
|
||||
)}
|
||||
>
|
||||
{isUser ? (
|
||||
<User className="w-4 h-4" aria-hidden="true" />
|
||||
) : isError ? (
|
||||
<AlertCircle className="w-4 h-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Bot className="w-4 h-4" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 min-w-0 max-w-2xl",
|
||||
isUser && "flex justify-end"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl px-4 py-3 text-sm",
|
||||
isUser
|
||||
? "bg-brand-600 text-white rounded-tr-sm"
|
||||
: isError
|
||||
? "bg-red-950 border border-red-800 text-red-200 rounded-tl-sm"
|
||||
: "bg-surface-800 text-surface-100 rounded-tl-sm"
|
||||
)}
|
||||
>
|
||||
{isUser ? (
|
||||
<p className="whitespace-pre-wrap break-words">{text}</p>
|
||||
) : (
|
||||
<MarkdownContent content={text} />
|
||||
)}
|
||||
{message.status === "streaming" && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block w-1.5 h-4 bg-current ml-0.5 animate-pulse-soft"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
114
web/components/chat/VirtualMessageList.tsx
Normal file
114
web/components/chat/VirtualMessageList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user