refactor(frontend): apply formatting and update chat, settings, project pages

This commit is contained in:
ZhenYi 2026-05-14 10:02:54 +08:00
parent 8731c01908
commit b8bd0ec545
112 changed files with 3014 additions and 961 deletions

View File

@ -82,6 +82,7 @@ export default function App() {
<Route path="/me/projects" element={<MePage />} /> <Route path="/me/projects" element={<MePage />} />
<Route path="/me/activity" element={<MePage />} /> <Route path="/me/activity" element={<MePage />} />
<Route path="/me/stars" element={<MePage />} /> <Route path="/me/stars" element={<MePage />} />
<Route path="/me/followers" element={<MePage />} />
<Route path="/me/following" element={<MePage />} /> <Route path="/me/following" element={<MePage />} />
<Route path="/me/chat" element={<ChatPage scope="personal" />} /> <Route path="/me/chat" element={<ChatPage scope="personal" />} />
<Route path="/me/chat/:conversationId" element={<ChatPage scope="personal" />} /> <Route path="/me/chat/:conversationId" element={<ChatPage scope="personal" />} />

View File

@ -19,6 +19,7 @@ export function ChangePasswordPage() {
const { register, handleSubmit, watch, formState: { errors } } = useForm<ChangePasswordParams & { confirmPassword: string; captcha: string }>(); const { register, handleSubmit, watch, formState: { errors } } = useForm<ChangePasswordParams & { confirmPassword: string; captcha: string }>();
// eslint-disable-next-line react-hooks/incompatible-library
const newPassword = watch("new_password"); const newPassword = watch("new_password");
const loadCaptcha = async () => { const loadCaptcha = async () => {
@ -26,7 +27,7 @@ export function ChangePasswordPage() {
const result = await getCaptcha(apiAuthCaptcha, true); const result = await getCaptcha(apiAuthCaptcha, true);
setCaptchaImage(result.base64); setCaptchaImage(result.base64);
setPublicKey(result.publicKey || ""); setPublicKey(result.publicKey || "");
} catch (err) { } catch {
setError("Failed to load captcha"); setError("Failed to load captcha");
} }
}; };
@ -55,11 +56,12 @@ export function ChangePasswordPage() {
}); });
navigate("/me/settings"); navigate("/me/settings");
} catch (err: any) { } catch (err) {
if (err.response?.status === 401) { const apiErr = err as { response?: { status?: number; data?: { message?: string } } };
if (apiErr.response?.status === 401) {
setError("Current password is incorrect"); setError("Current password is incorrect");
} else { } else {
setError(err.response?.data?.message || "Failed to change password"); setError(apiErr.response?.data?.message || "Failed to change password");
} }
loadCaptcha(); loadCaptcha();
} finally { } finally {

View File

@ -23,7 +23,7 @@ export function ForgotPasswordPage() {
try { try {
const result = await getCaptcha(apiAuthCaptcha, true); const result = await getCaptcha(apiAuthCaptcha, true);
setCaptchaImage(result.base64); setCaptchaImage(result.base64);
} catch (err) { } catch {
setError("Failed to load captcha"); setError("Failed to load captcha");
} }
}; };
@ -48,8 +48,9 @@ export function ForgotPasswordPage() {
// Reset password doesn't require RSA encryption since it's email-based // Reset password doesn't require RSA encryption since it's email-based
await apiUserRequestPasswordReset({ email: data.email }); await apiUserRequestPasswordReset({ email: data.email });
setSuccess(true); setSuccess(true);
} catch (err: any) { } catch (err) {
setError(err.response?.data?.message || "Failed to send reset email"); const apiErr = err as { response?: { status?: number; data?: { message?: string } } };
setError(apiErr.response?.data?.message || "Failed to send reset email");
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useState } from "react";
import { useNavigate, useLocation, Link } from "react-router-dom"; import { useNavigate, useLocation, Link } from "react-router-dom";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -27,14 +27,15 @@ export function LoginPage() {
const result = await getCaptcha(apiAuthCaptcha, true); const result = await getCaptcha(apiAuthCaptcha, true);
setCaptchaImage(result.base64); setCaptchaImage(result.base64);
setPublicKey(result.publicKey || ""); setPublicKey(result.publicKey || "");
} catch (err) { } catch {
setError("Failed to load captcha"); setError("Failed to load captcha");
} }
}; };
useEffect(() => { useState(() => {
loadCaptcha(); loadCaptcha();
}, []); return undefined;
});
const onSubmit = async (data: LoginParams) => { const onSubmit = async (data: LoginParams) => {
setError(""); setError("");
@ -49,16 +50,17 @@ export function LoginPage() {
totp_code: needs2FA ? data.totp_code : null, 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 }); navigate(from, { replace: true });
} catch (err: any) { } catch (err) {
if (err.response?.status === 428) { const apiErr = err as { response?: { status?: number; data?: { message?: string } } };
if (apiErr.response?.status === 428) {
setNeeds2FA(true); setNeeds2FA(true);
setError("Two-factor authentication required"); setError("Two-factor authentication required");
} else if (err.response?.status === 401) { } else if (apiErr.response?.status === 401) {
setError("Invalid username or password"); setError("Invalid username or password");
} else { } else {
setError(err.response?.data?.message || "Login failed"); setError(apiErr.response?.data?.message || "Login failed");
} }
loadCaptcha(); loadCaptcha();
} }

View File

