diff --git a/src/App.tsx b/src/App.tsx index b28711d..ac79e0e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -82,6 +82,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/app/auth/change-password.tsx b/src/app/auth/change-password.tsx index 090437f..945a3ba 100644 --- a/src/app/auth/change-password.tsx +++ b/src/app/auth/change-password.tsx @@ -19,6 +19,7 @@ export function ChangePasswordPage() { const { register, handleSubmit, watch, formState: { errors } } = useForm(); + // eslint-disable-next-line react-hooks/incompatible-library const newPassword = watch("new_password"); const loadCaptcha = async () => { @@ -26,7 +27,7 @@ export function ChangePasswordPage() { const result = await getCaptcha(apiAuthCaptcha, true); setCaptchaImage(result.base64); setPublicKey(result.publicKey || ""); - } catch (err) { + } catch { setError("Failed to load captcha"); } }; @@ -55,11 +56,12 @@ export function ChangePasswordPage() { }); navigate("/me/settings"); - } catch (err: any) { - if (err.response?.status === 401) { + } catch (err) { + const apiErr = err as { response?: { status?: number; data?: { message?: string } } }; + if (apiErr.response?.status === 401) { setError("Current password is incorrect"); } else { - setError(err.response?.data?.message || "Failed to change password"); + setError(apiErr.response?.data?.message || "Failed to change password"); } loadCaptcha(); } finally { diff --git a/src/app/auth/forgot-password.tsx b/src/app/auth/forgot-password.tsx index d3e6732..94c0c44 100644 --- a/src/app/auth/forgot-password.tsx +++ b/src/app/auth/forgot-password.tsx @@ -23,7 +23,7 @@ export function ForgotPasswordPage() { try { const result = await getCaptcha(apiAuthCaptcha, true); setCaptchaImage(result.base64); - } catch (err) { + } catch { setError("Failed to load captcha"); } }; @@ -48,8 +48,9 @@ export function ForgotPasswordPage() { // Reset password doesn't require RSA encryption since it's email-based await apiUserRequestPasswordReset({ email: data.email }); setSuccess(true); - } catch (err: any) { - setError(err.response?.data?.message || "Failed to send reset email"); + } catch (err) { + const apiErr = err as { response?: { status?: number; data?: { message?: string } } }; + setError(apiErr.response?.data?.message || "Failed to send reset email"); } finally { setLoading(false); } diff --git a/src/app/auth/login.tsx b/src/app/auth/login.tsx index 60671dd..2162333 100644 --- a/src/app/auth/login.tsx +++ b/src/app/auth/login.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { useNavigate, useLocation, Link } from "react-router-dom"; import { useForm } from "react-hook-form"; import { Button } from "@/components/ui/button"; @@ -27,14 +27,15 @@ export function LoginPage() { const result = await getCaptcha(apiAuthCaptcha, true); setCaptchaImage(result.base64); setPublicKey(result.publicKey || ""); - } catch (err) { + } catch { setError("Failed to load captcha"); } }; - useEffect(() => { + useState(() => { loadCaptcha(); - }, []); + return undefined; + }); const onSubmit = async (data: LoginParams) => { setError(""); @@ -49,16 +50,17 @@ export function LoginPage() { totp_code: needs2FA ? data.totp_code : null, }); - const from = (location.state as any)?.from?.pathname || "/me"; + const from = (location.state as { from?: { pathname?: string } })?.from?.pathname || "/me"; navigate(from, { replace: true }); - } catch (err: any) { - if (err.response?.status === 428) { + } catch (err) { + const apiErr = err as { response?: { status?: number; data?: { message?: string } } }; + if (apiErr.response?.status === 428) { setNeeds2FA(true); setError("Two-factor authentication required"); - } else if (err.response?.status === 401) { + } else if (apiErr.response?.status === 401) { setError("Invalid username or password"); } else { - setError(err.response?.data?.message || "Login failed"); + setError(apiErr.response?.data?.message || "Login failed"); } loadCaptcha(); } diff --git a/src/app/auth/register.tsx b/src/app/auth/register.tsx index 62da3b9..896a6b4 100644 --- a/src/app/auth/register.tsx +++ b/src/app/auth/register.tsx @@ -24,7 +24,7 @@ export function RegisterPage() { const result = await getCaptcha(apiAuthCaptcha, true); setCaptchaImage(result.base64); setPublicKey(result.publicKey || ""); - } catch (err) { + } catch { setError("Failed to load captcha"); } }; @@ -33,6 +33,7 @@ export function RegisterPage() { loadCaptcha(); }, []); + // eslint-disable-next-line react-hooks/incompatible-library const password = watch("password"); const onSubmit = async (data: RegisterParams & { confirmPassword: string }) => { @@ -56,11 +57,12 @@ export function RegisterPage() { }); navigate("/"); - } catch (err: any) { - if (err.response?.status === 409) { + } catch (err) { + const apiErr = err as { response?: { status?: number; data?: { message?: string } } }; + if (apiErr.response?.status === 409) { setError("Username or email already exists"); } else { - setError(err.response?.data?.message || "Registration failed"); + setError(apiErr.response?.data?.message || "Registration failed"); } loadCaptcha(); } finally { diff --git a/src/app/auth/reset-password.tsx b/src/app/auth/reset-password.tsx index 4967d6a..738aaa5 100644 --- a/src/app/auth/reset-password.tsx +++ b/src/app/auth/reset-password.tsx @@ -24,6 +24,7 @@ export function ResetPasswordPage() { defaultValues: { token } }); + // eslint-disable-next-line react-hooks/incompatible-library const password = watch("new_password"); const loadCaptcha = async () => { @@ -31,7 +32,7 @@ export function ResetPasswordPage() { const result = await getCaptcha(apiAuthCaptcha, true); setCaptchaImage(result.base64); setPublicKey(result.publicKey || ""); - } catch (err) { + } catch { setError("Failed to load captcha"); } }; @@ -59,11 +60,12 @@ export function ResetPasswordPage() { }); navigate("/auth/login"); - } catch (err: any) { - if (err.response?.status === 400) { + } catch (err) { + const apiErr = err as { response?: { status?: number; data?: { message?: string } } }; + if (apiErr.response?.status === 400) { setError("Invalid or expired reset token"); } else { - setError(err.response?.data?.message || "Failed to reset password"); + setError(apiErr.response?.data?.message || "Failed to reset password"); } } finally { setLoading(false); diff --git a/src/app/auth/two-factor.tsx b/src/app/auth/two-factor.tsx index 6a6ae51..75a139a 100644 --- a/src/app/auth/two-factor.tsx +++ b/src/app/auth/two-factor.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { useForm } from "react-hook-form"; import { Button } from "@/components/ui/button"; @@ -22,19 +22,20 @@ export function TwoFactorPage() { const { register, handleSubmit, formState: { errors } } = useForm(); - useEffect(() => { - loadStatus(); - }, []); - const loadStatus = async () => { try { const response = await api2faStatus(); setIsEnabled(response.data.is_enabled || false); - } catch (err) { + } catch { setError("Failed to load 2FA status"); } }; + useState(() => { + loadStatus(); + return undefined; + }); + const handleEnable = async () => { setLoading(true); setError(""); @@ -44,8 +45,9 @@ export function TwoFactorPage() { setQrCode(response.data.qr_code); setSecret(response.data.secret); setShowSetup(true); - } catch (err: any) { - setError(err.response?.data?.message || "Failed to enable 2FA"); + } catch (err) { + const apiErr = err as { response?: { status?: number; data?: { message?: string } } }; + setError(apiErr.response?.data?.message || "Failed to enable 2FA"); } finally { setLoading(false); } @@ -63,8 +65,9 @@ export function TwoFactorPage() { setShowSetup(false); setQrCode(""); setSecret(""); - } catch (err: any) { - setError(err.response?.data?.message || "Invalid verification code"); + } catch (err) { + const apiErr = err as { response?: { status?: number; data?: { message?: string } } }; + setError(apiErr.response?.data?.message || "Invalid verification code"); } finally { setLoading(false); } @@ -77,8 +80,9 @@ export function TwoFactorPage() { try { await api2faDisable(data); setIsEnabled(false); - } catch (err: any) { - setError(err.response?.data?.message || "Failed to disable 2FA"); + } catch (err) { + const apiErr = err as { response?: { status?: number; data?: { message?: string } } }; + setError(apiErr.response?.data?.message || "Failed to disable 2FA"); } finally { setLoading(false); } diff --git a/src/app/auth/verify-email.tsx b/src/app/auth/verify-email.tsx index 5d20cd9..b1b25cb 100644 --- a/src/app/auth/verify-email.tsx +++ b/src/app/auth/verify-email.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { useSearchParams, useNavigate } from "react-router-dom"; import { apiEmailVerify } from "@/client/api"; import { Button } from "@/components/ui/button"; @@ -10,35 +10,26 @@ type VerifyStatus = "idle" | "verifying" | "success" | "error"; export function VerifyEmailPage() { const [searchParams] = useSearchParams(); const navigate = useNavigate(); - const [status, setStatus] = useState("idle"); - const [message, setMessage] = useState(""); + const token = searchParams.get("token") || ""; + const [status, setStatus] = useState(token ? "verifying" : "error"); + const [message, setMessage] = useState(token ? "" : "Missing verification token"); - useEffect(() => { - const token = searchParams.get("token"); - if (!token) { - setStatus("error"); - setMessage("Missing verification token"); - return; + useState(() => { + if (token) { + apiEmailVerify({ token }) + .then((res) => { + const msg = res.data?.data ?? "Email verified successfully"; + setMessage(msg); + setStatus("success"); + setTimeout(() => navigate("/auth/login"), 3000); + }) + .catch((err) => { + const apiErr = err as { response?: { status?: number; data?: { message?: string } } }; + setMessage(apiErr.response?.data?.message || "Verification failed"); + setStatus("error"); + }); } - - const verify = async () => { - try { - setStatus("verifying"); - const res = await apiEmailVerify({ token }); - const msg = res.data?.data ?? "Email verified successfully"; - setMessage(msg); - setStatus("success"); - - // Redirect to login after 3 seconds - setTimeout(() => navigate("/auth/login"), 3000); - } catch (err: any) { - setMessage(err.response?.data?.message || "Verification failed"); - setStatus("error"); - } - }; - - verify(); - }, [searchParams, navigate]); + }); return (
diff --git a/src/app/channel/layout.tsx b/src/app/channel/layout.tsx index 82e93b8..385aae1 100644 --- a/src/app/channel/layout.tsx +++ b/src/app/channel/layout.tsx @@ -1,5 +1,6 @@ import { createContext, useContext, useState, useMemo, useCallback, type ReactNode } from "react"; import { Outlet } from "react-router-dom"; +import { useMatch } from "react-router-dom"; import { ChevronRight } from "lucide-react"; import { ServerIconRail } from "@/components/layout/ServerIconRail"; import { ChannelSidebar } from "@/components/layout/ChannelSidebar"; @@ -17,6 +18,7 @@ const ChannelContext = createContext({ setShowMembers: () => {}, }); +// eslint-disable-next-line react-refresh/only-export-components export const useChannel = () => useContext(ChannelContext); export function ChannelLayout({ children }: { children?: ReactNode }) { @@ -36,6 +38,9 @@ export function ChannelLayout({ children }: { children?: ReactNode }) { [], ); + const roomMatch = useMatch("/channel/:roomId"); + const mainShouldOwnScroll = !roomMatch; + return (
@@ -77,7 +82,10 @@ export function ChannelLayout({ children }: { children?: ReactNode }) {
-
+
{children ?? }
@@ -90,4 +98,4 @@ export function ChannelLayout({ children }: { children?: ReactNode }) {
); -} \ No newline at end of file +} diff --git a/src/app/chat/ChatConversationList.tsx b/src/app/chat/ChatConversationList.tsx index f081a5f..e452267 100644 --- a/src/app/chat/ChatConversationList.tsx +++ b/src/app/chat/ChatConversationList.tsx @@ -1,6 +1,6 @@ -import { useState } from "react"; +import { useState, useMemo, useEffect, useRef } from "react"; import { Link } from "react-router-dom"; -import { Plus, Trash2, MessageSquare, Loader2, Search, Edit2 } from "lucide-react"; +import { Plus, Trash2, MessageSquare, Loader2, Search, Edit2, X } from "lucide-react"; import { useConversationsQuery, useCreateConversationMutation, useDeleteConversationMutation } from "@/hooks/useAiChatQuery"; import { useChatPage } from "./ChatPageContext"; import type { ConversationResponse } from "@/client/model"; @@ -17,13 +17,66 @@ export function ChatConversationList({ selectedId, onSelect, onNew }: ChatConver const createMutation = useCreateConversationMutation(); const deleteMutation = useDeleteConversationMutation(); const [deletingId, setDeletingId] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [isSearchOpen, setIsSearchOpen] = useState(false); + const searchInputRef = useRef(null); const getConversationLink = (id: string) => { if (scope === "project" && projectName) return `/${projectName}/chat/${id}`; return `/me/chat/${id}`; }; - const conversations = data?.conversations || []; + const conversations = useMemo(() => data?.conversations || [], [data?.conversations]); + + // Filter conversations by search query + const filteredConversations = useMemo(() => { + if (!searchQuery.trim()) return conversations; + const q = searchQuery.toLowerCase(); + return conversations.filter((c) => (c.title || "Untitled Chat").toLowerCase().includes(q)); + }, [conversations, searchQuery]); + + // Group conversations by date + const groupedConversations = useMemo(() => { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + const weekAgo = new Date(today); + weekAgo.setDate(weekAgo.getDate() - 7); + const monthAgo = new Date(today); + monthAgo.setMonth(monthAgo.getMonth() - 1); + + const groups: { label: string; items: ConversationResponse[] }[] = []; + + const todayItems: ConversationResponse[] = []; + const yesterdayItems: ConversationResponse[] = []; + const thisWeekItems: ConversationResponse[] = []; + const thisMonthItems: ConversationResponse[] = []; + const earlierItems: ConversationResponse[] = []; + + for (const c of filteredConversations) { + const date = new Date(c.created_at); + if (date >= today) { + todayItems.push(c); + } else if (date >= yesterday) { + yesterdayItems.push(c); + } else if (date >= weekAgo) { + thisWeekItems.push(c); + } else if (date >= monthAgo) { + thisMonthItems.push(c); + } else { + earlierItems.push(c); + } + } + + if (todayItems.length > 0) groups.push({ label: "Today", items: todayItems }); + if (yesterdayItems.length > 0) groups.push({ label: "Yesterday", items: yesterdayItems }); + if (thisWeekItems.length > 0) groups.push({ label: "This Week", items: thisWeekItems }); + if (thisMonthItems.length > 0) groups.push({ label: "This Month", items: thisMonthItems }); + if (earlierItems.length > 0) groups.push({ label: "Earlier", items: earlierItems }); + + return groups; + }, [filteredConversations]); const handleNew = async () => { onNew(); @@ -58,6 +111,23 @@ export function ChatConversationList({ selectedId, onSelect, onNew }: ChatConver } }; + // Cmd/Ctrl+K to focus search + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + setIsSearchOpen(true); + setTimeout(() => searchInputRef.current?.focus(), 0); + } + if (e.key === "Escape" && isSearchOpen) { + setIsSearchOpen(false); + setSearchQuery(""); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isSearchOpen]); + return (
@@ -96,6 +166,39 @@ export function ChatConversationList({ selectedId, onSelect, onNew }: ChatConver
+ {/* Search Input */} + {isSearchOpen && ( +
+
+ + setSearchQuery(e.target.value)} + className="flex-1 text-sm bg-transparent outline-none min-w-0" + style={{ color: "var(--text-primary)" }} + /> + {searchQuery && ( + + )} +
+
+ )} + {/* New Chat Button */}
- ) : conversations.length === 0 ? ( + ) : filteredConversations.length === 0 ? (
- No conversations yet + {searchQuery ? "No matching conversations" : "No conversations yet"}

- Start a new chat to begin exploring with AI. + {searchQuery ? "Try a different search term." : "Start a new chat to begin exploring with AI."}

) : ( -
- {conversations.map((conversation) => ( - handleDelete(e, conversation.id)} - /> +
+ {groupedConversations.map((group) => ( +
+
+ {group.label} +
+
+ {group.items.map((conversation) => ( + handleDelete(e, conversation.id)} + /> + ))} +
+
))}
)} diff --git a/src/app/chat/ChatMessageBubble.tsx b/src/app/chat/ChatMessageBubble.tsx index f153b10..64ef391 100644 --- a/src/app/chat/ChatMessageBubble.tsx +++ b/src/app/chat/ChatMessageBubble.tsx @@ -1,49 +1,30 @@ import { Copy, Check, Sparkles, ClipboardList, Pencil, RefreshCw, GitFork } from "lucide-react"; -import { memo, useState } from "react"; +import { memo, useState, useMemo } from "react"; +import { useNavigate } from "react-router-dom"; import { useCurrentUserQuery } from "@/hooks/useAuth"; -import { useEditMessageMutation, useMessageVersionsQuery, useSwitchMessageVersionMutation } from "@/hooks/useAiChatQuery"; -import { MarkdownRenderer } from "@/components/ui/MarkdownRenderer"; +import { + useChatStreamRunner, + useEditMessageMutation, + useForkMessageMutation, + useMessageVersionsQuery, + useResendMessageMutation, + useSwitchMessageVersionMutation, +} from "@/hooks/useAiChatQuery"; +import { IrRenderer } from "@/lib/ir/renderer"; +import { parseContentBlocks, extractAnswerText, extractFullText } from "@/lib/ir/parser"; +import type { IrContentBlock, IrToolCallNode, IrToolResultNode } from "@/lib/ir/types"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Reasoning, ReasoningTrigger, ReasoningContent } from "@/components/ai-elements/reasoning"; import type { MessageResponse } from "@/hooks/useAiChatQuery"; import { getModelIcon } from "@/lib/icons/modelIcons"; +import { ToolCallBlock } from "@/components/chat/ToolCallBlock"; +import { useChatPage } from "./ChatPageContext"; interface ChatMessageBubbleProps { message: MessageResponse; conversationId: string; onRegenerate?: (newMessageId: string) => void; -} - -interface ContentBlock { - role: "thinking" | "assistant" | string; - content: string; -} - -/** Strip XML-format thinking tags and normalize thinking content. - * Thinking content is a reasoning trace, not formatted text — remove - * per-token stray \n that models emit during streaming, and collapse - * any remaining excessive newlines. */ -function stripThinkingXml(content: string): string { - return content - .replace(/<\/?thinking>/gi, "") - .replace(/<\/?response>/gi, "") - // Collapse 3+ \n, then strip single \n entirely (no space — avoids - // unwanted gaps in CJK text where there are no word boundaries). - .replace(/\n{3,}/g, "\n\n") - .replace(/(? void; } const AVATAR_COLORS = [ @@ -60,47 +41,9 @@ function hashColor(str: string): string { return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length]; } -/** Parse content into ordered blocks: [{role, content}, ...] */ -function parseBlocks(raw: unknown): ContentBlock[] { - if (Array.isArray(raw)) { - const blocks = raw.map((item) => { - if (item && typeof item === "object") { - const obj = item as Record; - const role = (typeof obj.role === "string" ? obj.role : "assistant") as string; - let content = typeof obj.content === "string" ? obj.content : ""; - if (role === "thinking") { - content = stripThinkingXml(content); - } - return { role, content }; - } - return null; - }).filter(Boolean) as ContentBlock[]; - if (blocks.length > 0) return blocks; - } - // Single text — wrap as one assistant block (fallback for old format) - const text = typeof raw === "string" ? raw : raw && typeof raw === "object" ? String((raw as Record).content ?? raw) : String(raw ?? ""); - if (!text) return []; - return [{ role: "assistant", content: text }]; -} +const PROSE_CLASS = "prose prose-sm dark:prose-invert max-w-none [&_p]:leading-[1.55] [&_p]:my-1 [&_ul]:my-1 [&_ol]:my-1 [&_li]:my-0 [&_h1]:mt-2 [&_h2]:mt-2 [&_h3]:mt-2 [&_pre]:my-1.5 [&_blockquote]:my-1"; -/** Extract answer text only (for simple copy). */ -function extractAnswerText(blocks: ContentBlock[]): string { - return blocks - .filter((b) => b.role !== "thinking") - .map((b) => b.content) - .join("\n\n"); -} - -/** Extract full content including thinking (for deep copy). */ -function extractFullText(blocks: ContentBlock[]): string { - return blocks.map((b) => { - if (b.role === "thinking") return `[Thinking]\n${b.content}\n[/Thinking]`; - return b.content; - }).join("\n\n"); -} - - -export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conversationId, onRegenerate }: ChatMessageBubbleProps) { +export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conversationId, onRegenerate, setIsStreaming }: ChatMessageBubbleProps) { const isUser = message.role === "user"; const [copied, setCopied] = useState<"answer" | "full" | false>(false); const [isEditing, setIsEditing] = useState(false); @@ -108,12 +51,29 @@ export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conv const [showVersions, setShowVersions] = useState(false); const { data: user } = useCurrentUserQuery(); const editMutation = useEditMessageMutation(); + const resendMutation = useResendMessageMutation(); + const forkMutation = useForkMessageMutation(); const switchVersionMutation = useSwitchMessageVersionMutation(); + const runStream = useChatStreamRunner(setIsStreaming); + const navigate = useNavigate(); + const { scope, projectName } = useChatPage(); - const blocks = isUser - ? [{ role: "user" as const, content: typeof message.content === "string" ? message.content : "" }] - : parseBlocks(message.content); - const plainText = isUser ? blocks[0]?.content || "" : extractAnswerText(blocks); + // Parse content into IrContentBlock[] (handles both old and future formats) + const blocks: IrContentBlock[] = useMemo(() => + isUser + ? [{ role: "user", nodes: [] }] + : parseContentBlocks(message.content), + [isUser, message.content] + ); + + // User message plain text + const userText = typeof message.content === "string" + ? message.content + : message.content && typeof message.content === "object" + ? String((message.content as Record).content ?? "") + : ""; + const plainText = isUser ? userText : extractAnswerText(blocks); + const hasThinking = blocks.some((b) => b.role === "thinking"); // Fetch versions when showing version switcher const versionsQuery = useMessageVersionsQuery(conversationId, message.id); @@ -155,18 +115,20 @@ export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conv content: editText.trim(), }); setIsEditing(false); - // If there's a regenerate callback, trigger AI response for the new message - if (onRegenerate) { - onRegenerate(newMsg.id); - } + onRegenerate?.(newMsg.id); + await runStream(conversationId, newMsg.id); } catch (err) { console.error("Failed to edit message:", err); } }; - const handleRegenerate = () => { - if (onRegenerate) { - onRegenerate(message.id); + const handleRegenerate = async () => { + try { + const newMsg = await resendMutation.mutateAsync({ conversationId, messageId: message.id }); + onRegenerate?.(newMsg.id); + await runStream(conversationId, newMsg.id); + } catch (err) { + console.error("Failed to regenerate:", err); } }; @@ -184,19 +146,12 @@ export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conv }; const handleFork = async () => { - // Forking from an AI message: creates a new user message that branches - // from this AI response point. The target_message_id will be the new - // user message created on the client side. try { - // For now, we'll fork by creating a new user message with parent = this AI message - // and then register the fork relationship - // The fork flow: user clicks "Fork" → creates a new empty user message - // with parent_message_id = this AI message → user types new content → submits - // This is handled at the ChatMessageList level - if (onRegenerate) { - // Use onRegenerate callback to signal the parent component - // that a fork should be initiated from this message - onRegenerate(message.id); + const fork = await forkMutation.mutateAsync({ conversationId, messageId: message.id }); + if (scope === "project" && projectName) { + navigate(`/${projectName}/chat/${fork.id}`); + } else { + navigate(`/me/chat/${fork.id}`); } } catch (err) { console.error("Failed to fork:", err); @@ -214,7 +169,7 @@ export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conv className="text-[10px] font-semibold rounded-full" style={{ backgroundColor: hashColor(user?.username || "user"), - color: "#ffffff", + color: "var(--text-inverse)", }} > {(user?.display_name || user?.username || "U")[0]?.toUpperCase()} @@ -237,7 +192,7 @@ export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conv
- {/* Interleaved blocks — thinking (collapsible) + answer (markdown) in order */} + {/* Interleaved blocks — thinking (collapsible) + answer (IrRenderer) */}
{isUser && isEditing ? (
@@ -281,7 +236,7 @@ export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conv color: editText.trim() ? "var(--text-inverse)" : "var(--text-muted)", }} > - {editMutation.isPending ? "Saving…" : "Save & Submit"} + {editMutation.isPending ? "Saving..." : "Save & Submit"}
@@ -290,16 +245,51 @@ export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conv ) : ( blocks.map((b, i) => { if (b.role === "thinking") { + // Thinking content rendered by Reasoning/Streamdown (not IrRenderer) + const thinkingText = b.nodes + .filter((n) => n.type === "text") + .map((n) => (n as { content: string }).content) + .join(""); return ( - {b.content} + {thinkingText} ); } + if (b.role === "tool_call") { + // Tool call visualization + const toolCallNode = b.nodes.find((n) => n.type === "tool_call") as IrToolCallNode | undefined; + if (toolCallNode) { + return ( + + ); + } + return null; + } + if (b.role === "tool_result") { + const toolResultNode = b.nodes.find((n) => n.type === "tool_result") as IrToolResultNode | undefined; + if (toolResultNode) { + return ( + + ); + } + return null; + } return (
0 ? "mt-3" : ""}> - +
); }) @@ -387,7 +377,7 @@ export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conv {copied === "answer" ? : } {copied === "answer" ? "Copied!" : "Copy"} - {blocks.some((b) => b.role === "thinking") && ( + {hasThinking && (
); -} \ No newline at end of file +} diff --git a/src/app/chat/ChatMessageInput.tsx b/src/app/chat/ChatMessageInput.tsx index 21ec586..bb0e332 100644 --- a/src/app/chat/ChatMessageInput.tsx +++ b/src/app/chat/ChatMessageInput.tsx @@ -1,9 +1,12 @@ import { useState } from "react"; import { AlertCircle } from "lucide-react"; -import { useCreateMessageMutation, useCreateConversationMutation, streamChat } from "@/hooks/useAiChatQuery"; +import { + useCreateMessageMutation, + useCreateConversationMutation, + useStopMessageMutation, + useChatStreamRunner, +} from "@/hooks/useAiChatQuery"; import { useChatPage } from "./ChatPageContext"; -import { useQueryClient } from "@tanstack/react-query"; -import { useStreamingStore } from "@/store/streaming"; import { PromptInput, PromptInputBody, @@ -29,11 +32,13 @@ export function ChatMessageInput({ onSelectConversation, }: ChatMessageInputProps) { const [showModelWarning, setShowModelWarning] = useState(false); + const [activeMessageId, setActiveMessageId] = useState(null); + const [activeStreamConversationId, setActiveStreamConversationId] = useState(null); const createMessageMutation = useCreateMessageMutation(); const createConversationMutation = useCreateConversationMutation(); + const stopMessageMutation = useStopMessageMutation(); const { scope, projectId, selectedModel, setSelectedModel } = useChatPage(); - const queryClient = useQueryClient(); - const streamingStore = useStreamingStore(); + const runStream = useChatStreamRunner(setIsStreaming); const handleSubmit = async ({ text }: PromptInputMessage) => { if (!text.trim()) return; @@ -83,38 +88,13 @@ export function ChatMessageInput({ if (!messageResponse?.id) return; - streamingStore.clear(activeConversationId); - setIsStreaming(true); - try { - const stream = streamChat(activeConversationId, messageResponse.id); - - for await (const chunk of stream) { - if (chunk.type === "token") { - streamingStore.append(activeConversationId, "token", String(chunk.data || ""), messageResponse.id); - } else if (chunk.type === "thinking") { - streamingStore.append(activeConversationId, "thinking", String(chunk.data || ""), messageResponse.id); - } else if (chunk.type === "tool_call" || chunk.type === "tool_result") { - // Tool events — ignored for now - } else if (chunk.type === "title") { - queryClient.invalidateQueries({ queryKey: ["ai-conversations", activeConversationId] }); - queryClient.invalidateQueries({ queryKey: ["ai-conversations"] }); - } else if (chunk.type === "done") { - streamingStore.markDone(activeConversationId); - queryClient.invalidateQueries({ queryKey: ["ai-messages", activeConversationId] }); - queryClient.invalidateQueries({ queryKey: ["ai-conversations", activeConversationId] }); - queryClient.invalidateQueries({ queryKey: ["ai-conversations"] }); - } else if (chunk.type === "error") { - console.error("Stream error:", chunk.data); - } else if (chunk.type === "billing_error") { - streamingStore.append(activeConversationId, "token", String(chunk.data || ""), messageResponse.id); - streamingStore.markDone(activeConversationId); - queryClient.invalidateQueries({ queryKey: ["ai-messages", activeConversationId] }); - } - } + setActiveMessageId(messageResponse.id); + setActiveStreamConversationId(activeConversationId); + await runStream(activeConversationId, messageResponse.id); } finally { - setIsStreaming(false); - streamingStore.clear(activeConversationId); + setActiveMessageId(null); + setActiveStreamConversationId(null); } } catch (err) { console.error("Failed to send message:", err); @@ -151,7 +131,12 @@ export function ChatMessageInput({ /> setIsStreaming(false)} + onStop={() => { + if (activeStreamConversationId && activeMessageId) { + stopMessageMutation.mutate({ conversationId: activeStreamConversationId, messageId: activeMessageId }); + } + setIsStreaming(false); + }} /> diff --git a/src/app/chat/ChatMessageList.tsx b/src/app/chat/ChatMessageList.tsx index 6273da8..64bea2a 100644 --- a/src/app/chat/ChatMessageList.tsx +++ b/src/app/chat/ChatMessageList.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, useCallback } from "react"; +import { useEffect, useMemo, useRef, useState, useCallback } from "react"; import { Loader2, Code, FileText, GitPullRequest, Brain, ChevronDown, Sparkles } from "lucide-react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useQueryClient } from "@tanstack/react-query"; @@ -8,12 +8,15 @@ import type { StreamPart } from "@/store/streaming"; import { ChatMessageBubble } from "./ChatMessageBubble"; import { useChatPage } from "./ChatPageContext"; import { getModelIcon } from "@/lib/icons/modelIcons"; -import { MarkdownRenderer } from "@/components/ui/MarkdownRenderer"; +import { IrRenderer } from "@/lib/ir/renderer"; import { Shimmer } from "@/components/ai-elements/shimmer"; import { Reasoning, ReasoningTrigger, ReasoningContent } from "@/components/ai-elements/reasoning"; +import { ToolCallBlock } from "@/components/chat/ToolCallBlock"; +import { useCodePreview } from "@/components/chat/CodePreviewContext"; interface ChatMessageListProps { conversationId: string | null; + setIsStreaming: (value: boolean) => void; } const PROMPT_SUGGESTIONS = [ @@ -26,17 +29,20 @@ const PROMPT_SUGGESTIONS = [ const OVERSCAN = 3; const ESTIMATED_SIZE = 200; -export function ChatMessageList({ conversationId }: ChatMessageListProps) { +const PROSE_CLASS = "prose prose-sm dark:prose-invert max-w-none [&_p]:leading-[1.55] [&_p]:my-1 [&_ul]:my-1 [&_ol]:my-1 [&_li]:my-0 [&_h1]:mt-2 [&_h2]:mt-2 [&_h3]:mt-2 [&_pre]:my-1.5 [&_blockquote]:my-1"; + +export function ChatMessageList({ conversationId, setIsStreaming }: ChatMessageListProps) { const { data, isLoading } = useMessagesQuery(conversationId || ""); const scrollRef = useRef(null); - const messages = data?.messages || []; + const messages = useMemo(() => data?.messages ?? [], [data?.messages]); const stream = useStreamingStore((s) => (conversationId ? s.streams[conversationId] : undefined)); const isStreaming = stream && !stream.isDone; const queryClient = useQueryClient(); + const codePreview = useCodePreview(); - // Whether user is scrolled near the bottom const [isAtBottom, setIsAtBottom] = useState(true); const [userScrolledUp, setUserScrolledUp] = useState(false); + const [activeUserAnchor, setActiveUserAnchor] = useState(0); const checkAtBottom = useCallback(() => { const el = scrollRef.current; @@ -47,20 +53,18 @@ export function ChatMessageList({ conversationId }: ChatMessageListProps) { if (atBottom) setUserScrolledUp(false); }, []); - const handleScroll = useCallback(() => { - const el = scrollRef.current; - if (!el) return; - const distance = el.scrollHeight - el.scrollTop - el.clientHeight; - const atBottom = distance < 200; - setIsAtBottom(atBottom); - if (!atBottom) setUserScrolledUp(true); - }, []); + const userAnchors = useMemo(() => { + return messages + .map((message, index) => ({ message, index })) + .filter(({ message }) => message.role === "user") + .map(({ index }) => index); + }, [messages]); + const showUserTimeline = userAnchors.length > 0 && !codePreview?.activeCode; - // Streaming bubble — rendered OUTSIDE the virtualizer to avoid - // recalculating positions on every token. The virtualizer only - // handles stable, persisted messages. const hasStreamingBubble = !!stream?.parts && stream.parts.length > 0; + const streamContentLength = stream?.parts.reduce((sum, part) => sum + part.content.length, 0) ?? 0; + // eslint-disable-next-line react-hooks/incompatible-library const virtualizer = useVirtualizer({ count: messages.length, getScrollElement: () => scrollRef.current, @@ -68,7 +72,39 @@ export function ChatMessageList({ conversationId }: ChatMessageListProps) { overscan: OVERSCAN, }); - // Auto-scroll to bottom when new content arrives and user is at bottom + const updateActiveUserAnchor = useCallback(() => { + if (messages.length === 0 || userAnchors.length === 0) { + setActiveUserAnchor(0); + return; + } + const visibleItems = virtualizer.getVirtualItems(); + if (visibleItems.length === 0) return; + const firstVisible = visibleItems[0]?.index ?? 0; + let closestAnchor = 0; + for (let i = 0; i < userAnchors.length; i++) { + if (userAnchors[i] <= firstVisible) { + closestAnchor = i; + } else { + break; + } + } + setActiveUserAnchor(closestAnchor); + }, [messages.length, userAnchors, virtualizer]); + + const handleScroll = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + const distance = el.scrollHeight - el.scrollTop - el.clientHeight; + const atBottom = distance < 200; + setIsAtBottom(atBottom); + if (!atBottom) setUserScrolledUp(true); + updateActiveUserAnchor(); + }, [updateActiveUserAnchor]); + + useEffect(() => { + updateActiveUserAnchor(); + }, [updateActiveUserAnchor, streamContentLength]); + useEffect(() => { if (isAtBottom && scrollRef.current) { requestAnimationFrame(() => { @@ -77,23 +113,20 @@ export function ChatMessageList({ conversationId }: ChatMessageListProps) { } }); } - }, [messages.length, stream?.parts?.length, isAtBottom]); + }, [messages.length, streamContentLength, isAtBottom]); - // Scroll to bottom initially useEffect(() => { if (conversationId && messages.length > 0) { checkAtBottom(); } }, [conversationId, messages.length, checkAtBottom]); - // Scroll to bottom on first load useEffect(() => { if (scrollRef.current && messages.length > 0) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } - }, [isLoading]); + }, [isLoading, messages.length]); - // Empty state — no conversation or no messages if (!conversationId || (messages.length === 0 && !hasStreamingBubble)) { return (

- Ask anything — I can help with code, writing, analysis, and much more. + Ask anything - I can help with code, writing, analysis, and much more.

@@ -164,7 +197,6 @@ export function ChatMessageList({ conversationId }: ChatMessageListProps) { return (
- {/* Streaming indicator — shown above input when not at bottom */} {isStreaming && userScrolledUp && (
)} - {/* Virtualized message list — persisted messages only */}
+ {showUserTimeline && ( +
+
+
+
+ {userAnchors.map((messageIndex, anchorIndex) => { + const isActive = anchorIndex === activeUserAnchor; + return ( + + ); + })} +
+
+
+ )}
{virtualizer.getVirtualItems().map((virtualItem) => { @@ -215,19 +283,17 @@ export function ChatMessageList({ conversationId }: ChatMessageListProps) { { + onRegenerate={() => { queryClient.invalidateQueries({ queryKey: ["ai-messages", conversationId] }); queryClient.invalidateQueries({ queryKey: ["ai-conversations", conversationId] }); }} + setIsStreaming={setIsStreaming} />
); })}
- {/* Streaming bubble — outside virtualizer so it doesn't trigger - position recalculations on every token. Uses a simple content-key - to help React skip unchanged MarkdownRenderer re-parsing. */} {hasStreamingBubble && (
@@ -240,17 +306,16 @@ export function ChatMessageList({ conversationId }: ChatMessageListProps) { function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boolean }) { const { selectedModel } = useChatPage(); - // Display state synced at animation-frame rate so ReactMarkdown only - // re-parses at ~60fps, not on every token from the SSE stream. const [displayParts, setDisplayParts] = useState([]); const [displayDone, setDisplayDone] = useState(false); const contentRef = useRef(null); const latestRef = useRef({ parts, isDone }); const rafRef = useRef(0); - latestRef.current = { parts, isDone }; + useEffect(() => { + latestRef.current = { parts, isDone }; + }); - // Start rAF sync loop when streaming begins const hasParts = parts.length > 0; useEffect(() => { if (!hasParts) return; @@ -266,15 +331,6 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole return () => cancelAnimationFrame(rafRef.current); }, [hasParts]); - // Final sync when streaming stops (captures last frame) - useEffect(() => { - if (isDone) { - setDisplayParts([...parts]); - setDisplayDone(true); - } - }, [isDone, parts]); - - // Reset height for virtualizer measurement useEffect(() => { if (contentRef.current) { contentRef.current.style.height = "auto"; @@ -290,7 +346,6 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole return (
- {/* Model Avatar */}
@@ -301,14 +356,11 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole {!displayDone && ( - responding… + responding... )}
- {/* Interleaved rendering — thinking (collapsible) + token in order. - Rendered from displayParts which sync at ~60fps via rAF. - rehype-raw + rehype-sanitize allow safe inline HTML. */}
{displayParts.map((part, i) => { if (part.type === "thinking") { @@ -320,16 +372,31 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole ); } - // Token content — rendered as full Markdown + safe HTML. - // MarkdownRenderer is memoized so only re-renders when content changes - // (which happens at rAF rate, not per SSE token). + if (part.type === "tool_call") { + return ( + + ); + } + if (part.type === "tool_result") { + return ( + + ); + } const isLast = i === displayParts.length - 1; return (
- + {isLast && !displayDone && }
); @@ -340,7 +407,6 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole ); } -/** Blinking cursor for typing feel during streaming. */ function StreamingCursor() { return ( {modelName[0]?.toUpperCase() || "?"}
); -} \ No newline at end of file +} diff --git a/src/app/chat/ChatModelSelector.tsx b/src/app/chat/ChatModelSelector.tsx index c858363..8914be4 100644 --- a/src/app/chat/ChatModelSelector.tsx +++ b/src/app/chat/ChatModelSelector.tsx @@ -40,12 +40,13 @@ function ModelAvatar({ modelName, size = 20 }: { modelName: string; size?: numbe } return (
{modelName[0]?.toUpperCase() || "?"} diff --git a/src/app/chat/ChatPage.tsx b/src/app/chat/ChatPage.tsx index d1b3433..df7ca38 100644 --- a/src/app/chat/ChatPage.tsx +++ b/src/app/chat/ChatPage.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect } from "react"; +import { useState, useCallback, useEffect, useMemo } from "react"; import { useParams, useNavigate } from "react-router-dom"; import { ChatPageContext } from "./ChatPageContext"; import type { SelectedModel } from "./ChatPageContext"; @@ -8,6 +8,8 @@ import { ChatMessageList } from "./ChatMessageList"; import { ChatMessageInput } from "./ChatMessageInput"; import { useProjectInfo } from "@/hooks/useProjectInfo"; import { useConversationQuery } from "@/hooks/useAiChatQuery"; +import { CodePreviewPanel } from "@/components/chat/CodePreviewPanel"; +import { CodePreviewProvider, type CodePreviewPayload } from "@/components/chat/CodePreviewContext"; interface ChatPageProps { scope: "personal" | "project"; @@ -20,33 +22,27 @@ export function ChatPage({ scope }: ChatPageProps) { }>(); const navigate = useNavigate(); const { data: projectInfo } = useProjectInfo(projectName); - const [selectedConversationId, setSelectedConversationId] = useState( - urlConversationId || null - ); + const selectedConversationId = urlConversationId || null; const [isStreaming, setIsStreaming] = useState(false); - const [selectedModel, setSelectedModel] = useState(null); - const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); - - useEffect(() => { - if (urlConversationId) { - setSelectedConversationId(urlConversationId); - } - }, [urlConversationId]); + const [userModel, setSelectedModel] = useState(null); + const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(true); + const [activeCode, setActiveCode] = useState(null); const { data: conversation } = useConversationQuery(selectedConversationId || ""); - useEffect(() => { - const conv = conversation as any; - if (conv?.model) { - setSelectedModel({ - model_name: conv.model, - }); + // Derive model from conversation data when it loads + const derivedModel = useMemo(() => { + if (conversation?.model) { + return { model_name: conversation.model } as SelectedModel; } - }, [conversation, setSelectedModel]); + return null; + }, [conversation]); + + // Use user-selected model if set, otherwise fall back to conversation model + const selectedModel = userModel || derivedModel; const handleSelectConversation = useCallback( (id: string) => { - setSelectedConversationId(id); if (scope === "personal") { navigate(`/me/chat/${id}`, { replace: true }); } else if (projectName) { @@ -57,7 +53,6 @@ export function ChatPage({ scope }: ChatPageProps) { ); const handleNewConversation = useCallback(() => { - setSelectedConversationId(null); if (scope === "personal") { navigate("/me/chat", { replace: true }); } else if (projectName) { @@ -65,69 +60,103 @@ export function ChatPage({ scope }: ChatPageProps) { } }, [scope, projectName, navigate]); + // Keyboard shortcuts: Cmd/Ctrl+K = new chat, Cmd/Ctrl+/ = focus input + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const isCmdOrCtrl = e.metaKey || e.ctrlKey; + if (!isCmdOrCtrl) return; + + if (e.key === "k") { + e.preventDefault(); + handleNewConversation(); + } else if (e.key === "/") { + e.preventDefault(); + // Focus the textarea in the input area + const textarea = document.querySelector('[data-slot="input-group-control"]') as HTMLElement; + textarea?.focus(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [handleNewConversation]); + const projectId = projectInfo?.uid; + const codePreviewValue = useMemo( + () => ({ + activeCode, + openCodePreview: setActiveCode, + closeCodePreview: () => setActiveCode(null), + }), + [activeCode] + ); return ( -
- {/* Sidebar - collapsible */} -
-
- {!isSidebarCollapsed && ( - - )} + +
+ {/* Sidebar - collapsible */} +
+
+ {!isSidebarCollapsed && ( + + )} +
-
- {/* Main Chat Area */} -
- setIsSidebarCollapsed(v => !v)} - /> + {/* Main Chat Area */} +
+ setIsSidebarCollapsed(v => !v)} + /> - {selectedConversationId ? ( - <> - - - - ) : ( -
-
- -
- + {selectedConversationId ? ( + <> + + + + ) : ( +
+
+ +
+ +
-
- )} + )} +
+ setActiveCode(null)} />
-
+ ); } diff --git a/src/app/explore/ExplorePage.tsx b/src/app/explore/ExplorePage.tsx index 6f13be4..50f758f 100644 --- a/src/app/explore/ExplorePage.tsx +++ b/src/app/explore/ExplorePage.tsx @@ -1,5 +1,5 @@ import { useState, useCallback } from "react"; -import { useNavigate, Link } from "react-router-dom"; +import { Link } from "react-router-dom"; import { Search, Lock, Globe, Users, Compass } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { search } from "@/client/api"; @@ -23,7 +23,6 @@ function hashColor(str: string): string { } export function ExplorePage() { - const navigate = useNavigate(); const [searchText, setSearchText] = useState(""); const searchParams: SearchParams = { @@ -142,7 +141,7 @@ export function ExplorePage() { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 3536568..d1e3bf1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -51,20 +51,16 @@ export function RootLayout() { } // Initialize new client - if (true) { - try { - const client = initWsClient({ - url: WS_CONFIG.url, - backendUrl: WS_CONFIG.backendUrl, - autoReconnect: WS_CONFIG.autoReconnect, - }); - wsClientRef.current = client; - client.connect(); - } catch (err) { - console.error("Failed to initialize WebSocket:", err); - } - } else { - console.warn("VITE_WS_URL not set, WebSocket disabled"); + try { + const client = initWsClient({ + url: WS_CONFIG.url, + backendUrl: WS_CONFIG.backendUrl, + autoReconnect: WS_CONFIG.autoReconnect, + }); + wsClientRef.current = client; + client.connect(); + } catch (err) { + console.error("Failed to initialize WebSocket:", err); } return () => { diff --git a/src/app/me/components/ActivityTimeline.tsx b/src/app/me/components/ActivityTimeline.tsx index e576172..50b04f0 100644 --- a/src/app/me/components/ActivityTimeline.tsx +++ b/src/app/me/components/ActivityTimeline.tsx @@ -21,7 +21,7 @@ interface ActivityTimelineProps { isLoading?: boolean; } -const ICON_MAP: Record = { +const ICON_MAP: Record> = { login: LogIn, logout: LogOut, register: UserPlus, diff --git a/src/app/me/components/CreateProjectModal.tsx b/src/app/me/components/CreateProjectModal.tsx index d761d94..68211e8 100644 --- a/src/app/me/components/CreateProjectModal.tsx +++ b/src/app/me/components/CreateProjectModal.tsx @@ -50,8 +50,9 @@ export function CreateProjectModal({ onClose }: CreateProjectModalProps) { if (res?.project) { navigate(`/${res.project.name}/repos`); } - } catch (err: any) { - setError(err.response?.data?.message || "Failed to create project. The slug might already be taken."); + } catch (err: unknown) { + const apiError = err as { response?: { data?: { message?: string } } }; + setError(apiError.response?.data?.message || "Failed to create project. The slug might already be taken."); } }; @@ -65,7 +66,7 @@ export function CreateProjectModal({ onClose }: CreateProjectModalProps) { {/* Header */}
-
+
@@ -141,7 +142,7 @@ export function CreateProjectModal({ onClose }: CreateProjectModalProps) { {/* Visibility Toggle */}
-
+
{form.is_public ? : }
@@ -187,7 +188,7 @@ export function CreateProjectModal({ onClose }: CreateProjectModalProps) {
-
+
window.location.href = "/me/followers"}>

{followerCount}

Followers

diff --git a/src/app/me/components/ProjectList.tsx b/src/app/me/components/ProjectList.tsx index 5c2e3ff..3dd28e7 100644 --- a/src/app/me/components/ProjectList.tsx +++ b/src/app/me/components/ProjectList.tsx @@ -53,7 +53,7 @@ export function ProjectList({ projects, isLoading }: ProjectListProps) { onClick={() => navigate(`/${project.name}`)} >
-
+
{project.display_name[0].toUpperCase()}
diff --git a/src/app/me/components/UserCardList.tsx b/src/app/me/components/UserCardList.tsx index d32a986..a5dd558 100644 --- a/src/app/me/components/UserCardList.tsx +++ b/src/app/me/components/UserCardList.tsx @@ -30,7 +30,7 @@ export function UserCardList({ users, onToggleFollow }: UserCardListProps) { > - + {user.username[0].toUpperCase()} diff --git a/src/app/project/board/KanbanBoard.tsx b/src/app/project/board/KanbanBoard.tsx index dac89d5..aebb55a 100644 --- a/src/app/project/board/KanbanBoard.tsx +++ b/src/app/project/board/KanbanBoard.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { useBoardDetailQuery } from "@/hooks/useBoardsQuery"; import { useBoardOperations } from "@/hooks/useBoardOperations"; @@ -30,13 +30,7 @@ export function KanbanBoard({ projectName, boardId }: KanbanBoardProps) { const [editCardTitle, setEditCardTitle] = useState(""); const [editCardDescription, setEditCardDescription] = useState(""); - useEffect(() => { - if (selectedCard) { - setEditCardTitle(selectedCard.title); - setEditCardDescription(selectedCard.description || ""); - } - }, [selectedCard]); - + const handleCreateColumn = async () => { if (!newColumnName.trim()) return; await ops.createColumn.mutateAsync({ @@ -129,7 +123,11 @@ export function KanbanBoard({ projectName, boardId }: KanbanBoardProps) { refetch(); } }} - onCardClick={(card) => setSelectedCard(card)} + onCardClick={(card) => { + setSelectedCard(card); + setEditCardTitle(card.title); + setEditCardDescription(card.description || ""); + }} onMoveCard={handleMoveCard} /> ))} diff --git a/src/app/project/channel/ChannelPage.tsx b/src/app/project/channel/ChannelPage.tsx index 4a78845..d59cbeb 100644 --- a/src/app/project/channel/ChannelPage.tsx +++ b/src/app/project/channel/ChannelPage.tsx @@ -9,7 +9,7 @@ import { useRoom, } from '@/contexts/room'; import { useProjectLayout } from '@/app/project/layout'; -import type { Message, ReactionGroup, Member } from '@/contexts/room'; +import type { Message, ReactionGroup, Member, ThreadState } from '@/contexts/room'; import { ThreadPanel, EditHistoryOverlay, @@ -54,7 +54,7 @@ function ChannelPageInner() { const [editingMessageId, setEditingMessageId] = useState(null); const [replyToMessageId, setReplyToMessageId] = useState(null); const [emojiPickerMessageId, setEmojiPickerMessageId] = useState(null); - const [activeThread, setActiveThread] = useState(null); + const [activeThread, setActiveThread] = useState(null); const [editHistoryMessageId, setEditHistoryMessageId] = useState(null); const [uploading, setUploading] = useState(false); @@ -85,16 +85,18 @@ function ChannelPageInner() { ]) .then(([aiRes, reposRes, skillsRes]) => { const aiData = aiRes.data?.data ?? []; - setAgents(aiData.map((a: any) => ({ model: a.model, model_name: a.model_name ?? null }))); + setAgents(aiData.map((a: { model: string; model_name: string | null }) => ({ model: a.model, model_name: a.model_name ?? null }))); if (reposRes) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any const repoList = (reposRes.data?.data?.items ?? []) as any[]; - setRepos(repoList.map((r: any) => ({ uid: r.uid, repo_name: r.repo_name }))); + setRepos(repoList.map((r: { uid: string; repo_name: string }) => ({ uid: r.uid, repo_name: r.repo_name }))); } if (skillsRes) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any const skillsData = (skillsRes.data?.data ?? []) as any[]; - setSkills(skillsData.map((s: any) => ({ id: s.id, name: s.name, slug: s.slug }))); + setSkills(skillsData.map((s: { id: number; name: string; slug: string }) => ({ id: s.id, name: s.name, slug: s.slug }))); } }) .catch(() => {}); @@ -114,7 +116,7 @@ function ChannelPageInner() { if (wsStatus === 'connected' && isConnected) { loadHistory(); } - }, [wsStatus, isConnected]); + }, [wsStatus, isConnected, loadHistory]); // Load older messages when scrolling to top const handleStartReached = useCallback(() => { @@ -350,6 +352,7 @@ function ChannelPageInner() { try { const { threadMessages } = await import('@/client/api'); const res = await threadMessages(roomIdParam, msg.thread); + // eslint-disable-next-line @typescript-eslint/no-explicit-any const threadMsgs: Message[] = (res.data?.data?.messages ?? []).map((r: any) => ({ ...r, _localReactions: [], is_streaming: false, isOptimistic: false, isOptimisticError: false, thinking_content: null, })); @@ -410,13 +413,13 @@ function ChannelPageInner() {
{wsStatus === 'reconnecting' && ( -
+
Reconnecting...
)} {wsStatus === 'disconnected' && ( -
+
Connection lost.
)} @@ -443,7 +446,7 @@ function ChannelPageInner() { members={members.map((m: Member) => ({ uid: m.uid, username: m.username, - avatar_url: (m as any).avatar_url ?? null, + avatar_url: m.avatar_url ?? null, }))} agents={agents} repos={repos} @@ -484,7 +487,7 @@ function ChannelPageInner() { thread={activeThread} typingUsers={typingUsersList} onClose={closeThread} - sendMessage={(content: string, opts?: any) => sendMessage(content, opts)} + sendMessage={(content: string, opts?: { contentType?: string; thread?: string; inReplyTo?: string; attachmentIds?: string[] }) => sendMessage(content, opts)} onTypingStart={() => { const c = safeGetClient(); if (c) c.sendTypingStart(roomIdParam); }} onTypingStop={() => { const c = safeGetClient(); if (c) c.sendTypingStop(roomIdParam); }} /> diff --git a/src/app/project/channel/RoomSettingsModal.tsx b/src/app/project/channel/RoomSettingsModal.tsx index 2bdb343..f883999 100644 --- a/src/app/project/channel/RoomSettingsModal.tsx +++ b/src/app/project/channel/RoomSettingsModal.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { Settings, Trash2, Loader2, Globe, Lock } from "lucide-react"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Button } from "@/components/ui/button"; @@ -27,12 +27,13 @@ export function RoomSettingsModal({ open, onOpenChange }: RoomSettingsModalProps const [isPublic, setIsPublic] = useState(true); const [isSaving, setIsSaving] = useState(false); - useEffect(() => { - if (currentRoom && open) { + const handleOpenChange = (newOpen: boolean) => { + onOpenChange(newOpen); + if (newOpen && currentRoom) { setName(currentRoom.room_name); - setIsPublic(currentRoom.public); + setIsPublic(currentRoom.public ?? true); } - }, [currentRoom, open]); + }; const handleUpdateRoom = async () => { if (!currentRoom || !name.trim()) return; @@ -63,7 +64,7 @@ export function RoomSettingsModal({ open, onOpenChange }: RoomSettingsModalProps }; return ( - + diff --git a/src/app/project/channel/settings/AiSettings.tsx b/src/app/project/channel/settings/AiSettings.tsx index 95ef28d..7d1cc17 100644 --- a/src/app/project/channel/settings/AiSettings.tsx +++ b/src/app/project/channel/settings/AiSettings.tsx @@ -1,7 +1,7 @@ import { Loader2, Shield, Search, Check } from "lucide-react"; import { Button } from "@/components/ui/button"; import { aiList, aiUpsert, aiDelete, modelCatalog } from "@/client/api"; -import type { RoomAiUpsertRequest, ModelWithPricingResponse } from "@/client/model"; +import type { RoomAiUpsertRequest, ModelWithPricingResponse, RoomAiResponse } from "@/client/model"; import { getModelIcon } from "@/lib/icons/modelIcons"; import { Plus, Trash2, Settings, X as XIcon } from "lucide-react"; import { @@ -35,12 +35,13 @@ function ModelAvatar({ modelName, size = 36 }: { modelName: string; size?: numbe } return (
{modelName[0]?.toUpperCase() || "?"} @@ -54,8 +55,8 @@ interface AiSettingsProps { } export function AiSettings({ roomId, onAiListChange }: AiSettingsProps) { - const [roomAis, setRoomAis] = useState([]); - const [isLoadingAi, setIsLoadingAi] = useState(false); + const [roomAis, setRoomAis] = useState([]); + const [isLoadingAi, setIsLoadingAi] = useState(true); const [showAddAi, setShowAddAi] = useState(false); const [selectedModelFull, setSelectedModelFull] = useState(null); const [aiParams, setAiParams] = useState({ @@ -98,7 +99,10 @@ export function AiSettings({ roomId, onAiListChange }: AiSettingsProps) { }; useEffect(() => { - fetchRoomAis(); + aiList(roomId) + .then((res) => setRoomAis(res.data.data || [])) + .catch((err) => console.error("Failed to fetch room AIs", err)) + .finally(() => setIsLoadingAi(false)); }, [roomId]); const handleAddAi = async () => { diff --git a/src/app/project/components/ProjectCreateMenuModal.tsx b/src/app/project/components/ProjectCreateMenuModal.tsx index 00e5b4a..6e019f7 100644 --- a/src/app/project/components/ProjectCreateMenuModal.tsx +++ b/src/app/project/components/ProjectCreateMenuModal.tsx @@ -77,8 +77,8 @@ export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: Project }); onClose(); navigate(`/${projectName}/repo/${repoForm.repo_name.trim()}`); - } catch (err: any) { - setError(err.response?.data?.message || "Failed to create repository."); + } catch (err: unknown) { + setError((err as { response?: { data?: { message?: string } } })?.response?.data?.message || "Failed to create repository."); } finally { setLoading(false); } @@ -98,8 +98,8 @@ export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: Project if (room) { navigate(`/${projectName}/channel/${room.id}`); } - } catch (err: any) { - setError(err.response?.data?.message || "Failed to create channel."); + } catch (err: unknown) { + setError((err as { response?: { data?: { message?: string } } })?.response?.data?.message || "Failed to create channel."); } finally { setLoading(false); } @@ -119,8 +119,8 @@ export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: Project if (res.data?.data) { navigate(`/${projectName}/board/${res.data.data.id}`); } - } catch (err: any) { - setError(err.response?.data?.message || "Failed to create board."); + } catch (err: unknown) { + setError((err as { response?: { data?: { message?: string } } })?.response?.data?.message || "Failed to create board."); } finally { setLoading(false); } @@ -142,8 +142,8 @@ export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: Project if (skill) { navigate(`/${projectName}/skills/${skill.slug}`); } - } catch (err: any) { - setError(err.response?.data?.message || "Failed to create skill."); + } catch (err: unknown) { + setError((err as { response?: { data?: { message?: string } } })?.response?.data?.message || "Failed to create skill."); } finally { setLoading(false); } @@ -179,7 +179,7 @@ export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: Project ].map(tab => (
- {error &&

{error}

} + {error &&

{error}

}
{issueDetail.body ? ( - + ) : (

No description provided.

)} @@ -294,6 +304,7 @@ export function IssueDetailPage() { @@ -326,7 +337,7 @@ export function IssueDetailPage() {
) : ( - + )}
diff --git a/src/app/project/issue-detail/IssueSidebar.tsx b/src/app/project/issue-detail/IssueSidebar.tsx index 9a2356c..722f7ef 100644 --- a/src/app/project/issue-detail/IssueSidebar.tsx +++ b/src/app/project/issue-detail/IssueSidebar.tsx @@ -146,7 +146,7 @@ export function IssueSidebar({ projectName, issueNumber }: IssueSidebarProps) { className="flex items-center gap-2" onClick={() => addLabel.mutate({ projectName, issueNumber, labelId: l.id })} > -
+
{l.name} {issueLabels.some(il => il.label_name === l.name) && } @@ -167,7 +167,7 @@ export function IssueSidebar({ projectName, issueNumber }: IssueSidebarProps) { key={l.label_name} variant="outline" className="text-[10px] px-1.5 py-0 h-5 border-none" - style={{ backgroundColor: "var(--border-default)", color: "var(--text-primary)", borderLeft: `3px solid ${l.label_color ?? "#5865F2"}` }} + style={{ backgroundColor: "var(--border-default)", color: "var(--text-primary)", borderLeft: `3px solid ${l.label_color ?? "var(--accent)"}` }} > {l.label_name}
) : (
- {filteredIssues.map((issue: any) => { + {filteredIssues.map((issue: IssueResponse) => { // Find priority label if exists - const priorityLabel = issue.labels?.find((l: any) => l.label_name?.toLowerCase().startsWith('priority:')); - const priority = priorityLabel ? priorityLabel.label_name.split(':')[1].toLowerCase() : null; - + const priorityLabel = issue.labels?.find((l: IssueLabelResponse) => l.label_name?.toLowerCase().startsWith('priority:')); + const priority = priorityLabel ? (priorityLabel.label_name ?? '').split(':')[1].toLowerCase() : null; + // Other labels - const otherLabels = issue.labels?.filter((l: any) => !l.label_name?.toLowerCase().startsWith('priority:')) || []; + const otherLabels = issue.labels?.filter((l: IssueLabelResponse) => !l.label_name?.toLowerCase().startsWith('priority:')) || []; return (
- {otherLabels.map((l: any) => ( + {otherLabels.map((l: IssueLabelResponse) => ( ({ setCurrentRoomName: () => {}, }); +// eslint-disable-next-line react-refresh/only-export-components export const useProjectLayout = () => useContext(ProjectContext); export function ProjectLayout() { @@ -31,12 +32,15 @@ export function ProjectLayout() { const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const { projectName } = useParams<{ projectName: string }>(); const channelMatch = useMatch("/:projectName/channel/:roomId"); + const chatMatch = useMatch("/:projectName/chat/*"); const roomId = channelMatch?.params.roomId ?? null; const isMobile = useIsMobile(); const isTablet = useIsTablet(); const canShowMembers = !isMobile && !isTablet; + const mainShouldOwnScroll = !channelMatch && !chatMatch; + return ( @@ -86,7 +90,7 @@ export function ProjectLayout() { >
diff --git a/src/app/project/pulls/PullsPage.tsx b/src/app/project/pulls/PullsPage.tsx index 60c1945..6487b9e 100644 --- a/src/app/project/pulls/PullsPage.tsx +++ b/src/app/project/pulls/PullsPage.tsx @@ -101,7 +101,7 @@ export function PullsPage() { {pr.status} diff --git a/src/app/project/repo/settings/BranchProtectionSettings.tsx b/src/app/project/repo/settings/BranchProtectionSettings.tsx index 9a14f48..2bce7f2 100644 --- a/src/app/project/repo/settings/BranchProtectionSettings.tsx +++ b/src/app/project/repo/settings/BranchProtectionSettings.tsx @@ -89,7 +89,7 @@ export default function BranchProtectionSettings() { const handleUpdate = async () => { if (!editForm) return; try { - await updateMutation.mutateAsync({ ...editForm } as any); + await updateMutation.mutateAsync({ ...editForm }); setEditingId(null); setEditForm(null); setMsg({ type: "success", text: "Branch protection rule updated" }); @@ -162,7 +162,7 @@ export default function BranchProtectionSettings() { - {branchOptions.map((b: any) => ( + {branchOptions.map((b: { name: string }) => (
diff --git a/src/app/project/repo/settings/GeneralSettings.tsx b/src/app/project/repo/settings/GeneralSettings.tsx index d11f168..9eeb4db 100644 --- a/src/app/project/repo/settings/GeneralSettings.tsx +++ b/src/app/project/repo/settings/GeneralSettings.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { useParams } from "react-router-dom"; import { useRepoInfoQuery, useUpdateRepoSettingsMutation } from "@/hooks/useRepoDetailQuery"; import { Loader2, Save, Globe, EyeOff, GitBranch, Zap, RotateCcw } from "lucide-react"; @@ -27,7 +27,10 @@ export default function GeneralSettings() { const [aiCodeReview, setAiCodeReview] = useState(false); const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null); - useEffect(() => { + // Sync form state when repoInfo changes (adjust during render, not in effect) + const [prevRepoInfo, setPrevRepoInfo] = useState(undefined); + if (repoInfo !== prevRepoInfo) { + setPrevRepoInfo(repoInfo); if (repoInfo) { setName(repoInfo.repo_name); setDescription(repoInfo.description ?? ""); @@ -35,7 +38,7 @@ export default function GeneralSettings() { setIsPrivate(repoInfo.is_private); setAiCodeReview(repoInfo.ai_code_review_enabled); } - }, [repoInfo]); + } if (!projectName || !repoName) return null; @@ -50,7 +53,7 @@ export default function GeneralSettings() { ai_code_review_enabled: aiCodeReview, }); setMsg({ type: "success", text: "Repository settings updated successfully" }); - } catch (err) { + } catch { setMsg({ type: "error", text: "Failed to update repository settings" }); } }; diff --git a/src/app/project/repos/ReposPage.tsx b/src/app/project/repos/ReposPage.tsx index a66b8bb..3a6a3fd 100644 --- a/src/app/project/repos/ReposPage.tsx +++ b/src/app/project/repos/ReposPage.tsx @@ -20,6 +20,7 @@ import { import type { ProjectRepositoryItem } from "@/client/model"; import { REPOS_PAGE } from "@/css/repo/styles"; import { useState, useMemo } from "react"; +import { ProjectCreateMenuModal } from "@/app/project/components/ProjectCreateMenuModal"; function getRelativeTime(dateStr: string | null) { if (!dateStr) return "Never"; @@ -40,6 +41,7 @@ export function ReposPage() { const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); const [searchQuery, setSearchQuery] = useState(""); + const [isCreateMenuOpen, setIsCreateMenuOpen] = useState(false); const { data: repos = [], isLoading, error, refetch } = useProjectReposQuery(projectName); @@ -80,8 +82,8 @@ export function ReposPage() {

Repositories

Host and manage your project source code

-
); } diff --git a/src/app/project/settings/AccessSettings.tsx b/src/app/project/settings/AccessSettings.tsx index 3fa72e0..0170942 100644 --- a/src/app/project/settings/AccessSettings.tsx +++ b/src/app/project/settings/AccessSettings.tsx @@ -6,7 +6,7 @@ import { projectJoinSettings, projectUpdateJoinSettings, projectJoinRequests, projectProcessJoinRequest, } from "@/client/api"; -import type { InvitationResponse, JoinSettingsResponse, JoinRequestResponse, MemberRole } from "@/client/model"; +import type { InvitationResponse, JoinSettingsResponse, JoinRequestResponse, MemberRole, QuestionSchema } from "@/client/model"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Loader2, Mail, X, Check, Shield, User, EyeOff } from "lucide-react"; @@ -78,7 +78,7 @@ export function AccessSettings() { const handleToggleApproval = async () => { if (!joinSettings) return; - try { setJsSaving(true); await projectUpdateJoinSettings(projectName!, { require_approval: !joinSettings.require_approval, require_questions: joinSettings.require_questions, questions: (joinSettings.questions as any[]) || [] }); setMsg({ type: "success", text: "Join settings updated" }); invalidateAll(); } + try { setJsSaving(true); await projectUpdateJoinSettings(projectName!, { require_approval: !joinSettings.require_approval, require_questions: joinSettings.require_questions, questions: (joinSettings.questions as QuestionSchema[]) || [] }); setMsg({ type: "success", text: "Join settings updated" }); invalidateAll(); } catch { setMsg({ type: "error", text: "Failed to update join settings" }); } finally { setJsSaving(false); } }; diff --git a/src/app/project/settings/GeneralSettings.tsx b/src/app/project/settings/GeneralSettings.tsx index 31678f4..ed70e77 100644 --- a/src/app/project/settings/GeneralSettings.tsx +++ b/src/app/project/settings/GeneralSettings.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { useParams } from "react-router-dom"; import { useProjectInfo, useInvalidateProjectInfo } from "@/hooks/useProjectInfo"; import { projectExchangeName, projectExchangeTitle, projectExchangeVisibility } from "@/client/api"; @@ -35,7 +35,10 @@ export function GeneralSettings() { const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null); const [copied, setCopied] = useState(false); - useEffect(() => { + // Sync form state when project info loads or changes + const [prevInfo, setPrevInfo] = useState(undefined); + if (info !== prevInfo) { + setPrevInfo(info); if (info) { setForm({ name: info.name, @@ -44,7 +47,7 @@ export function GeneralSettings() { is_public: info.is_public, }); } - }, [info]); + } if (!info || !projectName) return null; @@ -84,7 +87,7 @@ export function GeneralSettings() { await Promise.all(promises); setMsg({ type: "success", text: "Project settings updated successfully" }); invalidateProjectInfo(projectName); - } catch (err) { + } catch { setMsg({ type: "error", text: "Failed to update project settings" }); } finally { setSaving(null); @@ -124,12 +127,12 @@ export function GeneralSettings() {
- + {(form.display_name || form.name)[0]?.toUpperCase()}
- +
diff --git a/src/app/settings/AppearancePage.tsx b/src/app/settings/AppearancePage.tsx index 54c344a..373995e 100644 --- a/src/app/settings/AppearancePage.tsx +++ b/src/app/settings/AppearancePage.tsx @@ -39,9 +39,55 @@ const TIMEZONES = [ { value: "UTC", label: "UTC" }, ]; +const SelectField = ({ + label, + value, + onChange, + options, +}: { + label: string; + value: string; + onChange: (v: string) => void; + options: { value: string; label: string }[]; +}) => ( +
+ + +
+); + export function AppearancePage() { const { preferences: cachedPrefs, setPreferences: setCachedPrefs } = useSettingsDataCache(); - const [_prefs, setPrefs] = useState(cachedPrefs); + const [, setPrefs] = useState(cachedPrefs); const [loading, setLoading] = useState(!cachedPrefs); const [saving, setSaving] = useState(false); const [form, setForm] = useState({ @@ -56,27 +102,24 @@ export function AppearancePage() { useEffect(() => { if (cachedPrefs) return; - loadPrefs(); - }, []); - - const loadPrefs = async () => { - try { - setLoading(true); - const res = await getPreferences(); - const d = res.data.data!; - setPrefs(d); - setCachedPrefs(d); - setForm({ - language: d.language, - theme: d.theme, - timezone: d.timezone, - }); - } catch { - setMessage({ type: "error", text: "加载偏好设置失败" }); - } finally { - setLoading(false); - } - }; + (async () => { + try { + const res = await getPreferences(); + const d = res.data.data!; + setPrefs(d); + setCachedPrefs(d); + setForm({ + language: d.language, + theme: d.theme, + timezone: d.timezone, + }); + } catch { + setMessage({ type: "error", text: "加载偏好设置失败" }); + } finally { + setLoading(false); + } + })(); + }, [cachedPrefs, setCachedPrefs]); const handleSave = async () => { try { @@ -106,52 +149,6 @@ export function AppearancePage() { ); } - const SelectField = ({ - label, - value, - onChange, - options, - }: { - label: string; - value: string; - onChange: (v: string) => void; - options: { value: string; label: string }[]; - }) => ( -
- - -
- ); - return (

@@ -304,4 +301,4 @@ export function AppearancePage() {

); -} +} \ No newline at end of file diff --git a/src/app/settings/EmailPage.tsx b/src/app/settings/EmailPage.tsx index 96e284f..fdec107 100644 --- a/src/app/settings/EmailPage.tsx +++ b/src/app/settings/EmailPage.tsx @@ -20,22 +20,19 @@ export function EmailPage() { useEffect(() => { if (cachedEmail !== null) return; - loadEmail(); - }, []); - - const loadEmail = async () => { - try { - setLoading(true); - const res = await apiEmailGet(); - const e = res.data.data?.email ?? null; - setEmail(e); - setCachedEmail(e); - } catch { - setMessage({ type: "error", text: "加载邮箱信息失败" }); - } finally { - setLoading(false); - } - }; + (async () => { + try { + const res = await apiEmailGet(); + const e = res.data.data?.email ?? null; + setEmail(e); + setCachedEmail(e); + } catch { + setMessage({ type: "error", text: "加载邮箱信息失败" }); + } finally { + setLoading(false); + } + })(); + }, [cachedEmail, setCachedEmail]); const handleSave = async () => { if (!form.new_email || !form.password) { @@ -183,4 +180,4 @@ export function EmailPage() {
); -} +} \ No newline at end of file diff --git a/src/app/settings/MyAccountPage.tsx b/src/app/settings/MyAccountPage.tsx index eb2c2c8..fd6b4e6 100644 --- a/src/app/settings/MyAccountPage.tsx +++ b/src/app/settings/MyAccountPage.tsx @@ -33,28 +33,25 @@ export function MyAccountPage() { useEffect(() => { if (cachedProfile) return; - loadProfile(); - }, []); - - const loadProfile = async () => { - try { - setLoading(true); - const res = await getMyProfile(); - const d = res.data.data!; - setProfile(d); - setCachedProfile(d); - setForm({ - display_name: d.display_name ?? "", - avatar_url: d.avatar_url ?? "", - website_url: d.website_url ?? "", - organization: d.organization ?? "", - }); - } catch { - setMessage({ type: "error", text: "加载个人信息失败" }); - } finally { - setLoading(false); - } - }; + (async () => { + try { + const res = await getMyProfile(); + const d = res.data.data!; + setProfile(d); + setCachedProfile(d); + setForm({ + display_name: d.display_name ?? "", + avatar_url: d.avatar_url ?? "", + website_url: d.website_url ?? "", + organization: d.organization ?? "", + }); + } catch { + setMessage({ type: "error", text: "加载个人信息失败" }); + } finally { + setLoading(false); + } + })(); + }, [cachedProfile, setCachedProfile]); const handleSave = async () => { try { @@ -75,6 +72,27 @@ export function MyAccountPage() { } }; + const loadProfile = async () => { + try { + setLoading(true); + if (cachedProfile) return; + const res = await getMyProfile(); + const d = res.data.data!; + setProfile(d); + setCachedProfile(d); + setForm({ + display_name: d.display_name ?? "", + avatar_url: d.avatar_url ?? "", + website_url: d.website_url ?? "", + organization: d.organization ?? "", + }); + } catch { + setMessage({ type: "error", text: "加载个人信息失败" }); + } finally { + setLoading(false); + } + }; + const handleFileChange = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; @@ -89,14 +107,14 @@ export function MyAccountPage() { setMessage(null); const formData = new FormData(); formData.append("file", file); - - const res = await uploadAvatar(formData as any); + + const res = await uploadAvatar(formData); const newAvatarUrl = res.data.data?.avatar_url; if (newAvatarUrl) { setForm(f => ({ ...f, avatar_url: newAvatarUrl })); setMessage({ type: "success", text: "头像上传成功,请保存更改" }); } - } catch (err) { + } catch { setMessage({ type: "error", text: "头像上传失败" }); } finally { setUploading(false); @@ -155,12 +173,12 @@ export function MyAccountPage() { {profile?.username?.[0]?.toUpperCase() || "U"} - +
- {form.avatar_url && ( -
@@ -314,4 +332,4 @@ export function MyAccountPage() {
); -} +} \ No newline at end of file diff --git a/src/app/settings/NotificationsPage.tsx b/src/app/settings/NotificationsPage.tsx index 4ae644a..dbe1863 100644 --- a/src/app/settings/NotificationsPage.tsx +++ b/src/app/settings/NotificationsPage.tsx @@ -25,9 +25,33 @@ const DIGEST_MODES = [ { value: "off", label: "关闭" }, ]; +const ToggleRow = ({ + label, + desc, + checked, + onChange, +}: { + label: string; + desc: string; + checked: boolean; + onChange: (v: boolean) => void; +}) => ( +
+
+

+ {label} +

+

+ {desc} +

+
+ +
+); + export function NotificationsPage() { const { notificationPrefs: cachedPrefs, setNotificationPrefs: setCachedPrefs } = useSettingsDataCache(); - const [_prefs, setPrefs] = + const [, setPrefs] = useState(cachedPrefs); const [loading, setLoading] = useState(!cachedPrefs); const [saving, setSaving] = useState(false); @@ -48,32 +72,29 @@ export function NotificationsPage() { useEffect(() => { if (cachedPrefs) return; - loadPrefs(); - }, []); - - const loadPrefs = async () => { - try { - setLoading(true); - const res = await getNotificationPreferences(); - const d = res.data.data!; - setPrefs(d); - setCachedPrefs(d); - setForm({ - email_enabled: d.email_enabled, - in_app_enabled: d.in_app_enabled, - push_enabled: d.push_enabled, - digest_mode: d.digest_mode, - dnd_enabled: d.dnd_enabled, - marketing_enabled: d.marketing_enabled, - security_enabled: d.security_enabled, - product_enabled: d.product_enabled, - }); - } catch { - setMessage({ type: "error", text: "加载通知偏好失败" }); - } finally { - setLoading(false); - } - }; + (async () => { + try { + const res = await getNotificationPreferences(); + const d = res.data.data!; + setPrefs(d); + setCachedPrefs(d); + setForm({ + email_enabled: d.email_enabled, + in_app_enabled: d.in_app_enabled, + push_enabled: d.push_enabled, + digest_mode: d.digest_mode, + dnd_enabled: d.dnd_enabled, + marketing_enabled: d.marketing_enabled, + security_enabled: d.security_enabled, + product_enabled: d.product_enabled, + }); + } catch { + setMessage({ type: "error", text: "加载通知偏好失败" }); + } finally { + setLoading(false); + } + })(); + }, [cachedPrefs, setCachedPrefs]); const handleSave = async () => { try { @@ -108,30 +129,6 @@ export function NotificationsPage() { ); } - const ToggleRow = ({ - label, - desc, - checked, - onChange, - }: { - label: string; - desc: string; - checked: boolean; - onChange: (v: boolean) => void; - }) => ( -
-
-

- {label} -

-

- {desc} -

-
- -
- ); - return (

); -} +} \ No newline at end of file diff --git a/src/app/settings/PushSettingsPage.tsx b/src/app/settings/PushSettingsPage.tsx index 13d16bd..fda96b8 100644 --- a/src/app/settings/PushSettingsPage.tsx +++ b/src/app/settings/PushSettingsPage.tsx @@ -1,8 +1,6 @@ import { useEffect, useState } from "react"; -import { - getNotificationPreferences, - updateNotificationPreferences, -} from "@/client/api"; +import { getNotificationPreferences, updateNotificationPreferences } from "@/client/api"; +import type { NotificationPreferencesResponse } from "@/client/model"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { Loader2, Smartphone, ShieldCheck, AlertCircle } from "lucide-react"; @@ -12,28 +10,25 @@ export function PushSettingsPage() { const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(false); - + const [pushEnabled, setPushEnabled] = useState(false); - const [canPush, setCanPush] = useState(false); + const canPush = 'Notification' in window && 'serviceWorker' in navigator; useEffect(() => { - setCanPush('Notification' in window && 'serviceWorker' in navigator); - loadPreferences(); + (async () => { + try { + setLoading(true); + const res = await getNotificationPreferences(); + const data = res.data.data as NotificationPreferencesResponse | undefined; + setPushEnabled(data?.push_enabled ?? false); + } catch { + setError("Failed to load notification settings"); + } finally { + setLoading(false); + } + })(); }, []); - const loadPreferences = async () => { - try { - setLoading(true); - const res = await getNotificationPreferences(); - // The API returns push_enabled or similar in its schema - setPushEnabled((res.data.data as any)?.push_enabled ?? false); - } catch (err) { - setError("Failed to load notification settings"); - } finally { - setLoading(false); - } - }; - const handleTogglePush = async (checked: boolean) => { if (checked && 'Notification' in window) { const permission = await Notification.requestPermission(); @@ -47,13 +42,12 @@ export function PushSettingsPage() { setSaving(true); setError(null); await updateNotificationPreferences({ - // Update the specific push field - push_enabled: checked - } as any); + push_enabled: checked, + } as Partial); setPushEnabled(checked); setSuccess(true); setTimeout(() => setSuccess(false), 3000); - } catch (err) { + } catch { setError("Failed to update push settings"); } finally { setSaving(false); @@ -96,10 +90,10 @@ export function PushSettingsPage() {

Get notified of mentions, issues, and system alerts.

-
@@ -111,7 +105,7 @@ export function PushSettingsPage() {
- New Issues + New Issuesss
@@ -140,4 +134,4 @@ export function PushSettingsPage() { )}
); -} +} \ No newline at end of file diff --git a/src/client/aiChatApi.ts b/src/client/aiChatApi.ts index 91fda27..6613838 100644 --- a/src/client/aiChatApi.ts +++ b/src/client/aiChatApi.ts @@ -80,8 +80,11 @@ export async function stopMessage(conversationId: string, messageId: string): Pr await aiMessageStop(conversationId, messageId); } -export async function resendMessage(conversationId: string, messageId: string): Promise { - await aiMessageResend(conversationId, messageId); +export async function resendMessage(conversationId: string, messageId: string): Promise { + const res = await aiMessageResend(conversationId, messageId); + const msg = res.data.data; + if (!msg) throw new Error("Failed to resend message"); + return msg; } export async function editMessage(conversationId: string, messageId: string, content: string): Promise { @@ -118,16 +121,15 @@ export async function switchMessageVersion(conversationId: string, messageId: st return data.data; } -export interface ForkResponse { +export interface ForkConversationResponse { id: string; - conversation_id: string | null; - source_message_id: string; - fork_message_id: string; + title?: string | null; + model: string; created_at: string; } -export async function forkMessage(conversationId: string, messageId: string, targetMessageId: string): Promise { - const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || ""}/api/ai/conversations/${conversationId}/messages/${messageId}/fork/${targetMessageId}`, { +export async function forkMessage(conversationId: string, messageId: string): Promise { + const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || ""}/api/ai/conversations/${conversationId}/messages/${messageId}/fork`, { method: "POST", credentials: "include", }); @@ -136,14 +138,14 @@ export async function forkMessage(conversationId: string, messageId: string, tar return data.data; } -export async function listMessageForks(conversationId: string, messageId: string): Promise { +export async function listMessageForks(conversationId: string, messageId: string): Promise { const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || ""}/api/ai/conversations/${conversationId}/messages/${messageId}/forks`, { method: "GET", credentials: "include", }); if (!response.ok) throw new Error("Failed to list message forks"); const data = await response.json(); - return data.data ?? []; + return data.data?.forks ?? data.data ?? []; } export async function shareConversation(conversationId: string): Promise<{ share_token: string }> { @@ -156,6 +158,7 @@ export async function shareConversation(conversationId: string): Promise<{ share export interface StreamChunk { type: "token" | "thinking" | "tool_call" | "tool_result" | "done" | "error" | "title" | "billing_error"; + // eslint-disable-next-line @typescript-eslint/no-explicit-any data: any; } @@ -165,6 +168,7 @@ export async function* streamChat(conversationId: string, messageId: string): As credentials: "include", }); + if (!response.ok) throw new Error(`Stream request failed: ${response.status}`); if (!response.body) throw new Error("No response body"); const reader = response.body.getReader(); diff --git a/src/client/generated.ts b/src/client/generated.ts index 06fc522..b6aa203 100644 --- a/src/client/generated.ts +++ b/src/client/generated.ts @@ -89,7 +89,7 @@ import type { ApiResponseDiffResultResponse, ApiResponseDiffStatsResponse, ApiResponseEmailResponse, - ApiResponseForkResponse, + ApiResponseForkConversationResponse, ApiResponseGitInitResponse, ApiResponseGitReadmeResponse, ApiResponseGlobalMessageSearchResponse, @@ -132,6 +132,7 @@ import type { ApiResponseProjectInitResponse, ApiResponseProjectRepoCreateResponse, ApiResponseProjectRepositoryPagination, + ApiResponseProjectStatsResponse, ApiResponsePullRequestListResponse, ApiResponsePullRequestResponse, ApiResponsePullRequestSummaryResponse, @@ -783,13 +784,16 @@ const aiMessageChildren = ( ); } -const aiMessageFork = ( +/** + * @summary Fork a conversation from a specific message, creating a new conversation +with all messages up to and including the source message. + */ +const aiConversationFork = ( conversationId: string, - messageId: string, - targetMessageId: string, options?: AxiosRequestConfig - ): Promise> => { + messageId: string, options?: AxiosRequestConfig + ): Promise> => { return axiosInstance.post( - `/api/ai/conversations/${conversationId}/messages/${messageId}/fork/${targetMessageId}`,undefined,options + `/api/ai/conversations/${conversationId}/messages/${messageId}/fork`,undefined,options ); } @@ -2086,6 +2090,14 @@ const skillUpdate = ( ); } +const projectStats = ( + projectName: string, options?: AxiosRequestConfig + ): Promise> => { + return axiosInstance.get( + `/api/projects/${projectName}/stats`,options + ); + } + const projectIsWatch = ( projectName: string, options?: AxiosRequestConfig ): Promise> => { @@ -4543,7 +4555,7 @@ const getUserSummary = ( ); } -return {modelCapabilityCreate,modelCapabilityGet,modelCapabilityDelete,modelCapabilityUpdate,triggerCodeReview,modelList,modelCreate,modelCatalog,modelGet,modelDelete,modelUpdate,modelParameterProfileCreate,modelParameterProfileGet,modelParameterProfileDelete,modelParameterProfileUpdate,generatePrDescription,modelPricingCreate,modelPricingGet,modelPricingDelete,modelPricingUpdate,providerList,providerCreate,providerGet,providerDelete,providerUpdate,modelVersionList,modelVersionCreate,modelVersionGet,modelVersionDelete,modelVersionUpdate,modelCapabilityList,modelParameterProfileList,modelPricingList,triageIssue,aiConversationList,aiConversationCreate,aiConversationGet,aiConversationDelete,aiConversationUpdate,aiMessageList,aiMessageCreate,aiMessageGet,aiMessageChildren,aiMessageFork,aiMessageResend,aiMessageStop,messageStream,aiConversationShare,aiSharedConversationGet,api2faDisable,api2faEnable,api2faStatus,api2faVerify,apiAuthCaptcha,apiEmailGet,apiEmailChange,apiEmailVerify,apiAuthLogin,apiAuthLogout,apiAuthMe,apiUserChangePassword,apiUserConfirmPasswordReset,apiUserRequestPasswordReset,apiAuthRegister,gitInitBare,gitIsRepo,gitOpen,gitOpenBare,issueList,issueCreate,issueSummary,issueGet,issueDelete,issueUpdate,issueAssigneeList,issueAssigneeAdd,issueAssigneeRemove,issueClose,issueCommentList,issueCommentCreate,issueCommentGet,issueCommentDelete,issueCommentUpdate,issueCommentReactionList,issueCommentReactionAdd,issueCommentReactionRemove,issueLabelList,issueLabelAdd,issueLabelAddBulk,issueLabelRemove,issuePullRequestList,issuePullRequestLink,issuePullRequestUnlink,issueReactionList,issueReactionAdd,issueReactionRemove,issueReopen,issueRepoList,issueRepoLink,issueRepoUnlink,issueSubscribe,issueUnsubscribe,issueSubscriberList,labelList,labelCreate,labelDelete,mentionList,mentionReadAll,notificationList,notificationMarkAllRead,notificationArchive,notificationMarkRead,projectPresence,categoryList,categoryCreate,roomList,roomCreate,projectCreate,projectMyInvitations,projectMyJoinRequests,projectInfo,projectActivities,projectLogActivity,projectAuditLogs,projectLogAudit,projectAuditLog,projectBilling,projectBillingErrors,projectBillingHistory,boardList,boardCreate,boardGet,boardDelete,boardUpdate,columnCreate,cardCreate,cardDelete,cardUpdate,cardMove,columnDelete,columnUpdate,projectInvitations,projectInviteUser,projectAcceptInvitation,projectRejectInvitation,projectCancelInvitation,projectJoinRequests,projectSubmitJoinRequest,projectCancelJoinRequest,projectProcessJoinRequest,projectJoinAnswers,projectSubmitJoinAnswers,projectJoinSettings,projectUpdateJoinSettings,projectLabels,projectCreateLabel,projectGetLabel,projectDeleteLabel,projectUpdateLabel,projectIsLike,projectLike,projectUnlike,projectLikesCount,projectLikeUsers,projectMembers,projectMembersGrouped,projectUpdateMemberRole,projectRemoveMember,projectRepos,projectRepoCreate,projectRolePriorities,projectUpsertRolePriority,projectDeleteRolePriority,projectExchangeName,projectExchangeTitle,projectExchangeVisibility,skillList,skillCreate,skillScan,skillGet,skillDelete,skillUpdate,projectIsWatch,projectWatch,projectUnwatch,projectWatchesCount,projectWatchUsers,projectTransferRepo,pullRequestList,pullRequestCreate,pullRequestSummary,pullRequestGet,pullRequestDelete,pullRequestUpdate,pullRequestClose,pullRequestReopen,reviewCommentList,reviewCommentCreate,reviewCommentDelete,reviewCommentUpdate,reviewCommentReply,reviewCommentResolve,reviewCommentUnresolve,prCommitsList,mergeConflictCheck,prDiffSideBySide,mergeAnalysis,mergeExecute,mergeAbort,mergeIsInProgress,reviewRequestList,reviewRequestCreate,reviewRequestDelete,reviewRequestDismiss,reviewList,reviewSubmit,reviewUpdate,reviewDelete,branchProtectionList,branchProtectionCreate,branchProtectionCheckApprovals,branchProtectionGet,branchProtectionDelete,branchProtectionUpdate,gitUpdateRepo,gitArchive,gitArchiveCached,gitArchiveInvalidate,gitArchiveInvalidateAll,gitArchiveList,gitArchiveSummary,gitBlameFile,gitBlobCreate,gitBlobGet,gitBlobContent,gitBlobExists,gitBlobIsBinary,gitBlobSize,gitBranchList,gitBranchCreate,gitBranchCurrent,gitBranchDiff,gitBranchFastForward,gitBranchIsAncestor,gitBranchIsConflicted,gitBranchIsDetached,gitBranchIsMerged,gitBranchMergeBase,gitBranchMove,gitBranchDeleteRemote,gitBranchRename,gitBranchSummary,gitBranchSetUpstream,gitBranchGet,gitBranchDelete,gitBranchExists,gitBranchIsHead,gitBranchTrackingDifference,gitBranchUpstream,gitCommitLog,gitCommitCreate,gitCommitBranches,gitCommitCount,gitCommitGraph,gitCommitGraphReact,gitCommitReflog,gitCommitResolveRev,gitCommitTags,gitCommitWalk,gitCommitGet,gitCommitAmend,gitCommitAncestors,gitCommitAuthor,gitCommitCherryPick,gitCommitCherryPickAbort,gitCommitDescendants,gitCommitExists,gitCommitFirstParent,gitCommitIsCommit,gitCommitIsMerge,gitCommitIsTip,gitCommitMessage,gitCommitParentCount,gitCommitParentIds,gitCommitParent,gitCommitRefCount,gitCommitRefs,gitCommitRevert,gitCommitRevertAbort,gitCommitShortId,gitCommitSummary,gitCommitTreeId,gitConfigEntries,gitConfigGet,gitConfigSet,gitConfigDelete,gitConfigHas,gitContributors,gitDescriptionGet,gitDescriptionSet,gitDescriptionReset,gitDescriptionExists,gitDiffTreeToTree,gitDiffCommitToWorkdir,gitDiffCommitToIndex,gitDiffIndexToTree,gitDiffPatchId,gitDiffSideBySide,gitDiffStats,gitDiffWorkdirToIndex,gitMergeAbort,gitMergeAnalysisForRef,gitMergeAnalysis,gitMergeBase,gitMergeCommits,gitMergeheadList,gitMergeIsInProgress,gitMergeIsConflicted,gitMergeTrees,gitReadme,gitRefList,gitRefUpdate,gitRefCreate,gitRefRename,gitRefGet,gitRefDelete,gitRefExists,gitRefTarget,gitStar,gitUnstar,gitStarCount,gitIsStarred,gitStarUserList,gitTagList,gitTagCreate,gitTagCount,gitTagCreateLightweight,gitTagUpdateMessage,gitTagListNames,gitTagRename,gitTagSummary,gitTagGet,gitTagDelete,gitTagExists,gitTagIsAnnotated,gitTagMessage,gitTagTagger,gitTagTarget,gitTreeDiffstats,gitTreeEntryByCommitPath,gitTreeGet,gitTreeEntryByPath,gitTreeEntryCount,gitTreeEntry,gitTreeExists,gitTreeIsEmpty,gitTreeList,gitWatch,gitUnwatch,gitWatchCount,gitIsWatched,gitWatchUserList,gitWebhookList,gitWebhookCreate,gitWebhookGet,gitWebhookDelete,gitWebhookUpdate,categoryDelete,categoryUpdate,roomGet,roomDelete,roomUpdate,accessGrant,accessRevoke,aiList,aiUpsert,aiDelete,messageList,messageCreate,messageSearch,messageGet,messageUpdate,messageEditHistory,pinAdd,pinRemove,messageRevoke,participantList,pinList,stateUpdateDnd,stateSetReadSeq,threadList,threadCreate,threadMessages,search,searchMessages,listAccessKeys,createAccessKey,deleteAccessKey,uploadAvatar,userBilling,userBillingErrors,userBillingHistory,getMyContributionHeatmap,listSshKeys,addSshKey,getSshKey,deleteSshKey,updateSshKey,getNotificationPreferences,updateNotificationPreferences,getPreferences,updatePreferences,getMyProfile,updateMyProfile,getCurrentUserProjects,getCurrentUserRepos,getProfileByUsername,getUserActivity,isSubscribedToTarget,subscribeTarget,unsubscribeTarget,getSubscribers,getSubscriberCount,getFollowingList,getSubscriptionCount,getContributionHeatmap,getUserInfo,getUserProjects,getUserRepos,getUserStars,getUserSummary}}; +return {modelCapabilityCreate,modelCapabilityGet,modelCapabilityDelete,modelCapabilityUpdate,triggerCodeReview,modelList,modelCreate,modelCatalog,modelGet,modelDelete,modelUpdate,modelParameterProfileCreate,modelParameterProfileGet,modelParameterProfileDelete,modelParameterProfileUpdate,generatePrDescription,modelPricingCreate,modelPricingGet,modelPricingDelete,modelPricingUpdate,providerList,providerCreate,providerGet,providerDelete,providerUpdate,modelVersionList,modelVersionCreate,modelVersionGet,modelVersionDelete,modelVersionUpdate,modelCapabilityList,modelParameterProfileList,modelPricingList,triageIssue,aiConversationList,aiConversationCreate,aiConversationGet,aiConversationDelete,aiConversationUpdate,aiMessageList,aiMessageCreate,aiMessageGet,aiMessageChildren,aiConversationFork,aiMessageResend,aiMessageStop,messageStream,aiConversationShare,aiSharedConversationGet,api2faDisable,api2faEnable,api2faStatus,api2faVerify,apiAuthCaptcha,apiEmailGet,apiEmailChange,apiEmailVerify,apiAuthLogin,apiAuthLogout,apiAuthMe,apiUserChangePassword,apiUserConfirmPasswordReset,apiUserRequestPasswordReset,apiAuthRegister,gitInitBare,gitIsRepo,gitOpen,gitOpenBare,issueList,issueCreate,issueSummary,issueGet,issueDelete,issueUpdate,issueAssigneeList,issueAssigneeAdd,issueAssigneeRemove,issueClose,issueCommentList,issueCommentCreate,issueCommentGet,issueCommentDelete,issueCommentUpdate,issueCommentReactionList,issueCommentReactionAdd,issueCommentReactionRemove,issueLabelList,issueLabelAdd,issueLabelAddBulk,issueLabelRemove,issuePullRequestList,issuePullRequestLink,issuePullRequestUnlink,issueReactionList,issueReactionAdd,issueReactionRemove,issueReopen,issueRepoList,issueRepoLink,issueRepoUnlink,issueSubscribe,issueUnsubscribe,issueSubscriberList,labelList,labelCreate,labelDelete,mentionList,mentionReadAll,notificationList,notificationMarkAllRead,notificationArchive,notificationMarkRead,projectPresence,categoryList,categoryCreate,roomList,roomCreate,projectCreate,projectMyInvitations,projectMyJoinRequests,projectInfo,projectActivities,projectLogActivity,projectAuditLogs,projectLogAudit,projectAuditLog,projectBilling,projectBillingErrors,projectBillingHistory,boardList,boardCreate,boardGet,boardDelete,boardUpdate,columnCreate,cardCreate,cardDelete,cardUpdate,cardMove,columnDelete,columnUpdate,projectInvitations,projectInviteUser,projectAcceptInvitation,projectRejectInvitation,projectCancelInvitation,projectJoinRequests,projectSubmitJoinRequest,projectCancelJoinRequest,projectProcessJoinRequest,projectJoinAnswers,projectSubmitJoinAnswers,projectJoinSettings,projectUpdateJoinSettings,projectLabels,projectCreateLabel,projectGetLabel,projectDeleteLabel,projectUpdateLabel,projectIsLike,projectLike,projectUnlike,projectLikesCount,projectLikeUsers,projectMembers,projectMembersGrouped,projectUpdateMemberRole,projectRemoveMember,projectRepos,projectRepoCreate,projectRolePriorities,projectUpsertRolePriority,projectDeleteRolePriority,projectExchangeName,projectExchangeTitle,projectExchangeVisibility,skillList,skillCreate,skillScan,skillGet,skillDelete,skillUpdate,projectStats,projectIsWatch,projectWatch,projectUnwatch,projectWatchesCount,projectWatchUsers,projectTransferRepo,pullRequestList,pullRequestCreate,pullRequestSummary,pullRequestGet,pullRequestDelete,pullRequestUpdate,pullRequestClose,pullRequestReopen,reviewCommentList,reviewCommentCreate,reviewCommentDelete,reviewCommentUpdate,reviewCommentReply,reviewCommentResolve,reviewCommentUnresolve,prCommitsList,mergeConflictCheck,prDiffSideBySide,mergeAnalysis,mergeExecute,mergeAbort,mergeIsInProgress,reviewRequestList,reviewRequestCreate,reviewRequestDelete,reviewRequestDismiss,reviewList,reviewSubmit,reviewUpdate,reviewDelete,branchProtectionList,branchProtectionCreate,branchProtectionCheckApprovals,branchProtectionGet,branchProtectionDelete,branchProtectionUpdate,gitUpdateRepo,gitArchive,gitArchiveCached,gitArchiveInvalidate,gitArchiveInvalidateAll,gitArchiveList,gitArchiveSummary,gitBlameFile,gitBlobCreate,gitBlobGet,gitBlobContent,gitBlobExists,gitBlobIsBinary,gitBlobSize,gitBranchList,gitBranchCreate,gitBranchCurrent,gitBranchDiff,gitBranchFastForward,gitBranchIsAncestor,gitBranchIsConflicted,gitBranchIsDetached,gitBranchIsMerged,gitBranchMergeBase,gitBranchMove,gitBranchDeleteRemote,gitBranchRename,gitBranchSummary,gitBranchSetUpstream,gitBranchGet,gitBranchDelete,gitBranchExists,gitBranchIsHead,gitBranchTrackingDifference,gitBranchUpstream,gitCommitLog,gitCommitCreate,gitCommitBranches,gitCommitCount,gitCommitGraph,gitCommitGraphReact,gitCommitReflog,gitCommitResolveRev,gitCommitTags,gitCommitWalk,gitCommitGet,gitCommitAmend,gitCommitAncestors,gitCommitAuthor,gitCommitCherryPick,gitCommitCherryPickAbort,gitCommitDescendants,gitCommitExists,gitCommitFirstParent,gitCommitIsCommit,gitCommitIsMerge,gitCommitIsTip,gitCommitMessage,gitCommitParentCount,gitCommitParentIds,gitCommitParent,gitCommitRefCount,gitCommitRefs,gitCommitRevert,gitCommitRevertAbort,gitCommitShortId,gitCommitSummary,gitCommitTreeId,gitConfigEntries,gitConfigGet,gitConfigSet,gitConfigDelete,gitConfigHas,gitContributors,gitDescriptionGet,gitDescriptionSet,gitDescriptionReset,gitDescriptionExists,gitDiffTreeToTree,gitDiffCommitToWorkdir,gitDiffCommitToIndex,gitDiffIndexToTree,gitDiffPatchId,gitDiffSideBySide,gitDiffStats,gitDiffWorkdirToIndex,gitMergeAbort,gitMergeAnalysisForRef,gitMergeAnalysis,gitMergeBase,gitMergeCommits,gitMergeheadList,gitMergeIsInProgress,gitMergeIsConflicted,gitMergeTrees,gitReadme,gitRefList,gitRefUpdate,gitRefCreate,gitRefRename,gitRefGet,gitRefDelete,gitRefExists,gitRefTarget,gitStar,gitUnstar,gitStarCount,gitIsStarred,gitStarUserList,gitTagList,gitTagCreate,gitTagCount,gitTagCreateLightweight,gitTagUpdateMessage,gitTagListNames,gitTagRename,gitTagSummary,gitTagGet,gitTagDelete,gitTagExists,gitTagIsAnnotated,gitTagMessage,gitTagTagger,gitTagTarget,gitTreeDiffstats,gitTreeEntryByCommitPath,gitTreeGet,gitTreeEntryByPath,gitTreeEntryCount,gitTreeEntry,gitTreeExists,gitTreeIsEmpty,gitTreeList,gitWatch,gitUnwatch,gitWatchCount,gitIsWatched,gitWatchUserList,gitWebhookList,gitWebhookCreate,gitWebhookGet,gitWebhookDelete,gitWebhookUpdate,categoryDelete,categoryUpdate,roomGet,roomDelete,roomUpdate,accessGrant,accessRevoke,aiList,aiUpsert,aiDelete,messageList,messageCreate,messageSearch,messageGet,messageUpdate,messageEditHistory,pinAdd,pinRemove,messageRevoke,participantList,pinList,stateUpdateDnd,stateSetReadSeq,threadList,threadCreate,threadMessages,search,searchMessages,listAccessKeys,createAccessKey,deleteAccessKey,uploadAvatar,userBilling,userBillingErrors,userBillingHistory,getMyContributionHeatmap,listSshKeys,addSshKey,getSshKey,deleteSshKey,updateSshKey,getNotificationPreferences,updateNotificationPreferences,getPreferences,updatePreferences,getMyProfile,updateMyProfile,getCurrentUserProjects,getCurrentUserRepos,getProfileByUsername,getUserActivity,isSubscribedToTarget,subscribeTarget,unsubscribeTarget,getSubscribers,getSubscriberCount,getFollowingList,getSubscriptionCount,getContributionHeatmap,getUserInfo,getUserProjects,getUserRepos,getUserStars,getUserSummary}}; export type ModelCapabilityCreateResult = AxiosResponse export type ModelCapabilityGetResult = AxiosResponse export type ModelCapabilityDeleteResult = AxiosResponse @@ -4587,7 +4599,7 @@ export type AiMessageListResult = AxiosResponse export type AiMessageCreateResult = AxiosResponse export type AiMessageGetResult = AxiosResponse export type AiMessageChildrenResult = AxiosResponse -export type AiMessageForkResult = AxiosResponse +export type AiConversationForkResult = AxiosResponse export type AiMessageResendResult = AxiosResponse export type AiMessageStopResult = AxiosResponse export type MessageStreamResult = AxiosResponse @@ -4726,6 +4738,7 @@ export type SkillScanResult = AxiosResponse export type SkillGetResult = AxiosResponse export type SkillDeleteResult = AxiosResponse export type SkillUpdateResult = AxiosResponse +export type ProjectStatsResult = AxiosResponse export type ProjectIsWatchResult = AxiosResponse export type ProjectWatchResult = AxiosResponse export type ProjectUnwatchResult = AxiosResponse diff --git a/src/client/model/activityBreakdownItem.ts b/src/client/model/activityBreakdownItem.ts new file mode 100644 index 0000000..922b0d6 --- /dev/null +++ b/src/client/model/activityBreakdownItem.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v8.9.0 🍺 + * Do not edit manually. + * api + * OpenAPI spec version: 0.2.9 + */ + +export interface ActivityBreakdownItem { + event_type: string; + count: number; +} diff --git a/src/client/model/aiConversationListParams.ts b/src/client/model/aiConversationListParams.ts index 4e6e5d8..0e59f4a 100644 --- a/src/client/model/aiConversationListParams.ts +++ b/src/client/model/aiConversationListParams.ts @@ -10,4 +10,8 @@ export type AiConversationListParams = { * Filter by project */ project_id?: string; +/** + * Search query (title) + */ +q?: string; }; diff --git a/src/client/model/apiResponseForkConversationResponse.ts b/src/client/model/apiResponseForkConversationResponse.ts new file mode 100644 index 0000000..6776c13 --- /dev/null +++ b/src/client/model/apiResponseForkConversationResponse.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v8.9.0 🍺 + * Do not edit manually. + * api + * OpenAPI spec version: 0.2.9 + */ +import type { ApiResponseForkConversationResponseData } from './apiResponseForkConversationResponseData'; + +export interface ApiResponseForkConversationResponse { + code: number; + message: string; + data?: ApiResponseForkConversationResponseData; +} diff --git a/src/client/model/apiResponseForkConversationResponseData.ts b/src/client/model/apiResponseForkConversationResponseData.ts new file mode 100644 index 0000000..cd7d27f --- /dev/null +++ b/src/client/model/apiResponseForkConversationResponseData.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.9.0 🍺 + * Do not edit manually. + * api + * OpenAPI spec version: 0.2.9 + */ + +export type ApiResponseForkConversationResponseData = { + id: string; + /** @nullable */ + title?: string | null; + model: string; + created_at: string; +}; diff --git a/src/client/model/apiResponseProjectStatsResponse.ts b/src/client/model/apiResponseProjectStatsResponse.ts new file mode 100644 index 0000000..7534f3f --- /dev/null +++ b/src/client/model/apiResponseProjectStatsResponse.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.9.0 🍺 + * Do not edit manually. + * api + * OpenAPI spec version: 0.2.9 + */ +import type { ApiResponseProjectStatsResponseData } from './apiResponseProjectStatsResponseData'; + +export interface ApiResponseProjectStatsResponse { + code: number; + message: string; + /** Aggregated project statistics for dashboard display. */ + data?: ApiResponseProjectStatsResponseData; +} diff --git a/src/client/model/apiResponseProjectStatsResponseData.ts b/src/client/model/apiResponseProjectStatsResponseData.ts new file mode 100644 index 0000000..ad0e660 --- /dev/null +++ b/src/client/model/apiResponseProjectStatsResponseData.ts @@ -0,0 +1,35 @@ +/** + * Generated by orval v8.9.0 🍺 + * Do not edit manually. + * api + * OpenAPI spec version: 0.2.9 + */ +import type { ActivityBreakdownItem } from './activityBreakdownItem'; +import type { ProjectStatsActivityItem } from './projectStatsActivityItem'; + +/** + * Aggregated project statistics for dashboard display. + */ +export type ApiResponseProjectStatsResponseData = { + project_id: string; + project_name: string; + member_count: number; + /** @nullable */ + my_role?: string | null; + repo_count: number; + issue_total: number; + issue_open: number; + issue_closed: number; + pr_total: number; + pr_open: number; + pr_merged: number; + pr_closed: number; + room_count: number; + ai_call_count: number; + ai_input_tokens: number; + ai_output_tokens: number; + /** @nullable */ + ai_cost_usd?: string | null; + activity_last_30d: ActivityBreakdownItem[]; + recent_activities: ProjectStatsActivityItem[]; +}; diff --git a/src/client/model/forkConversationResponse.ts b/src/client/model/forkConversationResponse.ts new file mode 100644 index 0000000..01c781f --- /dev/null +++ b/src/client/model/forkConversationResponse.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.9.0 🍺 + * Do not edit manually. + * api + * OpenAPI spec version: 0.2.9 + */ + +export interface ForkConversationResponse { + id: string; + /** @nullable */ + title?: string | null; + model: string; + created_at: string; +} diff --git a/src/client/model/index.ts b/src/client/model/index.ts index f33e6b6..e70054a 100644 --- a/src/client/model/index.ts +++ b/src/client/model/index.ts @@ -7,6 +7,7 @@ export * from './accessKeyListResponse'; export * from './accessKeyResponse'; +export * from './activityBreakdownItem'; export * from './activityLogListResponse'; export * from './activityLogParams'; export * from './activityLogResponse'; @@ -161,8 +162,8 @@ export * from './apiResponseDiffStatsResponse'; export * from './apiResponseDiffStatsResponseData'; export * from './apiResponseEmailResponse'; export * from './apiResponseEmailResponseData'; -export * from './apiResponseForkResponse'; -export * from './apiResponseForkResponseData'; +export * from './apiResponseForkConversationResponse'; +export * from './apiResponseForkConversationResponseData'; export * from './apiResponseGitInitResponse'; export * from './apiResponseGitInitResponseData'; export * from './apiResponseGitReadmeResponse'; @@ -247,6 +248,8 @@ export * from './apiResponseProjectRepoCreateResponse'; export * from './apiResponseProjectRepoCreateResponseData'; export * from './apiResponseProjectRepositoryPagination'; export * from './apiResponseProjectRepositoryPaginationData'; +export * from './apiResponseProjectStatsResponse'; +export * from './apiResponseProjectStatsResponseData'; export * from './apiResponsePullRequestListResponse'; export * from './apiResponsePullRequestListResponseData'; export * from './apiResponsePullRequestResponse'; @@ -572,7 +575,7 @@ export * from './enable2FAResponse'; export * from './exchangeProjectName'; export * from './exchangeProjectTitle'; export * from './exchangeProjectVisibility'; -export * from './forkResponse'; +export * from './forkConversationResponse'; export * from './generatePrDescriptionRequest'; export * from './generatePrDescriptionResponse'; export * from './get2FAStatusResponse'; @@ -707,6 +710,8 @@ export * from './projectRepoCreateResponse'; export * from './projectRepositoryItem'; export * from './projectRepositoryPagination'; export * from './projectSearchItem'; +export * from './projectStatsActivityItem'; +export * from './projectStatsResponse'; export * from './providerResponse'; export * from './pullRequestCreateRequest'; export * from './pullRequestListParams'; diff --git a/src/client/model/projectStatsActivityItem.ts b/src/client/model/projectStatsActivityItem.ts new file mode 100644 index 0000000..bc277b7 --- /dev/null +++ b/src/client/model/projectStatsActivityItem.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v8.9.0 🍺 + * Do not edit manually. + * api + * OpenAPI spec version: 0.2.9 + */ + +export interface ProjectStatsActivityItem { + id: number; + event_type: string; + title: string; + actor_name: string; + /** @nullable */ + actor_avatar?: string | null; + created_at: string; +} diff --git a/src/client/model/projectStatsResponse.ts b/src/client/model/projectStatsResponse.ts new file mode 100644 index 0000000..6b612cc --- /dev/null +++ b/src/client/model/projectStatsResponse.ts @@ -0,0 +1,35 @@ +/** + * Generated by orval v8.9.0 🍺 + * Do not edit manually. + * api + * OpenAPI spec version: 0.2.9 + */ +import type { ActivityBreakdownItem } from './activityBreakdownItem'; +import type { ProjectStatsActivityItem } from './projectStatsActivityItem'; + +/** + * Aggregated project statistics for dashboard display. + */ +export interface ProjectStatsResponse { + project_id: string; + project_name: string; + member_count: number; + /** @nullable */ + my_role?: string | null; + repo_count: number; + issue_total: number; + issue_open: number; + issue_closed: number; + pr_total: number; + pr_open: number; + pr_merged: number; + pr_closed: number; + room_count: number; + ai_call_count: number; + ai_input_tokens: number; + ai_output_tokens: number; + /** @nullable */ + ai_cost_usd?: string | null; + activity_last_30d: ActivityBreakdownItem[]; + recent_activities: ProjectStatsActivityItem[]; +} diff --git a/src/components/ai-elements/prompt-input.tsx b/src/components/ai-elements/prompt-input.tsx index 5be6019..099ea8a 100644 --- a/src/components/ai-elements/prompt-input.tsx +++ b/src/components/ai-elements/prompt-input.tsx @@ -210,6 +210,7 @@ const ProviderAttachmentsContext = createContext( null ); +// eslint-disable-next-line react-refresh/only-export-components export const usePromptInputController = () => { const ctx = useContext(PromptInputController); if (!ctx) { @@ -224,6 +225,7 @@ export const usePromptInputController = () => { const useOptionalPromptInputController = () => useContext(PromptInputController); +// eslint-disable-next-line react-refresh/only-export-components export const useProviderAttachments = () => { const ctx = useContext(ProviderAttachmentsContext); if (!ctx) { @@ -371,6 +373,7 @@ export const PromptInputProvider = ({ const LocalAttachmentsContext = createContext(null); +// eslint-disable-next-line react-refresh/only-export-components export const usePromptInputAttachments = () => { // Prefer local context (inside PromptInput) as it has validation, fall back to provider const provider = useOptionalProviderAttachments(); @@ -395,9 +398,11 @@ export interface ReferencedSourcesContext { clear: () => void; } +// eslint-disable-next-line react-refresh/only-export-components export const LocalReferencedSourcesContext = createContext(null); +// eslint-disable-next-line react-refresh/only-export-components export const usePromptInputReferencedSources = () => { const ctx = useContext(LocalReferencedSourcesContext); if (!ctx) { @@ -862,6 +867,7 @@ export const PromptInput = ({ try { // Convert blob URLs to data URLs asynchronously const convertedFiles: FileUIPart[] = await Promise.all( + // eslint-disable-next-line @typescript-eslint/no-unused-vars files.map(async ({ id: _id, ...item }) => { if (item.url?.startsWith("blob:")) { const dataUrl = await convertBlobUrlToDataUrl(item.url); diff --git a/src/components/ai-elements/reasoning.tsx b/src/components/ai-elements/reasoning.tsx index b56adac..77fee6c 100644 --- a/src/components/ai-elements/reasoning.tsx +++ b/src/components/ai-elements/reasoning.tsx @@ -36,6 +36,7 @@ interface ReasoningContextValue { const ReasoningContext = createContext(null); +// eslint-disable-next-line react-refresh/only-export-components export const useReasoning = () => { const context = useContext(ReasoningContext); if (!context) { diff --git a/src/components/ai-elements/shimmer.tsx b/src/components/ai-elements/shimmer.tsx index 6b635d0..f5b98ee 100644 --- a/src/components/ai-elements/shimmer.tsx +++ b/src/components/ai-elements/shimmer.tsx @@ -3,26 +3,11 @@ import { cn } from "@/lib/utils"; import type { MotionProps } from "motion/react"; import { motion } from "motion/react"; -import type { CSSProperties, ElementType, JSX } from "react"; +import type { CSSProperties, ElementType } from "react"; import { memo, useMemo } from "react"; type MotionHTMLProps = MotionProps & Record; -// Cache motion components at module level to avoid creating during render -const motionComponentCache = new Map< - keyof JSX.IntrinsicElements, - React.ComponentType ->(); - -const getMotionComponent = (element: keyof JSX.IntrinsicElements) => { - let component = motionComponentCache.get(element); - if (!component) { - component = motion.create(element); - motionComponentCache.set(element, component); - } - return component; -}; - export interface TextShimmerProps { children: string; as?: ElementType; @@ -38,9 +23,7 @@ const ShimmerComponent = ({ duration = 2, spread = 2, }: TextShimmerProps) => { - const MotionComponent = getMotionComponent( - Component as keyof JSX.IntrinsicElements - ); + const MotionComponent = motion[Component as keyof typeof motion] as React.ComponentType; const dynamicSpread = useMemo( () => (children?.length ?? 0) * spread, diff --git a/src/components/channel/EditHistoryOverlay.tsx b/src/components/channel/EditHistoryOverlay.tsx index 0bd9d56..3cda077 100644 --- a/src/components/channel/EditHistoryOverlay.tsx +++ b/src/components/channel/EditHistoryOverlay.tsx @@ -16,7 +16,6 @@ export function EditHistoryOverlay({ messageId, roomId, onClose }: Props) { useEffect(() => { let cancelled = false; - setLoading(true); messageEditHistory(roomId, messageId) .then((res) => { if (!cancelled) setHistory(res.data?.data?.history ?? []); diff --git a/src/components/channel/MessageItem.tsx b/src/components/channel/MessageItem.tsx index 91d34f0..cfe46a7 100644 --- a/src/components/channel/MessageItem.tsx +++ b/src/components/channel/MessageItem.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { Edit2, Trash2, Reply, MessageSquare, Pin } from 'lucide-react'; -import { MentionRenderer } from '@/components/channel/mention/MentionRenderer'; +import { IrRenderer } from '@/lib/ir/renderer'; +import { extractIrNodes } from '@/lib/ir/parser'; import type { Message } from '@/contexts/room'; import { formatRelativeTime } from '@/contexts/room'; import { Avatar } from './Avatar'; @@ -148,7 +149,7 @@ export function MessageItem({ )} - + )}
diff --git a/src/components/channel/MessageList.tsx b/src/components/channel/MessageList.tsx index 638a9b5..59c73bf 100644 --- a/src/components/channel/MessageList.tsx +++ b/src/components/channel/MessageList.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useEffect, useState, useCallback } from 'react'; +import { useMemo, useRef, useEffect, useState, useCallback, forwardRef } from 'react'; import { Loader2, ChevronDown } from 'lucide-react'; import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'; import type { Message } from '@/contexts/room'; @@ -8,6 +8,25 @@ import { MessageItem } from './MessageItem'; import { MESSAGE_LIST } from '@/css/channel/styles'; import { useIsMobile } from '@/hooks/use-mobile'; +const VirtuosoScroller = forwardRef>( + ({ style, className, ...props }, ref) => ( +
+ ) +); + +VirtuosoScroller.displayName = 'VirtuosoScroller'; + interface Props { messages: Message[]; isLoadingHistory: boolean; @@ -93,8 +112,6 @@ export function MessageList({ useEffect(() => { initialScrollDoneRef.current = false; prevLengthRef.current = 0; - setIsAtBottom(true); - setNewMsgCount(0); if (renderedItems.length > 0) { const el = scrollerRef.current; @@ -120,6 +137,7 @@ export function MessageList({ if (isAtBottom) { const el = scrollerRef.current; if (el) el.scrollTop = el.scrollHeight; + // eslint-disable-next-line react-hooks/set-state-in-effect setNewMsgCount(0); } else { setNewMsgCount(prev => prev + (renderedItems.length - prevLen)); @@ -132,7 +150,7 @@ export function MessageList({ if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); setNewMsgCount(0); setIsAtBottom(true); - }, [renderedItems.length]); + }, []); if (displayMessages.length === 0 && !isLoadingHistory) { return ( @@ -148,6 +166,7 @@ export function MessageList({ key={roomId} ref={virtuosoRef} style={{ flex: 1 }} + components={{ Scroller: VirtuosoScroller }} data={renderedItems} initialTopMostItemIndex={ renderedItems.length > 0 diff --git a/src/components/channel/mention/MentionBottomSheet.tsx b/src/components/channel/mention/MentionBottomSheet.tsx index 3a497d0..6043009 100644 --- a/src/components/channel/mention/MentionBottomSheet.tsx +++ b/src/components/channel/mention/MentionBottomSheet.tsx @@ -43,11 +43,13 @@ export function MentionBottomSheet({ // Sync external query changes useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect setSearch(query); }, [query]); // Reset selection when filtered list changes useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect setSelectedIndex(0); }, [search]); @@ -248,7 +250,7 @@ export function MentionBottomSheet({ className="rounded-md text-[10px] font-bold" style={{ backgroundColor: hashColor(item.label), - color: "#ffffff", + color: "var(--text-inverse)", }} > {item.label[0]?.toUpperCase() || "?"} diff --git a/src/components/chat/CodePreviewContext.tsx b/src/components/chat/CodePreviewContext.tsx new file mode 100644 index 0000000..f165c56 --- /dev/null +++ b/src/components/chat/CodePreviewContext.tsx @@ -0,0 +1,23 @@ +import { createContext, useContext } from "react"; + +export interface CodePreviewPayload { + id: string; + code: string; + language: string; + lineCount: number; + previewMode?: "code" | "preview"; +} + +interface CodePreviewContextValue { + activeCode: CodePreviewPayload | null; + openCodePreview: (payload: CodePreviewPayload) => void; + closeCodePreview: () => void; +} + +const CodePreviewContext = createContext(null); + +export const CodePreviewProvider = CodePreviewContext.Provider; + +export function useCodePreview() { + return useContext(CodePreviewContext); +} diff --git a/src/components/chat/CodePreviewPanel.tsx b/src/components/chat/CodePreviewPanel.tsx new file mode 100644 index 0000000..6728d70 --- /dev/null +++ b/src/components/chat/CodePreviewPanel.tsx @@ -0,0 +1,130 @@ +import { memo, useCallback, useEffect, useMemo, useState } from "react"; +import { Check, Copy, PanelRightClose } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import type { CodePreviewPayload } from "@/components/chat/CodePreviewContext"; + +interface CodePreviewPanelProps { + code: CodePreviewPayload | null; + onClose: () => void; +} + +export const CodePreviewPanel = memo(function CodePreviewPanel({ code, onClose }: CodePreviewPanelProps) { + const [copied, setCopied] = useState(false); + const [viewMode, setViewMode] = useState<"code" | "preview">("code"); + const lines = useMemo(() => code?.code.replace(/\n$/, "").split("\n") ?? [], [code?.code]); + const canPreview = code?.language === "html"; + + const handleCopy = useCallback(() => { + if (!code) return; + navigator.clipboard.writeText(code.code).then(() => { + setCopied(true); + window.setTimeout(() => setCopied(false), 1600); + }); + }, [code]); + + // Sync viewMode when code changes + useEffect(() => { + if (code?.previewMode) { + setViewMode(code.previewMode); + } + }, [code?.previewMode]); + + return ( +