@ -24,7 +24,7 @@ export function RegisterPage() {
const result = await getCaptcha(apiAuthCaptcha, true); const result = await getCaptcha(apiAuthCaptcha, true);
setCaptchaImage(result.base64); setCaptchaImage(result.base64);
setPublicKey(result.publicKey || ""); setPublicKey(result.publicKey || "");
} catch (err) { } catch {
setError("Failed to load captcha"); setError("Failed to load captcha");
} }
}; };
@ -33,6 +33,7 @@ export function RegisterPage() {
loadCaptcha(); loadCaptcha();
}, []); }, []);
// eslint-disable-next-line react-hooks/incompatible-library
const password = watch("password"); const password = watch("password");
const onSubmit = async (data: RegisterParams & { confirmPassword: string }) => { const onSubmit = async (data: RegisterParams & { confirmPassword: string }) => {
@ -56,11 +57,12 @@ export function RegisterPage() {
}); });
navigate("/"); navigate("/");
} catch (err: any) { } catch (err) {
if (err.response?.status === 409) { const apiErr = err as { response?: { status?: number; data?: { message?: string } } };
if (apiErr.response?.status === 409) {
setError("Username or email already exists"); setError("Username or email already exists");
} else { } else {
setError(err.response?.data?.message || "Registration failed"); setError(apiErr.response?.data?.message || "Registration failed");
} }
loadCaptcha(); loadCaptcha();
} finally { } finally {

View File

@ -24,6 +24,7 @@ export function ResetPasswordPage() {
defaultValues: { token } defaultValues: { token }
}); });
// eslint-disable-next-line react-hooks/incompatible-library
const password = watch("new_password"); const password = watch("new_password");
const loadCaptcha = async () => { const loadCaptcha = async () => {
@ -31,7 +32,7 @@ export function ResetPasswordPage() {
const result = await getCaptcha(apiAuthCaptcha, true); const result = await getCaptcha(apiAuthCaptcha, true);
setCaptchaImage(result.base64); setCaptchaImage(result.base64);
setPublicKey(result.publicKey || ""); setPublicKey(result.publicKey || "");
} catch (err) { } catch {
setError("Failed to load captcha"); setError("Failed to load captcha");
} }
}; };
@ -59,11 +60,12 @@ export function ResetPasswordPage() {
}); });
navigate("/auth/login"); navigate("/auth/login");
} catch (err: any) { } catch (err) {
if (err.response?.status === 400) { const apiErr = err as { response?: { status?: number; data?: { message?: string } } };
if (apiErr.response?.status === 400) {
setError("Invalid or expired reset token"); setError("Invalid or expired reset token");
} else { } else {
setError(err.response?.data?.message || "Failed to reset password"); setError(apiErr.response?.data?.message || "Failed to reset password");
} }
} finally { } finally {
setLoading(false); setLoading(false);

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -22,19 +22,20 @@ export function TwoFactorPage() {
const { register, handleSubmit, formState: { errors } } = useForm<Disable2FAParams>(); const { register, handleSubmit, formState: { errors } } = useForm<Disable2FAParams>();
useEffect(() => {
loadStatus();
}, []);
const loadStatus = async () => { const loadStatus = async () => {
try { try {
const response = await api2faStatus(); const response = await api2faStatus();
setIsEnabled(response.data.is_enabled || false); setIsEnabled(response.data.is_enabled || false);
} catch (err) { } catch {
setError("Failed to load 2FA status"); setError("Failed to load 2FA status");
} }
}; };
useState(() => {
loadStatus();
return undefined;
});
const handleEnable = async () => { const handleEnable = async () => {
setLoading(true); setLoading(true);
setError(""); setError("");
@ -44,8 +45,9 @@ export function TwoFactorPage() {
setQrCode(response.data.qr_code); setQrCode(response.data.qr_code);
setSecret(response.data.secret); setSecret(response.data.secret);
setShowSetup(true); setShowSetup(true);
} catch (err: any) { } catch (err) {
setError(err.response?.data?.message || "Failed to enable 2FA"); const apiErr = err as { response?: { status?: number; data?: { message?: string } } };
setError(apiErr.response?.data?.message || "Failed to enable 2FA");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -63,8 +65,9 @@ export function TwoFactorPage() {
setShowSetup(false); setShowSetup(false);
setQrCode(""); setQrCode("");
setSecret(""); setSecret("");
} catch (err: any) { } catch (err) {
setError(err.response?.data?.message || "Invalid verification code"); const apiErr = err as { response?: { status?: number; data?: { message?: string } } };
setError(apiErr.response?.data?.message || "Invalid verification code");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -77,8 +80,9 @@ export function TwoFactorPage() {
try { try {
await api2faDisable(data); await api2faDisable(data);
setIsEnabled(false); setIsEnabled(false);
} catch (err: any) { } catch (err) {
setError(err.response?.data?.message || "Failed to disable 2FA"); const apiErr = err as { response?: { status?: number; data?: { message?: string } } };
setError(apiErr.response?.data?.message || "Failed to disable 2FA");
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState } from "react";
import { useSearchParams, useNavigate } from "react-router-dom"; import { useSearchParams, useNavigate } from "react-router-dom";
import { apiEmailVerify } from "@/client/api"; import { apiEmailVerify } from "@/client/api";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -10,35 +10,26 @@ type VerifyStatus = "idle" | "verifying" | "success" | "error";
export function VerifyEmailPage() { export function VerifyEmailPage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
const [status, setStatus] = useState<VerifyStatus>("idle"); const token = searchParams.get("token") || "";
const [message, setMessage] = useState(""); const [status, setStatus] = useState<VerifyStatus>(token ? "verifying" : "error");
const [message, setMessage] = useState(token ? "" : "Missing verification token");
useEffect(() => { useState(() => {
const token = searchParams.get("token"); if (token) {
if (!token) { apiEmailVerify({ token })
setStatus("error"); .then((res) => {
setMessage("Missing verification token");
return;
}
const verify = async () => {
try {
setStatus("verifying");
const res = await apiEmailVerify({ token });
const msg = res.data?.data ?? "Email verified successfully"; const msg = res.data?.data ?? "Email verified successfully";
setMessage(msg); setMessage(msg);
setStatus("success"); setStatus("success");
// Redirect to login after 3 seconds
setTimeout(() => navigate("/auth/login"), 3000); setTimeout(() => navigate("/auth/login"), 3000);
} catch (err: any) { })
setMessage(err.response?.data?.message || "Verification failed"); .catch((err) => {
const apiErr = err as { response?: { status?: number; data?: { message?: string } } };
setMessage(apiErr.response?.data?.message || "Verification failed");
setStatus("error"); setStatus("error");
});
} }
}; });
verify();
}, [searchParams, navigate]);
return ( return (
<div className={AUTH_FORM.container}> <div className={AUTH_FORM.container}>

View File

@ -1,5 +1,6 @@
import { createContext, useContext, useState, useMemo, useCallback, type ReactNode } from "react"; import { createContext, useContext, useState, useMemo, useCallback, type ReactNode } from "react";
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
import { useMatch } from "react-router-dom";
import { ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
import { ServerIconRail } from "@/components/layout/ServerIconRail"; import { ServerIconRail } from "@/components/layout/ServerIconRail";
import { ChannelSidebar } from "@/components/layout/ChannelSidebar"; import { ChannelSidebar } from "@/components/layout/ChannelSidebar";
@ -17,6 +18,7 @@ const ChannelContext = createContext<ChannelContextType>({
setShowMembers: () => {}, setShowMembers: () => {},
}); });
// eslint-disable-next-line react-refresh/only-export-components
export const useChannel = () => useContext(ChannelContext); export const useChannel = () => useContext(ChannelContext);
export function ChannelLayout({ children }: { children?: ReactNode }) { 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 ( return (
<ChannelContext.Provider value={contextValue}> <ChannelContext.Provider value={contextValue}>
<div className="flex h-screen overflow-hidden" style={{ backgroundColor: "var(--surface-ground)" }}> <div className="flex h-screen overflow-hidden" style={{ backgroundColor: "var(--surface-ground)" }}>
@ -77,7 +82,10 @@ export function ChannelLayout({ children }: { children?: ReactNode }) {
<div className="flex-1 flex flex-col overflow-hidden min-w-0" style={{ backgroundColor: "var(--surface-ground)" }}> <div className="flex-1 flex flex-col overflow-hidden min-w-0" style={{ backgroundColor: "var(--surface-ground)" }}>
<Header /> <Header />
<main className="flex-1 overflow-y-auto" style={{ backgroundColor: "var(--surface-ground)" }}> <main
className={mainShouldOwnScroll ? "flex-1 overflow-y-auto" : "flex-1 overflow-hidden min-h-0"}
style={{ backgroundColor: "var(--surface-ground)" }}
>
{children ?? <Outlet />} {children ?? <Outlet />}
</main> </main>
</div> </div>

View File

@ -1,6 +1,6 @@
import { useState } from "react"; import { useState, useMemo, useEffect, useRef } from "react";
import { Link } from "react-router-dom"; 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 { useConversationsQuery, useCreateConversationMutation, useDeleteConversationMutation } from "@/hooks/useAiChatQuery";
import { useChatPage } from "./ChatPageContext"; import { useChatPage } from "./ChatPageContext";
import type { ConversationResponse } from "@/client/model"; import type { ConversationResponse } from "@/client/model";
@ -17,13 +17,66 @@ export function ChatConversationList({ selectedId, onSelect, onNew }: ChatConver
const createMutation = useCreateConversationMutation(); const createMutation = useCreateConversationMutation();
const deleteMutation = useDeleteConversationMutation(); const deleteMutation = useDeleteConversationMutation();
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [isSearchOpen, setIsSearchOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const getConversationLink = (id: string) => { const getConversationLink = (id: string) => {
if (scope === "project" && projectName) return `/${projectName}/chat/${id}`; if (scope === "project" && projectName) return `/${projectName}/chat/${id}`;
return `/me/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 () => { const handleNew = async () => {
onNew(); 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 ( return (
<div <div
className="flex flex-col h-full shrink-0" className="flex flex-col h-full shrink-0"
@ -73,10 +143,10 @@ export function ChatConversationList({ selectedId, onSelect, onNew }: ChatConver
</span> </span>
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
<button <button
onClick={() => {}} onClick={() => setIsSearchOpen(!isSearchOpen)}
className="flex items-center justify-center w-7 h-7 rounded-lg transition-colors hover:bg-[var(--hover-bg)]" className="flex items-center justify-center w-7 h-7 rounded-lg transition-colors hover:bg-[var(--hover-bg)]"
style={{ color: "var(--text-muted)" }} style={{ color: "var(--text-muted)" }}
title="Search" title="Search (Ctrl+K)"
> >
<Search className="w-3.5 h-3.5" /> <Search className="w-3.5 h-3.5" />
</button> </button>
@ -96,6 +166,39 @@ export function ChatConversationList({ selectedId, onSelect, onNew }: ChatConver
</div> </div>
</div> </div>
{/* Search Input */}
{isSearchOpen && (
<div className="px-3 py-2 shrink-0">
<div
className="flex items-center gap-2 px-3 py-1.5 rounded-lg"
style={{
backgroundColor: "var(--surface-ground)",
border: "1px solid var(--border-default)",
}}
>
<Search className="w-3.5 h-3.5 shrink-0" style={{ color: "var(--text-muted)" }} />
<input
ref={searchInputRef}
type="text"
placeholder="Search conversations..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="flex-1 text-sm bg-transparent outline-none min-w-0"
style={{ color: "var(--text-primary)" }}
/>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="shrink-0"
style={{ color: "var(--text-muted)" }}
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
)}
{/* New Chat Button */} {/* New Chat Button */}
<div className="px-3 py-2"> <div className="px-3 py-2">
<button <button
@ -130,7 +233,7 @@ export function ChatConversationList({ selectedId, onSelect, onNew }: ChatConver
style={{ color: "var(--text-muted)" }} style={{ color: "var(--text-muted)" }}
/> />
</div> </div>
) : conversations.length === 0 ? ( ) : filteredConversations.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 px-4 gap-2.5"> <div className="flex flex-col items-center justify-center py-8 px-4 gap-2.5">
<div <div
className="w-10 h-10 rounded-xl flex items-center justify-center" className="w-10 h-10 rounded-xl flex items-center justify-center"
@ -145,18 +248,27 @@ export function ChatConversationList({ selectedId, onSelect, onNew }: ChatConver
className="text-[13px] font-medium text-center" className="text-[13px] font-medium text-center"
style={{ color: "var(--text-primary)" }} style={{ color: "var(--text-primary)" }}
> >
No conversations yet {searchQuery ? "No matching conversations" : "No conversations yet"}
</p> </p>
<p <p
className="text-[12px] text-center leading-relaxed" className="text-[12px] text-center leading-relaxed"
style={{ color: "var(--text-muted)" }} style={{ color: "var(--text-muted)" }}
> >
Start a new chat to begin exploring with AI. {searchQuery ? "Try a different search term." : "Start a new chat to begin exploring with AI."}
</p> </p>
</div> </div>
) : ( ) : (
<div className="space-y-3">
{groupedConversations.map((group) => (
<div key={group.label}>
<div
className="px-3 py-1 text-[11px] font-medium uppercase tracking-wide"
style={{ color: "var(--text-muted)" }}
>
{group.label}
</div>
<div className="space-y-0.5"> <div className="space-y-0.5">
{conversations.map((conversation) => ( {group.items.map((conversation) => (
<ConversationItem <ConversationItem
key={conversation.id} key={conversation.id}
conversation={conversation} conversation={conversation}
@ -167,6 +279,9 @@ export function ChatConversationList({ selectedId, onSelect, onNew }: ChatConver
/> />
))} ))}
</div> </div>
</div>
))}
</div>
)} )}
</div> </div>
</div> </div>

View File

@ -1,49 +1,30 @@
import { Copy, Check, Sparkles, ClipboardList, Pencil, RefreshCw, GitFork } from "lucide-react"; 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 { useCurrentUserQuery } from "@/hooks/useAuth";
import { useEditMessageMutation, useMessageVersionsQuery, useSwitchMessageVersionMutation } from "@/hooks/useAiChatQuery"; import {
import { MarkdownRenderer } from "@/components/ui/MarkdownRenderer"; 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 { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Reasoning, ReasoningTrigger, ReasoningContent } from "@/components/ai-elements/reasoning"; import { Reasoning, ReasoningTrigger, ReasoningContent } from "@/components/ai-elements/reasoning";
import type { MessageResponse } from "@/hooks/useAiChatQuery"; import type { MessageResponse } from "@/hooks/useAiChatQuery";
import { getModelIcon } from "@/lib/icons/modelIcons"; import { getModelIcon } from "@/lib/icons/modelIcons";
import { ToolCallBlock } from "@/components/chat/ToolCallBlock";
import { useChatPage } from "./ChatPageContext";
interface ChatMessageBubbleProps { interface ChatMessageBubbleProps {
message: MessageResponse; message: MessageResponse;
conversationId: string; conversationId: string;
onRegenerate?: (newMessageId: string) => void; onRegenerate?: (newMessageId: string) => void;
} setIsStreaming: (value: boolean) => 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(/(?<!\n)\n(?!\n)/g, "")
.trim();
}
/** Normalize answer content: remove per-token stray \n that the backend
* may have persisted from streaming (old records), while keeping
* intentional paragraph breaks (double \n). Single \n are removed
* entirely (not replaced with space) to avoid unwanted gaps in CJK text. */
function normalizeAnswerContent(content: string): string {
// Collapse any 3+ \n to \n\n first, then strip remaining single \n
return content
.replace(/\n{3,}/g, "\n\n")
.replace(/(?<!\n)\n(?!\n)/g, "")
.trim();
} }
const AVATAR_COLORS = [ const AVATAR_COLORS = [
@ -60,47 +41,9 @@ function hashColor(str: string): string {
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length]; return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
} }
/** Parse content into ordered blocks: [{role, content}, ...] */ 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";
function parseBlocks(raw: unknown): ContentBlock[] {
if (Array.isArray(raw)) {
const blocks = raw.map((item) => {
if (item && typeof item === "object") {
const obj = item as Record<string, unknown>;
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<string, unknown>).content ?? raw) : String(raw ?? "");
if (!text) return [];
return [{ role: "assistant", content: text }];
}
/** Extract answer text only (for simple copy). */ export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conversationId, onRegenerate, setIsStreaming }: ChatMessageBubbleProps) {
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) {
const isUser = message.role === "user"; const isUser = message.role === "user";
const [copied, setCopied] = useState<"answer" | "full" | false>(false); const [copied, setCopied] = useState<"answer" | "full" | false>(false);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
@ -108,12 +51,29 @@ export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conv
const [showVersions, setShowVersions] = useState(false); const [showVersions, setShowVersions] = useState(false);
const { data: user } = useCurrentUserQuery(); const { data: user } = useCurrentUserQuery();
const editMutation = useEditMessageMutation(); const editMutation = useEditMessageMutation();
const resendMutation = useResendMessageMutation();
const forkMutation = useForkMessageMutation();
const switchVersionMutation = useSwitchMessageVersionMutation(); const switchVersionMutation = useSwitchMessageVersionMutation();
const runStream = useChatStreamRunner(setIsStreaming);
const navigate = useNavigate();
const { scope, projectName } = useChatPage();
const blocks = isUser // Parse content into IrContentBlock[] (handles both old and future formats)
? [{ role: "user" as const, content: typeof message.content === "string" ? message.content : "" }] const blocks: IrContentBlock[] = useMemo(() =>
: parseBlocks(message.content); isUser
const plainText = isUser ? blocks[0]?.content || "" : extractAnswerText(blocks); ? [{ 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<string, unknown>).content ?? "")
: "";
const plainText = isUser ? userText : extractAnswerText(blocks);
const hasThinking = blocks.some((b) => b.role === "thinking");
// Fetch versions when showing version switcher // Fetch versions when showing version switcher
const versionsQuery = useMessageVersionsQuery(conversationId, message.id); const versionsQuery = useMessageVersionsQuery(conversationId, message.id);
@ -155,18 +115,20 @@ export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conv
content: editText.trim(), content: editText.trim(),
}); });
setIsEditing(false); setIsEditing(false);
// If there's a regenerate callback, trigger AI response for the new message onRegenerate?.(newMsg.id);
if (onRegenerate) { await runStream(conversationId, newMsg.id);
onRegenerate(newMsg.id);
}
} catch (err) { } catch (err) {
console.error("Failed to edit message:", err); console.error("Failed to edit message:", err);
} }
}; };
const handleRegenerate = () => { const handleRegenerate = async () => {
if (onRegenerate) { try {
onRegenerate(message.id); 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 () => { 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 { try {
// For now, we'll fork by creating a new user message with parent = this AI message const fork = await forkMutation.mutateAsync({ conversationId, messageId: message.id });
// and then register the fork relationship if (scope === "project" && projectName) {
// The fork flow: user clicks "Fork" → creates a new empty user message navigate(`/${projectName}/chat/${fork.id}`);
// with parent_message_id = this AI message → user types new content → submits } else {
// This is handled at the ChatMessageList level navigate(`/me/chat/${fork.id}`);
if (onRegenerate) {
// Use onRegenerate callback to signal the parent component
// that a fork should be initiated from this message
onRegenerate(message.id);
} }
} catch (err) { } catch (err) {
console.error("Failed to fork:", 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" className="text-[10px] font-semibold rounded-full"
style={{ style={{
backgroundColor: hashColor(user?.username || "user"), backgroundColor: hashColor(user?.username || "user"),
color: "#ffffff", color: "var(--text-inverse)",
}} }}
> >
{(user?.display_name || user?.username || "U")[0]?.toUpperCase()} {(user?.display_name || user?.username || "U")[0]?.toUpperCase()}
@ -237,7 +192,7 @@ export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conv
</span> </span>
</div> </div>
{/* Interleaved blocks — thinking (collapsible) + answer (markdown) in order */} {/* Interleaved blocks — thinking (collapsible) + answer (IrRenderer) */}
<div className="text-sm" style={{ color: "var(--text-primary)" }}> <div className="text-sm" style={{ color: "var(--text-primary)" }}>
{isUser && isEditing ? ( {isUser && isEditing ? (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@ -281,7 +236,7 @@ export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conv
color: editText.trim() ? "var(--text-inverse)" : "var(--text-muted)", color: editText.trim() ? "var(--text-inverse)" : "var(--text-muted)",
}} }}
> >
{editMutation.isPending ? "Saving" : "Save & Submit"} {editMutation.isPending ? "Saving..." : "Save & Submit"}
</button> </button>
</div> </div>
</div> </div>
@ -290,16 +245,51 @@ export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conv
) : ( ) : (
blocks.map((b, i) => { blocks.map((b, i) => {
if (b.role === "thinking") { 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 ( return (
<Reasoning key={i} defaultOpen={false}> <Reasoning key={i} defaultOpen={false}>
<ReasoningTrigger /> <ReasoningTrigger />
<ReasoningContent>{b.content}</ReasoningContent> <ReasoningContent>{thinkingText}</ReasoningContent>
</Reasoning> </Reasoning>
); );
} }
if (b.role === "tool_call") {
// Tool call visualization
const toolCallNode = b.nodes.find((n) => n.type === "tool_call") as IrToolCallNode | undefined;
if (toolCallNode) {
return (
<ToolCallBlock
key={i}
toolName={toolCallNode.tool}
args={toolCallNode.args}
status="ok"
/>
);
}
return null;
}
if (b.role === "tool_result") {
const toolResultNode = b.nodes.find((n) => n.type === "tool_result") as IrToolResultNode | undefined;
if (toolResultNode) {
return (
<ToolCallBlock
key={i}
toolName={toolResultNode.tool}
args={{}}
status={toolResultNode.status}
result={toolResultNode.content}
/>
);
}
return null;
}
return ( return (
<div key={i} className={i > 0 ? "mt-3" : ""}> <div key={i} className={i > 0 ? "mt-3" : ""}>
<MarkdownRenderer content={normalizeAnswerContent(b.content)} className="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" /> <IrRenderer nodes={b.nodes} className={PROSE_CLASS} />
</div> </div>
); );
}) })
@ -387,7 +377,7 @@ export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conv
{copied === "answer" ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />} {copied === "answer" ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
{copied === "answer" ? "Copied!" : "Copy"} {copied === "answer" ? "Copied!" : "Copy"}
</button> </button>
{blocks.some((b) => b.role === "thinking") && ( {hasThinking && (
<button <button
onClick={handleCopyFull} onClick={handleCopyFull}
className="inline-flex items-center gap-1 text-[11px] px-2 py-1 rounded-md transition-colors hover:bg-[var(--hover-bg)]" className="inline-flex items-center gap-1 text-[11px] px-2 py-1 rounded-md transition-colors hover:bg-[var(--hover-bg)]"
@ -449,12 +439,13 @@ function ModelAvatar({ modelName, size = 28 }: { modelName?: string | null; size
} }
return ( return (
<div <div
className="rounded-full flex items-center justify-center font-bold text-white shrink-0" className="rounded-full flex items-center justify-center font-bold shrink-0"
style={{ style={{
width: size, width: size,
height: size, height: size,
backgroundColor: hashColor(modelName), backgroundColor: hashColor(modelName),
fontSize: Math.max(10, size * 0.35), fontSize: Math.max(10, size * 0.35),
color: "var(--text-inverse)",
}} }}
> >
{modelName[0]?.toUpperCase() || "?"} {modelName[0]?.toUpperCase() || "?"}

View File

@ -1,9 +1,12 @@
import { useState } from "react"; import { useState } from "react";
import { AlertCircle } from "lucide-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 { useChatPage } from "./ChatPageContext";
import { useQueryClient } from "@tanstack/react-query";
import { useStreamingStore } from "@/store/streaming";
import { import {
PromptInput, PromptInput,
PromptInputBody, PromptInputBody,
@ -29,11 +32,13 @@ export function ChatMessageInput({
onSelectConversation, onSelectConversation,
}: ChatMessageInputProps) { }: ChatMessageInputProps) {
const [showModelWarning, setShowModelWarning] = useState(false); const [showModelWarning, setShowModelWarning] = useState(false);
const [activeMessageId, setActiveMessageId] = useState<string | null>(null);
const [activeStreamConversationId, setActiveStreamConversationId] = useState<string | null>(null);
const createMessageMutation = useCreateMessageMutation(); const createMessageMutation = useCreateMessageMutation();
const createConversationMutation = useCreateConversationMutation(); const createConversationMutation = useCreateConversationMutation();
const stopMessageMutation = useStopMessageMutation();
const { scope, projectId, selectedModel, setSelectedModel } = useChatPage(); const { scope, projectId, selectedModel, setSelectedModel } = useChatPage();
const queryClient = useQueryClient(); const runStream = useChatStreamRunner(setIsStreaming);
const streamingStore = useStreamingStore();
const handleSubmit = async ({ text }: PromptInputMessage) => { const handleSubmit = async ({ text }: PromptInputMessage) => {
if (!text.trim()) return; if (!text.trim()) return;
@ -83,38 +88,13 @@ export function ChatMessageInput({
if (!messageResponse?.id) return; if (!messageResponse?.id) return;
streamingStore.clear(activeConversationId);
setIsStreaming(true);
try { try {
const stream = streamChat(activeConversationId, messageResponse.id); setActiveMessageId(messageResponse.id);
setActiveStreamConversationId(activeConversationId);
for await (const chunk of stream) { await runStream(activeConversationId, messageResponse.id);
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] });
}
}
} finally { } finally {
setIsStreaming(false); setActiveMessageId(null);
streamingStore.clear(activeConversationId); setActiveStreamConversationId(null);
} }
} catch (err) { } catch (err) {
console.error("Failed to send message:", err); console.error("Failed to send message:", err);
@ -151,7 +131,12 @@ export function ChatMessageInput({
/> />
<PromptInputSubmit <PromptInputSubmit
status={isStreaming ? "streaming" : "ready"} status={isStreaming ? "streaming" : "ready"}
onStop={() => setIsStreaming(false)} onStop={() => {
if (activeStreamConversationId && activeMessageId) {
stopMessageMutation.mutate({ conversationId: activeStreamConversationId, messageId: activeMessageId });
}
setIsStreaming(false);
}}
/> />
</PromptInputFooter> </PromptInputFooter>
</PromptInput> </PromptInput>

View File

@ -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 { Loader2, Code, FileText, GitPullRequest, Brain, ChevronDown, Sparkles } from "lucide-react";
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
@ -8,12 +8,15 @@ import type { StreamPart } from "@/store/streaming";
import { ChatMessageBubble } from "./ChatMessageBubble"; import { ChatMessageBubble } from "./ChatMessageBubble";
import { useChatPage } from "./ChatPageContext"; import { useChatPage } from "./ChatPageContext";
import { getModelIcon } from "@/lib/icons/modelIcons"; 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 { Shimmer } from "@/components/ai-elements/shimmer";
import { Reasoning, ReasoningTrigger, ReasoningContent } from "@/components/ai-elements/reasoning"; import { Reasoning, ReasoningTrigger, ReasoningContent } from "@/components/ai-elements/reasoning";
import { ToolCallBlock } from "@/components/chat/ToolCallBlock";
import { useCodePreview } from "@/components/chat/CodePreviewContext";
interface ChatMessageListProps { interface ChatMessageListProps {
conversationId: string | null; conversationId: string | null;
setIsStreaming: (value: boolean) => void;
} }
const PROMPT_SUGGESTIONS = [ const PROMPT_SUGGESTIONS = [
@ -26,17 +29,20 @@ const PROMPT_SUGGESTIONS = [
const OVERSCAN = 3; const OVERSCAN = 3;
const ESTIMATED_SIZE = 200; 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 { data, isLoading } = useMessagesQuery(conversationId || "");
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const messages = data?.messages || []; const messages = useMemo(() => data?.messages ?? [], [data?.messages]);
const stream = useStreamingStore((s) => (conversationId ? s.streams[conversationId] : undefined)); const stream = useStreamingStore((s) => (conversationId ? s.streams[conversationId] : undefined));
const isStreaming = stream && !stream.isDone; const isStreaming = stream && !stream.isDone;
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const codePreview = useCodePreview();
// Whether user is scrolled near the bottom
const [isAtBottom, setIsAtBottom] = useState(true); const [isAtBottom, setIsAtBottom] = useState(true);
const [userScrolledUp, setUserScrolledUp] = useState(false); const [userScrolledUp, setUserScrolledUp] = useState(false);
const [activeUserAnchor, setActiveUserAnchor] = useState(0);
const checkAtBottom = useCallback(() => { const checkAtBottom = useCallback(() => {
const el = scrollRef.current; const el = scrollRef.current;
@ -47,20 +53,18 @@ export function ChatMessageList({ conversationId }: ChatMessageListProps) {
if (atBottom) setUserScrolledUp(false); if (atBottom) setUserScrolledUp(false);
}, []); }, []);
const handleScroll = useCallback(() => { const userAnchors = useMemo(() => {
const el = scrollRef.current; return messages
if (!el) return; .map((message, index) => ({ message, index }))
const distance = el.scrollHeight - el.scrollTop - el.clientHeight; .filter(({ message }) => message.role === "user")
const atBottom = distance < 200; .map(({ index }) => index);
setIsAtBottom(atBottom); }, [messages]);
if (!atBottom) setUserScrolledUp(true); 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 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({ const virtualizer = useVirtualizer({
count: messages.length, count: messages.length,
getScrollElement: () => scrollRef.current, getScrollElement: () => scrollRef.current,
@ -68,7 +72,39 @@ export function ChatMessageList({ conversationId }: ChatMessageListProps) {
overscan: OVERSCAN, 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(() => { useEffect(() => {
if (isAtBottom && scrollRef.current) { if (isAtBottom && scrollRef.current) {
requestAnimationFrame(() => { 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(() => { useEffect(() => {
if (conversationId && messages.length > 0) { if (conversationId && messages.length > 0) {
checkAtBottom(); checkAtBottom();
} }
}, [conversationId, messages.length, checkAtBottom]); }, [conversationId, messages.length, checkAtBottom]);
// Scroll to bottom on first load
useEffect(() => { useEffect(() => {
if (scrollRef.current && messages.length > 0) { if (scrollRef.current && messages.length > 0) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight; scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
} }
}, [isLoading]); }, [isLoading, messages.length]);
// Empty state — no conversation or no messages
if (!conversationId || (messages.length === 0 && !hasStreamingBubble)) { if (!conversationId || (messages.length === 0 && !hasStreamingBubble)) {
return ( return (
<div <div
@ -118,7 +151,7 @@ export function ChatMessageList({ conversationId }: ChatMessageListProps) {
How can I help you today? How can I help you today?
</h1> </h1>
<p className="text-[15px]" style={{ color: "var(--text-muted)", lineHeight: "1.6" }}> <p className="text-[15px]" style={{ color: "var(--text-muted)", lineHeight: "1.6" }}>
Ask anything I can help with code, writing, analysis, and much more. Ask anything - I can help with code, writing, analysis, and much more.
</p> </p>
</div> </div>
<div className="grid grid-cols-2 gap-3 w-full max-w-md"> <div className="grid grid-cols-2 gap-3 w-full max-w-md">
@ -164,7 +197,6 @@ export function ChatMessageList({ conversationId }: ChatMessageListProps) {
return ( return (
<div className="flex-1 flex flex-col min-h-0 relative" style={{ backgroundColor: "var(--surface-ground)" }}> <div className="flex-1 flex flex-col min-h-0 relative" style={{ backgroundColor: "var(--surface-ground)" }}>
{/* Streaming indicator — shown above input when not at bottom */}
{isStreaming && userScrolledUp && ( {isStreaming && userScrolledUp && (
<div <div
className="absolute bottom-2 left-1/2 -translate-x-1/2 z-10 px-4 py-1.5 rounded-full cursor-pointer shadow-lg transition-all hover:scale-105" className="absolute bottom-2 left-1/2 -translate-x-1/2 z-10 px-4 py-1.5 rounded-full cursor-pointer shadow-lg transition-all hover:scale-105"
@ -187,15 +219,51 @@ export function ChatMessageList({ conversationId }: ChatMessageListProps) {
</div> </div>
)} )}
{/* Virtualized message list — persisted messages only */}
<div <div
ref={scrollRef} ref={scrollRef}
className="flex-1 overflow-y-auto pb-24" className="app-scrollbar flex-1 overflow-y-auto pb-24"
onScroll={handleScroll} onScroll={handleScroll}
style={{ backgroundColor: "var(--surface-ground)" }} style={{ backgroundColor: "var(--surface-ground)" }}
> >
{showUserTimeline && (
<div className="pointer-events-none absolute bottom-28 left-4 top-6 z-10 hidden w-4 md:block">
<div className="relative h-full w-full">
<div <div
className="max-w-3xl mx-auto relative" className="absolute left-1/2 top-0 h-full w-px -translate-x-1/2"
style={{ backgroundColor: "var(--border-default)" }}
/>
<div className="relative flex h-full flex-col items-center justify-between py-1">
{userAnchors.map((messageIndex, anchorIndex) => {
const isActive = anchorIndex === activeUserAnchor;
return (
<button
key={`${messageIndex}-${anchorIndex}`}
onClick={() => {
virtualizer.scrollToIndex(messageIndex, { align: "center", behavior: "smooth" });
}}
className="pointer-events-auto flex h-4 w-4 items-center justify-center rounded-full transition-all"
title={`Jump to your message ${anchorIndex + 1}`}
aria-label={`Jump to your message ${anchorIndex + 1}`}
>
<span
className="block rounded-full transition-all"
style={{
width: isActive ? 7 : 5,
height: isActive ? 7 : 5,
backgroundColor: isActive ? "var(--accent)" : "var(--border-strong)",
boxShadow: "0 0 0 2px var(--surface-ground)",
opacity: isActive ? 1 : 0.72,
}}
/>
</button>
);
})}
</div>
</div>
</div>
)}
<div
className="relative mx-auto max-w-3xl"
style={{ height: `${virtualizer.getTotalSize()}px` }} style={{ height: `${virtualizer.getTotalSize()}px` }}
> >
{virtualizer.getVirtualItems().map((virtualItem) => { {virtualizer.getVirtualItems().map((virtualItem) => {
@ -215,19 +283,17 @@ export function ChatMessageList({ conversationId }: ChatMessageListProps) {
<ChatMessageBubble <ChatMessageBubble
message={message} message={message}
conversationId={conversationId} conversationId={conversationId}
onRegenerate={(_newMsgId: string) => { onRegenerate={() => {
queryClient.invalidateQueries({ queryKey: ["ai-messages", conversationId] }); queryClient.invalidateQueries({ queryKey: ["ai-messages", conversationId] });
queryClient.invalidateQueries({ queryKey: ["ai-conversations", conversationId] }); queryClient.invalidateQueries({ queryKey: ["ai-conversations", conversationId] });
}} }}
setIsStreaming={setIsStreaming}
/> />
</div> </div>
); );
})} })}
</div> </div>
{/* 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 && ( {hasStreamingBubble && (
<div className="max-w-3xl mx-auto w-full"> <div className="max-w-3xl mx-auto w-full">
<StreamingBubble parts={stream!.parts} isDone={stream!.isDone} /> <StreamingBubble parts={stream!.parts} isDone={stream!.isDone} />
@ -240,17 +306,16 @@ export function ChatMessageList({ conversationId }: ChatMessageListProps) {
function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boolean }) { function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boolean }) {
const { selectedModel } = useChatPage(); 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<StreamPart[]>([]); const [displayParts, setDisplayParts] = useState<StreamPart[]>([]);
const [displayDone, setDisplayDone] = useState(false); const [displayDone, setDisplayDone] = useState(false);
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const latestRef = useRef({ parts, isDone }); const latestRef = useRef({ parts, isDone });
const rafRef = useRef<number>(0); const rafRef = useRef<number>(0);
useEffect(() => {
latestRef.current = { parts, isDone }; latestRef.current = { parts, isDone };
});
// Start rAF sync loop when streaming begins
const hasParts = parts.length > 0; const hasParts = parts.length > 0;
useEffect(() => { useEffect(() => {
if (!hasParts) return; if (!hasParts) return;
@ -266,15 +331,6 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole
return () => cancelAnimationFrame(rafRef.current); return () => cancelAnimationFrame(rafRef.current);
}, [hasParts]); }, [hasParts]);
// Final sync when streaming stops (captures last frame)
useEffect(() => {
if (isDone) {
setDisplayParts([...parts]);
setDisplayDone(true);
}
}, [isDone, parts]);
// Reset height for virtualizer measurement
useEffect(() => { useEffect(() => {
if (contentRef.current) { if (contentRef.current) {
contentRef.current.style.height = "auto"; contentRef.current.style.height = "auto";
@ -290,7 +346,6 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole
return ( return (
<div ref={contentRef} className="flex gap-4 px-4 py-3 max-w-3xl mx-auto w-full"> <div ref={contentRef} className="flex gap-4 px-4 py-3 max-w-3xl mx-auto w-full">
{/* Model Avatar */}
<div className="shrink-0 pt-0.5"> <div className="shrink-0 pt-0.5">
<StreamingModelAvatar modelName={selectedModel?.model_name} size={28} /> <StreamingModelAvatar modelName={selectedModel?.model_name} size={28} />
</div> </div>
@ -301,14 +356,11 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole
</span> </span>
{!displayDone && ( {!displayDone && (
<span className="text-[11px] animate-pulse" style={{ color: "var(--text-muted)" }}> <span className="text-[11px] animate-pulse" style={{ color: "var(--text-muted)" }}>
responding responding...
</span> </span>
)} )}
</div> </div>
{/* Interleaved rendering thinking (collapsible) + token in order.
Rendered from displayParts which sync at ~60fps via rAF.
rehype-raw + rehype-sanitize allow safe inline HTML. */}
<div className="text-sm" style={{ color: "var(--text-primary)" }}> <div className="text-sm" style={{ color: "var(--text-primary)" }}>
{displayParts.map((part, i) => { {displayParts.map((part, i) => {
if (part.type === "thinking") { if (part.type === "thinking") {
@ -320,16 +372,31 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole
</Reasoning> </Reasoning>
); );
} }
// Token content — rendered as full Markdown + safe HTML. if (part.type === "tool_call") {
// MarkdownRenderer is memoized so only re-renders when content changes return (
// (which happens at rAF rate, not per SSE token). <ToolCallBlock
key={i}
toolName={part.toolName || "unknown"}
args={part.toolArgs || {}}
status={displayDone ? "ok" : "pending"}
/>
);
}
if (part.type === "tool_result") {
return (
<ToolCallBlock
key={i}
toolName={part.toolName || "unknown"}
args={part.toolArgs || {}}
status={part.toolStatus || "ok"}
result={part.content}
/>
);
}
const isLast = i === displayParts.length - 1; const isLast = i === displayParts.length - 1;
return ( return (
<div key={i}> <div key={i}>
<MarkdownRenderer <IrRenderer nodes={part.irNodes} className={PROSE_CLASS} />
content={part.content}
className="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"
/>
{isLast && !displayDone && <StreamingCursor />} {isLast && !displayDone && <StreamingCursor />}
</div> </div>
); );
@ -340,7 +407,6 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole
); );
} }
/** Blinking cursor for typing feel during streaming. */
function StreamingCursor() { function StreamingCursor() {
return ( return (
<span <span
@ -388,12 +454,13 @@ function StreamingModelAvatar({ modelName, size = 28 }: { modelName?: string | n
} }
return ( return (
<div <div
className="rounded-full flex items-center justify-center font-bold text-white shrink-0" className="rounded-full flex items-center justify-center font-bold shrink-0"
style={{ style={{
width: size, width: size,
height: size, height: size,
backgroundColor: hashColor(modelName), backgroundColor: hashColor(modelName),
fontSize: Math.max(10, size * 0.35), fontSize: Math.max(10, size * 0.35),
color: "var(--text-inverse)",
}} }}
> >
{modelName[0]?.toUpperCase() || "?"} {modelName[0]?.toUpperCase() || "?"}

View File

@ -40,12 +40,13 @@ function ModelAvatar({ modelName, size = 20 }: { modelName: string; size?: numbe
} }
return ( return (
<div <div
className="rounded-md flex items-center justify-center font-bold text-white shrink-0" className="rounded-md flex items-center justify-center font-bold shrink-0"
style={{ style={{
width: size, width: size,
height: size, height: size,
backgroundColor: hashColor(modelName), backgroundColor: hashColor(modelName),
fontSize: Math.max(9, size * 0.35), fontSize: Math.max(9, size * 0.35),
color: "var(--text-inverse)",
}} }}
> >
{modelName[0]?.toUpperCase() || "?"} {modelName[0]?.toUpperCase() || "?"}

View File

@ -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 { useParams, useNavigate } from "react-router-dom";
import { ChatPageContext } from "./ChatPageContext"; import { ChatPageContext } from "./ChatPageContext";
import type { SelectedModel } from "./ChatPageContext"; import type { SelectedModel } from "./ChatPageContext";
@ -8,6 +8,8 @@ import { ChatMessageList } from "./ChatMessageList";
import { ChatMessageInput } from "./ChatMessageInput"; import { ChatMessageInput } from "./ChatMessageInput";
import { useProjectInfo } from "@/hooks/useProjectInfo"; import { useProjectInfo } from "@/hooks/useProjectInfo";
import { useConversationQuery } from "@/hooks/useAiChatQuery"; import { useConversationQuery } from "@/hooks/useAiChatQuery";
import { CodePreviewPanel } from "@/components/chat/CodePreviewPanel";
import { CodePreviewProvider, type CodePreviewPayload } from "@/components/chat/CodePreviewContext";
interface ChatPageProps { interface ChatPageProps {
scope: "personal" | "project"; scope: "personal" | "project";
@ -20,33 +22,27 @@ export function ChatPage({ scope }: ChatPageProps) {
}>(); }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { data: projectInfo } = useProjectInfo(projectName); const { data: projectInfo } = useProjectInfo(projectName);
const [selectedConversationId, setSelectedConversationId] = useState<string | null>( const selectedConversationId = urlConversationId || null;
urlConversationId || null
);
const [isStreaming, setIsStreaming] = useState(false); const [isStreaming, setIsStreaming] = useState(false);
const [selectedModel, setSelectedModel] = useState<SelectedModel | null>(null); const [userModel, setSelectedModel] = useState<SelectedModel | null>(null);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(true);
const [activeCode, setActiveCode] = useState<CodePreviewPayload | null>(null);
useEffect(() => {
if (urlConversationId) {
setSelectedConversationId(urlConversationId);
}
}, [urlConversationId]);
const { data: conversation } = useConversationQuery(selectedConversationId || ""); const { data: conversation } = useConversationQuery(selectedConversationId || "");
useEffect(() => { // Derive model from conversation data when it loads
const conv = conversation as any; const derivedModel = useMemo(() => {
if (conv?.model) { if (conversation?.model) {
setSelectedModel({ return { model_name: conversation.model } as SelectedModel;
model_name: conv.model,
});
} }
}, [conversation, setSelectedModel]); return null;
}, [conversation]);
// Use user-selected model if set, otherwise fall back to conversation model
const selectedModel = userModel || derivedModel;
const handleSelectConversation = useCallback( const handleSelectConversation = useCallback(
(id: string) => { (id: string) => {
setSelectedConversationId(id);
if (scope === "personal") { if (scope === "personal") {
navigate(`/me/chat/${id}`, { replace: true }); navigate(`/me/chat/${id}`, { replace: true });
} else if (projectName) { } else if (projectName) {
@ -57,7 +53,6 @@ export function ChatPage({ scope }: ChatPageProps) {
); );
const handleNewConversation = useCallback(() => { const handleNewConversation = useCallback(() => {
setSelectedConversationId(null);
if (scope === "personal") { if (scope === "personal") {
navigate("/me/chat", { replace: true }); navigate("/me/chat", { replace: true });
} else if (projectName) { } else if (projectName) {
@ -65,12 +60,41 @@ export function ChatPage({ scope }: ChatPageProps) {
} }
}, [scope, projectName, navigate]); }, [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 projectId = projectInfo?.uid;
const codePreviewValue = useMemo(
() => ({
activeCode,
openCodePreview: setActiveCode,
closeCodePreview: () => setActiveCode(null),
}),
[activeCode]
);
return ( return (
<ChatPageContext.Provider <ChatPageContext.Provider
value={{ scope, projectName, projectId, selectedModel, setSelectedModel }} value={{ scope, projectName, projectId, selectedModel, setSelectedModel }}
> >
<CodePreviewProvider value={codePreviewValue}>
<div className="flex h-full" style={{ backgroundColor: "var(--surface-ground)" }}> <div className="flex h-full" style={{ backgroundColor: "var(--surface-ground)" }}>
{/* Sidebar - collapsible */} {/* Sidebar - collapsible */}
<div className="relative flex shrink-0"> <div className="relative flex shrink-0">
@ -93,7 +117,10 @@ export function ChatPage({ scope }: ChatPageProps) {
</div> </div>
{/* Main Chat Area */} {/* Main Chat Area */}
<div className="flex-1 flex flex-col min-w-0" style={{ backgroundColor: "var(--surface-ground)" }}> <div
className="flex min-w-0 flex-1 flex-col transition-[flex-basis,max-width] duration-300 ease-out"
style={{ backgroundColor: "var(--surface-ground)" }}
>
<ChatHeader <ChatHeader
conversationId={selectedConversationId} conversationId={selectedConversationId}
isStreaming={isStreaming} isStreaming={isStreaming}
@ -103,7 +130,7 @@ export function ChatPage({ scope }: ChatPageProps) {
{selectedConversationId ? ( {selectedConversationId ? (
<> <>
<ChatMessageList conversationId={selectedConversationId} /> <ChatMessageList conversationId={selectedConversationId} setIsStreaming={setIsStreaming} />
<ChatMessageInput <ChatMessageInput
conversationId={selectedConversationId} conversationId={selectedConversationId}
isStreaming={isStreaming} isStreaming={isStreaming}
@ -114,7 +141,7 @@ export function ChatPage({ scope }: ChatPageProps) {
) : ( ) : (
<div className="flex-1 flex flex-col items-center justify-center px-4 gap-4"> <div className="flex-1 flex flex-col items-center justify-center px-4 gap-4">
<div className="w-full max-w-3xl"> <div className="w-full max-w-3xl">
<ChatMessageList conversationId={null} /> <ChatMessageList conversationId={null} setIsStreaming={setIsStreaming} />
<div className="mt-4"> <div className="mt-4">
<ChatMessageInput <ChatMessageInput
conversationId={null} conversationId={null}
@ -127,7 +154,9 @@ export function ChatPage({ scope }: ChatPageProps) {
</div> </div>
)} )}
</div> </div>
<CodePreviewPanel code={activeCode} onClose={() => setActiveCode(null)} />
</div> </div>
</CodePreviewProvider>
</ChatPageContext.Provider> </ChatPageContext.Provider>
); );
} }

View File

@ -1,5 +1,5 @@
import { useState, useCallback } from "react"; 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 { Search, Lock, Globe, Users, Compass } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { search } from "@/client/api"; import { search } from "@/client/api";
@ -23,7 +23,6 @@ function hashColor(str: string): string {
} }
export function ExplorePage() { export function ExplorePage() {
const navigate = useNavigate();
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
const searchParams: SearchParams = { const searchParams: SearchParams = {
@ -142,7 +141,7 @@ export function ExplorePage() {
<AvatarFallback <AvatarFallback
style={{ style={{
backgroundColor: hashColor(project.display_name), backgroundColor: hashColor(project.display_name),
color: "#ffffff", color: "var(--text-inverse)",
}} }}
> >
<span className="text-[15px] font-semibold"> <span className="text-[15px] font-semibold">

View File

@ -51,7 +51,6 @@ export function RootLayout() {
} }
// Initialize new client // Initialize new client
if (true) {
try { try {
const client = initWsClient({ const client = initWsClient({
url: WS_CONFIG.url, url: WS_CONFIG.url,
@ -63,9 +62,6 @@ export function RootLayout() {
} catch (err) { } catch (err) {
console.error("Failed to initialize WebSocket:", err); console.error("Failed to initialize WebSocket:", err);
} }
} else {
console.warn("VITE_WS_URL not set, WebSocket disabled");
}
return () => { return () => {
// Don't disconnect on unmount - let it persist across navigation // Don't disconnect on unmount - let it persist across navigation

View File

@ -21,7 +21,7 @@ interface ActivityTimelineProps {
isLoading?: boolean; isLoading?: boolean;
} }
const ICON_MAP: Record<string, any> = { const ICON_MAP: Record<string, React.ComponentType<{ className?: string; "aria-hidden"?: string }>> = {
login: LogIn, login: LogIn,
logout: LogOut, logout: LogOut,
register: UserPlus, register: UserPlus,

View File

@ -50,8 +50,9 @@ export function CreateProjectModal({ onClose }: CreateProjectModalProps) {
if (res?.project) { if (res?.project) {
navigate(`/${res.project.name}/repos`); navigate(`/${res.project.name}/repos`);
} }
} catch (err: any) { } catch (err: unknown) {
setError(err.response?.data?.message || "Failed to create project. The slug might already be taken."); 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 */} {/* Header */}
<div className="px-6 py-5 flex items-center justify-between" style={{ backgroundColor: "var(--surface-elevated)", borderBottomColor: "var(--border-default)", borderBottomWidth: "1px", borderBottomStyle: "solid" }}> <div className="px-6 py-5 flex items-center justify-between" style={{ backgroundColor: "var(--surface-elevated)", borderBottomColor: "var(--border-default)", borderBottomWidth: "1px", borderBottomStyle: "solid" }}>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl flex items-center justify-center text-white shadow-lg" style={{ backgroundColor: "var(--accent)", boxShadow: "0 4px 14px var(--accent)" }}> <div className="w-10 h-10 rounded-xl flex items-center justify-center shadow-lg" style={{ backgroundColor: "var(--accent)", color: "var(--accent-fg)", boxShadow: "0 4px 14px var(--accent)" }}>
<Rocket className="w-5 h-5" /> <Rocket className="w-5 h-5" />
</div> </div>
<div> <div>
@ -141,7 +142,7 @@ export function CreateProjectModal({ onClose }: CreateProjectModalProps) {
{/* Visibility Toggle */} {/* Visibility Toggle */}
<div className="p-4 rounded-xl flex items-center justify-between" style={{ backgroundColor: "var(--surface-elevated)", borderColor: "var(--border-default)", borderWidth: "1px", borderStyle: "solid" }}> <div className="p-4 rounded-xl flex items-center justify-between" style={{ backgroundColor: "var(--surface-elevated)", borderColor: "var(--border-default)", borderWidth: "1px", borderStyle: "solid" }}>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className={`w-10 h-10 rounded-full flex items-center justify-center transition-colors ${form.is_public ? '' : ''}`} style={form.is_public ? { backgroundColor: "color-mix(in srgb, #23A559 10%, transparent)", color: "#23A559" } : { backgroundColor: "color-mix(in srgb, #F1C40F 10%, transparent)", color: "#F1C40F" }}> <div className={`w-10 h-10 rounded-full flex items-center justify-center transition-colors ${form.is_public ? '' : ''}`} style={form.is_public ? { backgroundColor: "color-mix(in srgb, var(--success) 10%, transparent)", color: "var(--success)" } : { backgroundColor: "color-mix(in srgb, var(--warning) 10%, transparent)", color: "var(--warning)" }}>
{form.is_public ? <Globe className="w-5 h-5" /> : <ShieldCheck className="w-5 h-5" />} {form.is_public ? <Globe className="w-5 h-5" /> : <ShieldCheck className="w-5 h-5" />}
</div> </div>
<div> <div>
@ -187,7 +188,7 @@ export function CreateProjectModal({ onClose }: CreateProjectModalProps) {
<Button <Button
type="submit" type="submit"
disabled={!form.name.trim() || !form.display_name.trim() || createMutation.isPending} disabled={!form.name.trim() || !form.display_name.trim() || createMutation.isPending}
className="text-white font-bold px-8 h-11 shadow-lg" className="font-bold px-8 h-11 shadow-lg"
style={{ backgroundColor: "var(--accent)", boxShadow: "0 4px 14px var(--accent)" }} style={{ backgroundColor: "var(--accent)", boxShadow: "0 4px 14px var(--accent)" }}
onMouseEnter={e => e.currentTarget.style.backgroundColor = "color-mix(in srgb, var(--accent) 85%, black)"} onMouseEnter={e => e.currentTarget.style.backgroundColor = "color-mix(in srgb, var(--accent) 85%, black)"}
onMouseLeave={e => e.currentTarget.style.backgroundColor = "var(--accent)"} onMouseLeave={e => e.currentTarget.style.backgroundColor = "var(--accent)"}

View File

@ -23,7 +23,7 @@ export function FollowerCardList({ users }: FollowerCardListProps) {
style={{ backgroundColor: "var(--surface-elevated)", borderColor: "var(--border-default)" }} style={{ backgroundColor: "var(--surface-elevated)", borderColor: "var(--border-default)" }}
> >
<Avatar className="w-12 h-12 rounded-lg"> <Avatar className="w-12 h-12 rounded-lg">
<AvatarFallback className="rounded-lg text-white" style={{ backgroundColor: "var(--accent)" }}> <AvatarFallback className="rounded-lg" style={{ backgroundColor: "var(--accent)", color: "var(--accent-fg)" }}>
U U
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>

View File

@ -9,10 +9,18 @@ import {
MessageSquare, MessageSquare,
PanelLeftClose PanelLeftClose
} from "lucide-react"; } from "lucide-react";
import type { ComponentType } from "react";
import { useCurrentUserQuery } from "@/hooks/useAuth"; import { useCurrentUserQuery } from "@/hooks/useAuth";
import { useUserInfoQuery, useUserStarsQuery, useUserFollowerCountQuery, useUserFollowingCountQuery, useUserSummaryQuery } from "@/hooks/useUserQuery"; import { useUserInfoQuery, useUserStarsQuery, useUserFollowerCountQuery, useUserFollowingCountQuery, useUserSummaryQuery } from "@/hooks/useUserQuery";
const ME_NAV_ITEMS = [ interface NavItem {
path: string;
name: string;
icon: ComponentType<{ className?: string }>;
end?: boolean;
}
const ME_NAV_ITEMS: NavItem[] = [
{ {
path: "/me", path: "/me",
name: "Overview", name: "Overview",
@ -54,7 +62,7 @@ const ME_NAV_ITEMS = [
name: "Followers", name: "Followers",
icon: Users, icon: Users,
}, },
] as const; ];
interface MeSidebarProps { interface MeSidebarProps {
onCollapse?: () => void; onCollapse?: () => void;
@ -101,7 +109,7 @@ export function MeSidebar({ onCollapse }: MeSidebarProps) {
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-[30px] h-[30px] rounded-xl flex items-center justify-center font-semibold text-white text-[12px]" <div className="w-[30px] h-[30px] rounded-xl flex items-center justify-center font-semibold text-[12px]"
style={{ backgroundColor: "var(--accent)" }}> style={{ backgroundColor: "var(--accent)" }}>
{username ? username[0].toUpperCase() : "U"} {username ? username[0].toUpperCase() : "U"}
</div> </div>
@ -124,7 +132,7 @@ export function MeSidebar({ onCollapse }: MeSidebarProps) {
<nav className="flex-1 overflow-y-auto py-2"> <nav className="flex-1 overflow-y-auto py-2">
{ME_NAV_ITEMS.slice(0, 4).map((item) => { {ME_NAV_ITEMS.slice(0, 4).map((item) => {
const active = isActive(item.path, (item as any).end); const active = isActive(item.path, item.end);
const count = getCount(item.name); const count = getCount(item.name);
return ( return (
@ -160,7 +168,7 @@ export function MeSidebar({ onCollapse }: MeSidebarProps) {
</div> </div>
{ME_NAV_ITEMS.slice(4).map((item) => { {ME_NAV_ITEMS.slice(4).map((item) => {
const active = isActive(item.path, (item as any).end); const active = isActive(item.path, item.end);
const count = getCount(item.name); const count = getCount(item.name);
return ( return (

View File

@ -73,7 +73,7 @@ export function ProfileHeader({ user, isMe, isLoading, starsCount: starsCountPro
<div className="flex flex-col md:flex-row gap-6 items-start"> <div className="flex flex-col md:flex-row gap-6 items-start">
<Avatar className="w-16 h-16 md:w-20 md:h-20 rounded-xl border-[0.5px] shadow-sm" style={{ borderColor: "var(--border-subtle)" }}> <Avatar className="w-16 h-16 md:w-20 md:h-20 rounded-xl border-[0.5px] shadow-sm" style={{ borderColor: "var(--border-subtle)" }}>
<AvatarImage src={user.avatar_url || undefined} alt={user.username} /> <AvatarImage src={user.avatar_url || undefined} alt={user.username} />
<AvatarFallback className="text-2xl rounded-xl text-white font-medium" style={{ backgroundColor: "var(--accent)" }}> <AvatarFallback className="text-2xl rounded-xl font-medium" style={{ backgroundColor: "var(--accent)", color: "var(--accent-fg)" }}>
{user.username[0].toUpperCase()} {user.username[0].toUpperCase()}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
@ -91,7 +91,7 @@ export function ProfileHeader({ user, isMe, isLoading, starsCount: starsCountPro
{!isMe && ( {!isMe && (
<Button <Button
variant={user.is_subscribe ? "outline" : "default"} variant={user.is_subscribe ? "outline" : "default"}
className={user.is_subscribe ? "" : "text-white"} className={user.is_subscribe ? "" : "text-[var(--accent-fg)]"}
style={user.is_subscribe ? {} : { backgroundColor: "var(--accent)" }} style={user.is_subscribe ? {} : { backgroundColor: "var(--accent)" }}
onClick={user.is_subscribe ? handleUnfollow : handleFollow} onClick={user.is_subscribe ? handleUnfollow : handleFollow}
disabled={followMutation.isPending || unfollowMutation.isPending} disabled={followMutation.isPending || unfollowMutation.isPending}
@ -143,7 +143,7 @@ export function ProfileHeader({ user, isMe, isLoading, starsCount: starsCountPro
<p className="text-[15px] font-semibold" style={{ color: "var(--text-primary)" }}>{starsCount}</p> <p className="text-[15px] font-semibold" style={{ color: "var(--text-primary)" }}>{starsCount}</p>
<p className="text-[10px] uppercase tracking-wider font-medium" style={{ color: "var(--text-tertiary)" }}>Stars</p> <p className="text-[10px] uppercase tracking-wider font-medium" style={{ color: "var(--text-tertiary)" }}>Stars</p>
</div> </div>
<div className="text-center"> <div className="text-center cursor-pointer hover:opacity-70 transition-opacity" onClick={() => window.location.href = "/me/followers"}>
<p className="text-[15px] font-semibold" style={{ color: "var(--text-primary)" }}>{followerCount}</p> <p className="text-[15px] font-semibold" style={{ color: "var(--text-primary)" }}>{followerCount}</p>
<p className="text-[10px] uppercase tracking-wider font-medium" style={{ color: "var(--text-tertiary)" }}>Followers</p> <p className="text-[10px] uppercase tracking-wider font-medium" style={{ color: "var(--text-tertiary)" }}>Followers</p>
</div> </div>

View File

@ -53,7 +53,7 @@ export function ProjectList({ projects, isLoading }: ProjectListProps) {
onClick={() => navigate(`/${project.name}`)} onClick={() => navigate(`/${project.name}`)}
> >
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-white text-[11px] font-bold shrink-0" style={{ backgroundColor: "var(--accent)" }}> <div className="w-8 h-8 rounded-lg flex items-center justify-center text-[11px] font-bold shrink-0" style={{ backgroundColor: "var(--accent)", color: "var(--accent-fg)" }}>
{project.display_name[0].toUpperCase()} {project.display_name[0].toUpperCase()}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">

View File

@ -30,7 +30,7 @@ export function UserCardList({ users, onToggleFollow }: UserCardListProps) {
> >
<Avatar className="w-12 h-12 rounded-lg"> <Avatar className="w-12 h-12 rounded-lg">
<AvatarImage src={user.avatar_url || undefined} alt={user.username} /> <AvatarImage src={user.avatar_url || undefined} alt={user.username} />
<AvatarFallback className="rounded-lg text-white" style={{ backgroundColor: "var(--accent)" }}> <AvatarFallback className="rounded-lg" style={{ backgroundColor: "var(--accent)", color: "var(--accent-fg)" }}>
{user.username[0].toUpperCase()} {user.username[0].toUpperCase()}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useBoardDetailQuery } from "@/hooks/useBoardsQuery"; import { useBoardDetailQuery } from "@/hooks/useBoardsQuery";
import { useBoardOperations } from "@/hooks/useBoardOperations"; import { useBoardOperations } from "@/hooks/useBoardOperations";
@ -30,12 +30,6 @@ export function KanbanBoard({ projectName, boardId }: KanbanBoardProps) {
const [editCardTitle, setEditCardTitle] = useState(""); const [editCardTitle, setEditCardTitle] = useState("");
const [editCardDescription, setEditCardDescription] = useState(""); const [editCardDescription, setEditCardDescription] = useState("");
useEffect(() => {
if (selectedCard) {
setEditCardTitle(selectedCard.title);
setEditCardDescription(selectedCard.description || "");
}
}, [selectedCard]);
const handleCreateColumn = async () => { const handleCreateColumn = async () => {
if (!newColumnName.trim()) return; if (!newColumnName.trim()) return;
@ -129,7 +123,11 @@ export function KanbanBoard({ projectName, boardId }: KanbanBoardProps) {
refetch(); refetch();
} }
}} }}
onCardClick={(card) => setSelectedCard(card)} onCardClick={(card) => {
setSelectedCard(card);
setEditCardTitle(card.title);
setEditCardDescription(card.description || "");
}}
onMoveCard={handleMoveCard} onMoveCard={handleMoveCard}
/> />
))} ))}

View File

@ -9,7 +9,7 @@ import {
useRoom, useRoom,
} from '@/contexts/room'; } from '@/contexts/room';
import { useProjectLayout } from '@/app/project/layout'; import { useProjectLayout } from '@/app/project/layout';
import type { Message, ReactionGroup, Member } from '@/contexts/room'; import type { Message, ReactionGroup, Member, ThreadState } from '@/contexts/room';
import { import {
ThreadPanel, ThreadPanel,
EditHistoryOverlay, EditHistoryOverlay,
@ -54,7 +54,7 @@ function ChannelPageInner() {
const [editingMessageId, setEditingMessageId] = useState<string | null>(null); const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
const [replyToMessageId, setReplyToMessageId] = useState<string | null>(null); const [replyToMessageId, setReplyToMessageId] = useState<string | null>(null);
const [emojiPickerMessageId, setEmojiPickerMessageId] = useState<string | null>(null); const [emojiPickerMessageId, setEmojiPickerMessageId] = useState<string | null>(null);
const [activeThread, setActiveThread] = useState<any>(null); const [activeThread, setActiveThread] = useState<ThreadState | null>(null);
const [editHistoryMessageId, setEditHistoryMessageId] = useState<string | null>(null); const [editHistoryMessageId, setEditHistoryMessageId] = useState<string | null>(null);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
@ -85,16 +85,18 @@ function ChannelPageInner() {
]) ])
.then(([aiRes, reposRes, skillsRes]) => { .then(([aiRes, reposRes, skillsRes]) => {
const aiData = aiRes.data?.data ?? []; 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) { if (reposRes) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const repoList = (reposRes.data?.data?.items ?? []) as 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) { if (skillsRes) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const skillsData = (skillsRes.data?.data ?? []) as 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(() => {}); .catch(() => {});
@ -114,7 +116,7 @@ function ChannelPageInner() {
if (wsStatus === 'connected' && isConnected) { if (wsStatus === 'connected' && isConnected) {
loadHistory(); loadHistory();
} }
}, [wsStatus, isConnected]); }, [wsStatus, isConnected, loadHistory]);
// Load older messages when scrolling to top // Load older messages when scrolling to top
const handleStartReached = useCallback(() => { const handleStartReached = useCallback(() => {
@ -350,6 +352,7 @@ function ChannelPageInner() {
try { try {
const { threadMessages } = await import('@/client/api'); const { threadMessages } = await import('@/client/api');
const res = await threadMessages(roomIdParam, msg.thread); 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) => ({ const threadMsgs: Message[] = (res.data?.data?.messages ?? []).map((r: any) => ({
...r, _localReactions: [], is_streaming: false, isOptimistic: false, isOptimisticError: false, thinking_content: null, ...r, _localReactions: [], is_streaming: false, isOptimistic: false, isOptimisticError: false, thinking_content: null,
})); }));
@ -410,13 +413,13 @@ function ChannelPageInner() {
<div className="channel-root" style={{ display: 'flex', height: '100%', overflow: 'hidden' }}> <div className="channel-root" style={{ display: 'flex', height: '100%', overflow: 'hidden' }}>
<div className="channel-panel" style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}> <div className="channel-panel" style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
{wsStatus === 'reconnecting' && ( {wsStatus === 'reconnecting' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 16px', backgroundColor: 'var(--warning)', color: '#000', fontSize: 13, fontWeight: 500 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 16px', backgroundColor: 'var(--warning)', color: 'var(--text-primary)', fontSize: 13, fontWeight: 500 }}>
<AlertCircle className="w-4 h-4" />Reconnecting... <AlertCircle className="w-4 h-4" />Reconnecting...
</div> </div>
)} )}
{wsStatus === 'disconnected' && ( {wsStatus === 'disconnected' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 16px', backgroundColor: 'var(--error)', color: '#fff', fontSize: 13, fontWeight: 500 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 16px', backgroundColor: 'var(--error)', color: 'var(--text-inverse)', fontSize: 13, fontWeight: 500 }}>
<AlertCircle className="w-4 h-4" />Connection lost. <button onClick={() => safeGetClient()?.connect()} style={{ textDecoration: 'underline', marginLeft: 'auto', background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', padding: 0 }}>Reconnect now</button> <AlertCircle className="w-4 h-4" />Connection lost. <button onClick={() => safeGetClient()?.connect()} style={{ textDecoration: 'underline', marginLeft: 'auto', background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', padding: 0 }}>Reconnect now</button>
</div> </div>
)} )}
@ -443,7 +446,7 @@ function ChannelPageInner() {
members={members.map((m: Member) => ({ members={members.map((m: Member) => ({
uid: m.uid, uid: m.uid,
username: m.username, username: m.username,
avatar_url: (m as any).avatar_url ?? null, avatar_url: m.avatar_url ?? null,
}))} }))}
agents={agents} agents={agents}
repos={repos} repos={repos}
@ -484,7 +487,7 @@ function ChannelPageInner() {
thread={activeThread} thread={activeThread}
typingUsers={typingUsersList} typingUsers={typingUsersList}
onClose={closeThread} 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); }} onTypingStart={() => { const c = safeGetClient(); if (c) c.sendTypingStart(roomIdParam); }}
onTypingStop={() => { const c = safeGetClient(); if (c) c.sendTypingStop(roomIdParam); }} onTypingStop={() => { const c = safeGetClient(); if (c) c.sendTypingStop(roomIdParam); }}
/> />

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState } from "react";
import { Settings, Trash2, Loader2, Globe, Lock } from "lucide-react"; import { Settings, Trash2, Loader2, Globe, Lock } from "lucide-react";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -27,12 +27,13 @@ export function RoomSettingsModal({ open, onOpenChange }: RoomSettingsModalProps
const [isPublic, setIsPublic] = useState(true); const [isPublic, setIsPublic] = useState(true);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
useEffect(() => { const handleOpenChange = (newOpen: boolean) => {
if (currentRoom && open) { onOpenChange(newOpen);
if (newOpen && currentRoom) {
setName(currentRoom.room_name); setName(currentRoom.room_name);
setIsPublic(currentRoom.public); setIsPublic(currentRoom.public ?? true);
} }
}, [currentRoom, open]); };
const handleUpdateRoom = async () => { const handleUpdateRoom = async () => {
if (!currentRoom || !name.trim()) return; if (!currentRoom || !name.trim()) return;
@ -63,7 +64,7 @@ export function RoomSettingsModal({ open, onOpenChange }: RoomSettingsModalProps
}; };
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={handleOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-lg p-0 flex flex-col" style={{ backgroundColor: "var(--surface-ground)" }}> <SheetContent side="right" className="w-full sm:max-w-lg p-0 flex flex-col" style={{ backgroundColor: "var(--surface-ground)" }}>
<SheetHeader className="p-6 pb-0 shrink-0"> <SheetHeader className="p-6 pb-0 shrink-0">
<SheetTitle className="flex items-center gap-2" style={{ color: "var(--text-primary)" }}> <SheetTitle className="flex items-center gap-2" style={{ color: "var(--text-primary)" }}>

View File

@ -1,7 +1,7 @@
import { Loader2, Shield, Search, Check } from "lucide-react"; import { Loader2, Shield, Search, Check } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { aiList, aiUpsert, aiDelete, modelCatalog } from "@/client/api"; 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 { getModelIcon } from "@/lib/icons/modelIcons";
import { Plus, Trash2, Settings, X as XIcon } from "lucide-react"; import { Plus, Trash2, Settings, X as XIcon } from "lucide-react";
import { import {
@ -35,12 +35,13 @@ function ModelAvatar({ modelName, size = 36 }: { modelName: string; size?: numbe
} }
return ( return (
<div <div
className="rounded-lg flex items-center justify-center font-bold text-white shrink-0" className="rounded-lg flex items-center justify-center font-bold shrink-0"
style={{ style={{
width: size, width: size,
height: size, height: size,
backgroundColor: hashColor(modelName), backgroundColor: hashColor(modelName),
fontSize: Math.max(10, size * 0.35), fontSize: Math.max(10, size * 0.35),
color: "var(--text-inverse)",
}} }}
> >
{modelName[0]?.toUpperCase() || "?"} {modelName[0]?.toUpperCase() || "?"}
@ -54,8 +55,8 @@ interface AiSettingsProps {
} }
export function AiSettings({ roomId, onAiListChange }: AiSettingsProps) { export function AiSettings({ roomId, onAiListChange }: AiSettingsProps) {
const [roomAis, setRoomAis] = useState<any[]>([]); const [roomAis, setRoomAis] = useState<RoomAiResponse[]>([]);
const [isLoadingAi, setIsLoadingAi] = useState(false); const [isLoadingAi, setIsLoadingAi] = useState(true);
const [showAddAi, setShowAddAi] = useState(false); const [showAddAi, setShowAddAi] = useState(false);
const [selectedModelFull, setSelectedModelFull] = useState<ModelWithPricingResponse | null>(null); const [selectedModelFull, setSelectedModelFull] = useState<ModelWithPricingResponse | null>(null);
const [aiParams, setAiParams] = useState({ const [aiParams, setAiParams] = useState({
@ -98,7 +99,10 @@ export function AiSettings({ roomId, onAiListChange }: AiSettingsProps) {
}; };
useEffect(() => { useEffect(() => {
fetchRoomAis(); aiList(roomId)
.then((res) => setRoomAis(res.data.data || []))
.catch((err) => console.error("Failed to fetch room AIs", err))
.finally(() => setIsLoadingAi(false));
}, [roomId]); }, [roomId]);
const handleAddAi = async () => { const handleAddAi = async () => {

View File

@ -77,8 +77,8 @@ export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: Project
}); });
onClose(); onClose();
navigate(`/${projectName}/repo/${repoForm.repo_name.trim()}`); navigate(`/${projectName}/repo/${repoForm.repo_name.trim()}`);
} catch (err: any) { } catch (err: unknown) {
setError(err.response?.data?.message || "Failed to create repository."); setError((err as { response?: { data?: { message?: string } } })?.response?.data?.message || "Failed to create repository.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -98,8 +98,8 @@ export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: Project
if (room) { if (room) {
navigate(`/${projectName}/channel/${room.id}`); navigate(`/${projectName}/channel/${room.id}`);
} }
} catch (err: any) { } catch (err: unknown) {
setError(err.response?.data?.message || "Failed to create channel."); setError((err as { response?: { data?: { message?: string } } })?.response?.data?.message || "Failed to create channel.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -119,8 +119,8 @@ export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: Project
if (res.data?.data) { if (res.data?.data) {
navigate(`/${projectName}/board/${res.data.data.id}`); navigate(`/${projectName}/board/${res.data.data.id}`);
} }
} catch (err: any) { } catch (err: unknown) {
setError(err.response?.data?.message || "Failed to create board."); setError((err as { response?: { data?: { message?: string } } })?.response?.data?.message || "Failed to create board.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -142,8 +142,8 @@ export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: Project
if (skill) { if (skill) {
navigate(`/${projectName}/skills/${skill.slug}`); navigate(`/${projectName}/skills/${skill.slug}`);
} }
} catch (err: any) { } catch (err: unknown) {
setError(err.response?.data?.message || "Failed to create skill."); setError((err as { response?: { data?: { message?: string } } })?.response?.data?.message || "Failed to create skill.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -179,7 +179,7 @@ export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: Project
].map(tab => ( ].map(tab => (
<button <button
key={tab.id} key={tab.id}
onClick={() => { setActiveTab(tab.id as any); setError(null); }} onClick={() => { setActiveTab(tab.id as typeof activeTab); setError(null); }}
className="flex-1 py-2.5 text-[12px] font-bold transition-all border-b-2 flex items-center justify-center gap-2" className="flex-1 py-2.5 text-[12px] font-bold transition-all border-b-2 flex items-center justify-center gap-2"
style={{ style={{
borderColor: activeTab === tab.id ? "var(--accent)" : "transparent", borderColor: activeTab === tab.id ? "var(--accent)" : "transparent",
@ -235,7 +235,7 @@ export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: Project
</div> </div>
<div className="pt-2 flex flex-col gap-3"> <div className="pt-2 flex flex-col gap-3">
{error && <p className="text-[12px] font-medium p-2 rounded border" style={{ color: "var(--destructive)", backgroundColor: "var(--destructive-alpha10, rgba(220,38,38,0.1))", borderColor: "var(--destructive)" }}>{error}</p>} {error && <p className="text-[12px] font-medium p-2 rounded border" style={{ color: "var(--destructive)", backgroundColor: "var(--destructive-alpha10)", borderColor: "var(--destructive)" }}>{error}</p>}
<Button <Button
type="submit" type="submit"
disabled={!repoForm.repo_name.trim() || loading} disabled={!repoForm.repo_name.trim() || loading}
@ -284,7 +284,7 @@ export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: Project
</div> </div>
<div className="pt-2 flex flex-col gap-3"> <div className="pt-2 flex flex-col gap-3">
{error && <p className="text-[12px] font-medium p-2 rounded border" style={{ color: "var(--destructive)", backgroundColor: "var(--destructive-alpha10, rgba(220,38,38,0.1))", borderColor: "var(--destructive)" }}>{error}</p>} {error && <p className="text-[12px] font-medium p-2 rounded border" style={{ color: "var(--destructive)", backgroundColor: "var(--destructive-alpha10)", borderColor: "var(--destructive)" }}>{error}</p>}
<Button <Button
type="submit" type="submit"
disabled={!channelForm.room_name.trim() || loading} disabled={!channelForm.room_name.trim() || loading}
@ -326,7 +326,7 @@ export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: Project
</div> </div>
<div className="pt-2 flex flex-col gap-3"> <div className="pt-2 flex flex-col gap-3">
{error && <p className="text-[12px] font-medium p-2 rounded border" style={{ color: "var(--destructive)", backgroundColor: "var(--destructive-alpha10, rgba(220,38,38,0.1))", borderColor: "var(--destructive)" }}>{error}</p>} {error && <p className="text-[12px] font-medium p-2 rounded border" style={{ color: "var(--destructive)", backgroundColor: "var(--destructive-alpha10)", borderColor: "var(--destructive)" }}>{error}</p>}
<Button <Button
type="submit" type="submit"
disabled={!boardForm.name.trim() || loading} disabled={!boardForm.name.trim() || loading}
@ -368,7 +368,7 @@ export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: Project
</div> </div>
<div className="pt-2 flex flex-col gap-3"> <div className="pt-2 flex flex-col gap-3">
{error && <p className="text-[12px] font-medium p-2 rounded border" style={{ color: "var(--destructive)", backgroundColor: "var(--destructive-alpha10, rgba(220,38,38,0.1))", borderColor: "var(--destructive)" }}>{error}</p>} {error && <p className="text-[12px] font-medium p-2 rounded border" style={{ color: "var(--destructive)", backgroundColor: "var(--destructive-alpha10)", borderColor: "var(--destructive)" }}>{error}</p>}
<Button <Button
type="submit" type="submit"
disabled={!skillForm.name.trim() || loading} disabled={!skillForm.name.trim() || loading}

View File

@ -16,7 +16,8 @@ import {
} from "@/hooks/useIssueExtraQuery"; } from "@/hooks/useIssueExtraQuery";
import {LoadingState} from "@/components/ui/LoadingState"; import {LoadingState} from "@/components/ui/LoadingState";
import {ErrorState} from "@/components/ui/ErrorState"; import {ErrorState} from "@/components/ui/ErrorState";
import {MarkdownRenderer} from "@/components/ui/MarkdownRenderer"; import {IrRenderer} from "@/lib/ir/renderer";
import {extractIrNodes} from "@/lib/ir/parser";
import {Button} from "@/components/ui/button"; import {Button} from "@/components/ui/button";
import {Textarea} from "@/components/ui/textarea"; import {Textarea} from "@/components/ui/textarea";
import {Input} from "@/components/ui/input"; import {Input} from "@/components/ui/input";
@ -138,6 +139,15 @@ export function IssueDetailPage() {
); );
}; };
const handleDeleteComment = (commentId: number) => {
if (!projectName || !number) return;
if (!window.confirm("Are you sure you want to delete this comment?")) return;
deleteComment.mutate(
{ projectName, issueNumber: number, commentId },
{ onSuccess: () => refetchComments() }
);
};
const isOwnComment = (comment: IssueCommentResponse) => { const isOwnComment = (comment: IssueCommentResponse) => {
if (!currentUser) return false; if (!currentUser) return false;
@ -204,7 +214,7 @@ export function IssueDetailPage() {
className="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider" className="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider"
style={{ style={{
backgroundColor: issueDetail.state === "open" ? "var(--status-online)" : "var(--accent)", backgroundColor: issueDetail.state === "open" ? "var(--status-online)" : "var(--accent)",
color: "#fff" color: "var(--text-inverse)"
}} }}
> >
{issueDetail.state} {issueDetail.state}
@ -248,7 +258,7 @@ export function IssueDetailPage() {
</div> </div>
<div className="p-4"> <div className="p-4">
{issueDetail.body ? ( {issueDetail.body ? (
<MarkdownRenderer content={issueDetail.body}/> <IrRenderer nodes={extractIrNodes(issueDetail.body)}/>
) : ( ) : (
<p className="text-sm italic" style={{ color: "var(--text-muted)" }}>No description provided.</p> <p className="text-sm italic" style={{ color: "var(--text-muted)" }}>No description provided.</p>
)} )}
@ -294,6 +304,7 @@ export function IssueDetailPage() {
<Pencil className="w-3 h-3"/> <Pencil className="w-3 h-3"/>
</Button> </Button>
<Button variant="ghost" size="icon-sm" <Button variant="ghost" size="icon-sm"
onClick={() => handleDeleteComment(comment.id)}
disabled={isMutating}> disabled={isMutating}>
<Trash2 className="w-3 h-3 text-destructive"/> <Trash2 className="w-3 h-3 text-destructive"/>
</Button> </Button>
@ -326,7 +337,7 @@ export function IssueDetailPage() {
</div> </div>
</div> </div>
) : ( ) : (
<MarkdownRenderer content={comment.body}/> <IrRenderer nodes={extractIrNodes(comment.body)}/>
)} )}
</div> </div>
</div> </div>

View File

@ -146,7 +146,7 @@ export function IssueSidebar({ projectName, issueNumber }: IssueSidebarProps) {
className="flex items-center gap-2" className="flex items-center gap-2"
onClick={() => addLabel.mutate({ projectName, issueNumber, labelId: l.id })} onClick={() => addLabel.mutate({ projectName, issueNumber, labelId: l.id })}
> >
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: l.color || "#5865F2" }} /> <div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: l.color || "var(--accent)" }} />
<span className="flex-1">{l.name}</span> <span className="flex-1">{l.name}</span>
{issueLabels.some(il => il.label_name === l.name) && <Loader2 className="w-3 h-3 animate-spin" />} {issueLabels.some(il => il.label_name === l.name) && <Loader2 className="w-3 h-3 animate-spin" />}
</DropdownMenuItem> </DropdownMenuItem>
@ -167,7 +167,7 @@ export function IssueSidebar({ projectName, issueNumber }: IssueSidebarProps) {
key={l.label_name} key={l.label_name}
variant="outline" variant="outline"
className="text-[10px] px-1.5 py-0 h-5 border-none" 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} {l.label_name}
<button <button

View File

@ -19,6 +19,7 @@ import {
import { ISSUES_PAGE } from "@/css/issues/styles"; import { ISSUES_PAGE } from "@/css/issues/styles";
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { stripMarkdown, truncate } from "@/lib/utils"; import { stripMarkdown, truncate } from "@/lib/utils";
import type { IssueResponse, IssueLabelResponse } from "@/client/model";
export function IssuesPage() { export function IssuesPage() {
const { projectName } = useParams<{ projectName: string }>(); const { projectName } = useParams<{ projectName: string }>();
@ -162,13 +163,13 @@ export function IssuesPage() {
</div> </div>
) : ( ) : (
<div className={ISSUES_PAGE.issueList}> <div className={ISSUES_PAGE.issueList}>
{filteredIssues.map((issue: any) => { {filteredIssues.map((issue: IssueResponse) => {
// Find priority label if exists // Find priority label if exists
const priorityLabel = issue.labels?.find((l: any) => l.label_name?.toLowerCase().startsWith('priority:')); const priorityLabel = issue.labels?.find((l: IssueLabelResponse) => l.label_name?.toLowerCase().startsWith('priority:'));
const priority = priorityLabel ? priorityLabel.label_name.split(':')[1].toLowerCase() : null; const priority = priorityLabel ? (priorityLabel.label_name ?? '').split(':')[1].toLowerCase() : null;
// Other labels // 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 ( return (
<div <div
@ -187,7 +188,7 @@ export function IssuesPage() {
{/* Inline Labels */} {/* Inline Labels */}
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
{otherLabels.map((l: any) => ( {otherLabels.map((l: IssueLabelResponse) => (
<span <span
key={l.label_id} key={l.label_id}
className={ISSUES_PAGE.label} className={ISSUES_PAGE.label}

View File

@ -36,8 +36,8 @@ export function NewIssuePage() {
body: body.trim() || null, body: body.trim() || null,
}); });
navigate(`/${projectName}/issues/${newIssue.number}`); navigate(`/${projectName}/issues/${newIssue.number}`);
} catch (err: any) { } catch (err: unknown) {
setError(err.response?.data?.message || "Failed to create issue. Please try again."); setError((err as { response?: { data?: { message?: string } } })?.response?.data?.message || "Failed to create issue. Please try again.");
} }
}; };

View File

@ -23,6 +23,7 @@ const ProjectContext = createContext<ProjectContextType>({
setCurrentRoomName: () => {}, setCurrentRoomName: () => {},
}); });
// eslint-disable-next-line react-refresh/only-export-components
export const useProjectLayout = () => useContext(ProjectContext); export const useProjectLayout = () => useContext(ProjectContext);
export function ProjectLayout() { export function ProjectLayout() {
@ -31,12 +32,15 @@ export function ProjectLayout() {
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const { projectName } = useParams<{ projectName: string }>(); const { projectName } = useParams<{ projectName: string }>();
const channelMatch = useMatch("/:projectName/channel/:roomId"); const channelMatch = useMatch("/:projectName/channel/:roomId");
const chatMatch = useMatch("/:projectName/chat/*");
const roomId = channelMatch?.params.roomId ?? null; const roomId = channelMatch?.params.roomId ?? null;
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const isTablet = useIsTablet(); const isTablet = useIsTablet();
const canShowMembers = !isMobile && !isTablet; const canShowMembers = !isMobile && !isTablet;
const mainShouldOwnScroll = !channelMatch && !chatMatch;
return ( return (
<ProjectContext.Provider value={{ showMembers, setShowMembers, currentRoomName, setCurrentRoomName }}> <ProjectContext.Provider value={{ showMembers, setShowMembers, currentRoomName, setCurrentRoomName }}>
<RoomProvider roomId={roomId} projectName={projectName}> <RoomProvider roomId={roomId} projectName={projectName}>
@ -86,7 +90,7 @@ export function ProjectLayout() {
> >
<Header /> <Header />
<main <main
className="flex-1 overflow-y-auto" className={mainShouldOwnScroll ? "flex-1 overflow-y-auto" : "flex-1 overflow-hidden min-h-0"}
style={{ backgroundColor: "var(--surface-ground)" }} style={{ backgroundColor: "var(--surface-ground)" }}
> >
<Outlet /> <Outlet />

View File

@ -101,7 +101,7 @@ export function PullsPage() {
</h3> </h3>
<span <span
className="px-2 py-0.5 rounded-full text-xs font-medium" className="px-2 py-0.5 rounded-full text-xs font-medium"
style={{ backgroundColor: STATUS_COLORS[pr.status] || "var(--text-muted)", color: "#fff" }} style={{ backgroundColor: STATUS_COLORS[pr.status] || "var(--text-muted)", color: "var(--text-inverse)" }}
> >
{pr.status} {pr.status}
</span> </span>

View File

@ -89,7 +89,7 @@ export default function BranchProtectionSettings() {
const handleUpdate = async () => { const handleUpdate = async () => {
if (!editForm) return; if (!editForm) return;
try { try {
await updateMutation.mutateAsync({ ...editForm } as any); await updateMutation.mutateAsync({ ...editForm });
setEditingId(null); setEditingId(null);
setEditForm(null); setEditForm(null);
setMsg({ type: "success", text: "Branch protection rule updated" }); setMsg({ type: "success", text: "Branch protection rule updated" });
@ -162,7 +162,7 @@ export default function BranchProtectionSettings() {
<SelectValue placeholder="Select a branch" /> <SelectValue placeholder="Select a branch" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{branchOptions.map((b: any) => ( {branchOptions.map((b: { name: string }) => (
<SelectItem key={b.name} value={b.name}> <SelectItem key={b.name} value={b.name}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<GitBranch className="w-3 h-3" /> <GitBranch className="w-3 h-3" />

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useState } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { useRepoInfoQuery, useUpdateRepoSettingsMutation } from "@/hooks/useRepoDetailQuery"; import { useRepoInfoQuery, useUpdateRepoSettingsMutation } from "@/hooks/useRepoDetailQuery";
import { Loader2, Save, Globe, EyeOff, GitBranch, Zap, RotateCcw } from "lucide-react"; 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 [aiCodeReview, setAiCodeReview] = useState(false);
const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null); 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<typeof repoInfo>(undefined);
if (repoInfo !== prevRepoInfo) {
setPrevRepoInfo(repoInfo);
if (repoInfo) { if (repoInfo) {
setName(repoInfo.repo_name); setName(repoInfo.repo_name);
setDescription(repoInfo.description ?? ""); setDescription(repoInfo.description ?? "");
@ -35,7 +38,7 @@ export default function GeneralSettings() {
setIsPrivate(repoInfo.is_private); setIsPrivate(repoInfo.is_private);
setAiCodeReview(repoInfo.ai_code_review_enabled); setAiCodeReview(repoInfo.ai_code_review_enabled);
} }
}, [repoInfo]); }
if (!projectName || !repoName) return null; if (!projectName || !repoName) return null;
@ -50,7 +53,7 @@ export default function GeneralSettings() {
ai_code_review_enabled: aiCodeReview, ai_code_review_enabled: aiCodeReview,
}); });
setMsg({ type: "success", text: "Repository settings updated successfully" }); setMsg({ type: "success", text: "Repository settings updated successfully" });
} catch (err) { } catch {
setMsg({ type: "error", text: "Failed to update repository settings" }); setMsg({ type: "error", text: "Failed to update repository settings" });
} }
}; };

View File

@ -20,6 +20,7 @@ import {
import type { ProjectRepositoryItem } from "@/client/model"; import type { ProjectRepositoryItem } from "@/client/model";
import { REPOS_PAGE } from "@/css/repo/styles"; import { REPOS_PAGE } from "@/css/repo/styles";
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { ProjectCreateMenuModal } from "@/app/project/components/ProjectCreateMenuModal";
function getRelativeTime(dateStr: string | null) { function getRelativeTime(dateStr: string | null) {
if (!dateStr) return "Never"; if (!dateStr) return "Never";
@ -40,6 +41,7 @@ export function ReposPage() {
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [isCreateMenuOpen, setIsCreateMenuOpen] = useState(false);
const { data: repos = [], isLoading, error, refetch } = useProjectReposQuery(projectName); const { data: repos = [], isLoading, error, refetch } = useProjectReposQuery(projectName);
@ -81,7 +83,7 @@ export function ReposPage() {
<p className={REPOS_PAGE.pageSub}>Host and manage your project source code</p> <p className={REPOS_PAGE.pageSub}>Host and manage your project source code</p>
</div> </div>
<button <button
onClick={() => navigate(`/${projectName}/settings/repos/new`)} onClick={() => setIsCreateMenuOpen(true)}
className={REPOS_PAGE.newBtn} className={REPOS_PAGE.newBtn}
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
@ -196,7 +198,7 @@ export function ReposPage() {
{viewMode === 'grid' && !searchQuery && ( {viewMode === 'grid' && !searchQuery && (
<div <div
className={`${REPOS_PAGE.repoCard} ${REPOS_PAGE.emptyCard}`} className={`${REPOS_PAGE.repoCard} ${REPOS_PAGE.emptyCard}`}
onClick={() => navigate(`/${projectName}/settings/repos/new`)} onClick={() => setIsCreateMenuOpen(true)}
> >
<FolderPlus className={REPOS_PAGE.emptyIcon} /> <FolderPlus className={REPOS_PAGE.emptyIcon} />
<span className={REPOS_PAGE.emptyText}>Create a new repository</span> <span className={REPOS_PAGE.emptyText}>Create a new repository</span>
@ -205,6 +207,9 @@ export function ReposPage() {
)} )}
</div> </div>
)} )}
{isCreateMenuOpen && (
<ProjectCreateMenuModal onClose={() => setIsCreateMenuOpen(false)} initialTab="repo" />
)}
</div> </div>
); );
} }

View File

@ -6,7 +6,7 @@ import {
projectJoinSettings, projectUpdateJoinSettings, projectJoinSettings, projectUpdateJoinSettings,
projectJoinRequests, projectProcessJoinRequest, projectJoinRequests, projectProcessJoinRequest,
} from "@/client/api"; } 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Loader2, Mail, X, Check, Shield, User, EyeOff } from "lucide-react"; import { Loader2, Mail, X, Check, Shield, User, EyeOff } from "lucide-react";
@ -78,7 +78,7 @@ export function AccessSettings() {
const handleToggleApproval = async () => { const handleToggleApproval = async () => {
if (!joinSettings) return; 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" }); } catch { setMsg({ type: "error", text: "Failed to update join settings" }); }
finally { setJsSaving(false); } finally { setJsSaving(false); }
}; };

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useState } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { useProjectInfo, useInvalidateProjectInfo } from "@/hooks/useProjectInfo"; import { useProjectInfo, useInvalidateProjectInfo } from "@/hooks/useProjectInfo";
import { projectExchangeName, projectExchangeTitle, projectExchangeVisibility } from "@/client/api"; 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 [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
useEffect(() => { // Sync form state when project info loads or changes
const [prevInfo, setPrevInfo] = useState<typeof info>(undefined);
if (info !== prevInfo) {
setPrevInfo(info);
if (info) { if (info) {
setForm({ setForm({
name: info.name, name: info.name,
@ -44,7 +47,7 @@ export function GeneralSettings() {
is_public: info.is_public, is_public: info.is_public,
}); });
} }
}, [info]); }
if (!info || !projectName) return null; if (!info || !projectName) return null;
@ -84,7 +87,7 @@ export function GeneralSettings() {
await Promise.all(promises); await Promise.all(promises);
setMsg({ type: "success", text: "Project settings updated successfully" }); setMsg({ type: "success", text: "Project settings updated successfully" });
invalidateProjectInfo(projectName); invalidateProjectInfo(projectName);
} catch (err) { } catch {
setMsg({ type: "error", text: "Failed to update project settings" }); setMsg({ type: "error", text: "Failed to update project settings" });
} finally { } finally {
setSaving(null); setSaving(null);
@ -124,12 +127,12 @@ export function GeneralSettings() {
<div className={PROJECT_SETTINGS.projectAvatar}> <div className={PROJECT_SETTINGS.projectAvatar}>
<Avatar className="w-full h-full rounded-xl"> <Avatar className="w-full h-full rounded-xl">
<AvatarImage src={info.avatar_url || undefined} /> <AvatarImage src={info.avatar_url || undefined} />
<AvatarFallback className="bg-transparent text-white"> <AvatarFallback className="bg-transparent" style={{ color: "var(--text-inverse)" }}>
{(form.display_name || form.name)[0]?.toUpperCase()} {(form.display_name || form.name)[0]?.toUpperCase()}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className={PROJECT_SETTINGS.avatarOverlay}> <div className={PROJECT_SETTINGS.avatarOverlay}>
<Camera className="w-6 h-6 text-white" /> <Camera className="w-6 h-6" style={{ color: "var(--text-inverse)" }} />
</div> </div>
</div> </div>
<div className={PROJECT_SETTINGS.avatarHint}> <div className={PROJECT_SETTINGS.avatarHint}>

View File

@ -39,73 +39,6 @@ const TIMEZONES = [
{ value: "UTC", label: "UTC" }, { value: "UTC", label: "UTC" },
]; ];
export function AppearancePage() {
const { preferences: cachedPrefs, setPreferences: setCachedPrefs } = useSettingsDataCache();
const [_prefs, setPrefs] = useState<PreferencesResponse | null>(cachedPrefs);
const [loading, setLoading] = useState(!cachedPrefs);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({
language: cachedPrefs?.language ?? "zh-CN",
theme: cachedPrefs?.theme ?? "dark",
timezone: cachedPrefs?.timezone ?? "Asia/Shanghai",
});
const [message, setMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
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);
}
};
const handleSave = async () => {
try {
setSaving(true);
setMessage(null);
await updatePreferences({
language: form.language,
theme: form.theme,
timezone: form.timezone,
});
setMessage({ type: "success", text: "外观设置已保存" });
} catch {
setMessage({ type: "error", text: "保存失败,请重试" });
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className={SETTINGS_PAGE.loadingState}>
<Loader2
className="w-6 h-6 animate-spin"
style={{ color: "var(--text-muted)" }}
/>
</div>
);
}
const SelectField = ({ const SelectField = ({
label, label,
value, value,
@ -152,6 +85,70 @@ export function AppearancePage() {
</div> </div>
); );
export function AppearancePage() {
const { preferences: cachedPrefs, setPreferences: setCachedPrefs } = useSettingsDataCache();
const [, setPrefs] = useState<PreferencesResponse | null>(cachedPrefs);
const [loading, setLoading] = useState(!cachedPrefs);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({
language: cachedPrefs?.language ?? "zh-CN",
theme: cachedPrefs?.theme ?? "dark",
timezone: cachedPrefs?.timezone ?? "Asia/Shanghai",
});
const [message, setMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
useEffect(() => {
if (cachedPrefs) return;
(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 {
setSaving(true);
setMessage(null);
await updatePreferences({
language: form.language,
theme: form.theme,
timezone: form.timezone,
});
setMessage({ type: "success", text: "外观设置已保存" });
} catch {
setMessage({ type: "error", text: "保存失败,请重试" });
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className={SETTINGS_PAGE.loadingState}>
<Loader2
className="w-6 h-6 animate-spin"
style={{ color: "var(--text-muted)" }}
/>
</div>
);
}
return ( return (
<div> <div>
<h1 className={SETTINGS_PAGE.pageHeader} style={{ color: "var(--text-primary)" }}> <h1 className={SETTINGS_PAGE.pageHeader} style={{ color: "var(--text-primary)" }}>

View File

@ -20,12 +20,8 @@ export function EmailPage() {
useEffect(() => { useEffect(() => {
if (cachedEmail !== null) return; if (cachedEmail !== null) return;
loadEmail(); (async () => {
}, []);
const loadEmail = async () => {
try { try {
setLoading(true);
const res = await apiEmailGet(); const res = await apiEmailGet();
const e = res.data.data?.email ?? null; const e = res.data.data?.email ?? null;
setEmail(e); setEmail(e);
@ -35,7 +31,8 @@ export function EmailPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; })();
}, [cachedEmail, setCachedEmail]);
const handleSave = async () => { const handleSave = async () => {
if (!form.new_email || !form.password) { if (!form.new_email || !form.password) {

View File

@ -33,12 +33,8 @@ export function MyAccountPage() {
useEffect(() => { useEffect(() => {
if (cachedProfile) return; if (cachedProfile) return;
loadProfile(); (async () => {
}, []);
const loadProfile = async () => {
try { try {
setLoading(true);
const res = await getMyProfile(); const res = await getMyProfile();
const d = res.data.data!; const d = res.data.data!;
setProfile(d); setProfile(d);
@ -54,7 +50,8 @@ export function MyAccountPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; })();
}, [cachedProfile, setCachedProfile]);
const handleSave = async () => { const handleSave = async () => {
try { 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<HTMLInputElement>) => { const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
@ -90,13 +108,13 @@ export function MyAccountPage() {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
const res = await uploadAvatar(formData as any); const res = await uploadAvatar(formData);
const newAvatarUrl = res.data.data?.avatar_url; const newAvatarUrl = res.data.data?.avatar_url;
if (newAvatarUrl) { if (newAvatarUrl) {
setForm(f => ({ ...f, avatar_url: newAvatarUrl })); setForm(f => ({ ...f, avatar_url: newAvatarUrl }));
setMessage({ type: "success", text: "头像上传成功,请保存更改" }); setMessage({ type: "success", text: "头像上传成功,请保存更改" });
} }
} catch (err) { } catch {
setMessage({ type: "error", text: "头像上传失败" }); setMessage({ type: "error", text: "头像上传失败" });
} finally { } finally {
setUploading(false); setUploading(false);

View File

@ -25,9 +25,33 @@ const DIGEST_MODES = [
{ value: "off", label: "关闭" }, { value: "off", label: "关闭" },
]; ];
const ToggleRow = ({
label,
desc,
checked,
onChange,
}: {
label: string;
desc: string;
checked: boolean;
onChange: (v: boolean) => void;
}) => (
<div className={NOTIFICATIONS_PAGE.toggleRow}>
<div className="flex-1 pr-4">
<p className={NOTIFICATIONS_PAGE.toggleLabel} style={{ color: "var(--text-primary)" }}>
{label}
</p>
<p className={NOTIFICATIONS_PAGE.toggleLabelDesc} style={{ color: "var(--text-muted)" }}>
{desc}
</p>
</div>
<Switch checked={checked} onCheckedChange={onChange} />
</div>
);
export function NotificationsPage() { export function NotificationsPage() {
const { notificationPrefs: cachedPrefs, setNotificationPrefs: setCachedPrefs } = useSettingsDataCache(); const { notificationPrefs: cachedPrefs, setNotificationPrefs: setCachedPrefs } = useSettingsDataCache();
const [_prefs, setPrefs] = const [, setPrefs] =
useState<NotificationPreferencesResponse | null>(cachedPrefs); useState<NotificationPreferencesResponse | null>(cachedPrefs);
const [loading, setLoading] = useState(!cachedPrefs); const [loading, setLoading] = useState(!cachedPrefs);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -48,12 +72,8 @@ export function NotificationsPage() {
useEffect(() => { useEffect(() => {
if (cachedPrefs) return; if (cachedPrefs) return;
loadPrefs(); (async () => {
}, []);
const loadPrefs = async () => {
try { try {
setLoading(true);
const res = await getNotificationPreferences(); const res = await getNotificationPreferences();
const d = res.data.data!; const d = res.data.data!;
setPrefs(d); setPrefs(d);
@ -73,7 +93,8 @@ export function NotificationsPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; })();
}, [cachedPrefs, setCachedPrefs]);
const handleSave = async () => { const handleSave = async () => {
try { try {
@ -108,30 +129,6 @@ export function NotificationsPage() {
); );
} }
const ToggleRow = ({
label,
desc,
checked,
onChange,
}: {
label: string;
desc: string;
checked: boolean;
onChange: (v: boolean) => void;
}) => (
<div className={NOTIFICATIONS_PAGE.toggleRow}>
<div className="flex-1 pr-4">
<p className={NOTIFICATIONS_PAGE.toggleLabel} style={{ color: "var(--text-primary)" }}>
{label}
</p>
<p className={NOTIFICATIONS_PAGE.toggleLabelDesc} style={{ color: "var(--text-muted)" }}>
{desc}
</p>
</div>
<Switch checked={checked} onCheckedChange={onChange} />
</div>
);
return ( return (
<div> <div>
<h1 <h1

View File

@ -1,8 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import { getNotificationPreferences, updateNotificationPreferences } from "@/client/api";
getNotificationPreferences, import type { NotificationPreferencesResponse } from "@/client/model";
updateNotificationPreferences,
} from "@/client/api";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Loader2, Smartphone, ShieldCheck, AlertCircle } from "lucide-react"; import { Loader2, Smartphone, ShieldCheck, AlertCircle } from "lucide-react";
@ -14,25 +12,22 @@ export function PushSettingsPage() {
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [pushEnabled, setPushEnabled] = useState(false); const [pushEnabled, setPushEnabled] = useState(false);
const [canPush, setCanPush] = useState(false); const canPush = 'Notification' in window && 'serviceWorker' in navigator;
useEffect(() => { useEffect(() => {
setCanPush('Notification' in window && 'serviceWorker' in navigator); (async () => {
loadPreferences();
}, []);
const loadPreferences = async () => {
try { try {
setLoading(true); setLoading(true);
const res = await getNotificationPreferences(); const res = await getNotificationPreferences();
// The API returns push_enabled or similar in its schema const data = res.data.data as NotificationPreferencesResponse | undefined;
setPushEnabled((res.data.data as any)?.push_enabled ?? false); setPushEnabled(data?.push_enabled ?? false);
} catch (err) { } catch {
setError("Failed to load notification settings"); setError("Failed to load notification settings");
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; })();
}, []);
const handleTogglePush = async (checked: boolean) => { const handleTogglePush = async (checked: boolean) => {
if (checked && 'Notification' in window) { if (checked && 'Notification' in window) {
@ -47,13 +42,12 @@ export function PushSettingsPage() {
setSaving(true); setSaving(true);
setError(null); setError(null);
await updateNotificationPreferences({ await updateNotificationPreferences({
// Update the specific push field push_enabled: checked,
push_enabled: checked } as Partial<NotificationPreferencesResponse>);
} as any);
setPushEnabled(checked); setPushEnabled(checked);
setSuccess(true); setSuccess(true);
setTimeout(() => setSuccess(false), 3000); setTimeout(() => setSuccess(false), 3000);
} catch (err) { } catch {
setError("Failed to update push settings"); setError("Failed to update push settings");
} finally { } finally {
setSaving(false); setSaving(false);
@ -111,7 +105,7 @@ export function PushSettingsPage() {
<Switch checked={true} disabled /> <Switch checked={true} disabled />
</div> </div>
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span style={{ color: "var(--text-primary)" }}>New Issues</span> <span style={{ color: "var(--text-primary)" }}>New Issuesss</span>
<Switch checked={true} disabled /> <Switch checked={true} disabled />
</div> </div>
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">

View File

@ -80,8 +80,11 @@ export async function stopMessage(conversationId: string, messageId: string): Pr
await aiMessageStop(conversationId, messageId); await aiMessageStop(conversationId, messageId);
} }
export async function resendMessage(conversationId: string, messageId: string): Promise<void> { export async function resendMessage(conversationId: string, messageId: string): Promise<MessageResponse> {
await aiMessageResend(conversationId, messageId); 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<MessageResponse> { export async function editMessage(conversationId: string, messageId: string, content: string): Promise<MessageResponse> {
@ -118,16 +121,15 @@ export async function switchMessageVersion(conversationId: string, messageId: st
return data.data; return data.data;
} }
export interface ForkResponse { export interface ForkConversationResponse {
id: string; id: string;
conversation_id: string | null; title?: string | null;
source_message_id: string; model: string;
fork_message_id: string;
created_at: string; created_at: string;
} }
export async function forkMessage(conversationId: string, messageId: string, targetMessageId: string): Promise<ForkResponse> { export async function forkMessage(conversationId: string, messageId: string): Promise<ForkConversationResponse> {
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || ""}/api/ai/conversations/${conversationId}/messages/${messageId}/fork/${targetMessageId}`, { const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || ""}/api/ai/conversations/${conversationId}/messages/${messageId}/fork`, {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
}); });
@ -136,14 +138,14 @@ export async function forkMessage(conversationId: string, messageId: string, tar
return data.data; return data.data;
} }
export async function listMessageForks(conversationId: string, messageId: string): Promise<ForkResponse[]> { export async function listMessageForks(conversationId: string, messageId: string): Promise<ForkConversationResponse[]> {
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || ""}/api/ai/conversations/${conversationId}/messages/${messageId}/forks`, { const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || ""}/api/ai/conversations/${conversationId}/messages/${messageId}/forks`, {
method: "GET", method: "GET",
credentials: "include", credentials: "include",
}); });
if (!response.ok) throw new Error("Failed to list message forks"); if (!response.ok) throw new Error("Failed to list message forks");
const data = await response.json(); const data = await response.json();
return data.data ?? []; return data.data?.forks ?? data.data ?? [];
} }
export async function shareConversation(conversationId: string): Promise<{ share_token: string }> { 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 { export interface StreamChunk {
type: "token" | "thinking" | "tool_call" | "tool_result" | "done" | "error" | "title" | "billing_error"; type: "token" | "thinking" | "tool_call" | "tool_result" | "done" | "error" | "title" | "billing_error";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: any; data: any;
} }
@ -165,6 +168,7 @@ export async function* streamChat(conversationId: string, messageId: string): As
credentials: "include", credentials: "include",
}); });
if (!response.ok) throw new Error(`Stream request failed: ${response.status}`);
if (!response.body) throw new Error("No response body"); if (!response.body) throw new Error("No response body");
const reader = response.body.getReader(); const reader = response.body.getReader();

File diff suppressed because one or more lines are too long

View File

@ -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;
}

View File

@ -10,4 +10,8 @@ export type AiConversationListParams = {
* Filter by project * Filter by project
*/ */
project_id?: string; project_id?: string;
/**
* Search query (title)
*/
q?: string;
}; };

View File

@ -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;
}

View File

@ -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;
};

View File

@ -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;
}

View File

@ -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[];
};

View File

@ -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;
}

View File

@ -7,6 +7,7 @@
export * from './accessKeyListResponse'; export * from './accessKeyListResponse';
export * from './accessKeyResponse'; export * from './accessKeyResponse';
export * from './activityBreakdownItem';
export * from './activityLogListResponse'; export * from './activityLogListResponse';
export * from './activityLogParams'; export * from './activityLogParams';
export * from './activityLogResponse'; export * from './activityLogResponse';
@ -161,8 +162,8 @@ export * from './apiResponseDiffStatsResponse';
export * from './apiResponseDiffStatsResponseData'; export * from './apiResponseDiffStatsResponseData';
export * from './apiResponseEmailResponse'; export * from './apiResponseEmailResponse';
export * from './apiResponseEmailResponseData'; export * from './apiResponseEmailResponseData';
export * from './apiResponseForkResponse'; export * from './apiResponseForkConversationResponse';
export * from './apiResponseForkResponseData'; export * from './apiResponseForkConversationResponseData';
export * from './apiResponseGitInitResponse'; export * from './apiResponseGitInitResponse';
export * from './apiResponseGitInitResponseData'; export * from './apiResponseGitInitResponseData';
export * from './apiResponseGitReadmeResponse'; export * from './apiResponseGitReadmeResponse';
@ -247,6 +248,8 @@ export * from './apiResponseProjectRepoCreateResponse';
export * from './apiResponseProjectRepoCreateResponseData'; export * from './apiResponseProjectRepoCreateResponseData';
export * from './apiResponseProjectRepositoryPagination'; export * from './apiResponseProjectRepositoryPagination';
export * from './apiResponseProjectRepositoryPaginationData'; export * from './apiResponseProjectRepositoryPaginationData';
export * from './apiResponseProjectStatsResponse';
export * from './apiResponseProjectStatsResponseData';
export * from './apiResponsePullRequestListResponse'; export * from './apiResponsePullRequestListResponse';
export * from './apiResponsePullRequestListResponseData'; export * from './apiResponsePullRequestListResponseData';
export * from './apiResponsePullRequestResponse'; export * from './apiResponsePullRequestResponse';
@ -572,7 +575,7 @@ export * from './enable2FAResponse';
export * from './exchangeProjectName'; export * from './exchangeProjectName';
export * from './exchangeProjectTitle'; export * from './exchangeProjectTitle';
export * from './exchangeProjectVisibility'; export * from './exchangeProjectVisibility';
export * from './forkResponse'; export * from './forkConversationResponse';
export * from './generatePrDescriptionRequest'; export * from './generatePrDescriptionRequest';
export * from './generatePrDescriptionResponse'; export * from './generatePrDescriptionResponse';
export * from './get2FAStatusResponse'; export * from './get2FAStatusResponse';
@ -707,6 +710,8 @@ export * from './projectRepoCreateResponse';
export * from './projectRepositoryItem'; export * from './projectRepositoryItem';
export * from './projectRepositoryPagination'; export * from './projectRepositoryPagination';
export * from './projectSearchItem'; export * from './projectSearchItem';
export * from './projectStatsActivityItem';
export * from './projectStatsResponse';
export * from './providerResponse'; export * from './providerResponse';
export * from './pullRequestCreateRequest'; export * from './pullRequestCreateRequest';
export * from './pullRequestListParams'; export * from './pullRequestListParams';

View File

@ -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;
}

View File

@ -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[];
}

View File

@ -210,6 +210,7 @@ const ProviderAttachmentsContext = createContext<AttachmentsContext | null>(
null null
); );
// eslint-disable-next-line react-refresh/only-export-components
export const usePromptInputController = () => { export const usePromptInputController = () => {
const ctx = useContext(PromptInputController); const ctx = useContext(PromptInputController);
if (!ctx) { if (!ctx) {
@ -224,6 +225,7 @@ export const usePromptInputController = () => {
const useOptionalPromptInputController = () => const useOptionalPromptInputController = () =>
useContext(PromptInputController); useContext(PromptInputController);
// eslint-disable-next-line react-refresh/only-export-components
export const useProviderAttachments = () => { export const useProviderAttachments = () => {
const ctx = useContext(ProviderAttachmentsContext); const ctx = useContext(ProviderAttachmentsContext);
if (!ctx) { if (!ctx) {
@ -371,6 +373,7 @@ export const PromptInputProvider = ({
const LocalAttachmentsContext = createContext<AttachmentsContext | null>(null); const LocalAttachmentsContext = createContext<AttachmentsContext | null>(null);
// eslint-disable-next-line react-refresh/only-export-components
export const usePromptInputAttachments = () => { export const usePromptInputAttachments = () => {
// Prefer local context (inside PromptInput) as it has validation, fall back to provider // Prefer local context (inside PromptInput) as it has validation, fall back to provider
const provider = useOptionalProviderAttachments(); const provider = useOptionalProviderAttachments();
@ -395,9 +398,11 @@ export interface ReferencedSourcesContext {
clear: () => void; clear: () => void;
} }
// eslint-disable-next-line react-refresh/only-export-components
export const LocalReferencedSourcesContext = export const LocalReferencedSourcesContext =
createContext<ReferencedSourcesContext | null>(null); createContext<ReferencedSourcesContext | null>(null);
// eslint-disable-next-line react-refresh/only-export-components
export const usePromptInputReferencedSources = () => { export const usePromptInputReferencedSources = () => {
const ctx = useContext(LocalReferencedSourcesContext); const ctx = useContext(LocalReferencedSourcesContext);
if (!ctx) { if (!ctx) {
@ -862,6 +867,7 @@ export const PromptInput = ({
try { try {
// Convert blob URLs to data URLs asynchronously // Convert blob URLs to data URLs asynchronously
const convertedFiles: FileUIPart[] = await Promise.all( const convertedFiles: FileUIPart[] = await Promise.all(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
files.map(async ({ id: _id, ...item }) => { files.map(async ({ id: _id, ...item }) => {
if (item.url?.startsWith("blob:")) { if (item.url?.startsWith("blob:")) {
const dataUrl = await convertBlobUrlToDataUrl(item.url); const dataUrl = await convertBlobUrlToDataUrl(item.url);

View File

@ -36,6 +36,7 @@ interface ReasoningContextValue {
const ReasoningContext = createContext<ReasoningContextValue | null>(null); const ReasoningContext = createContext<ReasoningContextValue | null>(null);
// eslint-disable-next-line react-refresh/only-export-components
export const useReasoning = () => { export const useReasoning = () => {
const context = useContext(ReasoningContext); const context = useContext(ReasoningContext);
if (!context) { if (!context) {

View File

@ -3,26 +3,11 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { MotionProps } from "motion/react"; import type { MotionProps } from "motion/react";
import { motion } 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"; import { memo, useMemo } from "react";
type MotionHTMLProps = MotionProps & Record<string, unknown>; type MotionHTMLProps = MotionProps & Record<string, unknown>;
// Cache motion components at module level to avoid creating during render
const motionComponentCache = new Map<
keyof JSX.IntrinsicElements,
React.ComponentType<MotionHTMLProps>
>();
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 { export interface TextShimmerProps {
children: string; children: string;
as?: ElementType; as?: ElementType;
@ -38,9 +23,7 @@ const ShimmerComponent = ({
duration = 2, duration = 2,
spread = 2, spread = 2,
}: TextShimmerProps) => { }: TextShimmerProps) => {
const MotionComponent = getMotionComponent( const MotionComponent = motion[Component as keyof typeof motion] as React.ComponentType<MotionHTMLProps>;
Component as keyof JSX.IntrinsicElements
);
const dynamicSpread = useMemo( const dynamicSpread = useMemo(
() => (children?.length ?? 0) * spread, () => (children?.length ?? 0) * spread,

View File

@ -16,7 +16,6 @@ export function EditHistoryOverlay({ messageId, roomId, onClose }: Props) {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setLoading(true);
messageEditHistory(roomId, messageId) messageEditHistory(roomId, messageId)
.then((res) => { .then((res) => {
if (!cancelled) setHistory(res.data?.data?.history ?? []); if (!cancelled) setHistory(res.data?.data?.history ?? []);

View File

@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Edit2, Trash2, Reply, MessageSquare, Pin } from 'lucide-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 type { Message } from '@/contexts/room';
import { formatRelativeTime } from '@/contexts/room'; import { formatRelativeTime } from '@/contexts/room';
import { Avatar } from './Avatar'; import { Avatar } from './Avatar';
@ -148,7 +149,7 @@ export function MessageItem({
</pre> </pre>
</details> </details>
)} )}
<MentionRenderer content={msg.content} /> <IrRenderer nodes={extractIrNodes(msg.content)} />
</> </>
)} )}
</div> </div>

View File

@ -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 { Loader2, ChevronDown } from 'lucide-react';
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'; import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
import type { Message } from '@/contexts/room'; import type { Message } from '@/contexts/room';
@ -8,6 +8,25 @@ import { MessageItem } from './MessageItem';
import { MESSAGE_LIST } from '@/css/channel/styles'; import { MESSAGE_LIST } from '@/css/channel/styles';
import { useIsMobile } from '@/hooks/use-mobile'; import { useIsMobile } from '@/hooks/use-mobile';
const VirtuosoScroller = forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
({ style, className, ...props }, ref) => (
<div
ref={ref}
{...props}
className={['app-scrollbar', className].filter(Boolean).join(' ')}
data-scrollbar="room"
style={{
...style,
overflowX: 'hidden',
overscrollBehavior: 'contain',
paddingRight: 2,
}}
/>
)
);
VirtuosoScroller.displayName = 'VirtuosoScroller';
interface Props { interface Props {
messages: Message[]; messages: Message[];
isLoadingHistory: boolean; isLoadingHistory: boolean;
@ -93,8 +112,6 @@ export function MessageList({
useEffect(() => { useEffect(() => {
initialScrollDoneRef.current = false; initialScrollDoneRef.current = false;
prevLengthRef.current = 0; prevLengthRef.current = 0;
setIsAtBottom(true);
setNewMsgCount(0);
if (renderedItems.length > 0) { if (renderedItems.length > 0) {
const el = scrollerRef.current; const el = scrollerRef.current;
@ -120,6 +137,7 @@ export function MessageList({
if (isAtBottom) { if (isAtBottom) {
const el = scrollerRef.current; const el = scrollerRef.current;
if (el) el.scrollTop = el.scrollHeight; if (el) el.scrollTop = el.scrollHeight;
// eslint-disable-next-line react-hooks/set-state-in-effect
setNewMsgCount(0); setNewMsgCount(0);
} else { } else {
setNewMsgCount(prev => prev + (renderedItems.length - prevLen)); setNewMsgCount(prev => prev + (renderedItems.length - prevLen));
@ -132,7 +150,7 @@ export function MessageList({
if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
setNewMsgCount(0); setNewMsgCount(0);
setIsAtBottom(true); setIsAtBottom(true);
}, [renderedItems.length]); }, []);
if (displayMessages.length === 0 && !isLoadingHistory) { if (displayMessages.length === 0 && !isLoadingHistory) {
return ( return (
@ -148,6 +166,7 @@ export function MessageList({
key={roomId} key={roomId}
ref={virtuosoRef} ref={virtuosoRef}
style={{ flex: 1 }} style={{ flex: 1 }}
components={{ Scroller: VirtuosoScroller }}
data={renderedItems} data={renderedItems}
initialTopMostItemIndex={ initialTopMostItemIndex={
renderedItems.length > 0 renderedItems.length > 0

View File

@ -43,11 +43,13 @@ export function MentionBottomSheet({
// Sync external query changes // Sync external query changes
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setSearch(query); setSearch(query);
}, [query]); }, [query]);
// Reset selection when filtered list changes // Reset selection when filtered list changes
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setSelectedIndex(0); setSelectedIndex(0);
}, [search]); }, [search]);
@ -248,7 +250,7 @@ export function MentionBottomSheet({
className="rounded-md text-[10px] font-bold" className="rounded-md text-[10px] font-bold"
style={{ style={{
backgroundColor: hashColor(item.label), backgroundColor: hashColor(item.label),
color: "#ffffff", color: "var(--text-inverse)",
}} }}
> >
{item.label[0]?.toUpperCase() || "?"} {item.label[0]?.toUpperCase() || "?"}

View File

@ -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<CodePreviewContextValue | null>(null);
export const CodePreviewProvider = CodePreviewContext.Provider;
export function useCodePreview() {
return useContext(CodePreviewContext);
}

View File

@ -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 (
<aside
className="h-full shrink-0 overflow-hidden border-l transition-[width,opacity,transform] duration-300 ease-out"
style={{
width: code ? "min(48vw, 760px)" : 0,
opacity: code ? 1 : 0,
transform: code ? "translateX(0)" : "translateX(18px)",
borderColor: "var(--border-subtle)",
backgroundColor: "var(--surface-ground)",
}}
aria-hidden={!code}
>
<div className="flex h-full w-[min(48vw,760px)] flex-col">
<div
className="flex h-12 shrink-0 items-center justify-between gap-3 border-b px-3"
style={{ borderColor: "var(--border-subtle)" }}
>
<div className="min-w-0">
<div className="truncate text-sm font-medium" style={{ color: "var(--text-primary)" }}>
{code?.language || "text"}
</div>
<div className="text-xs" style={{ color: "var(--text-muted)" }}>
{code?.lineCount ?? 0} lines
</div>
</div>
<div className="flex items-center gap-1">
{canPreview && (
<div className="flex items-center rounded-md border overflow-hidden mr-2" style={{ borderColor: "var(--border-default)" }}>
<button
onClick={() => setViewMode("code")}
className="px-3 py-1 text-xs font-medium transition-colors"
style={{
backgroundColor: viewMode === "code" ? "var(--accent)" : "transparent",
color: viewMode === "code" ? "var(--accent-fg)" : "var(--text-secondary)",
}}
>
Code
</button>
<button
onClick={() => setViewMode("preview")}
className="px-3 py-1 text-xs font-medium transition-colors"
style={{
backgroundColor: viewMode === "preview" ? "var(--accent)" : "transparent",
color: viewMode === "preview" ? "var(--accent-fg)" : "var(--text-secondary)",
}}
>
Preview
</button>
</div>
)}
<Button variant="ghost" size="sm" onClick={handleCopy} disabled={!code}>
{copied ? <Check data-icon="inline-start" /> : <Copy data-icon="inline-start" />}
{copied ? "Copied" : "Copy"}
</Button>
<Button variant="ghost" size="icon-sm" onClick={onClose} aria-label="Close code preview">
<PanelRightClose />
</Button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto">
{viewMode === "preview" && canPreview ? (
<iframe
title="HTML Preview"
srcDoc={code?.code ?? ""}
className="w-full h-full border-0"
sandbox="allow-scripts"
/>
) : (
<div className="grid min-w-max grid-cols-[auto_1fr] text-[13px] leading-[1.65]">
<div
className="select-none border-r px-3 py-3 text-right font-mono tabular-nums"
style={{
borderColor: "var(--border-subtle)",
color: "var(--text-muted)",
backgroundColor: "var(--surface-elevated)",
}}
aria-hidden="true"
>
{lines.map((_, index) => (
<div key={index}>{index + 1}</div>
))}
</div>
<pre
className="m-0 min-h-full px-4 py-3 font-mono outline-none"
style={{
color: "var(--text-primary)",
backgroundColor: "var(--surface-ground)",
}}
>
<code>{code?.code ?? ""}</code>
</pre>
</div>
)}
</div>
</div>
</aside>
);
});

View File

@ -0,0 +1,98 @@
import { useState } from "react";
import { ChevronRight, Wrench, CheckCircle, XCircle, Loader2 } from "lucide-react";
interface ToolCallBlockProps {
toolName: string;
args: Record<string, unknown>;
status?: "pending" | "ok" | "error";
result?: string;
}
export function ToolCallBlock({ toolName, args, status = "pending", result }: ToolCallBlockProps) {
const [isExpanded, setIsExpanded] = useState(false);
const isPending = status === "pending";
const isError = status === "error";
const isOk = status === "ok";
return (
<div
className="my-2 rounded-lg overflow-hidden"
style={{
border: "1px solid var(--border-default)",
backgroundColor: "var(--surface-elevated)",
}}
>
{/* Header */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-[var(--hover-bg)]"
style={{ color: "var(--text-primary)" }}
>
<ChevronRight
className={`w-3.5 h-3.5 shrink-0 transition-transform ${isExpanded ? "rotate-90" : ""}`}
style={{ color: "var(--text-muted)" }}
/>
<Wrench className="w-3.5 h-3.5 shrink-0" style={{ color: "var(--accent)" }} />
<span className="text-xs font-medium flex-1">
{isPending ? (
<span className="flex items-center gap-1.5">
<Loader2 className="w-3 h-3 animate-spin" style={{ color: "var(--accent)" }} />
Using <code className="font-mono text-[11px]">{toolName}</code>
</span>
) : (
<span>
Used <code className="font-mono text-[11px]">{toolName}</code>
</span>
)}
</span>
{isOk && <CheckCircle className="w-3.5 h-3.5 shrink-0" style={{ color: "var(--success)" }} />}
{isError && <XCircle className="w-3.5 h-3.5 shrink-0" style={{ color: "var(--destructive)" }} />}
</button>
{/* Expandable content */}
{isExpanded && (
<div
className="px-3 pb-3 text-xs"
style={{ color: "var(--text-secondary)", borderTop: "1px solid var(--border-subtle)" }}
>
{/* Arguments */}
<div className="py-2">
<div className="text-[11px] font-medium mb-1" style={{ color: "var(--text-muted)" }}>
Arguments
</div>
<pre
className="p-2 rounded text-[11px] font-mono overflow-x-auto"
style={{
backgroundColor: "var(--surface-ground)",
border: "1px solid var(--border-subtle)",
color: "var(--text-primary)",
}}
>
{JSON.stringify(args, null, 2)}
</pre>
</div>
{/* Result */}
{result && (
<div className="py-2">
<div className="text-[11px] font-medium mb-1" style={{ color: "var(--text-muted)" }}>
Result
</div>
<pre
className="p-2 rounded text-[11px] font-mono overflow-x-auto"
style={{
backgroundColor: "var(--surface-ground)",
border: "1px solid var(--border-subtle)",
color: "var(--text-primary)",
}}
>
{result}
</pre>
</div>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,30 @@
interface DataCardRendererProps {
cardType: string;
data: Record<string, unknown>;
}
/** Data card renderer placeholder for P4 interactive layer.
* Currently shows a minimal card with title. Will be upgraded
* to fetch and render IssueCard/RepoCard/PRCard components. */
export function DataCardRenderer({ cardType, data }: DataCardRendererProps) {
const title = (data.title as string) ?? (data.name as string) ?? `${cardType} card`;
return (
<div className="my-2 p-3 rounded-xl" style={{
backgroundColor: "var(--surface-elevated)",
border: "1px solid var(--border-default)",
}}>
<div className="flex items-center gap-2">
<span className="text-[10px] uppercase font-bold px-1.5 py-0.5 rounded" style={{
backgroundColor: "var(--accent-muted)",
color: "var(--accent)",
}}>
{cardType}
</span>
<span className="text-sm font-semibold" style={{ color: "var(--text-primary)" }}>
{title}
</span>
</div>
</div>
);
}

View File

@ -0,0 +1,52 @@
import { Users, Bot, GitBranch, Puzzle } from "lucide-react";
type MentionEntityType = "ai" | "repo" | "user" | "skill";
const TYPE_ICONS: Record<MentionEntityType, React.ReactNode> = {
ai: <Bot className="w-3 h-3" />,
repo: <GitBranch className="w-3 h-3" />,
user: <Users className="w-3 h-3" />,
skill: <Puzzle className="w-3 h-3" />,
};
const TYPE_COLORS: Record<MentionEntityType, { bg: string; text: string }> = {
ai: { bg: "rgba(59,130,246,0.12)", text: "#3b82f6" },
repo: { bg: "rgba(16,185,129,0.12)", text: "#10b981" },
user: { bg: "rgba(139,92,246,0.12)", text: "#8b5cf6" },
skill: { bg: "rgba(245,158,11,0.12)", text: "#f59e0b" },
};
interface MentionChipRendererProps {
entityType: string;
entityId: string;
entityLabel: string;
}
export function MentionChipRenderer({ entityType, entityLabel }: MentionChipRendererProps) {
const type = entityType as MentionEntityType;
const colors = TYPE_COLORS[type] ?? TYPE_COLORS.ai;
const icon = TYPE_ICONS[type] ?? TYPE_ICONS.ai;
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 3,
padding: "1px 6px",
borderRadius: 4,
fontSize: "inherit",
fontWeight: 500,
backgroundColor: colors.bg,
color: colors.text,
whiteSpace: "nowrap",
cursor: "pointer",
transition: "opacity 0.1s",
}}
title={`${entityType}: ${entityLabel}`}
>
{icon}
<span>{entityLabel}</span>
</span>
);
}

View File

@ -0,0 +1,25 @@
interface MermaidRendererProps {
source: string;
}
/** Mermaid diagram renderer placeholder for P4 interactive layer.
* Currently renders source in a styled code block. Will be upgraded
* to use mermaid.js runtime rendering in a future phase. */
export function MermaidRenderer({ source }: MermaidRendererProps) {
return (
<div className="my-2 p-4 rounded-xl" style={{
backgroundColor: "var(--surface-elevated)",
border: "1px solid var(--border-default)",
}}>
<div className="text-xs font-semibold mb-2" style={{ color: "var(--text-muted)" }}>
Mermaid Diagram
</div>
<pre className="text-sm overflow-x-auto" style={{
color: "var(--text-secondary)",
lineHeight: 1.6,
}}>
{source}
</pre>
</div>
);
}

View File

@ -45,8 +45,8 @@ export const ChannelSidebar = memo(function ChannelSidebar({onCollapse}: Channel
const {data: projectInfo} = useProjectInfo(projectName); const {data: projectInfo} = useProjectInfo(projectName);
const [isCreateMenuOpen, setIsCreateMenuOpen] = useState(false); const [isCreateMenuOpen, setIsCreateMenuOpen] = useState(false);
const rooms = roomsData?.rooms ?? []; const rooms = useMemo(() => roomsData?.rooms ?? [], [roomsData?.rooms]);
const categories = roomsData?.categories ?? []; const categories = useMemo(() => roomsData?.categories ?? [], [roomsData?.categories]);
const pathParts = location.pathname.split("/").filter(Boolean); const pathParts = location.pathname.split("/").filter(Boolean);
const isActive = useCallback((path: string) => { const isActive = useCallback((path: string) => {

View File

@ -40,7 +40,9 @@ export const ServerIconRail = memo(function ServerIconRail() {
const handleLogout = useCallback(async () => { const handleLogout = useCallback(async () => {
try { try {
await logoutMutation.mutateAsync(); await logoutMutation.mutateAsync();
} catch { } } catch {
// Intentionally empty - navigation proceeds on error
}
navigate("/auth/login", { replace: true }); navigate("/auth/login", { replace: true });
}, [logoutMutation, navigate]); }, [logoutMutation, navigate]);
@ -85,7 +87,7 @@ export const ServerIconRail = memo(function ServerIconRail() {
> >
<Avatar className="w-full h-full rounded-2xl"> <Avatar className="w-full h-full rounded-2xl">
<AvatarImage src={project.avatar_url || undefined} alt={project.display_name} className="object-cover" /> <AvatarImage src={project.avatar_url || undefined} alt={project.display_name} className="object-cover" />
<AvatarFallback style={{ backgroundColor: isActive ? "transparent" : color, color: "#ffffff" }}> <AvatarFallback style={{ backgroundColor: isActive ? "transparent" : color, color: "var(--text-inverse)" }}>
<span className="text-[13px] font-medium">{project.display_name[0]}</span> <span className="text-[13px] font-medium">{project.display_name[0]}</span>
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>

View File

@ -8,7 +8,8 @@ import {
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { MarkdownRenderer } from "@/components/ui/MarkdownRenderer"; import { IrRenderer } from "@/lib/ir/renderer";
import { extractIrNodes } from "@/lib/ir/parser";
import type { ReviewCommentThread } from "@/client/model"; import type { ReviewCommentThread } from "@/client/model";
function relativeTime(dateStr: string) { function relativeTime(dateStr: string) {
@ -104,7 +105,7 @@ export function InlineCommentThread({
{/* Body */} {/* Body */}
<div className="text-sm text-foreground"> <div className="text-sm text-foreground">
<MarkdownRenderer content={root.body} /> <IrRenderer nodes={extractIrNodes(root.body)} />
</div> </div>
{/* Actions */} {/* Actions */}
@ -165,7 +166,7 @@ export function InlineCommentThread({
</div> </div>
<div className="text-sm text-foreground"> <div className="text-sm text-foreground">
<MarkdownRenderer content={reply.body} /> <IrRenderer nodes={extractIrNodes(reply.body)} />
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,4 +1,4 @@
import { useState, useMemo } from "react"; import { useState, useMemo, useEffect } from "react";
import { import {
FileCode, FileCode,
FileBox, FileBox,
@ -331,15 +331,19 @@ export function PullRequestDiff({
const { data: commentData } = usePRCommentListQuery(namespace, repo, prNumber); const { data: commentData } = usePRCommentListQuery(namespace, repo, prNumber);
const files = diffData?.files || []; const files = useMemo(() => diffData?.files || [], [diffData?.files]);
const threads = commentData?.threads || []; const threads = useMemo(() => commentData?.threads || [], [commentData?.threads]);
// Auto-select first file // Auto-select first file
useMemo(() => { useEffect(() => {
if (!activeFile && files.length > 0) { // eslint-disable-next-line react-hooks/set-state-in-effect
setActiveFile(files[0].path); setActiveFile((prev) => {
if (!prev && files.length > 0) {
return files[0].path;
} }
}, [files, activeFile]); return prev;
});
}, [files]);
const activeFileData = useMemo(() => { const activeFileData = useMemo(() => {
return files.find((f) => f.path === activeFile); return files.find((f) => f.path === activeFile);

View File

@ -14,7 +14,8 @@ import {
import { useRepoCommitDetailQuery, useRepoCommitDiffQuery } from "@/hooks/useRepoDetailQuery"; import { useRepoCommitDetailQuery, useRepoCommitDiffQuery } from "@/hooks/useRepoDetailQuery";
import { LoadingState } from "@/components/ui/LoadingState"; import { LoadingState } from "@/components/ui/LoadingState";
import { REPO_DIFF } from "@/css/repo/styles"; import { REPO_DIFF } from "@/css/repo/styles";
import { useState, useMemo } from "react"; import { useState, useMemo, useEffect } from "react";
import type { DiffDeltaResponse, DiffLineResponse } from "@/client/model";
function relativeTime(dateStr: string) { function relativeTime(dateStr: string) {
if (!dateStr) return ''; if (!dateStr) return '';
@ -70,18 +71,21 @@ export function CommitDetail() {
const [activeFilePath, setActiveFilePath] = useState<string | null>(null); const [activeFilePath, setActiveFilePath] = useState<string | null>(null);
const deltas = diff?.deltas || []; const deltas = useMemo(() => diff?.deltas || [], [diff?.deltas]);
// Auto-select first file if none selected // Auto-select first file if none selected
useMemo(() => { useEffect(() => {
if (!activeFilePath && deltas.length > 0) { // eslint-disable-next-line react-hooks/set-state-in-effect
const firstPath = deltas[0].new_file.path || deltas[0].old_file.path; setActiveFilePath((prev) => {
setActiveFilePath(firstPath); if (!prev && deltas.length > 0) {
return deltas[0].new_file.path || deltas[0].old_file.path;
} }
}, [deltas, activeFilePath]); return prev;
});
}, [deltas]);
const activeDelta = useMemo(() => { const activeDelta = useMemo(() => {
return deltas.find((d: any) => (d.new_file.path || d.old_file.path) === activeFilePath); return deltas.find((d: DiffDeltaResponse) => (d.new_file.path || d.old_file.path) === activeFilePath);
}, [deltas, activeFilePath]); }, [deltas, activeFilePath]);
if (isLoadingCommit || isLoadingDiff) { if (isLoadingCommit || isLoadingDiff) {
@ -118,6 +122,7 @@ export function CommitDetail() {
{/* Placeholder for PR Number if available */} {/* Placeholder for PR Number if available */}
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/20 text-[11px] font-medium"> <div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/20 text-[11px] font-medium">
<GitPullRequest className="w-3 h-3" /> <GitPullRequest className="w-3 h-3" />
{/* eslint-disable-next-line react-hooks/purity */}
<span>#{Math.floor(Math.random() * 900) + 100}</span> <span>#{Math.floor(Math.random() * 900) + 100}</span>
</div> </div>
</div> </div>
@ -161,7 +166,7 @@ export function CommitDetail() {
Files Changed · <span className="text-muted-foreground font-normal">{deltas.length}</span> Files Changed · <span className="text-muted-foreground font-normal">{deltas.length}</span>
</div> </div>
<div className={REPO_DIFF.sidebarList}> <div className={REPO_DIFF.sidebarList}>
{deltas.map((delta: any) => { {deltas.map((delta: DiffDeltaResponse) => {
const path = delta.new_file.path || delta.old_file.path; const path = delta.new_file.path || delta.old_file.path;
const isActive = path === activeFilePath; const isActive = path === activeFilePath;
return ( return (
@ -211,7 +216,7 @@ export function CommitDetail() {
<div className={REPO_DIFF.diffContainer}> <div className={REPO_DIFF.diffContainer}>
<table className={REPO_DIFF.diffTable}> <table className={REPO_DIFF.diffTable}>
<tbody> <tbody>
{activeDelta.lines.map((line: any, idx: number) => { {activeDelta.lines.map((line: DiffLineResponse, idx: number) => {
const isAdded = line.origin === '+'; const isAdded = line.origin === '+';
const isRemoved = line.origin === '-'; const isRemoved = line.origin === '-';
const isHunk = line.origin === 'H'; const isHunk = line.origin === 'H';

View File

@ -27,10 +27,10 @@ export function RepoBranchesTab() {
const defaultBranch = "main"; const defaultBranch = "main";
const activeBranches = repoBranches.filter( const activeBranches = repoBranches.filter(
(b: any) => !b.is_remote (b: BranchInfoResponse) => !b.is_remote
); );
const remoteBranches = repoBranches.filter( const remoteBranches = repoBranches.filter(
(b: any) => b.is_remote (b: BranchInfoResponse) => b.is_remote
); );
return ( return (

View File

@ -63,7 +63,8 @@ export function RepoCodeTab() {
}; };
useEffect(() => { useEffect(() => {
if (repoBranches.length > 0 && !selectedBranch) { if (repoBranches.length > 0 && !repoBranches.some((b) => b.name === selectedBranch)) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setSelectedBranch(defaultBranch); setSelectedBranch(defaultBranch);
} }
}, [repoBranches, defaultBranch, selectedBranch]); }, [repoBranches, defaultBranch, selectedBranch]);

View File

@ -29,7 +29,8 @@ export function RepoTagsTab() {
</span> </span>
</div> </div>
<div className={REPO_TAGS.list}> <div className={REPO_TAGS.list}>
{repoTags.map((tag: any) => ( {repoTags.map((tag: // eslint-disable-next-line @typescript-eslint/no-explicit-any
any) => (
<div <div
key={tag.name || tag} key={tag.name || tag}
className={REPO_TAGS.item} className={REPO_TAGS.item}

View File

@ -54,4 +54,5 @@ export function SettingsDataCacheProvider({ children }: { children: ReactNode })
); );
} }
// eslint-disable-next-line react-refresh/only-export-components
export const useSettingsDataCache = () => useContext(SettingsDataCacheContext); export const useSettingsDataCache = () => useContext(SettingsDataCacheContext);

View File

@ -143,7 +143,7 @@ export function SettingsModal() {
} }
requestAnimationFrame(frame); requestAnimationFrame(frame);
}, [closeSettingsModal, navigate]); }, [closeSettingsModal, navigate, location.pathname]);
const ActiveComponent = SECTIONS[activeSection]; const ActiveComponent = SECTIONS[activeSection];

View File

@ -10,33 +10,31 @@ interface CustomTheme {
[key: string]: string; [key: string]: string;
} }
interface ThemeCustomizationProps { function applyThemeVars(vars: CustomTheme) {
className?: string;
}
export function useThemeCustomization() {
const [customVars, setCustomVars] = useState<CustomTheme>({});
const [hasChanges, setHasChanges] = useState(false);
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
setCustomVars(JSON.parse(stored));
} catch {}
}
}, []);
useEffect(() => {
applyThemeVars(customVars);
}, [customVars]);
const applyThemeVars = (vars: CustomTheme) => {
const root = document.documentElement; const root = document.documentElement;
Object.entries(vars).forEach(([key, value]) => { Object.entries(vars).forEach(([key, value]) => {
root.style.setProperty(`--${key}`, value); root.style.setProperty(`--${key}`, value);
}); });
}; }
export function useThemeCustomization() {
const [customVars, setCustomVars] = useState<CustomTheme>(() => {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
return JSON.parse(stored) as CustomTheme;
} catch {
console.error("Failed to parse stored theme vars");
return {};
}
}
return {};
});
const [hasChanges, setHasChanges] = useState(false);
useEffect(() => {
applyThemeVars(customVars);
}, [customVars]);
const updateVar = (key: string, value: string) => { const updateVar = (key: string, value: string) => {
setCustomVars((prev) => { setCustomVars((prev) => {
@ -73,7 +71,7 @@ export function useThemeCustomization() {
return { customVars, updateVar, resetVar, save, resetAll, hasChanges }; return { customVars, updateVar, resetVar, save, resetAll, hasChanges };
} }
export function ThemeCustomization({ className }: ThemeCustomizationProps) { export function ThemeCustomization({ className }: { className?: string }) {
const { customVars, updateVar, resetVar, save, resetAll, hasChanges } = useThemeCustomization(); const { customVars, updateVar, resetVar, save, resetAll, hasChanges } = useThemeCustomization();
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
@ -224,6 +222,8 @@ export function loadThemeVars() {
Object.entries(vars).forEach(([key, value]) => { Object.entries(vars).forEach(([key, value]) => {
document.documentElement.style.setProperty(`--${key}`, value); document.documentElement.style.setProperty(`--${key}`, value);
}); });
} catch {} } catch {
console.error("Failed to parse stored theme vars");
}
} }
} }

View File

@ -1,32 +1,184 @@
import { memo, useRef, useEffect } from "react"; import {memo, useEffect, useId, useState} from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import rehypeSanitize from "rehype-sanitize"; import rehypeSanitize from "rehype-sanitize";
import {Check, ChevronDown, ChevronRight, Copy, PanelRightOpen, Eye} from "lucide-react";
import {Button} from "@/components/ui/button";
import {cn} from "@/lib/utils";
import {useCodePreview} from "@/components/chat/CodePreviewContext";
interface MarkdownRendererProps { interface MarkdownRendererProps {
content: string; content: string;
className?: string; className?: string;
} }
/** Sanitize raw HTML: strip <script> and event handlers. */ /** Extract text content from React children (handles string, array, or element). */
function sanitizeHtml(raw: string): string { function extractText(children: React.ReactNode): string {
return raw if (typeof children === "string") return children;
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "") if (Array.isArray(children)) return children.map(extractText).join("");
.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, ""); if (children && typeof children === "object" && "props" in children) {
// @ts-expect-error recursive extraction of children text
return extractText((children as React.ReactElement).props.children);
}
return "";
} }
/** Render raw HTML inside a Shadow DOM to scope CSS to this block only. */ const INLINE_CODE_LINE_LIMIT = 36;
function HtmlBlock({ html }: { html: string }) {
const ref = useRef<HTMLDivElement>(null); /** Collapsed code card. Small blocks expand inline; large blocks open the chat code panel. */
function CodeBlock({children, className}: { children: React.ReactNode; className?: string }) {
const [copied, setCopied] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const preview = useCodePreview();
const reactId = useId();
// Extract language from className (e.g., "language-javascript")
const cls = Array.isArray(className) ? className.join(" ") : (className || "");
const match = /language-([^\s]+)/.exec(cls);
const language = match?.[1] || "";
const displayLanguage = language || "text";
const content = extractText(children);
const lines = content.replace(/\n$/, "").split("\n");
const lineCount = content.trim() ? lines.length : 0;
const opensPanel = lineCount > INLINE_CODE_LINE_LIMIT && !!preview;
const canExpandInline = !opensPanel;
const previewId = `${reactId}-${displayLanguage}`;
const activePreviewId = preview?.activeCode?.id;
const openCodePreview = preview?.openCodePreview;
useEffect(() => { useEffect(() => {
const el = ref.current; if (opensPanel && activePreviewId === previewId) {
if (!el) return; openCodePreview?.({
const shadow = el.shadowRoot || el.attachShadow({ mode: "open" }); id: previewId,
shadow.innerHTML = sanitizeHtml(html); code: content,
}, [html]); language: displayLanguage,
lineCount,
});
}
}, [opensPanel, activePreviewId, openCodePreview, previewId, content, displayLanguage, lineCount]);
return <div ref={ref} className="my-2" />; const handleCopy = () => {
navigator.clipboard.writeText(content).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
const handlePrimaryAction = () => {
if (canExpandInline) {
setIsOpen((value) => !value);
return;
}
openCodePreview?.({
id: previewId,
code: content,
language: displayLanguage,
lineCount,
});
};
const handleView = () => {
openCodePreview?.({
id: previewId,
code: content,
language: displayLanguage,
lineCount,
previewMode: "preview",
});
};
return (
<div
className="not-prose my-2 overflow-hidden rounded-lg border transition-colors"
style={{
backgroundColor: "var(--surface-elevated)",
borderColor: "var(--border-default)",
}}
>
<div
className="flex min-h-10 items-center justify-between gap-3 px-3 py-1.5"
style={{borderColor: "var(--border-subtle)"}}
>
<button
type="button"
onClick={handlePrimaryAction}
className="flex min-w-0 flex-1 items-center gap-2 rounded-md py-1 text-left transition-colors hover:bg-[var(--hover-bg)]"
style={{color: "var(--text-primary)"}}
>
{canExpandInline ? (
isOpen ? <ChevronDown /> : <ChevronRight />
) : (
<PanelRightOpen />
)}
<span className="truncate text-xs font-medium">{displayLanguage}</span>
<span className="text-xs" style={{color: "var(--text-muted)"}}>
{lineCount} lines
</span>
</button>
<div className="flex shrink-0 items-center gap-1">
{opensPanel && (
<Button variant="ghost" size="sm" onClick={handlePrimaryAction}>
<PanelRightOpen data-icon="inline-start" />
Open
</Button>
)}
{language === "html" && (
<Button variant="ghost" size="sm" onClick={handleView}>
<Eye data-icon="inline-start" />
View
</Button>
)}
<Button variant="ghost" size="icon-xs" onClick={handleCopy} aria-label="Copy code">
{copied ? <Check /> : <Copy />}
</Button>
</div>
</div>
<div
className={cn(
"grid transition-[grid-template-rows,opacity] duration-200 ease-out",
isOpen && canExpandInline ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
)}
>
<div className="min-h-0 overflow-hidden">
<div className="flex border-t" style={{borderColor: "var(--border-subtle)"}}>
{lineCount > 1 && (
<div
className="shrink-0 select-none px-3 py-3 text-right text-[13px] leading-[1.6] font-mono tabular-nums"
style={{
color: "var(--text-muted)",
borderRight: "1px solid var(--border-subtle)",
minWidth: "3rem",
}}
aria-hidden="true"
>
{lines.map((_, i) => (
<div key={i}>{i + 1}</div>
))}
</div>
)}
<pre
className="m-0 flex-1 overflow-x-auto px-3 py-3 text-[13px] leading-[1.6] font-mono"
style={{backgroundColor: "var(--surface-elevated)"}}
>
<code className={className}>{children}</code>
</pre>
</div>
</div>
</div>
{!isOpen && canExpandInline && (
<button
type="button"
onClick={handlePrimaryAction}
className="block w-full border-t px-3 py-2 text-left text-xs transition-colors hover:bg-[var(--hover-bg)]"
style={{borderColor: "var(--border-subtle)", color: "var(--text-muted)"}}
>
Expand code
</button>
)}
</div>
);
} }
export const MarkdownRenderer = memo(function MarkdownRenderer({content, className}: MarkdownRendererProps) { export const MarkdownRenderer = memo(function MarkdownRenderer({content, className}: MarkdownRendererProps) {
@ -64,6 +216,8 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, classN
overflow-x: auto; overflow-x: auto;
font-size: 13px; font-size: 13px;
line-height: 1.6; line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
} }
.markdown-code-block code { .markdown-code-block code {
background: none !important; background: none !important;
@ -101,12 +255,7 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, classN
const match = /language-(\w+)/.exec(cls); const match = /language-(\w+)/.exec(cls);
const isInline = !match; const isInline = !match;
// ````html` blocks: render inside Shadow DOM to scope CSS // Inline code → keep existing style
if (match?.[1] === "html") {
const raw = typeof children === "string" ? children : "";
return <HtmlBlock html={raw} />;
}
if (isInline) { if (isInline) {
return ( return (
<code <code
@ -122,13 +271,10 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, classN
</code> </code>
); );
} }
return (
<pre className="markdown-code-block"> // Fenced code block with copy + line numbers.
<code className={className} {...props}> // All languages, including html, use the same collapsed/code-panel flow.
{children} return <CodeBlock className={className}>{children}</CodeBlock>;
</code>
</pre>
);
}, },
}} }}
> >

View File

@ -46,4 +46,5 @@ function Badge({
) )
} }
// eslint-disable-next-line react-refresh/only-export-components
export { Badge, badgeVariants } export { Badge, badgeVariants }

View File

@ -75,9 +75,11 @@ function ButtonGroupSeparator({
) )
} }
// eslint-disable-next-line react-refresh/only-export-components
export { buttonGroupVariants }
export { export {
ButtonGroup, ButtonGroup,
ButtonGroupSeparator, ButtonGroupSeparator,
ButtonGroupText, ButtonGroupText,
buttonGroupVariants,
} }

View File

@ -64,4 +64,5 @@ function Button({
) )
} }
// eslint-disable-next-line react-refresh/only-export-components
export { Button, buttonVariants } export { Button, buttonVariants }

View File

@ -56,14 +56,40 @@ function Carousel({
}, },
plugins plugins
) )
const [canScrollPrev, setCanScrollPrev] = React.useState(false) const subscribeToCarousel = React.useCallback(
const [canScrollNext, setCanScrollNext] = React.useState(false) (onStoreChange: () => void) => {
if (!api) return () => {}
api.on("select", onStoreChange)
api.on("reInit", onStoreChange)
return () => {
api.off("select", onStoreChange)
api.off("reInit", onStoreChange)
}
},
[api]
)
const onSelect = React.useCallback((api: CarouselApi) => { const getCanScrollPrev = React.useCallback(
if (!api) return () => api?.canScrollPrev() ?? false,
setCanScrollPrev(api.canScrollPrev()) [api]
setCanScrollNext(api.canScrollNext()) )
}, [])
const getCanScrollNext = React.useCallback(
() => api?.canScrollNext() ?? false,
[api]
)
const canScrollPrev = React.useSyncExternalStore(
subscribeToCarousel,
getCanScrollPrev,
() => false
)
const canScrollNext = React.useSyncExternalStore(
subscribeToCarousel,
getCanScrollNext,
() => false
)
const scrollPrev = React.useCallback(() => { const scrollPrev = React.useCallback(() => {
api?.scrollPrev() api?.scrollPrev()
@ -91,17 +117,6 @@ function Carousel({
setApi(api) setApi(api)
}, [api, setApi]) }, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return ( return (
<CarouselContext.Provider <CarouselContext.Provider
value={{ value={{
@ -229,6 +244,9 @@ function CarouselNext({
) )
} }
// eslint-disable-next-line react-refresh/only-export-components
export { useCarousel }
export { export {
type CarouselApi, type CarouselApi,
Carousel, Carousel,
@ -236,5 +254,4 @@ export {
CarouselItem, CarouselItem,
CarouselPrevious, CarouselPrevious,
CarouselNext, CarouselNext,
useCarousel,
} }

View File

@ -279,6 +279,9 @@ function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null) return React.useRef<HTMLDivElement | null>(null)
} }
// eslint-disable-next-line react-refresh/only-export-components
export { useComboboxAnchor }
export { export {
Combobox, Combobox,
ComboboxInput, ComboboxInput,
@ -295,5 +298,4 @@ export {
ComboboxChipsInput, ComboboxChipsInput,
ComboboxTrigger, ComboboxTrigger,
ComboboxValue, ComboboxValue,
useComboboxAnchor,
} }

View File

@ -19,4 +19,5 @@ function DirectionProvider({
const useDirection = Direction.useDirection const useDirection = Direction.useDirection
// eslint-disable-next-line react-refresh/only-export-components
export { DirectionProvider, useDirection } export { DirectionProvider, useDirection }

View File

@ -151,6 +151,9 @@ function NavigationMenuIndicator({
) )
} }
// eslint-disable-next-line react-refresh/only-export-components
export { navigationMenuTriggerStyle }
export { export {
NavigationMenu, NavigationMenu,
NavigationMenuList, NavigationMenuList,
@ -160,5 +163,4 @@ export {
NavigationMenuLink, NavigationMenuLink,
NavigationMenuIndicator, NavigationMenuIndicator,
NavigationMenuViewport, NavigationMenuViewport,
navigationMenuTriggerStyle,
} }

View File

@ -672,6 +672,9 @@ function SidebarMenuSubButton({
) )
} }
// eslint-disable-next-line react-refresh/only-export-components
export { useSidebar }
export { export {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@ -696,5 +699,4 @@ export {
SidebarRail, SidebarRail,
SidebarSeparator, SidebarSeparator,
SidebarTrigger, SidebarTrigger,
useSidebar,
} }

View File

@ -87,4 +87,5 @@ function TabsContent({
) )
} }
// eslint-disable-next-line react-refresh/only-export-components
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View File

@ -42,4 +42,5 @@ function Toggle({
) )
} }
// eslint-disable-next-line react-refresh/only-export-components
export { Toggle, toggleVariants } export { Toggle, toggleVariants }

View File

@ -11,6 +11,13 @@ import {
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useWsEvent, useWsStatus, getWsClient, useRoomSubscription } from '@/ws'; import { useWsEvent, useWsStatus, getWsClient, useRoomSubscription } from '@/ws';
import { roomGet, participantList, pinList, threadList } from '@/client/api'; import { roomGet, participantList, pinList, threadList } from '@/client/api';
import type { AxiosResponse } from 'axios';
import type {
ApiResponseRoomResponse,
ApiResponseRoomParticipantListResponse,
ApiResponseVecRoomPinResponse,
ApiResponseVecRoomThreadResponse,
} from '@/client/generated';
import { useCurrentUserQuery } from '@/hooks/useAuth'; import { useCurrentUserQuery } from '@/hooks/useAuth';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { MessageRepository } from '@/lib/db/repository'; import { MessageRepository } from '@/lib/db/repository';
@ -62,8 +69,8 @@ export interface RoomContextValue {
streamingChunks: Map<RoomId, Array<{ type: string; content: string; seq?: number }>>; streamingChunks: Map<RoomId, Array<{ type: string; content: string; seq?: number }>>;
activeAiStream: ActiveAiStream | null; activeAiStream: ActiveAiStream | null;
cancelAiStream: () => void; cancelAiStream: () => void;
getAiList: () => Promise<any>; getAiList: () => Promise<unknown>;
upsertAi: (config: any) => Promise<any>; upsertAi: (config: Record<string, unknown>) => Promise<unknown>;
deleteAi: (agentId: string) => void; deleteAi: (agentId: string) => void;
/** Pin management */ /** Pin management */
@ -79,12 +86,12 @@ export interface RoomContextValue {
voiceLeave: () => void; voiceLeave: () => void;
/** General */ /** General */
searchMessages: (query: string, opts?: any) => Promise<any>; searchMessages: (query: string, opts?: Record<string, unknown>) => Promise<unknown>;
createInvite: (opts?: any) => Promise<void>; createInvite: (opts?: Record<string, unknown>) => Promise<void>;
updatePresence: (status: string) => void; updatePresence: (status: string) => void;
grantAccess: (targetUserId: string, role: string) => void; grantAccess: (targetUserId: string, role: string) => void;
banUser: (userId: string, reason?: string) => void; banUser: (userId: string, reason?: string) => void;
createThread: (parentSeq: number) => Promise<any> | undefined; createThread: (parentSeq: number) => Promise<unknown> | undefined;
/** UI state */ /** UI state */
setCurrentRoom: (room: { id: string; room_name: string; topic?: string; public: boolean } | null) => void; setCurrentRoom: (room: { id: string; room_name: string; topic?: string; public: boolean } | null) => void;
@ -108,9 +115,6 @@ export function RoomProvider({ roomId, projectName, children }: RoomProviderProp
const { data: user } = useCurrentUserQuery(); const { data: user } = useCurrentUserQuery();
const currentUserId = user?.uid ?? null; const currentUserId = user?.uid ?? null;
const roomIdRef = useRef(roomId);
roomIdRef.current = roomId;
// ── Messages ── // ── Messages ──
const { const {
@ -179,18 +183,17 @@ export function RoomProvider({ roomId, projectName, children }: RoomProviderProp
// ── Exposed actions ── // ── Exposed actions ──
const getAiList = useCallback(async () => { const getAiList = useCallback(async () => {
const client = safeGetClient(); const client = safeGetClient();
if (client && roomId) return client.getAiList(roomId); if (client && roomId) return client.getAiList(roomId);
return null; return null;
}, [roomId]); }, [roomId]);
const upsertAi = useCallback(async (config: Record<string, unknown>) => {
const upsertAi = useCallback(async (config: any) => {
const client = safeGetClient(); const client = safeGetClient();
if (client && roomId) return client.upsertAi(roomId, config); if (client && roomId) return client.upsertAi(roomId, config);
return null; return null;
}, [roomId]); }, [roomId]);
const deleteAi = useCallback((agentId: string) => { const deleteAi = useCallback((agentId: string) => {
const client = safeGetClient(); const client = safeGetClient();
if (client && roomId) client.deleteAi(roomId, agentId); if (client && roomId) client.deleteAi(roomId, agentId);
@ -219,7 +222,7 @@ export function RoomProvider({ roomId, projectName, children }: RoomProviderProp
if (client) client.emitRaw('custom_status_update', { emoji, text, expires_at: expires_at ?? null }); if (client) client.emitRaw('custom_status_update', { emoji, text, expires_at: expires_at ?? null });
}, []); }, []);
const searchMessages = useCallback(async (query: string, opts?: any) => { const searchMessages = useCallback(async (query: string, opts?: Record<string, unknown>) => {
const client = safeGetClient(); const client = safeGetClient();
if (client) return client.search(query, { room: roomId, ...opts }); if (client) return client.search(query, { room: roomId, ...opts });
return null; return null;
@ -235,7 +238,7 @@ export function RoomProvider({ roomId, projectName, children }: RoomProviderProp
if (client && roomId) client.emitRaw('voice_leave', { room: roomId }); if (client && roomId) client.emitRaw('voice_leave', { room: roomId });
}, [roomId]); }, [roomId]);
const createInvite = useCallback(async (options: any) => { const createInvite = useCallback(async (options: Record<string, unknown>) => {
const client = safeGetClient(); const client = safeGetClient();
if (client && roomId) client.emitRaw('invite_create', { room: roomId, ...options }); if (client && roomId) client.emitRaw('invite_create', { room: roomId, ...options });
}, [roomId]); }, [roomId]);
@ -260,17 +263,24 @@ export function RoomProvider({ roomId, projectName, children }: RoomProviderProp
// ── Room switch cleanup ── // ── Room switch cleanup ──
useEffect(() => { // Reset state during render when roomId changes
if (!roomId) return; const [prevRoomId, setPrevRoomId] = useState(roomId);
clearMessages(); if (roomId !== prevRoomId) {
cleanupStream(); setPrevRoomId(roomId);
setCurrentRoom(null); setCurrentRoom(null);
setMembers([]); setMembers([]);
setPinnedMessages([]); setPinnedMessages([]);
setThreads([]); setThreads([]);
setTypingUsers(new Map()); setTypingUsers(new Map());
}
// Imperative cleanup when roomId changes
useEffect(() => {
if (!roomId) return;
clearMessages();
cleanupStream();
mergePendingMessages(); mergePendingMessages();
}, [roomId]); }, [roomId, clearMessages, cleanupStream, mergePendingMessages]);
// ── Load room info ── // ── Load room info ──
@ -286,7 +296,7 @@ export function RoomProvider({ roomId, projectName, children }: RoomProviderProp
]); ]);
if (roomRes.status === 'fulfilled') { if (roomRes.status === 'fulfilled') {
const data = (roomRes.value as any).data?.data; const data = (roomRes.value as AxiosResponse<ApiResponseRoomResponse>).data?.data;
if (data) { if (data) {
setCurrentRoom({ id: data.id, room_name: data.room_name, public: data.public }); setCurrentRoom({ id: data.id, room_name: data.room_name, public: data.public });
// Update presence to online when joining a room // Update presence to online when joining a room
@ -294,15 +304,16 @@ export function RoomProvider({ roomId, projectName, children }: RoomProviderProp
} }
} }
if (membersRes.status === 'fulfilled') { if (membersRes.status === 'fulfilled') {
const participants = (membersRes.value as any).data?.data?.participants ?? []; const membersData = (membersRes.value as AxiosResponse<ApiResponseRoomParticipantListResponse>).data?.data;
setMembers(participants.map((p: any) => mapParticipantToMember(p, 'online'))); const participants = membersData?.participants ?? [];
setMembers(participants.map((p) => mapParticipantToMember(p, 'online')));
} }
if (pinsRes.status === 'fulfilled') { if (pinsRes.status === 'fulfilled') {
setPinnedMessages((pinsRes.value as any).data?.data ?? []); setPinnedMessages((pinsRes.value as AxiosResponse<ApiResponseVecRoomPinResponse>).data?.data ?? []);
} }
if (threadsRes.status === 'fulfilled') { if (threadsRes.status === 'fulfilled') {
const threadData = (threadsRes.value as any).data?.data ?? []; const threadData = (threadsRes.value as AxiosResponse<ApiResponseVecRoomThreadResponse>).data?.data ?? [];
const mapped: ThreadState[] = threadData.map((t: any) => ({ const mapped: ThreadState[] = threadData.map((t) => ({
...t, ...t,
messages: [], messages: [],
isOpen: false, isOpen: false,
@ -320,14 +331,17 @@ export function RoomProvider({ roomId, projectName, children }: RoomProviderProp
// ── WS: message events ── // ── WS: message events ──
useWsEvent('message_new', (event) => { useWsEvent('message_new', (event) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
handleNewMessage(event as any); handleNewMessage(event as any);
}); });
useWsEvent('message_edited', (event) => { useWsEvent('message_edited', (event) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
handleEditedMessage(event as any); handleEditedMessage(event as any);
}); });
useWsEvent('message_revoked', (event) => { useWsEvent('message_revoked', (event) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
handleRevokedMessage(event as any); handleRevokedMessage(event as any);
}); });
@ -391,6 +405,7 @@ export function RoomProvider({ roomId, projectName, children }: RoomProviderProp
// ── WS: Reaction ── // ── WS: Reaction ──
useWsEvent('reaction_batch_updated', (event) => { useWsEvent('reaction_batch_updated', (event) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
handleReactionUpdate(event as any); handleReactionUpdate(event as any);
}); });
@ -479,7 +494,7 @@ export function RoomProvider({ roomId, projectName, children }: RoomProviderProp
useWsEvent('room_settings_updated', (event) => { useWsEvent('room_settings_updated', (event) => {
if (event.room_id !== roomId) return; if (event.room_id !== roomId) return;
setCurrentRoom((prev) => prev ? { ...prev, public: (event.data as any).public_status ?? prev.public } : prev); setCurrentRoom((prev) => prev ? { ...prev, public: (event.data as { public_status?: boolean }).public_status ?? prev.public } : prev);
}); });
// ── WS: Thread events ── // ── WS: Thread events ──
@ -488,7 +503,7 @@ export function RoomProvider({ roomId, projectName, children }: RoomProviderProp
if (event.room_id !== roomId) return; if (event.room_id !== roomId) return;
setThreads((prev) => { setThreads((prev) => {
if (prev.some((t) => t.id === event.data.id)) return prev; if (prev.some((t) => t.id === event.data.id)) return prev;
return [...prev, { ...event.data, messages: [], isOpen: false, last_message_at: event.data.created_at, last_message_preview: null } as any]; return [...prev, { ...event.data, messages: [], isOpen: false, last_message_at: event.data.created_at, last_message_preview: null } as ThreadState];
}); });
}); });

View File

@ -45,12 +45,14 @@ const [isHistoryLoaded, setIsHistoryLoaded] = useState(false);
const loadMessagesAbortRef = useRef<AbortController | null>(null); const loadMessagesAbortRef = useRef<AbortController | null>(null);
// ── Room switch state reset ── // ── Room switch state reset ──
const lastRoomIdRef = useRef<RoomId | null>(roomId); const lastRoomIdRef = useRef<RoomId | null>(null);
useEffect(() => {
if (roomId !== lastRoomIdRef.current) { if (roomId !== lastRoomIdRef.current) {
setIsHistoryLoaded(false); setIsHistoryLoaded(false);
setNextCursor(null); setNextCursor(null);
lastRoomIdRef.current = roomId; lastRoomIdRef.current = roomId;
} }
}, [roomId]);
// ── Core operations ── // ── Core operations ──
@ -93,6 +95,7 @@ const [isHistoryLoaded, setIsHistoryLoaded] = useState(false);
room: roomId, room: roomId,
before_seq: useCursor ?? null, before_seq: useCursor ?? null,
limit, limit,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any; }) as any;
if (resp?.messages) rawMsgs = resp.messages; if (resp?.messages) rawMsgs = resp.messages;
} catch { /* WS failed */ } } catch { /* WS failed */ }
@ -110,7 +113,8 @@ const [isHistoryLoaded, setIsHistoryLoaded] = useState(false);
const json = await res.json(); const json = await res.json();
rawMsgs = (json.data ?? json).messages ?? []; rawMsgs = (json.data ?? json).messages ?? [];
} }
} catch (err: any) { } catch (// eslint-disable-next-line @typescript-eslint/no-explicit-any
err: any) {
if (err.name === 'AbortError') return; if (err.name === 'AbortError') return;
throw err; throw err;
} }
@ -148,6 +152,7 @@ const [isHistoryLoaded, setIsHistoryLoaded] = useState(false);
room: roomId, room: roomId,
after_seq: meta.maxSeq, after_seq: meta.maxSeq,
limit: 100 limit: 100
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any; }) as any;
if (resp?.messages && resp.messages.length > 0) { if (resp?.messages && resp.messages.length > 0) {
@ -236,7 +241,8 @@ const [isHistoryLoaded, setIsHistoryLoaded] = useState(false);
const newMsg = mapToMessage(msg); const newMsg = mapToMessage(msg);
const optimistic = await db.messages const optimistic = await db.messages
.where({ room: targetRoomId, content: msg.content, isOptimistic: true as any }) .where({ room: targetRoomId, content: msg.content, isOptimistic: true as // eslint-disable-next-line @typescript-eslint/no-explicit-any
any })
.first(); .first();
if (optimistic) { if (optimistic) {
@ -312,15 +318,21 @@ function mapToMessage(r: RoomMessageResponse): Message {
in_reply_to: r.in_reply_to, in_reply_to: r.in_reply_to,
content: r.content, content: r.content,
content_type: r.content_type, content_type: r.content_type,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
edited_at: (r as any).edited_at, edited_at: (r as any).edited_at,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
revoked: (r as any).revoked, revoked: (r as any).revoked,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
revoked_by: (r as any).revoked_by, revoked_by: (r as any).revoked_by,
send_at: r.send_at, send_at: r.send_at,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
_localReactions: (r as any).reactions ?? [], _localReactions: (r as any).reactions ?? [],
isOptimistic: false, isOptimistic: false,
isOptimisticError: false, isOptimisticError: false,
is_streaming: false, is_streaming: false,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
thinking_content: (r as any).thinking_content ?? null, thinking_content: (r as any).thinking_content ?? null,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
attachment_ids: (r as any).attachment_ids, attachment_ids: (r as any).attachment_ids,
}; };
} }

View File

@ -39,16 +39,22 @@ export function shouldGroup(prev: Message, curr: Message): boolean {
export function mapHttpMessage(r: RoomMessageResponse): Message { export function mapHttpMessage(r: RoomMessageResponse): Message {
return { return {
...r, ...r,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
thread: (r as any).thread ?? (r as any).thread_id, thread: (r as any).thread ?? (r as any).thread_id,
_localReactions: [], _localReactions: [],
is_streaming: false, is_streaming: false,
isOptimistic: false, isOptimistic: false,
isOptimisticError: false, isOptimisticError: false,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
thinking_content: (r as any).thinking_content ?? null, thinking_content: (r as any).thinking_content ?? null,
}; };
} }
export function mapParticipantToMember(p: any, presence?: Member['presence']): Member { export function mapParticipantToMember(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
p: any,
presence?: Member['presence'],
): Member {
return { return {
uid: p.user, uid: p.user,
username: p.user_info?.username ?? p.user, username: p.user_info?.username ?? p.user,
@ -61,7 +67,11 @@ export function mapParticipantToMember(p: any, presence?: Member['presence']): M
}; };
} }
export function mapStreamData(data: any): any { export function mapStreamData(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: any,
): // eslint-disable-next-line @typescript-eslint/no-explicit-any
any {
return { return {
message_id: data.message_id ?? data.id, message_id: data.message_id ?? data.id,
content: data.content ?? '', content: data.content ?? '',

View File

@ -26,7 +26,7 @@ export const CHANNEL_PAGE = {
panel: 'channel-panel', panel: 'channel-panel',
reconnectBanner: 'flex items-center gap-2 px-4 py-2 text-[13px] font-medium', reconnectBanner: 'flex items-center gap-2 px-4 py-2 text-[13px] font-medium',
reconnectBannerBg: 'var(--warning)', reconnectBannerBg: 'var(--warning)',
reconnectBannerColor: '#000', reconnectBannerColor: 'var(--text-primary)',
typingIndicator: 'flex items-center gap-2 px-4 py-1 text-[12px]', typingIndicator: 'flex items-center gap-2 px-4 py-1 text-[12px]',
typingDots: 'typing-dots', typingDots: 'typing-dots',
} as const; } as const;

View File

@ -9,12 +9,15 @@ import {
listMessages, listMessages,
createMessage as apiCreateMessage, createMessage as apiCreateMessage,
stopMessage as apiStopMessage, stopMessage as apiStopMessage,
resendMessage as apiResendMessage,
streamChat, streamChat,
editMessage as apiEditMessage, editMessage as apiEditMessage,
listMessageVersions as apiListMessageVersions, listMessageVersions as apiListMessageVersions,
switchMessageVersion as apiSwitchMessageVersion, switchMessageVersion as apiSwitchMessageVersion,
listMessageForks as apiListMessageForks, listMessageForks as apiListMessageForks,
forkMessage as apiForkMessage,
} from "@/client/aiChatApi"; } from "@/client/aiChatApi";
import { useStreamingStore } from "@/store/streaming";
const CONVERSATIONS_KEY = "ai-conversations"; const CONVERSATIONS_KEY = "ai-conversations";
const MESSAGES_KEY = "ai-messages"; const MESSAGES_KEY = "ai-messages";
@ -97,6 +100,87 @@ export function useStopMessageMutation() {
}); });
} }
export function useResendMessageMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ conversationId, messageId }: { conversationId: string; messageId: string }) =>
apiResendMessage(conversationId, messageId),
onSuccess: (_, vars) => {
queryClient.invalidateQueries({ queryKey: [MESSAGES_KEY, vars.conversationId] });
queryClient.invalidateQueries({ queryKey: [CONVERSATIONS_KEY, vars.conversationId] });
},
});
}
export function useForkMessageMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ conversationId, messageId }: { conversationId: string; messageId: string }) =>
apiForkMessage(conversationId, messageId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [CONVERSATIONS_KEY] });
},
});
}
export function useChatStreamRunner(setIsStreaming?: (value: boolean) => void) {
const queryClient = useQueryClient();
const streamingStore = useStreamingStore();
return async (conversationId: string, messageId: string) => {
streamingStore.clear(conversationId);
setIsStreaming?.(true);
let clearOnFinish = false;
try {
for await (const chunk of streamChat(conversationId, messageId)) {
if (chunk.type === "token") {
streamingStore.append(conversationId, "token", String(chunk.data || ""), messageId);
} else if (chunk.type === "thinking") {
streamingStore.append(conversationId, "thinking", String(chunk.data || ""), messageId);
} else if (chunk.type === "tool_call") {
streamingStore.addToolPart(conversationId, {
type: "tool_call",
content: chunk.data?.display || chunk.data?.tool || "",
toolName: chunk.data?.tool || "unknown",
toolArgs: chunk.data?.args || {},
}, messageId);
} else if (chunk.type === "tool_result") {
streamingStore.addToolPart(conversationId, {
type: "tool_result",
content: String(chunk.data?.result || chunk.data?.display || ""),
toolName: chunk.data?.tool || "unknown",
toolArgs: {},
toolStatus: chunk.data?.status || "ok",
}, messageId);
} else if (chunk.type === "title") {
queryClient.invalidateQueries({ queryKey: [CONVERSATIONS_KEY, conversationId] });
queryClient.invalidateQueries({ queryKey: [CONVERSATIONS_KEY] });
} else if (chunk.type === "done") {
streamingStore.markDone(conversationId);
if (chunk.data === "ok") {
clearOnFinish = true;
await queryClient.invalidateQueries({ queryKey: [MESSAGES_KEY, conversationId] });
await queryClient.invalidateQueries({ queryKey: [CONVERSATIONS_KEY, conversationId] });
await queryClient.invalidateQueries({ queryKey: [CONVERSATIONS_KEY] });
}
} else if (chunk.type === "billing_error") {
streamingStore.append(conversationId, "token", String(chunk.data || ""), messageId);
streamingStore.markDone(conversationId);
queryClient.invalidateQueries({ queryKey: [MESSAGES_KEY, conversationId] });
} else if (chunk.type === "error") {
streamingStore.append(conversationId, "token", String(chunk.data || "Stream failed"), messageId);
streamingStore.markDone(conversationId);
}
}
} finally {
setIsStreaming?.(false);
if (clearOnFinish) {
streamingStore.clear(conversationId);
}
}
};
}
export { streamChat }; export { streamChat };
export type { MessageResponse }; export type { MessageResponse };

View File

@ -12,6 +12,7 @@ export function useCurrentUserQuery() {
return (res.data?.data as ContextMe) ?? null; return (res.data?.data as ContextMe) ?? null;
}, },
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
retry: (failureCount, error: any) => { retry: (failureCount, error: any) => {
// Don't retry on unauthorized // Don't retry on unauthorized
if (error?.status === 401 || error?.response?.status === 401 || error?.message === "Unauthorized") { if (error?.status === 401 || error?.response?.status === 401 || error?.message === "Unauthorized") {

View File

@ -73,7 +73,8 @@ export function useRepoBranchesQuery({ namespace, repo }: RepoParams) {
queryKey: [REPO_BRANCHES_QUERY_KEY, namespace, repo], queryKey: [REPO_BRANCHES_QUERY_KEY, namespace, repo],
queryFn: async () => { queryFn: async () => {
const res = await gitBranchList(namespace, repo); const res = await gitBranchList(namespace, repo);
return (res.data.data as unknown as any[]) || []; return (res.data.data as unknown as // eslint-disable-next-line @typescript-eslint/no-explicit-any
any[]) || [];
}, },
enabled: !!namespace && !!repo, enabled: !!namespace && !!repo,
}); });
@ -84,7 +85,8 @@ export function useRepoTagsQuery({ namespace, repo }: RepoParams) {
queryKey: [REPO_TAGS_QUERY_KEY, namespace, repo], queryKey: [REPO_TAGS_QUERY_KEY, namespace, repo],
queryFn: async () => { queryFn: async () => {
const res = await gitTagList(namespace, repo); const res = await gitTagList(namespace, repo);
return (res.data.data as unknown as any[]) || []; return (res.data.data as unknown as // eslint-disable-next-line @typescript-eslint/no-explicit-any
any[]) || [];
}, },
enabled: !!namespace && !!repo, enabled: !!namespace && !!repo,
}); });
@ -95,7 +97,8 @@ export function useRepoBranchSummaryQuery({ namespace, repo }: RepoParams) {
queryKey: [REPO_BRANCH_SUMMARY_QUERY_KEY, namespace, repo], queryKey: [REPO_BRANCH_SUMMARY_QUERY_KEY, namespace, repo],
queryFn: async () => { queryFn: async () => {
const res = await gitBranchSummary(namespace, repo); const res = await gitBranchSummary(namespace, repo);
return (res.data.data as unknown as any) || {}; return (res.data.data as unknown as // eslint-disable-next-line @typescript-eslint/no-explicit-any
any) || {};
}, },
enabled: !!namespace && !!repo, enabled: !!namespace && !!repo,
}); });
@ -106,7 +109,8 @@ export function useRepoPullsQuery({ namespace, repo }: RepoParams) {
queryKey: [REPO_PULLS_QUERY_KEY, namespace, repo], queryKey: [REPO_PULLS_QUERY_KEY, namespace, repo],
queryFn: async () => { queryFn: async () => {
const res = await pullRequestList(namespace, repo); const res = await pullRequestList(namespace, repo);
return ((res.data.data as unknown as { pull_requests?: unknown[] })?.pull_requests ?? []) as unknown as any[]; return ((res.data.data as unknown as { pull_requests?: unknown[] })?.pull_requests ?? []) as unknown as // eslint-disable-next-line @typescript-eslint/no-explicit-any
any[];
}, },
enabled: !!namespace && !!repo, enabled: !!namespace && !!repo,
}); });
@ -121,7 +125,8 @@ export function useRepoTreeQuery({ namespace, repo, oid }: RepoTreeParams) {
queryKey: [REPO_TREE_QUERY_KEY, namespace, repo, oid], queryKey: [REPO_TREE_QUERY_KEY, namespace, repo, oid],
queryFn: async () => { queryFn: async () => {
const res = await gitTreeList(namespace, repo, oid); const res = await gitTreeList(namespace, repo, oid);
return (res.data.data as unknown as any[]) || []; return (res.data.data as unknown as // eslint-disable-next-line @typescript-eslint/no-explicit-any
any[]) || [];
}, },
enabled: !!namespace && !!repo && !!oid, enabled: !!namespace && !!repo && !!oid,
}); });
@ -135,6 +140,7 @@ export function useRepoCommitDetailQuery({ namespace, repo, oid }: RepoTreeParam
queryKey: [REPO_COMMIT_DETAIL_QUERY_KEY, namespace, repo, oid], queryKey: [REPO_COMMIT_DETAIL_QUERY_KEY, namespace, repo, oid],
queryFn: async () => { queryFn: async () => {
const res = await gitCommitGet(namespace, repo, oid); const res = await gitCommitGet(namespace, repo, oid);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return res.data.data as any; return res.data.data as any;
}, },
enabled: !!namespace && !!repo && !!oid, enabled: !!namespace && !!repo && !!oid,
@ -149,6 +155,7 @@ export function useRepoCommitDiffQuery({ namespace, repo, oid, baseOid }: RepoTr
old_tree: baseOid || "", old_tree: baseOid || "",
new_tree: oid, new_tree: oid,
}); });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return res.data.data as any; return res.data.data as any;
}, },
enabled: !!namespace && !!repo && !!oid, enabled: !!namespace && !!repo && !!oid,
@ -195,7 +202,8 @@ export function useBranchProtectionQuery({ namespace, repo }: RepoParams) {
queryKey: [BRANCH_PROTECTION_QUERY_KEY, namespace, repo], queryKey: [BRANCH_PROTECTION_QUERY_KEY, namespace, repo],
queryFn: async () => { queryFn: async () => {
const res = await branchProtectionList(namespace, repo); const res = await branchProtectionList(namespace, repo);
return (res.data.data as unknown as any[]) || []; return (res.data.data as unknown as // eslint-disable-next-line @typescript-eslint/no-explicit-any
any[]) || [];
}, },
enabled: !!namespace && !!repo, enabled: !!namespace && !!repo,
}); });

View File

@ -84,7 +84,8 @@ export function useUserFollowerCountQuery(username: string) {
queryKey: [USER_QUERY_KEY, username, "followers-count"], queryKey: [USER_QUERY_KEY, username, "followers-count"],
queryFn: async (): Promise<number> => { queryFn: async (): Promise<number> => {
const res = await getSubscriberCount(username); const res = await getSubscriberCount(username);
return (res.data as any)?.data?.count ?? 0; return (res.data as // eslint-disable-next-line @typescript-eslint/no-explicit-any
any)?.data?.count ?? 0;
}, },
enabled: !!username, enabled: !!username,
}); });
@ -95,7 +96,8 @@ export function useUserFollowingCountQuery(username: string) {
queryKey: [USER_QUERY_KEY, username, "following-count"], queryKey: [USER_QUERY_KEY, username, "following-count"],
queryFn: async (): Promise<number> => { queryFn: async (): Promise<number> => {
const res = await getSubscriptionCount(username); const res = await getSubscriptionCount(username);
return (res.data as any)?.data?.count ?? 0; return (res.data as // eslint-disable-next-line @typescript-eslint/no-explicit-any
any)?.data?.count ?? 0;
}, },
enabled: !!username, enabled: !!username,
}); });
@ -229,7 +231,8 @@ export function useIsSubscribedQuery(username: string) {
queryFn: async (): Promise<boolean> => { queryFn: async (): Promise<boolean> => {
try { try {
const res = await isSubscribedToTarget(username); const res = await isSubscribedToTarget(username);
return (res.data as any)?.data?.is_subscribed ?? false; return (res.data as // eslint-disable-next-line @typescript-eslint/no-explicit-any
any)?.data?.is_subscribed ?? false;
} catch { } catch {
return false; return false;
} }

Some files were not shown because too many files have changed in this diff Show More