refactor(frontend): apply formatting and update chat, settings, project pages
This commit is contained in:
parent
8731c01908
commit
b8bd0ec545
@ -82,6 +82,7 @@ export default function App() {
|
||||
<Route path="/me/projects" element={<MePage />} />
|
||||
<Route path="/me/activity" element={<MePage />} />
|
||||
<Route path="/me/stars" element={<MePage />} />
|
||||
<Route path="/me/followers" element={<MePage />} />
|
||||
<Route path="/me/following" element={<MePage />} />
|
||||
<Route path="/me/chat" element={<ChatPage scope="personal" />} />
|
||||
<Route path="/me/chat/:conversationId" element={<ChatPage scope="personal" />} />
|
||||
|
||||
@ -19,6 +19,7 @@ export function ChangePasswordPage() {
|
||||
|
||||
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 loadCaptcha = async () => {
|
||||
@ -26,7 +27,7 @@ export function ChangePasswordPage() {
|
||||
const result = await getCaptcha(apiAuthCaptcha, true);
|
||||
setCaptchaImage(result.base64);
|
||||
setPublicKey(result.publicKey || "");
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setError("Failed to load captcha");
|
||||
}
|
||||
};
|
||||
@ -55,11 +56,12 @@ export function ChangePasswordPage() {
|
||||
});
|
||||
|
||||
navigate("/me/settings");
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 401) {
|
||||
} catch (err) {
|
||||
const apiErr = err as { response?: { status?: number; data?: { message?: string } } };
|
||||
if (apiErr.response?.status === 401) {
|
||||
setError("Current password is incorrect");
|
||||
} else {
|
||||
setError(err.response?.data?.message || "Failed to change password");
|
||||
setError(apiErr.response?.data?.message || "Failed to change password");
|
||||
}
|
||||
loadCaptcha();
|
||||
} finally {
|
||||
|
||||
@ -23,7 +23,7 @@ export function ForgotPasswordPage() {
|
||||
try {
|
||||
const result = await getCaptcha(apiAuthCaptcha, true);
|
||||
setCaptchaImage(result.base64);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setError("Failed to load captcha");
|
||||
}
|
||||
};
|
||||
@ -48,8 +48,9 @@ export function ForgotPasswordPage() {
|
||||
// Reset password doesn't require RSA encryption since it's email-based
|
||||
await apiUserRequestPasswordReset({ email: data.email });
|
||||
setSuccess(true);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || "Failed to send reset email");
|
||||
} catch (err) {
|
||||
const apiErr = err as { response?: { status?: number; data?: { message?: string } } };
|
||||
setError(apiErr.response?.data?.message || "Failed to send reset email");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useLocation, Link } from "react-router-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -27,14 +27,15 @@ export function LoginPage() {
|
||||
const result = await getCaptcha(apiAuthCaptcha, true);
|
||||
setCaptchaImage(result.base64);
|
||||
setPublicKey(result.publicKey || "");
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setError("Failed to load captcha");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
useState(() => {
|
||||
loadCaptcha();
|
||||
}, []);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const onSubmit = async (data: LoginParams) => {
|
||||
setError("");
|
||||
@ -49,16 +50,17 @@ export function LoginPage() {
|
||||
totp_code: needs2FA ? data.totp_code : null,
|
||||
});
|
||||
|
||||
const from = (location.state as any)?.from?.pathname || "/me";
|
||||
const from = (location.state as { from?: { pathname?: string } })?.from?.pathname || "/me";
|
||||
navigate(from, { replace: true });
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 428) {
|
||||
} catch (err) {
|
||||
const apiErr = err as { response?: { status?: number; data?: { message?: string } } };
|
||||
if (apiErr.response?.status === 428) {
|
||||
setNeeds2FA(true);
|
||||
setError("Two-factor authentication required");
|
||||
} else if (err.response?.status === 401) {
|
||||
} else if (apiErr.response?.status === 401) {
|
||||
setError("Invalid username or password");
|
||||
} else {
|
||||
setError(err.response?.data?.message || "Login failed");
|
||||
setError(apiErr.response?.data?.message || "Login failed");
|
||||
}
|
||||
loadCaptcha();
|
||||
}
|
||||
|
||||
@ -24,7 +24,7 @@ export function RegisterPage() {
|
||||
const result = await getCaptcha(apiAuthCaptcha, true);
|
||||
setCaptchaImage(result.base64);
|
||||
setPublicKey(result.publicKey || "");
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setError("Failed to load captcha");
|
||||
}
|
||||
};
|
||||
@ -33,6 +33,7 @@ export function RegisterPage() {
|
||||
loadCaptcha();
|
||||
}, []);
|
||||
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const password = watch("password");
|
||||
|
||||
const onSubmit = async (data: RegisterParams & { confirmPassword: string }) => {
|
||||
@ -56,11 +57,12 @@ export function RegisterPage() {
|
||||
});
|
||||
|
||||
navigate("/");
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 409) {
|
||||
} catch (err) {
|
||||
const apiErr = err as { response?: { status?: number; data?: { message?: string } } };
|
||||
if (apiErr.response?.status === 409) {
|
||||
setError("Username or email already exists");
|
||||
} else {
|
||||
setError(err.response?.data?.message || "Registration failed");
|
||||
setError(apiErr.response?.data?.message || "Registration failed");
|
||||
}
|
||||
loadCaptcha();
|
||||
} finally {
|
||||
|
||||
@ -24,6 +24,7 @@ export function ResetPasswordPage() {
|
||||
defaultValues: { token }
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const password = watch("new_password");
|
||||
|
||||
const loadCaptcha = async () => {
|
||||
@ -31,7 +32,7 @@ export function ResetPasswordPage() {
|
||||
const result = await getCaptcha(apiAuthCaptcha, true);
|
||||
setCaptchaImage(result.base64);
|
||||
setPublicKey(result.publicKey || "");
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setError("Failed to load captcha");
|
||||
}
|
||||
};
|
||||
@ -59,11 +60,12 @@ export function ResetPasswordPage() {
|
||||
});
|
||||
|
||||
navigate("/auth/login");
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 400) {
|
||||
} catch (err) {
|
||||
const apiErr = err as { response?: { status?: number; data?: { message?: string } } };
|
||||
if (apiErr.response?.status === 400) {
|
||||
setError("Invalid or expired reset token");
|
||||
} else {
|
||||
setError(err.response?.data?.message || "Failed to reset password");
|
||||
setError(apiErr.response?.data?.message || "Failed to reset password");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -22,19 +22,20 @@ export function TwoFactorPage() {
|
||||
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<Disable2FAParams>();
|
||||
|
||||
useEffect(() => {
|
||||
loadStatus();
|
||||
}, []);
|
||||
|
||||
const loadStatus = async () => {
|
||||
try {
|
||||
const response = await api2faStatus();
|
||||
setIsEnabled(response.data.is_enabled || false);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setError("Failed to load 2FA status");
|
||||
}
|
||||
};
|
||||
|
||||
useState(() => {
|
||||
loadStatus();
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const handleEnable = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
@ -44,8 +45,9 @@ export function TwoFactorPage() {
|
||||
setQrCode(response.data.qr_code);
|
||||
setSecret(response.data.secret);
|
||||
setShowSetup(true);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || "Failed to enable 2FA");
|
||||
} catch (err) {
|
||||
const apiErr = err as { response?: { status?: number; data?: { message?: string } } };
|
||||
setError(apiErr.response?.data?.message || "Failed to enable 2FA");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -63,8 +65,9 @@ export function TwoFactorPage() {
|
||||
setShowSetup(false);
|
||||
setQrCode("");
|
||||
setSecret("");
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || "Invalid verification code");
|
||||
} catch (err) {
|
||||
const apiErr = err as { response?: { status?: number; data?: { message?: string } } };
|
||||
setError(apiErr.response?.data?.message || "Invalid verification code");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -77,8 +80,9 @@ export function TwoFactorPage() {
|
||||
try {
|
||||
await api2faDisable(data);
|
||||
setIsEnabled(false);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || "Failed to disable 2FA");
|
||||
} catch (err) {
|
||||
const apiErr = err as { response?: { status?: number; data?: { message?: string } } };
|
||||
setError(apiErr.response?.data?.message || "Failed to disable 2FA");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { useSearchParams, useNavigate } from "react-router-dom";
|
||||
import { apiEmailVerify } from "@/client/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -10,35 +10,26 @@ type VerifyStatus = "idle" | "verifying" | "success" | "error";
|
||||
export function VerifyEmailPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<VerifyStatus>("idle");
|
||||
const [message, setMessage] = useState("");
|
||||
const token = searchParams.get("token") || "";
|
||||
const [status, setStatus] = useState<VerifyStatus>(token ? "verifying" : "error");
|
||||
const [message, setMessage] = useState(token ? "" : "Missing verification token");
|
||||
|
||||
useEffect(() => {
|
||||
const token = searchParams.get("token");
|
||||
if (!token) {
|
||||
setStatus("error");
|
||||
setMessage("Missing verification token");
|
||||
return;
|
||||
useState(() => {
|
||||
if (token) {
|
||||
apiEmailVerify({ token })
|
||||
.then((res) => {
|
||||
const msg = res.data?.data ?? "Email verified successfully";
|
||||
setMessage(msg);
|
||||
setStatus("success");
|
||||
setTimeout(() => navigate("/auth/login"), 3000);
|
||||
})
|
||||
.catch((err) => {
|
||||
const apiErr = err as { response?: { status?: number; data?: { message?: string } } };
|
||||
setMessage(apiErr.response?.data?.message || "Verification failed");
|
||||
setStatus("error");
|
||||
});
|
||||
}
|
||||
|
||||
const verify = async () => {
|
||||
try {
|
||||
setStatus("verifying");
|
||||
const res = await apiEmailVerify({ token });
|
||||
const msg = res.data?.data ?? "Email verified successfully";
|
||||
setMessage(msg);
|
||||
setStatus("success");
|
||||
|
||||
// Redirect to login after 3 seconds
|
||||
setTimeout(() => navigate("/auth/login"), 3000);
|
||||
} catch (err: any) {
|
||||
setMessage(err.response?.data?.message || "Verification failed");
|
||||
setStatus("error");
|
||||
}
|
||||
};
|
||||
|
||||
verify();
|
||||
}, [searchParams, navigate]);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={AUTH_FORM.container}>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { createContext, useContext, useState, useMemo, useCallback, type ReactNode } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { useMatch } from "react-router-dom";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { ServerIconRail } from "@/components/layout/ServerIconRail";
|
||||
import { ChannelSidebar } from "@/components/layout/ChannelSidebar";
|
||||
@ -17,6 +18,7 @@ const ChannelContext = createContext<ChannelContextType>({
|
||||
setShowMembers: () => {},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const useChannel = () => useContext(ChannelContext);
|
||||
|
||||
export function ChannelLayout({ children }: { children?: ReactNode }) {
|
||||
@ -36,6 +38,9 @@ export function ChannelLayout({ children }: { children?: ReactNode }) {
|
||||
[],
|
||||
);
|
||||
|
||||
const roomMatch = useMatch("/channel/:roomId");
|
||||
const mainShouldOwnScroll = !roomMatch;
|
||||
|
||||
return (
|
||||
<ChannelContext.Provider value={contextValue}>
|
||||
<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)" }}>
|
||||
<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 />}
|
||||
</main>
|
||||
</div>
|
||||
@ -90,4 +98,4 @@ export function ChannelLayout({ children }: { children?: ReactNode }) {
|
||||
</div>
|
||||
</ChannelContext.Provider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useMemo, useEffect, useRef } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Plus, Trash2, MessageSquare, Loader2, Search, Edit2 } from "lucide-react";
|
||||
import { Plus, Trash2, MessageSquare, Loader2, Search, Edit2, X } from "lucide-react";
|
||||
import { useConversationsQuery, useCreateConversationMutation, useDeleteConversationMutation } from "@/hooks/useAiChatQuery";
|
||||
import { useChatPage } from "./ChatPageContext";
|
||||
import type { ConversationResponse } from "@/client/model";
|
||||
@ -17,13 +17,66 @@ export function ChatConversationList({ selectedId, onSelect, onNew }: ChatConver
|
||||
const createMutation = useCreateConversationMutation();
|
||||
const deleteMutation = useDeleteConversationMutation();
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const getConversationLink = (id: string) => {
|
||||
if (scope === "project" && projectName) return `/${projectName}/chat/${id}`;
|
||||
return `/me/chat/${id}`;
|
||||
};
|
||||
|
||||
const conversations = data?.conversations || [];
|
||||
const conversations = useMemo(() => data?.conversations || [], [data?.conversations]);
|
||||
|
||||
// Filter conversations by search query
|
||||
const filteredConversations = useMemo(() => {
|
||||
if (!searchQuery.trim()) return conversations;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return conversations.filter((c) => (c.title || "Untitled Chat").toLowerCase().includes(q));
|
||||
}, [conversations, searchQuery]);
|
||||
|
||||
// Group conversations by date
|
||||
const groupedConversations = useMemo(() => {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const weekAgo = new Date(today);
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
const monthAgo = new Date(today);
|
||||
monthAgo.setMonth(monthAgo.getMonth() - 1);
|
||||
|
||||
const groups: { label: string; items: ConversationResponse[] }[] = [];
|
||||
|
||||
const todayItems: ConversationResponse[] = [];
|
||||
const yesterdayItems: ConversationResponse[] = [];
|
||||
const thisWeekItems: ConversationResponse[] = [];
|
||||
const thisMonthItems: ConversationResponse[] = [];
|
||||
const earlierItems: ConversationResponse[] = [];
|
||||
|
||||
for (const c of filteredConversations) {
|
||||
const date = new Date(c.created_at);
|
||||
if (date >= today) {
|
||||
todayItems.push(c);
|
||||
} else if (date >= yesterday) {
|
||||
yesterdayItems.push(c);
|
||||
} else if (date >= weekAgo) {
|
||||
thisWeekItems.push(c);
|
||||
} else if (date >= monthAgo) {
|
||||
thisMonthItems.push(c);
|
||||
} else {
|
||||
earlierItems.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
if (todayItems.length > 0) groups.push({ label: "Today", items: todayItems });
|
||||
if (yesterdayItems.length > 0) groups.push({ label: "Yesterday", items: yesterdayItems });
|
||||
if (thisWeekItems.length > 0) groups.push({ label: "This Week", items: thisWeekItems });
|
||||
if (thisMonthItems.length > 0) groups.push({ label: "This Month", items: thisMonthItems });
|
||||
if (earlierItems.length > 0) groups.push({ label: "Earlier", items: earlierItems });
|
||||
|
||||
return groups;
|
||||
}, [filteredConversations]);
|
||||
|
||||
const handleNew = async () => {
|
||||
onNew();
|
||||
@ -58,6 +111,23 @@ export function ChatConversationList({ selectedId, onSelect, onNew }: ChatConver
|
||||
}
|
||||
};
|
||||
|
||||
// Cmd/Ctrl+K to focus search
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
setIsSearchOpen(true);
|
||||
setTimeout(() => searchInputRef.current?.focus(), 0);
|
||||
}
|
||||
if (e.key === "Escape" && isSearchOpen) {
|
||||
setIsSearchOpen(false);
|
||||
setSearchQuery("");
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isSearchOpen]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col h-full shrink-0"
|
||||
@ -73,10 +143,10 @@ export function ChatConversationList({ selectedId, onSelect, onNew }: ChatConver
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
onClick={() => {}}
|
||||
onClick={() => setIsSearchOpen(!isSearchOpen)}
|
||||
className="flex items-center justify-center w-7 h-7 rounded-lg transition-colors hover:bg-[var(--hover-bg)]"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
title="Search"
|
||||
title="Search (Ctrl+K)"
|
||||
>
|
||||
<Search className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@ -96,6 +166,39 @@ export function ChatConversationList({ selectedId, onSelect, onNew }: ChatConver
|
||||
</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 */}
|
||||
<div className="px-3 py-2">
|
||||
<button
|
||||
@ -130,7 +233,7 @@ export function ChatConversationList({ selectedId, onSelect, onNew }: ChatConver
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
/>
|
||||
</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="w-10 h-10 rounded-xl flex items-center justify-center"
|
||||
@ -145,26 +248,38 @@ export function ChatConversationList({ selectedId, onSelect, onNew }: ChatConver
|
||||
className="text-[13px] font-medium text-center"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
No conversations yet
|
||||
{searchQuery ? "No matching conversations" : "No conversations yet"}
|
||||
</p>
|
||||
<p
|
||||
className="text-[12px] text-center leading-relaxed"
|
||||
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>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{conversations.map((conversation) => (
|
||||
<ConversationItem
|
||||
key={conversation.id}
|
||||
conversation={conversation}
|
||||
isSelected={selectedId === conversation.id}
|
||||
isDeleting={deletingId === conversation.id}
|
||||
linkTo={getConversationLink(conversation.id)}
|
||||
onDelete={(e) => handleDelete(e, conversation.id)}
|
||||
/>
|
||||
<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">
|
||||
{group.items.map((conversation) => (
|
||||
<ConversationItem
|
||||
key={conversation.id}
|
||||
conversation={conversation}
|
||||
isSelected={selectedId === conversation.id}
|
||||
isDeleting={deletingId === conversation.id}
|
||||
linkTo={getConversationLink(conversation.id)}
|
||||
onDelete={(e) => handleDelete(e, conversation.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1,49 +1,30 @@
|
||||
import { Copy, Check, Sparkles, ClipboardList, Pencil, RefreshCw, GitFork } from "lucide-react";
|
||||
import { memo, useState } from "react";
|
||||
import { memo, useState, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useCurrentUserQuery } from "@/hooks/useAuth";
|
||||
import { useEditMessageMutation, useMessageVersionsQuery, useSwitchMessageVersionMutation } from "@/hooks/useAiChatQuery";
|
||||
import { MarkdownRenderer } from "@/components/ui/MarkdownRenderer";
|
||||
import {
|
||||
useChatStreamRunner,
|
||||
useEditMessageMutation,
|
||||
useForkMessageMutation,
|
||||
useMessageVersionsQuery,
|
||||
useResendMessageMutation,
|
||||
useSwitchMessageVersionMutation,
|
||||
} from "@/hooks/useAiChatQuery";
|
||||
import { IrRenderer } from "@/lib/ir/renderer";
|
||||
import { parseContentBlocks, extractAnswerText, extractFullText } from "@/lib/ir/parser";
|
||||
import type { IrContentBlock, IrToolCallNode, IrToolResultNode } from "@/lib/ir/types";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Reasoning, ReasoningTrigger, ReasoningContent } from "@/components/ai-elements/reasoning";
|
||||
import type { MessageResponse } from "@/hooks/useAiChatQuery";
|
||||
import { getModelIcon } from "@/lib/icons/modelIcons";
|
||||
import { ToolCallBlock } from "@/components/chat/ToolCallBlock";
|
||||
import { useChatPage } from "./ChatPageContext";
|
||||
|
||||
interface ChatMessageBubbleProps {
|
||||
message: MessageResponse;
|
||||
conversationId: string;
|
||||
onRegenerate?: (newMessageId: string) => void;
|
||||
}
|
||||
|
||||
interface ContentBlock {
|
||||
role: "thinking" | "assistant" | string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** Strip XML-format thinking tags and normalize thinking content.
|
||||
* Thinking content is a reasoning trace, not formatted text — remove
|
||||
* per-token stray \n that models emit during streaming, and collapse
|
||||
* any remaining excessive newlines. */
|
||||
function stripThinkingXml(content: string): string {
|
||||
return content
|
||||
.replace(/<\/?thinking>/gi, "")
|
||||
.replace(/<\/?response>/gi, "")
|
||||
// Collapse 3+ \n, then strip single \n entirely (no space — avoids
|
||||
// unwanted gaps in CJK text where there are no word boundaries).
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.replace(/(?<!\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();
|
||||
setIsStreaming: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const AVATAR_COLORS = [
|
||||
@ -60,47 +41,9 @@ function hashColor(str: string): string {
|
||||
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
|
||||
}
|
||||
|
||||
/** Parse content into ordered blocks: [{role, content}, ...] */
|
||||
function parseBlocks(raw: unknown): ContentBlock[] {
|
||||
if (Array.isArray(raw)) {
|
||||
const blocks = raw.map((item) => {
|
||||
if (item && typeof item === "object") {
|
||||
const obj = item as Record<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 }];
|
||||
}
|
||||
const PROSE_CLASS = "prose prose-sm dark:prose-invert max-w-none [&_p]:leading-[1.55] [&_p]:my-1 [&_ul]:my-1 [&_ol]:my-1 [&_li]:my-0 [&_h1]:mt-2 [&_h2]:mt-2 [&_h3]:mt-2 [&_pre]:my-1.5 [&_blockquote]:my-1";
|
||||
|
||||
/** Extract answer text only (for simple copy). */
|
||||
function extractAnswerText(blocks: ContentBlock[]): string {
|
||||
return blocks
|
||||
.filter((b) => b.role !== "thinking")
|
||||
.map((b) => b.content)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
/** Extract full content including thinking (for deep copy). */
|
||||
function extractFullText(blocks: ContentBlock[]): string {
|
||||
return blocks.map((b) => {
|
||||
if (b.role === "thinking") return `[Thinking]\n${b.content}\n[/Thinking]`;
|
||||
return b.content;
|
||||
}).join("\n\n");
|
||||
}
|
||||
|
||||
|
||||
export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conversationId, onRegenerate }: ChatMessageBubbleProps) {
|
||||
export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conversationId, onRegenerate, setIsStreaming }: ChatMessageBubbleProps) {
|
||||
const isUser = message.role === "user";
|
||||
const [copied, setCopied] = useState<"answer" | "full" | false>(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
@ -108,12 +51,29 @@ export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conv
|
||||
const [showVersions, setShowVersions] = useState(false);
|
||||
const { data: user } = useCurrentUserQuery();
|
||||
const editMutation = useEditMessageMutation();
|
||||
const resendMutation = useResendMessageMutation();
|
||||
const forkMutation = useForkMessageMutation();
|
||||
const switchVersionMutation = useSwitchMessageVersionMutation();
|
||||
const runStream = useChatStreamRunner(setIsStreaming);
|
||||
const navigate = useNavigate();
|
||||
const { scope, projectName } = useChatPage();
|
||||
|
||||
const blocks = isUser
|
||||
? [{ role: "user" as const, content: typeof message.content === "string" ? message.content : "" }]
|
||||
: parseBlocks(message.content);
|
||||
const plainText = isUser ? blocks[0]?.content || "" : extractAnswerText(blocks);
|
||||
// Parse content into IrContentBlock[] (handles both old and future formats)
|
||||
const blocks: IrContentBlock[] = useMemo(() =>
|
||||
isUser
|
||||
? [{ role: "user", nodes: [] }]
|
||||
: parseContentBlocks(message.content),
|
||||
[isUser, message.content]
|
||||
);
|
||||
|
||||
// User message plain text
|
||||
const userText = typeof message.content === "string"
|
||||
? message.content
|
||||
: message.content && typeof message.content === "object"
|
||||
? String((message.content as Record<string, unknown>).content ?? "")
|
||||
: "";
|
||||
const plainText = isUser ? userText : extractAnswerText(blocks);
|
||||
const hasThinking = blocks.some((b) => b.role === "thinking");
|
||||
|
||||
// Fetch versions when showing version switcher
|
||||
const versionsQuery = useMessageVersionsQuery(conversationId, message.id);
|
||||
@ -155,18 +115,20 @@ export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conv
|
||||
content: editText.trim(),
|
||||
});
|
||||
setIsEditing(false);
|
||||
// If there's a regenerate callback, trigger AI response for the new message
|
||||
if (onRegenerate) {
|
||||
onRegenerate(newMsg.id);
|
||||
}
|
||||
onRegenerate?.(newMsg.id);
|
||||
await runStream(conversationId, newMsg.id);
|
||||
} catch (err) {
|
||||
console.error("Failed to edit message:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegenerate = () => {
|
||||
if (onRegenerate) {
|
||||
onRegenerate(message.id);
|
||||
const handleRegenerate = async () => {
|
||||
try {
|
||||
const newMsg = await resendMutation.mutateAsync({ conversationId, messageId: message.id });
|
||||
onRegenerate?.(newMsg.id);
|
||||
await runStream(conversationId, newMsg.id);
|
||||
} catch (err) {
|
||||
console.error("Failed to regenerate:", err);
|
||||
}
|
||||
};
|
||||
|
||||
@ -184,19 +146,12 @@ export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conv
|
||||
};
|
||||
|
||||
const handleFork = async () => {
|
||||
// Forking from an AI message: creates a new user message that branches
|
||||
// from this AI response point. The target_message_id will be the new
|
||||
// user message created on the client side.
|
||||
try {
|
||||
// For now, we'll fork by creating a new user message with parent = this AI message
|
||||
// and then register the fork relationship
|
||||
// The fork flow: user clicks "Fork" → creates a new empty user message
|
||||
// with parent_message_id = this AI message → user types new content → submits
|
||||
// This is handled at the ChatMessageList level
|
||||
if (onRegenerate) {
|
||||
// Use onRegenerate callback to signal the parent component
|
||||
// that a fork should be initiated from this message
|
||||
onRegenerate(message.id);
|
||||
const fork = await forkMutation.mutateAsync({ conversationId, messageId: message.id });
|
||||
if (scope === "project" && projectName) {
|
||||
navigate(`/${projectName}/chat/${fork.id}`);
|
||||
} else {
|
||||
navigate(`/me/chat/${fork.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fork:", err);
|
||||
@ -214,7 +169,7 @@ export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conv
|
||||
className="text-[10px] font-semibold rounded-full"
|
||||
style={{
|
||||
backgroundColor: hashColor(user?.username || "user"),
|
||||
color: "#ffffff",
|
||||
color: "var(--text-inverse)",
|
||||
}}
|
||||
>
|
||||
{(user?.display_name || user?.username || "U")[0]?.toUpperCase()}
|
||||
@ -237,7 +192,7 @@ export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conv
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Interleaved blocks — thinking (collapsible) + answer (markdown) in order */}
|
||||
{/* Interleaved blocks — thinking (collapsible) + answer (IrRenderer) */}
|
||||
<div className="text-sm" style={{ color: "var(--text-primary)" }}>
|
||||
{isUser && isEditing ? (
|
||||
<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)",
|
||||
}}
|
||||
>
|
||||
{editMutation.isPending ? "Saving…" : "Save & Submit"}
|
||||
{editMutation.isPending ? "Saving..." : "Save & Submit"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -290,16 +245,51 @@ export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conv
|
||||
) : (
|
||||
blocks.map((b, i) => {
|
||||
if (b.role === "thinking") {
|
||||
// Thinking content rendered by Reasoning/Streamdown (not IrRenderer)
|
||||
const thinkingText = b.nodes
|
||||
.filter((n) => n.type === "text")
|
||||
.map((n) => (n as { content: string }).content)
|
||||
.join("");
|
||||
return (
|
||||
<Reasoning key={i} defaultOpen={false}>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>{b.content}</ReasoningContent>
|
||||
<ReasoningContent>{thinkingText}</ReasoningContent>
|
||||
</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 (
|
||||
<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>
|
||||
);
|
||||
})
|
||||
@ -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" ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
{blocks.some((b) => b.role === "thinking") && (
|
||||
{hasThinking && (
|
||||
<button
|
||||
onClick={handleCopyFull}
|
||||
className="inline-flex items-center gap-1 text-[11px] px-2 py-1 rounded-md transition-colors hover:bg-[var(--hover-bg)]"
|
||||
@ -449,15 +439,16 @@ function ModelAvatar({ modelName, size = 28 }: { modelName?: string | null; size
|
||||
}
|
||||
return (
|
||||
<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={{
|
||||
width: size,
|
||||
height: size,
|
||||
backgroundColor: hashColor(modelName),
|
||||
fontSize: Math.max(10, size * 0.35),
|
||||
color: "var(--text-inverse)",
|
||||
}}
|
||||
>
|
||||
{modelName[0]?.toUpperCase() || "?"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { useCreateMessageMutation, useCreateConversationMutation, streamChat } from "@/hooks/useAiChatQuery";
|
||||
import {
|
||||
useCreateMessageMutation,
|
||||
useCreateConversationMutation,
|
||||
useStopMessageMutation,
|
||||
useChatStreamRunner,
|
||||
} from "@/hooks/useAiChatQuery";
|
||||
import { useChatPage } from "./ChatPageContext";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useStreamingStore } from "@/store/streaming";
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputBody,
|
||||
@ -29,11 +32,13 @@ export function ChatMessageInput({
|
||||
onSelectConversation,
|
||||
}: ChatMessageInputProps) {
|
||||
const [showModelWarning, setShowModelWarning] = useState(false);
|
||||
const [activeMessageId, setActiveMessageId] = useState<string | null>(null);
|
||||
const [activeStreamConversationId, setActiveStreamConversationId] = useState<string | null>(null);
|
||||
const createMessageMutation = useCreateMessageMutation();
|
||||
const createConversationMutation = useCreateConversationMutation();
|
||||
const stopMessageMutation = useStopMessageMutation();
|
||||
const { scope, projectId, selectedModel, setSelectedModel } = useChatPage();
|
||||
const queryClient = useQueryClient();
|
||||
const streamingStore = useStreamingStore();
|
||||
const runStream = useChatStreamRunner(setIsStreaming);
|
||||
|
||||
const handleSubmit = async ({ text }: PromptInputMessage) => {
|
||||
if (!text.trim()) return;
|
||||
@ -83,38 +88,13 @@ export function ChatMessageInput({
|
||||
|
||||
if (!messageResponse?.id) return;
|
||||
|
||||
streamingStore.clear(activeConversationId);
|
||||
setIsStreaming(true);
|
||||
|
||||
try {
|
||||
const stream = streamChat(activeConversationId, messageResponse.id);
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (chunk.type === "token") {
|
||||
streamingStore.append(activeConversationId, "token", String(chunk.data || ""), messageResponse.id);
|
||||
} else if (chunk.type === "thinking") {
|
||||
streamingStore.append(activeConversationId, "thinking", String(chunk.data || ""), messageResponse.id);
|
||||
} else if (chunk.type === "tool_call" || chunk.type === "tool_result") {
|
||||
// Tool events — ignored for now
|
||||
} else if (chunk.type === "title") {
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-conversations", activeConversationId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-conversations"] });
|
||||
} else if (chunk.type === "done") {
|
||||
streamingStore.markDone(activeConversationId);
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-messages", activeConversationId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-conversations", activeConversationId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-conversations"] });
|
||||
} else if (chunk.type === "error") {
|
||||
console.error("Stream error:", chunk.data);
|
||||
} else if (chunk.type === "billing_error") {
|
||||
streamingStore.append(activeConversationId, "token", String(chunk.data || ""), messageResponse.id);
|
||||
streamingStore.markDone(activeConversationId);
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-messages", activeConversationId] });
|
||||
}
|
||||
}
|
||||
setActiveMessageId(messageResponse.id);
|
||||
setActiveStreamConversationId(activeConversationId);
|
||||
await runStream(activeConversationId, messageResponse.id);
|
||||
} finally {
|
||||
setIsStreaming(false);
|
||||
streamingStore.clear(activeConversationId);
|
||||
setActiveMessageId(null);
|
||||
setActiveStreamConversationId(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to send message:", err);
|
||||
@ -151,7 +131,12 @@ export function ChatMessageInput({
|
||||
/>
|
||||
<PromptInputSubmit
|
||||
status={isStreaming ? "streaming" : "ready"}
|
||||
onStop={() => setIsStreaming(false)}
|
||||
onStop={() => {
|
||||
if (activeStreamConversationId && activeMessageId) {
|
||||
stopMessageMutation.mutate({ conversationId: activeStreamConversationId, messageId: activeMessageId });
|
||||
}
|
||||
setIsStreaming(false);
|
||||
}}
|
||||
/>
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
||||
import { Loader2, Code, FileText, GitPullRequest, Brain, ChevronDown, Sparkles } from "lucide-react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
@ -8,12 +8,15 @@ import type { StreamPart } from "@/store/streaming";
|
||||
import { ChatMessageBubble } from "./ChatMessageBubble";
|
||||
import { useChatPage } from "./ChatPageContext";
|
||||
import { getModelIcon } from "@/lib/icons/modelIcons";
|
||||
import { MarkdownRenderer } from "@/components/ui/MarkdownRenderer";
|
||||
import { IrRenderer } from "@/lib/ir/renderer";
|
||||
import { Shimmer } from "@/components/ai-elements/shimmer";
|
||||
import { Reasoning, ReasoningTrigger, ReasoningContent } from "@/components/ai-elements/reasoning";
|
||||
import { ToolCallBlock } from "@/components/chat/ToolCallBlock";
|
||||
import { useCodePreview } from "@/components/chat/CodePreviewContext";
|
||||
|
||||
interface ChatMessageListProps {
|
||||
conversationId: string | null;
|
||||
setIsStreaming: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const PROMPT_SUGGESTIONS = [
|
||||
@ -26,17 +29,20 @@ const PROMPT_SUGGESTIONS = [
|
||||
const OVERSCAN = 3;
|
||||
const ESTIMATED_SIZE = 200;
|
||||
|
||||
export function ChatMessageList({ conversationId }: ChatMessageListProps) {
|
||||
const PROSE_CLASS = "prose prose-sm dark:prose-invert max-w-none [&_p]:leading-[1.55] [&_p]:my-1 [&_ul]:my-1 [&_ol]:my-1 [&_li]:my-0 [&_h1]:mt-2 [&_h2]:mt-2 [&_h3]:mt-2 [&_pre]:my-1.5 [&_blockquote]:my-1";
|
||||
|
||||
export function ChatMessageList({ conversationId, setIsStreaming }: ChatMessageListProps) {
|
||||
const { data, isLoading } = useMessagesQuery(conversationId || "");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const messages = data?.messages || [];
|
||||
const messages = useMemo(() => data?.messages ?? [], [data?.messages]);
|
||||
const stream = useStreamingStore((s) => (conversationId ? s.streams[conversationId] : undefined));
|
||||
const isStreaming = stream && !stream.isDone;
|
||||
const queryClient = useQueryClient();
|
||||
const codePreview = useCodePreview();
|
||||
|
||||
// Whether user is scrolled near the bottom
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
const [userScrolledUp, setUserScrolledUp] = useState(false);
|
||||
const [activeUserAnchor, setActiveUserAnchor] = useState(0);
|
||||
|
||||
const checkAtBottom = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
@ -47,20 +53,18 @@ export function ChatMessageList({ conversationId }: ChatMessageListProps) {
|
||||
if (atBottom) setUserScrolledUp(false);
|
||||
}, []);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
const atBottom = distance < 200;
|
||||
setIsAtBottom(atBottom);
|
||||
if (!atBottom) setUserScrolledUp(true);
|
||||
}, []);
|
||||
const userAnchors = useMemo(() => {
|
||||
return messages
|
||||
.map((message, index) => ({ message, index }))
|
||||
.filter(({ message }) => message.role === "user")
|
||||
.map(({ index }) => index);
|
||||
}, [messages]);
|
||||
const showUserTimeline = userAnchors.length > 0 && !codePreview?.activeCode;
|
||||
|
||||
// Streaming bubble — rendered OUTSIDE the virtualizer to avoid
|
||||
// recalculating positions on every token. The virtualizer only
|
||||
// handles stable, persisted messages.
|
||||
const hasStreamingBubble = !!stream?.parts && stream.parts.length > 0;
|
||||
const streamContentLength = stream?.parts.reduce((sum, part) => sum + part.content.length, 0) ?? 0;
|
||||
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const virtualizer = useVirtualizer({
|
||||
count: messages.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
@ -68,7 +72,39 @@ export function ChatMessageList({ conversationId }: ChatMessageListProps) {
|
||||
overscan: OVERSCAN,
|
||||
});
|
||||
|
||||
// Auto-scroll to bottom when new content arrives and user is at bottom
|
||||
const updateActiveUserAnchor = useCallback(() => {
|
||||
if (messages.length === 0 || userAnchors.length === 0) {
|
||||
setActiveUserAnchor(0);
|
||||
return;
|
||||
}
|
||||
const visibleItems = virtualizer.getVirtualItems();
|
||||
if (visibleItems.length === 0) return;
|
||||
const firstVisible = visibleItems[0]?.index ?? 0;
|
||||
let closestAnchor = 0;
|
||||
for (let i = 0; i < userAnchors.length; i++) {
|
||||
if (userAnchors[i] <= firstVisible) {
|
||||
closestAnchor = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
setActiveUserAnchor(closestAnchor);
|
||||
}, [messages.length, userAnchors, virtualizer]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
const atBottom = distance < 200;
|
||||
setIsAtBottom(atBottom);
|
||||
if (!atBottom) setUserScrolledUp(true);
|
||||
updateActiveUserAnchor();
|
||||
}, [updateActiveUserAnchor]);
|
||||
|
||||
useEffect(() => {
|
||||
updateActiveUserAnchor();
|
||||
}, [updateActiveUserAnchor, streamContentLength]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAtBottom && scrollRef.current) {
|
||||
requestAnimationFrame(() => {
|
||||
@ -77,23 +113,20 @@ export function ChatMessageList({ conversationId }: ChatMessageListProps) {
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [messages.length, stream?.parts?.length, isAtBottom]);
|
||||
}, [messages.length, streamContentLength, isAtBottom]);
|
||||
|
||||
// Scroll to bottom initially
|
||||
useEffect(() => {
|
||||
if (conversationId && messages.length > 0) {
|
||||
checkAtBottom();
|
||||
}
|
||||
}, [conversationId, messages.length, checkAtBottom]);
|
||||
|
||||
// Scroll to bottom on first load
|
||||
useEffect(() => {
|
||||
if (scrollRef.current && messages.length > 0) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [isLoading]);
|
||||
}, [isLoading, messages.length]);
|
||||
|
||||
// Empty state — no conversation or no messages
|
||||
if (!conversationId || (messages.length === 0 && !hasStreamingBubble)) {
|
||||
return (
|
||||
<div
|
||||
@ -118,7 +151,7 @@ export function ChatMessageList({ conversationId }: ChatMessageListProps) {
|
||||
How can I help you today?
|
||||
</h1>
|
||||
<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>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 w-full max-w-md">
|
||||
@ -164,7 +197,6 @@ export function ChatMessageList({ conversationId }: ChatMessageListProps) {
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<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"
|
||||
@ -187,15 +219,51 @@ export function ChatMessageList({ conversationId }: ChatMessageListProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Virtualized message list — persisted messages only */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto pb-24"
|
||||
className="app-scrollbar flex-1 overflow-y-auto pb-24"
|
||||
onScroll={handleScroll}
|
||||
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
|
||||
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="max-w-3xl mx-auto relative"
|
||||
className="relative mx-auto max-w-3xl"
|
||||
style={{ height: `${virtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
@ -215,19 +283,17 @@ export function ChatMessageList({ conversationId }: ChatMessageListProps) {
|
||||
<ChatMessageBubble
|
||||
message={message}
|
||||
conversationId={conversationId}
|
||||
onRegenerate={(_newMsgId: string) => {
|
||||
onRegenerate={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-messages", conversationId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-conversations", conversationId] });
|
||||
}}
|
||||
setIsStreaming={setIsStreaming}
|
||||
/>
|
||||
</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 && (
|
||||
<div className="max-w-3xl mx-auto w-full">
|
||||
<StreamingBubble parts={stream!.parts} isDone={stream!.isDone} />
|
||||
@ -240,17 +306,16 @@ export function ChatMessageList({ conversationId }: ChatMessageListProps) {
|
||||
|
||||
function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boolean }) {
|
||||
const { selectedModel } = useChatPage();
|
||||
// Display state synced at animation-frame rate so ReactMarkdown only
|
||||
// re-parses at ~60fps, not on every token from the SSE stream.
|
||||
const [displayParts, setDisplayParts] = useState<StreamPart[]>([]);
|
||||
const [displayDone, setDisplayDone] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const latestRef = useRef({ parts, isDone });
|
||||
const rafRef = useRef<number>(0);
|
||||
|
||||
latestRef.current = { parts, isDone };
|
||||
useEffect(() => {
|
||||
latestRef.current = { parts, isDone };
|
||||
});
|
||||
|
||||
// Start rAF sync loop when streaming begins
|
||||
const hasParts = parts.length > 0;
|
||||
useEffect(() => {
|
||||
if (!hasParts) return;
|
||||
@ -266,15 +331,6 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole
|
||||
return () => cancelAnimationFrame(rafRef.current);
|
||||
}, [hasParts]);
|
||||
|
||||
// Final sync when streaming stops (captures last frame)
|
||||
useEffect(() => {
|
||||
if (isDone) {
|
||||
setDisplayParts([...parts]);
|
||||
setDisplayDone(true);
|
||||
}
|
||||
}, [isDone, parts]);
|
||||
|
||||
// Reset height for virtualizer measurement
|
||||
useEffect(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.height = "auto";
|
||||
@ -290,7 +346,6 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole
|
||||
|
||||
return (
|
||||
<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">
|
||||
<StreamingModelAvatar modelName={selectedModel?.model_name} size={28} />
|
||||
</div>
|
||||
@ -301,14 +356,11 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole
|
||||
</span>
|
||||
{!displayDone && (
|
||||
<span className="text-[11px] animate-pulse" style={{ color: "var(--text-muted)" }}>
|
||||
responding…
|
||||
responding...
|
||||
</span>
|
||||
)}
|
||||
</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)" }}>
|
||||
{displayParts.map((part, i) => {
|
||||
if (part.type === "thinking") {
|
||||
@ -320,16 +372,31 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole
|
||||
</Reasoning>
|
||||
);
|
||||
}
|
||||
// Token content — rendered as full Markdown + safe HTML.
|
||||
// MarkdownRenderer is memoized so only re-renders when content changes
|
||||
// (which happens at rAF rate, not per SSE token).
|
||||
if (part.type === "tool_call") {
|
||||
return (
|
||||
<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;
|
||||
return (
|
||||
<div key={i}>
|
||||
<MarkdownRenderer
|
||||
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"
|
||||
/>
|
||||
<IrRenderer nodes={part.irNodes} className={PROSE_CLASS} />
|
||||
{isLast && !displayDone && <StreamingCursor />}
|
||||
</div>
|
||||
);
|
||||
@ -340,7 +407,6 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole
|
||||
);
|
||||
}
|
||||
|
||||
/** Blinking cursor for typing feel during streaming. */
|
||||
function StreamingCursor() {
|
||||
return (
|
||||
<span
|
||||
@ -388,15 +454,16 @@ function StreamingModelAvatar({ modelName, size = 28 }: { modelName?: string | n
|
||||
}
|
||||
return (
|
||||
<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={{
|
||||
width: size,
|
||||
height: size,
|
||||
backgroundColor: hashColor(modelName),
|
||||
fontSize: Math.max(10, size * 0.35),
|
||||
color: "var(--text-inverse)",
|
||||
}}
|
||||
>
|
||||
{modelName[0]?.toUpperCase() || "?"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -40,12 +40,13 @@ function ModelAvatar({ modelName, size = 20 }: { modelName: string; size?: numbe
|
||||
}
|
||||
return (
|
||||
<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={{
|
||||
width: size,
|
||||
height: size,
|
||||
backgroundColor: hashColor(modelName),
|
||||
fontSize: Math.max(9, size * 0.35),
|
||||
color: "var(--text-inverse)",
|
||||
}}
|
||||
>
|
||||
{modelName[0]?.toUpperCase() || "?"}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { ChatPageContext } from "./ChatPageContext";
|
||||
import type { SelectedModel } from "./ChatPageContext";
|
||||
@ -8,6 +8,8 @@ import { ChatMessageList } from "./ChatMessageList";
|
||||
import { ChatMessageInput } from "./ChatMessageInput";
|
||||
import { useProjectInfo } from "@/hooks/useProjectInfo";
|
||||
import { useConversationQuery } from "@/hooks/useAiChatQuery";
|
||||
import { CodePreviewPanel } from "@/components/chat/CodePreviewPanel";
|
||||
import { CodePreviewProvider, type CodePreviewPayload } from "@/components/chat/CodePreviewContext";
|
||||
|
||||
interface ChatPageProps {
|
||||
scope: "personal" | "project";
|
||||
@ -20,33 +22,27 @@ export function ChatPage({ scope }: ChatPageProps) {
|
||||
}>();
|
||||
const navigate = useNavigate();
|
||||
const { data: projectInfo } = useProjectInfo(projectName);
|
||||
const [selectedConversationId, setSelectedConversationId] = useState<string | null>(
|
||||
urlConversationId || null
|
||||
);
|
||||
const selectedConversationId = urlConversationId || null;
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState<SelectedModel | null>(null);
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (urlConversationId) {
|
||||
setSelectedConversationId(urlConversationId);
|
||||
}
|
||||
}, [urlConversationId]);
|
||||
const [userModel, setSelectedModel] = useState<SelectedModel | null>(null);
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(true);
|
||||
const [activeCode, setActiveCode] = useState<CodePreviewPayload | null>(null);
|
||||
|
||||
const { data: conversation } = useConversationQuery(selectedConversationId || "");
|
||||
|
||||
useEffect(() => {
|
||||
const conv = conversation as any;
|
||||
if (conv?.model) {
|
||||
setSelectedModel({
|
||||
model_name: conv.model,
|
||||
});
|
||||
// Derive model from conversation data when it loads
|
||||
const derivedModel = useMemo(() => {
|
||||
if (conversation?.model) {
|
||||
return { model_name: conversation.model } as SelectedModel;
|
||||
}
|
||||
}, [conversation, setSelectedModel]);
|
||||
return null;
|
||||
}, [conversation]);
|
||||
|
||||
// Use user-selected model if set, otherwise fall back to conversation model
|
||||
const selectedModel = userModel || derivedModel;
|
||||
|
||||
const handleSelectConversation = useCallback(
|
||||
(id: string) => {
|
||||
setSelectedConversationId(id);
|
||||
if (scope === "personal") {
|
||||
navigate(`/me/chat/${id}`, { replace: true });
|
||||
} else if (projectName) {
|
||||
@ -57,7 +53,6 @@ export function ChatPage({ scope }: ChatPageProps) {
|
||||
);
|
||||
|
||||
const handleNewConversation = useCallback(() => {
|
||||
setSelectedConversationId(null);
|
||||
if (scope === "personal") {
|
||||
navigate("/me/chat", { replace: true });
|
||||
} else if (projectName) {
|
||||
@ -65,69 +60,103 @@ export function ChatPage({ scope }: ChatPageProps) {
|
||||
}
|
||||
}, [scope, projectName, navigate]);
|
||||
|
||||
// Keyboard shortcuts: Cmd/Ctrl+K = new chat, Cmd/Ctrl+/ = focus input
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const isCmdOrCtrl = e.metaKey || e.ctrlKey;
|
||||
if (!isCmdOrCtrl) return;
|
||||
|
||||
if (e.key === "k") {
|
||||
e.preventDefault();
|
||||
handleNewConversation();
|
||||
} else if (e.key === "/") {
|
||||
e.preventDefault();
|
||||
// Focus the textarea in the input area
|
||||
const textarea = document.querySelector('[data-slot="input-group-control"]') as HTMLElement;
|
||||
textarea?.focus();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [handleNewConversation]);
|
||||
|
||||
const projectId = projectInfo?.uid;
|
||||
const codePreviewValue = useMemo(
|
||||
() => ({
|
||||
activeCode,
|
||||
openCodePreview: setActiveCode,
|
||||
closeCodePreview: () => setActiveCode(null),
|
||||
}),
|
||||
[activeCode]
|
||||
);
|
||||
|
||||
return (
|
||||
<ChatPageContext.Provider
|
||||
value={{ scope, projectName, projectId, selectedModel, setSelectedModel }}
|
||||
>
|
||||
<div className="flex h-full" style={{ backgroundColor: "var(--surface-ground)" }}>
|
||||
{/* Sidebar - collapsible */}
|
||||
<div className="relative flex shrink-0">
|
||||
<div
|
||||
style={{
|
||||
width: isSidebarCollapsed ? 0 : 260,
|
||||
transition: "width 0.2s ease",
|
||||
overflow: "hidden",
|
||||
backgroundColor: "var(--surface-sidebar)",
|
||||
}}
|
||||
>
|
||||
{!isSidebarCollapsed && (
|
||||
<ChatConversationList
|
||||
selectedId={selectedConversationId}
|
||||
onSelect={handleSelectConversation}
|
||||
onNew={handleNewConversation}
|
||||
/>
|
||||
)}
|
||||
<CodePreviewProvider value={codePreviewValue}>
|
||||
<div className="flex h-full" style={{ backgroundColor: "var(--surface-ground)" }}>
|
||||
{/* Sidebar - collapsible */}
|
||||
<div className="relative flex shrink-0">
|
||||
<div
|
||||
style={{
|
||||
width: isSidebarCollapsed ? 0 : 260,
|
||||
transition: "width 0.2s ease",
|
||||
overflow: "hidden",
|
||||
backgroundColor: "var(--surface-sidebar)",
|
||||
}}
|
||||
>
|
||||
{!isSidebarCollapsed && (
|
||||
<ChatConversationList
|
||||
selectedId={selectedConversationId}
|
||||
onSelect={handleSelectConversation}
|
||||
onNew={handleNewConversation}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Chat Area */}
|
||||
<div className="flex-1 flex flex-col min-w-0" style={{ backgroundColor: "var(--surface-ground)" }}>
|
||||
<ChatHeader
|
||||
conversationId={selectedConversationId}
|
||||
isStreaming={isStreaming}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
onToggleSidebar={() => setIsSidebarCollapsed(v => !v)}
|
||||
/>
|
||||
{/* Main Chat Area */}
|
||||
<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
|
||||
conversationId={selectedConversationId}
|
||||
isStreaming={isStreaming}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
onToggleSidebar={() => setIsSidebarCollapsed(v => !v)}
|
||||
/>
|
||||
|
||||
{selectedConversationId ? (
|
||||
<>
|
||||
<ChatMessageList conversationId={selectedConversationId} />
|
||||
<ChatMessageInput
|
||||
conversationId={selectedConversationId}
|
||||
isStreaming={isStreaming}
|
||||
setIsStreaming={setIsStreaming}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center px-4 gap-4">
|
||||
<div className="w-full max-w-3xl">
|
||||
<ChatMessageList conversationId={null} />
|
||||
<div className="mt-4">
|
||||
<ChatMessageInput
|
||||
conversationId={null}
|
||||
isStreaming={isStreaming}
|
||||
setIsStreaming={setIsStreaming}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
/>
|
||||
{selectedConversationId ? (
|
||||
<>
|
||||
<ChatMessageList conversationId={selectedConversationId} setIsStreaming={setIsStreaming} />
|
||||
<ChatMessageInput
|
||||
conversationId={selectedConversationId}
|
||||
isStreaming={isStreaming}
|
||||
setIsStreaming={setIsStreaming}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center px-4 gap-4">
|
||||
<div className="w-full max-w-3xl">
|
||||
<ChatMessageList conversationId={null} setIsStreaming={setIsStreaming} />
|
||||
<div className="mt-4">
|
||||
<ChatMessageInput
|
||||
conversationId={null}
|
||||
isStreaming={isStreaming}
|
||||
setIsStreaming={setIsStreaming}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
<CodePreviewPanel code={activeCode} onClose={() => setActiveCode(null)} />
|
||||
</div>
|
||||
</div>
|
||||
</CodePreviewProvider>
|
||||
</ChatPageContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Search, Lock, Globe, Users, Compass } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { search } from "@/client/api";
|
||||
@ -23,7 +23,6 @@ function hashColor(str: string): string {
|
||||
}
|
||||
|
||||
export function ExplorePage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchText, setSearchText] = useState("");
|
||||
|
||||
const searchParams: SearchParams = {
|
||||
@ -142,7 +141,7 @@ export function ExplorePage() {
|
||||
<AvatarFallback
|
||||
style={{
|
||||
backgroundColor: hashColor(project.display_name),
|
||||
color: "#ffffff",
|
||||
color: "var(--text-inverse)",
|
||||
}}
|
||||
>
|
||||
<span className="text-[15px] font-semibold">
|
||||
|
||||
@ -51,20 +51,16 @@ export function RootLayout() {
|
||||
}
|
||||
|
||||
// Initialize new client
|
||||
if (true) {
|
||||
try {
|
||||
const client = initWsClient({
|
||||
url: WS_CONFIG.url,
|
||||
backendUrl: WS_CONFIG.backendUrl,
|
||||
autoReconnect: WS_CONFIG.autoReconnect,
|
||||
});
|
||||
wsClientRef.current = client;
|
||||
client.connect();
|
||||
} catch (err) {
|
||||
console.error("Failed to initialize WebSocket:", err);
|
||||
}
|
||||
} else {
|
||||
console.warn("VITE_WS_URL not set, WebSocket disabled");
|
||||
try {
|
||||
const client = initWsClient({
|
||||
url: WS_CONFIG.url,
|
||||
backendUrl: WS_CONFIG.backendUrl,
|
||||
autoReconnect: WS_CONFIG.autoReconnect,
|
||||
});
|
||||
wsClientRef.current = client;
|
||||
client.connect();
|
||||
} catch (err) {
|
||||
console.error("Failed to initialize WebSocket:", err);
|
||||
}
|
||||
|
||||
return () => {
|
||||
|
||||
@ -21,7 +21,7 @@ interface ActivityTimelineProps {
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const ICON_MAP: Record<string, any> = {
|
||||
const ICON_MAP: Record<string, React.ComponentType<{ className?: string; "aria-hidden"?: string }>> = {
|
||||
login: LogIn,
|
||||
logout: LogOut,
|
||||
register: UserPlus,
|
||||
|
||||
@ -50,8 +50,9 @@ export function CreateProjectModal({ onClose }: CreateProjectModalProps) {
|
||||
if (res?.project) {
|
||||
navigate(`/${res.project.name}/repos`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || "Failed to create project. The slug might already be taken.");
|
||||
} catch (err: unknown) {
|
||||
const apiError = err as { response?: { data?: { message?: string } } };
|
||||
setError(apiError.response?.data?.message || "Failed to create project. The slug might already be taken.");
|
||||
}
|
||||
};
|
||||
|
||||
@ -65,7 +66,7 @@ export function CreateProjectModal({ onClose }: CreateProjectModalProps) {
|
||||
{/* Header */}
|
||||
<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="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" />
|
||||
</div>
|
||||
<div>
|
||||
@ -141,7 +142,7 @@ export function CreateProjectModal({ onClose }: CreateProjectModalProps) {
|
||||
{/* 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="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" />}
|
||||
</div>
|
||||
<div>
|
||||
@ -187,7 +188,7 @@ export function CreateProjectModal({ onClose }: CreateProjectModalProps) {
|
||||
<Button
|
||||
type="submit"
|
||||
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)" }}
|
||||
onMouseEnter={e => e.currentTarget.style.backgroundColor = "color-mix(in srgb, var(--accent) 85%, black)"}
|
||||
onMouseLeave={e => e.currentTarget.style.backgroundColor = "var(--accent)"}
|
||||
|
||||
@ -23,7 +23,7 @@ export function FollowerCardList({ users }: FollowerCardListProps) {
|
||||
style={{ backgroundColor: "var(--surface-elevated)", borderColor: "var(--border-default)" }}
|
||||
>
|
||||
<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
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
@ -9,10 +9,18 @@ import {
|
||||
MessageSquare,
|
||||
PanelLeftClose
|
||||
} from "lucide-react";
|
||||
import type { ComponentType } from "react";
|
||||
import { useCurrentUserQuery } from "@/hooks/useAuth";
|
||||
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",
|
||||
name: "Overview",
|
||||
@ -54,7 +62,7 @@ const ME_NAV_ITEMS = [
|
||||
name: "Followers",
|
||||
icon: Users,
|
||||
},
|
||||
] as const;
|
||||
];
|
||||
|
||||
interface MeSidebarProps {
|
||||
onCollapse?: () => void;
|
||||
@ -101,7 +109,7 @@ export function MeSidebar({ onCollapse }: MeSidebarProps) {
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<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)" }}>
|
||||
{username ? username[0].toUpperCase() : "U"}
|
||||
</div>
|
||||
@ -124,7 +132,7 @@ export function MeSidebar({ onCollapse }: MeSidebarProps) {
|
||||
|
||||
<nav className="flex-1 overflow-y-auto py-2">
|
||||
{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);
|
||||
|
||||
return (
|
||||
@ -160,7 +168,7 @@ export function MeSidebar({ onCollapse }: MeSidebarProps) {
|
||||
</div>
|
||||
|
||||
{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);
|
||||
|
||||
return (
|
||||
|
||||
@ -73,7 +73,7 @@ export function ProfileHeader({ user, isMe, isLoading, starsCount: starsCountPro
|
||||
<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)" }}>
|
||||
<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()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
@ -91,7 +91,7 @@ export function ProfileHeader({ user, isMe, isLoading, starsCount: starsCountPro
|
||||
{!isMe && (
|
||||
<Button
|
||||
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)" }}
|
||||
onClick={user.is_subscribe ? handleUnfollow : handleFollow}
|
||||
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-[10px] uppercase tracking-wider font-medium" style={{ color: "var(--text-tertiary)" }}>Stars</p>
|
||||
</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-[10px] uppercase tracking-wider font-medium" style={{ color: "var(--text-tertiary)" }}>Followers</p>
|
||||
</div>
|
||||
|
||||
@ -53,7 +53,7 @@ export function ProjectList({ projects, isLoading }: ProjectListProps) {
|
||||
onClick={() => navigate(`/${project.name}`)}
|
||||
>
|
||||
<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()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
@ -30,7 +30,7 @@ export function UserCardList({ users, onToggleFollow }: UserCardListProps) {
|
||||
>
|
||||
<Avatar className="w-12 h-12 rounded-lg">
|
||||
<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()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useBoardDetailQuery } from "@/hooks/useBoardsQuery";
|
||||
import { useBoardOperations } from "@/hooks/useBoardOperations";
|
||||
@ -30,13 +30,7 @@ export function KanbanBoard({ projectName, boardId }: KanbanBoardProps) {
|
||||
const [editCardTitle, setEditCardTitle] = useState("");
|
||||
const [editCardDescription, setEditCardDescription] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCard) {
|
||||
setEditCardTitle(selectedCard.title);
|
||||
setEditCardDescription(selectedCard.description || "");
|
||||
}
|
||||
}, [selectedCard]);
|
||||
|
||||
|
||||
const handleCreateColumn = async () => {
|
||||
if (!newColumnName.trim()) return;
|
||||
await ops.createColumn.mutateAsync({
|
||||
@ -129,7 +123,11 @@ export function KanbanBoard({ projectName, boardId }: KanbanBoardProps) {
|
||||
refetch();
|
||||
}
|
||||
}}
|
||||
onCardClick={(card) => setSelectedCard(card)}
|
||||
onCardClick={(card) => {
|
||||
setSelectedCard(card);
|
||||
setEditCardTitle(card.title);
|
||||
setEditCardDescription(card.description || "");
|
||||
}}
|
||||
onMoveCard={handleMoveCard}
|
||||
/>
|
||||
))}
|
||||
|
||||
@ -9,7 +9,7 @@ import {
|
||||
useRoom,
|
||||
} from '@/contexts/room';
|
||||
import { useProjectLayout } from '@/app/project/layout';
|
||||
import type { Message, ReactionGroup, Member } from '@/contexts/room';
|
||||
import type { Message, ReactionGroup, Member, ThreadState } from '@/contexts/room';
|
||||
import {
|
||||
ThreadPanel,
|
||||
EditHistoryOverlay,
|
||||
@ -54,7 +54,7 @@ function ChannelPageInner() {
|
||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
|
||||
const [replyToMessageId, setReplyToMessageId] = 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 [uploading, setUploading] = useState(false);
|
||||
|
||||
@ -85,16 +85,18 @@ function ChannelPageInner() {
|
||||
])
|
||||
.then(([aiRes, reposRes, skillsRes]) => {
|
||||
const aiData = aiRes.data?.data ?? [];
|
||||
setAgents(aiData.map((a: any) => ({ model: a.model, model_name: a.model_name ?? null })));
|
||||
setAgents(aiData.map((a: { model: string; model_name: string | null }) => ({ model: a.model, model_name: a.model_name ?? null })));
|
||||
|
||||
if (reposRes) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const repoList = (reposRes.data?.data?.items ?? []) as any[];
|
||||
setRepos(repoList.map((r: any) => ({ uid: r.uid, repo_name: r.repo_name })));
|
||||
setRepos(repoList.map((r: { uid: string; repo_name: string }) => ({ uid: r.uid, repo_name: r.repo_name })));
|
||||
}
|
||||
|
||||
if (skillsRes) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const skillsData = (skillsRes.data?.data ?? []) as any[];
|
||||
setSkills(skillsData.map((s: any) => ({ id: s.id, name: s.name, slug: s.slug })));
|
||||
setSkills(skillsData.map((s: { id: number; name: string; slug: string }) => ({ id: s.id, name: s.name, slug: s.slug })));
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
@ -114,7 +116,7 @@ function ChannelPageInner() {
|
||||
if (wsStatus === 'connected' && isConnected) {
|
||||
loadHistory();
|
||||
}
|
||||
}, [wsStatus, isConnected]);
|
||||
}, [wsStatus, isConnected, loadHistory]);
|
||||
|
||||
// Load older messages when scrolling to top
|
||||
const handleStartReached = useCallback(() => {
|
||||
@ -350,6 +352,7 @@ function ChannelPageInner() {
|
||||
try {
|
||||
const { threadMessages } = await import('@/client/api');
|
||||
const res = await threadMessages(roomIdParam, msg.thread);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const threadMsgs: Message[] = (res.data?.data?.messages ?? []).map((r: any) => ({
|
||||
...r, _localReactions: [], is_streaming: false, isOptimistic: false, isOptimisticError: false, thinking_content: null,
|
||||
}));
|
||||
@ -410,13 +413,13 @@ function ChannelPageInner() {
|
||||
<div className="channel-root" style={{ display: 'flex', height: '100%', overflow: 'hidden' }}>
|
||||
<div className="channel-panel" style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
{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...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
@ -443,7 +446,7 @@ function ChannelPageInner() {
|
||||
members={members.map((m: Member) => ({
|
||||
uid: m.uid,
|
||||
username: m.username,
|
||||
avatar_url: (m as any).avatar_url ?? null,
|
||||
avatar_url: m.avatar_url ?? null,
|
||||
}))}
|
||||
agents={agents}
|
||||
repos={repos}
|
||||
@ -484,7 +487,7 @@ function ChannelPageInner() {
|
||||
thread={activeThread}
|
||||
typingUsers={typingUsersList}
|
||||
onClose={closeThread}
|
||||
sendMessage={(content: string, opts?: any) => sendMessage(content, opts)}
|
||||
sendMessage={(content: string, opts?: { contentType?: string; thread?: string; inReplyTo?: string; attachmentIds?: string[] }) => sendMessage(content, opts)}
|
||||
onTypingStart={() => { const c = safeGetClient(); if (c) c.sendTypingStart(roomIdParam); }}
|
||||
onTypingStop={() => { const c = safeGetClient(); if (c) c.sendTypingStop(roomIdParam); }}
|
||||
/>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { Settings, Trash2, Loader2, Globe, Lock } from "lucide-react";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -27,12 +27,13 @@ export function RoomSettingsModal({ open, onOpenChange }: RoomSettingsModalProps
|
||||
const [isPublic, setIsPublic] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentRoom && open) {
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
onOpenChange(newOpen);
|
||||
if (newOpen && currentRoom) {
|
||||
setName(currentRoom.room_name);
|
||||
setIsPublic(currentRoom.public);
|
||||
setIsPublic(currentRoom.public ?? true);
|
||||
}
|
||||
}, [currentRoom, open]);
|
||||
};
|
||||
|
||||
const handleUpdateRoom = async () => {
|
||||
if (!currentRoom || !name.trim()) return;
|
||||
@ -63,7 +64,7 @@ export function RoomSettingsModal({ open, onOpenChange }: RoomSettingsModalProps
|
||||
};
|
||||
|
||||
return (
|
||||
<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)" }}>
|
||||
<SheetHeader className="p-6 pb-0 shrink-0">
|
||||
<SheetTitle className="flex items-center gap-2" style={{ color: "var(--text-primary)" }}>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Loader2, Shield, Search, Check } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { aiList, aiUpsert, aiDelete, modelCatalog } from "@/client/api";
|
||||
import type { RoomAiUpsertRequest, ModelWithPricingResponse } from "@/client/model";
|
||||
import type { RoomAiUpsertRequest, ModelWithPricingResponse, RoomAiResponse } from "@/client/model";
|
||||
import { getModelIcon } from "@/lib/icons/modelIcons";
|
||||
import { Plus, Trash2, Settings, X as XIcon } from "lucide-react";
|
||||
import {
|
||||
@ -35,12 +35,13 @@ function ModelAvatar({ modelName, size = 36 }: { modelName: string; size?: numbe
|
||||
}
|
||||
return (
|
||||
<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={{
|
||||
width: size,
|
||||
height: size,
|
||||
backgroundColor: hashColor(modelName),
|
||||
fontSize: Math.max(10, size * 0.35),
|
||||
color: "var(--text-inverse)",
|
||||
}}
|
||||
>
|
||||
{modelName[0]?.toUpperCase() || "?"}
|
||||
@ -54,8 +55,8 @@ interface AiSettingsProps {
|
||||
}
|
||||
|
||||
export function AiSettings({ roomId, onAiListChange }: AiSettingsProps) {
|
||||
const [roomAis, setRoomAis] = useState<any[]>([]);
|
||||
const [isLoadingAi, setIsLoadingAi] = useState(false);
|
||||
const [roomAis, setRoomAis] = useState<RoomAiResponse[]>([]);
|
||||
const [isLoadingAi, setIsLoadingAi] = useState(true);
|
||||
const [showAddAi, setShowAddAi] = useState(false);
|
||||
const [selectedModelFull, setSelectedModelFull] = useState<ModelWithPricingResponse | null>(null);
|
||||
const [aiParams, setAiParams] = useState({
|
||||
@ -98,7 +99,10 @@ export function AiSettings({ roomId, onAiListChange }: AiSettingsProps) {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoomAis();
|
||||
aiList(roomId)
|
||||
.then((res) => setRoomAis(res.data.data || []))
|
||||
.catch((err) => console.error("Failed to fetch room AIs", err))
|
||||
.finally(() => setIsLoadingAi(false));
|
||||
}, [roomId]);
|
||||
|
||||
const handleAddAi = async () => {
|
||||
|
||||
@ -77,8 +77,8 @@ export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: Project
|
||||
});
|
||||
onClose();
|
||||
navigate(`/${projectName}/repo/${repoForm.repo_name.trim()}`);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || "Failed to create repository.");
|
||||
} catch (err: unknown) {
|
||||
setError((err as { response?: { data?: { message?: string } } })?.response?.data?.message || "Failed to create repository.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -98,8 +98,8 @@ export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: Project
|
||||
if (room) {
|
||||
navigate(`/${projectName}/channel/${room.id}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || "Failed to create channel.");
|
||||
} catch (err: unknown) {
|
||||
setError((err as { response?: { data?: { message?: string } } })?.response?.data?.message || "Failed to create channel.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -119,8 +119,8 @@ export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: Project
|
||||
if (res.data?.data) {
|
||||
navigate(`/${projectName}/board/${res.data.data.id}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || "Failed to create board.");
|
||||
} catch (err: unknown) {
|
||||
setError((err as { response?: { data?: { message?: string } } })?.response?.data?.message || "Failed to create board.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -142,8 +142,8 @@ export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: Project
|
||||
if (skill) {
|
||||
navigate(`/${projectName}/skills/${skill.slug}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || "Failed to create skill.");
|
||||
} catch (err: unknown) {
|
||||
setError((err as { response?: { data?: { message?: string } } })?.response?.data?.message || "Failed to create skill.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -179,7 +179,7 @@ export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: Project
|
||||
].map(tab => (
|
||||
<button
|
||||
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"
|
||||
style={{
|
||||
borderColor: activeTab === tab.id ? "var(--accent)" : "transparent",
|
||||
@ -235,7 +235,7 @@ export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: Project
|
||||
</div>
|
||||
|
||||
<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
|
||||
type="submit"
|
||||
disabled={!repoForm.repo_name.trim() || loading}
|
||||
@ -284,7 +284,7 @@ export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: Project
|
||||
</div>
|
||||
|
||||
<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
|
||||
type="submit"
|
||||
disabled={!channelForm.room_name.trim() || loading}
|
||||
@ -326,7 +326,7 @@ export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: Project
|
||||
</div>
|
||||
|
||||
<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
|
||||
type="submit"
|
||||
disabled={!boardForm.name.trim() || loading}
|
||||
@ -368,7 +368,7 @@ export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: Project
|
||||
</div>
|
||||
|
||||
<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
|
||||
type="submit"
|
||||
disabled={!skillForm.name.trim() || loading}
|
||||
|
||||
@ -16,7 +16,8 @@ import {
|
||||
} from "@/hooks/useIssueExtraQuery";
|
||||
import {LoadingState} from "@/components/ui/LoadingState";
|
||||
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 {Textarea} from "@/components/ui/textarea";
|
||||
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) => {
|
||||
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"
|
||||
style={{
|
||||
backgroundColor: issueDetail.state === "open" ? "var(--status-online)" : "var(--accent)",
|
||||
color: "#fff"
|
||||
color: "var(--text-inverse)"
|
||||
}}
|
||||
>
|
||||
{issueDetail.state}
|
||||
@ -248,7 +258,7 @@ export function IssueDetailPage() {
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{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>
|
||||
)}
|
||||
@ -294,6 +304,7 @@ export function IssueDetailPage() {
|
||||
<Pencil className="w-3 h-3"/>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm"
|
||||
onClick={() => handleDeleteComment(comment.id)}
|
||||
disabled={isMutating}>
|
||||
<Trash2 className="w-3 h-3 text-destructive"/>
|
||||
</Button>
|
||||
@ -326,7 +337,7 @@ export function IssueDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownRenderer content={comment.body}/>
|
||||
<IrRenderer nodes={extractIrNodes(comment.body)}/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -146,7 +146,7 @@ export function IssueSidebar({ projectName, issueNumber }: IssueSidebarProps) {
|
||||
className="flex items-center gap-2"
|
||||
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>
|
||||
{issueLabels.some(il => il.label_name === l.name) && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
</DropdownMenuItem>
|
||||
@ -167,7 +167,7 @@ export function IssueSidebar({ projectName, issueNumber }: IssueSidebarProps) {
|
||||
key={l.label_name}
|
||||
variant="outline"
|
||||
className="text-[10px] px-1.5 py-0 h-5 border-none"
|
||||
style={{ backgroundColor: "var(--border-default)", color: "var(--text-primary)", borderLeft: `3px solid ${l.label_color ?? "#5865F2"}` }}
|
||||
style={{ backgroundColor: "var(--border-default)", color: "var(--text-primary)", borderLeft: `3px solid ${l.label_color ?? "var(--accent)"}` }}
|
||||
>
|
||||
{l.label_name}
|
||||
<button
|
||||
|
||||
@ -19,6 +19,7 @@ import {
|
||||
import { ISSUES_PAGE } from "@/css/issues/styles";
|
||||
import { useState, useMemo } from "react";
|
||||
import { stripMarkdown, truncate } from "@/lib/utils";
|
||||
import type { IssueResponse, IssueLabelResponse } from "@/client/model";
|
||||
|
||||
export function IssuesPage() {
|
||||
const { projectName } = useParams<{ projectName: string }>();
|
||||
@ -162,13 +163,13 @@ export function IssuesPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className={ISSUES_PAGE.issueList}>
|
||||
{filteredIssues.map((issue: any) => {
|
||||
{filteredIssues.map((issue: IssueResponse) => {
|
||||
// Find priority label if exists
|
||||
const priorityLabel = issue.labels?.find((l: any) => l.label_name?.toLowerCase().startsWith('priority:'));
|
||||
const priority = priorityLabel ? priorityLabel.label_name.split(':')[1].toLowerCase() : null;
|
||||
|
||||
const priorityLabel = issue.labels?.find((l: IssueLabelResponse) => l.label_name?.toLowerCase().startsWith('priority:'));
|
||||
const priority = priorityLabel ? (priorityLabel.label_name ?? '').split(':')[1].toLowerCase() : null;
|
||||
|
||||
// Other labels
|
||||
const otherLabels = issue.labels?.filter((l: any) => !l.label_name?.toLowerCase().startsWith('priority:')) || [];
|
||||
const otherLabels = issue.labels?.filter((l: IssueLabelResponse) => !l.label_name?.toLowerCase().startsWith('priority:')) || [];
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -187,7 +188,7 @@ export function IssuesPage() {
|
||||
|
||||
{/* Inline Labels */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{otherLabels.map((l: any) => (
|
||||
{otherLabels.map((l: IssueLabelResponse) => (
|
||||
<span
|
||||
key={l.label_id}
|
||||
className={ISSUES_PAGE.label}
|
||||
|
||||
@ -36,8 +36,8 @@ export function NewIssuePage() {
|
||||
body: body.trim() || null,
|
||||
});
|
||||
navigate(`/${projectName}/issues/${newIssue.number}`);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || "Failed to create issue. Please try again.");
|
||||
} catch (err: unknown) {
|
||||
setError((err as { response?: { data?: { message?: string } } })?.response?.data?.message || "Failed to create issue. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -23,6 +23,7 @@ const ProjectContext = createContext<ProjectContextType>({
|
||||
setCurrentRoomName: () => {},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const useProjectLayout = () => useContext(ProjectContext);
|
||||
|
||||
export function ProjectLayout() {
|
||||
@ -31,12 +32,15 @@ export function ProjectLayout() {
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
const { projectName } = useParams<{ projectName: string }>();
|
||||
const channelMatch = useMatch("/:projectName/channel/:roomId");
|
||||
const chatMatch = useMatch("/:projectName/chat/*");
|
||||
const roomId = channelMatch?.params.roomId ?? null;
|
||||
const isMobile = useIsMobile();
|
||||
const isTablet = useIsTablet();
|
||||
|
||||
const canShowMembers = !isMobile && !isTablet;
|
||||
|
||||
const mainShouldOwnScroll = !channelMatch && !chatMatch;
|
||||
|
||||
return (
|
||||
<ProjectContext.Provider value={{ showMembers, setShowMembers, currentRoomName, setCurrentRoomName }}>
|
||||
<RoomProvider roomId={roomId} projectName={projectName}>
|
||||
@ -86,7 +90,7 @@ export function ProjectLayout() {
|
||||
>
|
||||
<Header />
|
||||
<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)" }}
|
||||
>
|
||||
<Outlet />
|
||||
|
||||
@ -101,7 +101,7 @@ export function PullsPage() {
|
||||
</h3>
|
||||
<span
|
||||
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}
|
||||
</span>
|
||||
|
||||
@ -89,7 +89,7 @@ export default function BranchProtectionSettings() {
|
||||
const handleUpdate = async () => {
|
||||
if (!editForm) return;
|
||||
try {
|
||||
await updateMutation.mutateAsync({ ...editForm } as any);
|
||||
await updateMutation.mutateAsync({ ...editForm });
|
||||
setEditingId(null);
|
||||
setEditForm(null);
|
||||
setMsg({ type: "success", text: "Branch protection rule updated" });
|
||||
@ -162,7 +162,7 @@ export default function BranchProtectionSettings() {
|
||||
<SelectValue placeholder="Select a branch" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{branchOptions.map((b: any) => (
|
||||
{branchOptions.map((b: { name: string }) => (
|
||||
<SelectItem key={b.name} value={b.name}>
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="w-3 h-3" />
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useRepoInfoQuery, useUpdateRepoSettingsMutation } from "@/hooks/useRepoDetailQuery";
|
||||
import { Loader2, Save, Globe, EyeOff, GitBranch, Zap, RotateCcw } from "lucide-react";
|
||||
@ -27,7 +27,10 @@ export default function GeneralSettings() {
|
||||
const [aiCodeReview, setAiCodeReview] = useState(false);
|
||||
const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Sync form state when repoInfo changes (adjust during render, not in effect)
|
||||
const [prevRepoInfo, setPrevRepoInfo] = useState<typeof repoInfo>(undefined);
|
||||
if (repoInfo !== prevRepoInfo) {
|
||||
setPrevRepoInfo(repoInfo);
|
||||
if (repoInfo) {
|
||||
setName(repoInfo.repo_name);
|
||||
setDescription(repoInfo.description ?? "");
|
||||
@ -35,7 +38,7 @@ export default function GeneralSettings() {
|
||||
setIsPrivate(repoInfo.is_private);
|
||||
setAiCodeReview(repoInfo.ai_code_review_enabled);
|
||||
}
|
||||
}, [repoInfo]);
|
||||
}
|
||||
|
||||
if (!projectName || !repoName) return null;
|
||||
|
||||
@ -50,7 +53,7 @@ export default function GeneralSettings() {
|
||||
ai_code_review_enabled: aiCodeReview,
|
||||
});
|
||||
setMsg({ type: "success", text: "Repository settings updated successfully" });
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setMsg({ type: "error", text: "Failed to update repository settings" });
|
||||
}
|
||||
};
|
||||
|
||||
@ -20,6 +20,7 @@ import {
|
||||
import type { ProjectRepositoryItem } from "@/client/model";
|
||||
import { REPOS_PAGE } from "@/css/repo/styles";
|
||||
import { useState, useMemo } from "react";
|
||||
import { ProjectCreateMenuModal } from "@/app/project/components/ProjectCreateMenuModal";
|
||||
|
||||
function getRelativeTime(dateStr: string | null) {
|
||||
if (!dateStr) return "Never";
|
||||
@ -40,6 +41,7 @@ export function ReposPage() {
|
||||
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [isCreateMenuOpen, setIsCreateMenuOpen] = useState(false);
|
||||
|
||||
const { data: repos = [], isLoading, error, refetch } = useProjectReposQuery(projectName);
|
||||
|
||||
@ -80,8 +82,8 @@ export function ReposPage() {
|
||||
<h1 className={REPOS_PAGE.pageTitle}>Repositories</h1>
|
||||
<p className={REPOS_PAGE.pageSub}>Host and manage your project source code</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate(`/${projectName}/settings/repos/new`)}
|
||||
<button
|
||||
onClick={() => setIsCreateMenuOpen(true)}
|
||||
className={REPOS_PAGE.newBtn}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
@ -194,9 +196,9 @@ export function ReposPage() {
|
||||
|
||||
{/* New Repo Guided Card */}
|
||||
{viewMode === 'grid' && !searchQuery && (
|
||||
<div
|
||||
<div
|
||||
className={`${REPOS_PAGE.repoCard} ${REPOS_PAGE.emptyCard}`}
|
||||
onClick={() => navigate(`/${projectName}/settings/repos/new`)}
|
||||
onClick={() => setIsCreateMenuOpen(true)}
|
||||
>
|
||||
<FolderPlus className={REPOS_PAGE.emptyIcon} />
|
||||
<span className={REPOS_PAGE.emptyText}>Create a new repository</span>
|
||||
@ -205,6 +207,9 @@ export function ReposPage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isCreateMenuOpen && (
|
||||
<ProjectCreateMenuModal onClose={() => setIsCreateMenuOpen(false)} initialTab="repo" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@ import {
|
||||
projectJoinSettings, projectUpdateJoinSettings,
|
||||
projectJoinRequests, projectProcessJoinRequest,
|
||||
} from "@/client/api";
|
||||
import type { InvitationResponse, JoinSettingsResponse, JoinRequestResponse, MemberRole } from "@/client/model";
|
||||
import type { InvitationResponse, JoinSettingsResponse, JoinRequestResponse, MemberRole, QuestionSchema } from "@/client/model";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Loader2, Mail, X, Check, Shield, User, EyeOff } from "lucide-react";
|
||||
@ -78,7 +78,7 @@ export function AccessSettings() {
|
||||
|
||||
const handleToggleApproval = async () => {
|
||||
if (!joinSettings) return;
|
||||
try { setJsSaving(true); await projectUpdateJoinSettings(projectName!, { require_approval: !joinSettings.require_approval, require_questions: joinSettings.require_questions, questions: (joinSettings.questions as any[]) || [] }); setMsg({ type: "success", text: "Join settings updated" }); invalidateAll(); }
|
||||
try { setJsSaving(true); await projectUpdateJoinSettings(projectName!, { require_approval: !joinSettings.require_approval, require_questions: joinSettings.require_questions, questions: (joinSettings.questions as QuestionSchema[]) || [] }); setMsg({ type: "success", text: "Join settings updated" }); invalidateAll(); }
|
||||
catch { setMsg({ type: "error", text: "Failed to update join settings" }); }
|
||||
finally { setJsSaving(false); }
|
||||
};
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useProjectInfo, useInvalidateProjectInfo } from "@/hooks/useProjectInfo";
|
||||
import { projectExchangeName, projectExchangeTitle, projectExchangeVisibility } from "@/client/api";
|
||||
@ -35,7 +35,10 @@ export function GeneralSettings() {
|
||||
const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Sync form state when project info loads or changes
|
||||
const [prevInfo, setPrevInfo] = useState<typeof info>(undefined);
|
||||
if (info !== prevInfo) {
|
||||
setPrevInfo(info);
|
||||
if (info) {
|
||||
setForm({
|
||||
name: info.name,
|
||||
@ -44,7 +47,7 @@ export function GeneralSettings() {
|
||||
is_public: info.is_public,
|
||||
});
|
||||
}
|
||||
}, [info]);
|
||||
}
|
||||
|
||||
if (!info || !projectName) return null;
|
||||
|
||||
@ -84,7 +87,7 @@ export function GeneralSettings() {
|
||||
await Promise.all(promises);
|
||||
setMsg({ type: "success", text: "Project settings updated successfully" });
|
||||
invalidateProjectInfo(projectName);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setMsg({ type: "error", text: "Failed to update project settings" });
|
||||
} finally {
|
||||
setSaving(null);
|
||||
@ -124,12 +127,12 @@ export function GeneralSettings() {
|
||||
<div className={PROJECT_SETTINGS.projectAvatar}>
|
||||
<Avatar className="w-full h-full rounded-xl">
|
||||
<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()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<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 className={PROJECT_SETTINGS.avatarHint}>
|
||||
|
||||
@ -39,9 +39,55 @@ const TIMEZONES = [
|
||||
{ value: "UTC", label: "UTC" },
|
||||
];
|
||||
|
||||
const SelectField = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
options: { value: string; label: string }[];
|
||||
}) => (
|
||||
<div>
|
||||
<Label
|
||||
className={SETTINGS_PAGE.formLabel}
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger
|
||||
className="w-[260px] text-[14px]"
|
||||
style={{
|
||||
backgroundColor: "var(--surface-elevated)",
|
||||
borderColor: "var(--border-default)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
style={{
|
||||
backgroundColor: "var(--surface-elevated)",
|
||||
borderColor: "var(--border-default)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
{options.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
export function AppearancePage() {
|
||||
const { preferences: cachedPrefs, setPreferences: setCachedPrefs } = useSettingsDataCache();
|
||||
const [_prefs, setPrefs] = useState<PreferencesResponse | null>(cachedPrefs);
|
||||
const [, setPrefs] = useState<PreferencesResponse | null>(cachedPrefs);
|
||||
const [loading, setLoading] = useState(!cachedPrefs);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
@ -56,27 +102,24 @@ export function AppearancePage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (cachedPrefs) return;
|
||||
loadPrefs();
|
||||
}, []);
|
||||
|
||||
const loadPrefs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await getPreferences();
|
||||
const d = res.data.data!;
|
||||
setPrefs(d);
|
||||
setCachedPrefs(d);
|
||||
setForm({
|
||||
language: d.language,
|
||||
theme: d.theme,
|
||||
timezone: d.timezone,
|
||||
});
|
||||
} catch {
|
||||
setMessage({ type: "error", text: "加载偏好设置失败" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
(async () => {
|
||||
try {
|
||||
const res = await getPreferences();
|
||||
const d = res.data.data!;
|
||||
setPrefs(d);
|
||||
setCachedPrefs(d);
|
||||
setForm({
|
||||
language: d.language,
|
||||
theme: d.theme,
|
||||
timezone: d.timezone,
|
||||
});
|
||||
} catch {
|
||||
setMessage({ type: "error", text: "加载偏好设置失败" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [cachedPrefs, setCachedPrefs]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
@ -106,52 +149,6 @@ export function AppearancePage() {
|
||||
);
|
||||
}
|
||||
|
||||
const SelectField = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
options: { value: string; label: string }[];
|
||||
}) => (
|
||||
<div>
|
||||
<Label
|
||||
className={SETTINGS_PAGE.formLabel}
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger
|
||||
className="w-[260px] text-[14px]"
|
||||
style={{
|
||||
backgroundColor: "var(--surface-elevated)",
|
||||
borderColor: "var(--border-default)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
style={{
|
||||
backgroundColor: "var(--surface-elevated)",
|
||||
borderColor: "var(--border-default)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
{options.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className={SETTINGS_PAGE.pageHeader} style={{ color: "var(--text-primary)" }}>
|
||||
@ -304,4 +301,4 @@ export function AppearancePage() {
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -20,22 +20,19 @@ export function EmailPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (cachedEmail !== null) return;
|
||||
loadEmail();
|
||||
}, []);
|
||||
|
||||
const loadEmail = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await apiEmailGet();
|
||||
const e = res.data.data?.email ?? null;
|
||||
setEmail(e);
|
||||
setCachedEmail(e);
|
||||
} catch {
|
||||
setMessage({ type: "error", text: "加载邮箱信息失败" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiEmailGet();
|
||||
const e = res.data.data?.email ?? null;
|
||||
setEmail(e);
|
||||
setCachedEmail(e);
|
||||
} catch {
|
||||
setMessage({ type: "error", text: "加载邮箱信息失败" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [cachedEmail, setCachedEmail]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.new_email || !form.password) {
|
||||
@ -183,4 +180,4 @@ export function EmailPage() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -33,28 +33,25 @@ export function MyAccountPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (cachedProfile) return;
|
||||
loadProfile();
|
||||
}, []);
|
||||
|
||||
const loadProfile = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await getMyProfile();
|
||||
const d = res.data.data!;
|
||||
setProfile(d);
|
||||
setCachedProfile(d);
|
||||
setForm({
|
||||
display_name: d.display_name ?? "",
|
||||
avatar_url: d.avatar_url ?? "",
|
||||
website_url: d.website_url ?? "",
|
||||
organization: d.organization ?? "",
|
||||
});
|
||||
} catch {
|
||||
setMessage({ type: "error", text: "加载个人信息失败" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
(async () => {
|
||||
try {
|
||||
const res = await getMyProfile();
|
||||
const d = res.data.data!;
|
||||
setProfile(d);
|
||||
setCachedProfile(d);
|
||||
setForm({
|
||||
display_name: d.display_name ?? "",
|
||||
avatar_url: d.avatar_url ?? "",
|
||||
website_url: d.website_url ?? "",
|
||||
organization: d.organization ?? "",
|
||||
});
|
||||
} catch {
|
||||
setMessage({ type: "error", text: "加载个人信息失败" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [cachedProfile, setCachedProfile]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
@ -75,6 +72,27 @@ export function MyAccountPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadProfile = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
if (cachedProfile) return;
|
||||
const res = await getMyProfile();
|
||||
const d = res.data.data!;
|
||||
setProfile(d);
|
||||
setCachedProfile(d);
|
||||
setForm({
|
||||
display_name: d.display_name ?? "",
|
||||
avatar_url: d.avatar_url ?? "",
|
||||
website_url: d.website_url ?? "",
|
||||
organization: d.organization ?? "",
|
||||
});
|
||||
} catch {
|
||||
setMessage({ type: "error", text: "加载个人信息失败" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
@ -89,14 +107,14 @@ export function MyAccountPage() {
|
||||
setMessage(null);
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const res = await uploadAvatar(formData as any);
|
||||
|
||||
const res = await uploadAvatar(formData);
|
||||
const newAvatarUrl = res.data.data?.avatar_url;
|
||||
if (newAvatarUrl) {
|
||||
setForm(f => ({ ...f, avatar_url: newAvatarUrl }));
|
||||
setMessage({ type: "success", text: "头像上传成功,请保存更改" });
|
||||
}
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setMessage({ type: "error", text: "头像上传失败" });
|
||||
} finally {
|
||||
setUploading(false);
|
||||
@ -155,12 +173,12 @@ export function MyAccountPage() {
|
||||
{profile?.username?.[0]?.toUpperCase() || "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
@ -169,9 +187,9 @@ export function MyAccountPage() {
|
||||
上传新头像
|
||||
</Button>
|
||||
{form.avatar_url && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={removeAvatar}
|
||||
>
|
||||
@ -183,12 +201,12 @@ export function MyAccountPage() {
|
||||
<p className={SETTINGS_PAGE.avatarHint}>
|
||||
支持 JPG, PNG 或 GIF. 最大 2MB.
|
||||
</p>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -314,4 +332,4 @@ export function MyAccountPage() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -25,9 +25,33 @@ const DIGEST_MODES = [
|
||||
{ value: "off", label: "关闭" },
|
||||
];
|
||||
|
||||
const ToggleRow = ({
|
||||
label,
|
||||
desc,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
desc: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) => (
|
||||
<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() {
|
||||
const { notificationPrefs: cachedPrefs, setNotificationPrefs: setCachedPrefs } = useSettingsDataCache();
|
||||
const [_prefs, setPrefs] =
|
||||
const [, setPrefs] =
|
||||
useState<NotificationPreferencesResponse | null>(cachedPrefs);
|
||||
const [loading, setLoading] = useState(!cachedPrefs);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@ -48,32 +72,29 @@ export function NotificationsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (cachedPrefs) return;
|
||||
loadPrefs();
|
||||
}, []);
|
||||
|
||||
const loadPrefs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await getNotificationPreferences();
|
||||
const d = res.data.data!;
|
||||
setPrefs(d);
|
||||
setCachedPrefs(d);
|
||||
setForm({
|
||||
email_enabled: d.email_enabled,
|
||||
in_app_enabled: d.in_app_enabled,
|
||||
push_enabled: d.push_enabled,
|
||||
digest_mode: d.digest_mode,
|
||||
dnd_enabled: d.dnd_enabled,
|
||||
marketing_enabled: d.marketing_enabled,
|
||||
security_enabled: d.security_enabled,
|
||||
product_enabled: d.product_enabled,
|
||||
});
|
||||
} catch {
|
||||
setMessage({ type: "error", text: "加载通知偏好失败" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
(async () => {
|
||||
try {
|
||||
const res = await getNotificationPreferences();
|
||||
const d = res.data.data!;
|
||||
setPrefs(d);
|
||||
setCachedPrefs(d);
|
||||
setForm({
|
||||
email_enabled: d.email_enabled,
|
||||
in_app_enabled: d.in_app_enabled,
|
||||
push_enabled: d.push_enabled,
|
||||
digest_mode: d.digest_mode,
|
||||
dnd_enabled: d.dnd_enabled,
|
||||
marketing_enabled: d.marketing_enabled,
|
||||
security_enabled: d.security_enabled,
|
||||
product_enabled: d.product_enabled,
|
||||
});
|
||||
} catch {
|
||||
setMessage({ type: "error", text: "加载通知偏好失败" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [cachedPrefs, setCachedPrefs]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
@ -108,30 +129,6 @@ export function NotificationsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const ToggleRow = ({
|
||||
label,
|
||||
desc,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
desc: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) => (
|
||||
<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 (
|
||||
<div>
|
||||
<h1
|
||||
@ -336,4 +333,4 @@ export function NotificationsPage() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
getNotificationPreferences,
|
||||
updateNotificationPreferences,
|
||||
} from "@/client/api";
|
||||
import { getNotificationPreferences, updateNotificationPreferences } from "@/client/api";
|
||||
import type { NotificationPreferencesResponse } from "@/client/model";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Loader2, Smartphone, ShieldCheck, AlertCircle } from "lucide-react";
|
||||
@ -12,28 +10,25 @@ export function PushSettingsPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
|
||||
const [pushEnabled, setPushEnabled] = useState(false);
|
||||
const [canPush, setCanPush] = useState(false);
|
||||
const canPush = 'Notification' in window && 'serviceWorker' in navigator;
|
||||
|
||||
useEffect(() => {
|
||||
setCanPush('Notification' in window && 'serviceWorker' in navigator);
|
||||
loadPreferences();
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await getNotificationPreferences();
|
||||
const data = res.data.data as NotificationPreferencesResponse | undefined;
|
||||
setPushEnabled(data?.push_enabled ?? false);
|
||||
} catch {
|
||||
setError("Failed to load notification settings");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const loadPreferences = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await getNotificationPreferences();
|
||||
// The API returns push_enabled or similar in its schema
|
||||
setPushEnabled((res.data.data as any)?.push_enabled ?? false);
|
||||
} catch (err) {
|
||||
setError("Failed to load notification settings");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePush = async (checked: boolean) => {
|
||||
if (checked && 'Notification' in window) {
|
||||
const permission = await Notification.requestPermission();
|
||||
@ -47,13 +42,12 @@ export function PushSettingsPage() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
await updateNotificationPreferences({
|
||||
// Update the specific push field
|
||||
push_enabled: checked
|
||||
} as any);
|
||||
push_enabled: checked,
|
||||
} as Partial<NotificationPreferencesResponse>);
|
||||
setPushEnabled(checked);
|
||||
setSuccess(true);
|
||||
setTimeout(() => setSuccess(false), 3000);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setError("Failed to update push settings");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@ -96,10 +90,10 @@ export function PushSettingsPage() {
|
||||
<p className="text-xs" style={{ color: "var(--text-muted)" }}>Get notified of mentions, issues, and system alerts.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={pushEnabled}
|
||||
onCheckedChange={handleTogglePush}
|
||||
disabled={saving || !canPush}
|
||||
<Switch
|
||||
checked={pushEnabled}
|
||||
onCheckedChange={handleTogglePush}
|
||||
disabled={saving || !canPush}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -111,7 +105,7 @@ export function PushSettingsPage() {
|
||||
<Switch checked={true} disabled />
|
||||
</div>
|
||||
<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 />
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
@ -140,4 +134,4 @@ export function PushSettingsPage() {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -80,8 +80,11 @@ export async function stopMessage(conversationId: string, messageId: string): Pr
|
||||
await aiMessageStop(conversationId, messageId);
|
||||
}
|
||||
|
||||
export async function resendMessage(conversationId: string, messageId: string): Promise<void> {
|
||||
await aiMessageResend(conversationId, messageId);
|
||||
export async function resendMessage(conversationId: string, messageId: string): Promise<MessageResponse> {
|
||||
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> {
|
||||
@ -118,16 +121,15 @@ export async function switchMessageVersion(conversationId: string, messageId: st
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export interface ForkResponse {
|
||||
export interface ForkConversationResponse {
|
||||
id: string;
|
||||
conversation_id: string | null;
|
||||
source_message_id: string;
|
||||
fork_message_id: string;
|
||||
title?: string | null;
|
||||
model: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function forkMessage(conversationId: string, messageId: string, targetMessageId: string): Promise<ForkResponse> {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || ""}/api/ai/conversations/${conversationId}/messages/${messageId}/fork/${targetMessageId}`, {
|
||||
export async function forkMessage(conversationId: string, messageId: string): Promise<ForkConversationResponse> {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || ""}/api/ai/conversations/${conversationId}/messages/${messageId}/fork`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
@ -136,14 +138,14 @@ export async function forkMessage(conversationId: string, messageId: string, tar
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function listMessageForks(conversationId: string, messageId: string): Promise<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`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to list message forks");
|
||||
const data = await response.json();
|
||||
return data.data ?? [];
|
||||
return data.data?.forks ?? data.data ?? [];
|
||||
}
|
||||
|
||||
export async function shareConversation(conversationId: string): Promise<{ share_token: string }> {
|
||||
@ -156,6 +158,7 @@ export async function shareConversation(conversationId: string): Promise<{ share
|
||||
|
||||
export interface StreamChunk {
|
||||
type: "token" | "thinking" | "tool_call" | "tool_result" | "done" | "error" | "title" | "billing_error";
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data: any;
|
||||
}
|
||||
|
||||
@ -165,6 +168,7 @@ export async function* streamChat(conversationId: string, messageId: string): As
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Stream request failed: ${response.status}`);
|
||||
if (!response.body) throw new Error("No response body");
|
||||
|
||||
const reader = response.body.getReader();
|
||||
|
||||
File diff suppressed because one or more lines are too long
11
src/client/model/activityBreakdownItem.ts
Normal file
11
src/client/model/activityBreakdownItem.ts
Normal 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;
|
||||
}
|
||||
@ -10,4 +10,8 @@ export type AiConversationListParams = {
|
||||
* Filter by project
|
||||
*/
|
||||
project_id?: string;
|
||||
/**
|
||||
* Search query (title)
|
||||
*/
|
||||
q?: string;
|
||||
};
|
||||
|
||||
13
src/client/model/apiResponseForkConversationResponse.ts
Normal file
13
src/client/model/apiResponseForkConversationResponse.ts
Normal 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;
|
||||
}
|
||||
14
src/client/model/apiResponseForkConversationResponseData.ts
Normal file
14
src/client/model/apiResponseForkConversationResponseData.ts
Normal 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;
|
||||
};
|
||||
14
src/client/model/apiResponseProjectStatsResponse.ts
Normal file
14
src/client/model/apiResponseProjectStatsResponse.ts
Normal 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;
|
||||
}
|
||||
35
src/client/model/apiResponseProjectStatsResponseData.ts
Normal file
35
src/client/model/apiResponseProjectStatsResponseData.ts
Normal 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[];
|
||||
};
|
||||
14
src/client/model/forkConversationResponse.ts
Normal file
14
src/client/model/forkConversationResponse.ts
Normal 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;
|
||||
}
|
||||
@ -7,6 +7,7 @@
|
||||
|
||||
export * from './accessKeyListResponse';
|
||||
export * from './accessKeyResponse';
|
||||
export * from './activityBreakdownItem';
|
||||
export * from './activityLogListResponse';
|
||||
export * from './activityLogParams';
|
||||
export * from './activityLogResponse';
|
||||
@ -161,8 +162,8 @@ export * from './apiResponseDiffStatsResponse';
|
||||
export * from './apiResponseDiffStatsResponseData';
|
||||
export * from './apiResponseEmailResponse';
|
||||
export * from './apiResponseEmailResponseData';
|
||||
export * from './apiResponseForkResponse';
|
||||
export * from './apiResponseForkResponseData';
|
||||
export * from './apiResponseForkConversationResponse';
|
||||
export * from './apiResponseForkConversationResponseData';
|
||||
export * from './apiResponseGitInitResponse';
|
||||
export * from './apiResponseGitInitResponseData';
|
||||
export * from './apiResponseGitReadmeResponse';
|
||||
@ -247,6 +248,8 @@ export * from './apiResponseProjectRepoCreateResponse';
|
||||
export * from './apiResponseProjectRepoCreateResponseData';
|
||||
export * from './apiResponseProjectRepositoryPagination';
|
||||
export * from './apiResponseProjectRepositoryPaginationData';
|
||||
export * from './apiResponseProjectStatsResponse';
|
||||
export * from './apiResponseProjectStatsResponseData';
|
||||
export * from './apiResponsePullRequestListResponse';
|
||||
export * from './apiResponsePullRequestListResponseData';
|
||||
export * from './apiResponsePullRequestResponse';
|
||||
@ -572,7 +575,7 @@ export * from './enable2FAResponse';
|
||||
export * from './exchangeProjectName';
|
||||
export * from './exchangeProjectTitle';
|
||||
export * from './exchangeProjectVisibility';
|
||||
export * from './forkResponse';
|
||||
export * from './forkConversationResponse';
|
||||
export * from './generatePrDescriptionRequest';
|
||||
export * from './generatePrDescriptionResponse';
|
||||
export * from './get2FAStatusResponse';
|
||||
@ -707,6 +710,8 @@ export * from './projectRepoCreateResponse';
|
||||
export * from './projectRepositoryItem';
|
||||
export * from './projectRepositoryPagination';
|
||||
export * from './projectSearchItem';
|
||||
export * from './projectStatsActivityItem';
|
||||
export * from './projectStatsResponse';
|
||||
export * from './providerResponse';
|
||||
export * from './pullRequestCreateRequest';
|
||||
export * from './pullRequestListParams';
|
||||
|
||||
16
src/client/model/projectStatsActivityItem.ts
Normal file
16
src/client/model/projectStatsActivityItem.ts
Normal 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;
|
||||
}
|
||||
35
src/client/model/projectStatsResponse.ts
Normal file
35
src/client/model/projectStatsResponse.ts
Normal 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[];
|
||||
}
|
||||
@ -210,6 +210,7 @@ const ProviderAttachmentsContext = createContext<AttachmentsContext | null>(
|
||||
null
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const usePromptInputController = () => {
|
||||
const ctx = useContext(PromptInputController);
|
||||
if (!ctx) {
|
||||
@ -224,6 +225,7 @@ export const usePromptInputController = () => {
|
||||
const useOptionalPromptInputController = () =>
|
||||
useContext(PromptInputController);
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const useProviderAttachments = () => {
|
||||
const ctx = useContext(ProviderAttachmentsContext);
|
||||
if (!ctx) {
|
||||
@ -371,6 +373,7 @@ export const PromptInputProvider = ({
|
||||
|
||||
const LocalAttachmentsContext = createContext<AttachmentsContext | null>(null);
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const usePromptInputAttachments = () => {
|
||||
// Prefer local context (inside PromptInput) as it has validation, fall back to provider
|
||||
const provider = useOptionalProviderAttachments();
|
||||
@ -395,9 +398,11 @@ export interface ReferencedSourcesContext {
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const LocalReferencedSourcesContext =
|
||||
createContext<ReferencedSourcesContext | null>(null);
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const usePromptInputReferencedSources = () => {
|
||||
const ctx = useContext(LocalReferencedSourcesContext);
|
||||
if (!ctx) {
|
||||
@ -862,6 +867,7 @@ export const PromptInput = ({
|
||||
try {
|
||||
// Convert blob URLs to data URLs asynchronously
|
||||
const convertedFiles: FileUIPart[] = await Promise.all(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
files.map(async ({ id: _id, ...item }) => {
|
||||
if (item.url?.startsWith("blob:")) {
|
||||
const dataUrl = await convertBlobUrlToDataUrl(item.url);
|
||||
|
||||
@ -36,6 +36,7 @@ interface ReasoningContextValue {
|
||||
|
||||
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const useReasoning = () => {
|
||||
const context = useContext(ReasoningContext);
|
||||
if (!context) {
|
||||
|
||||
@ -3,26 +3,11 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { MotionProps } from "motion/react";
|
||||
import { motion } from "motion/react";
|
||||
import type { CSSProperties, ElementType, JSX } from "react";
|
||||
import type { CSSProperties, ElementType } from "react";
|
||||
import { memo, useMemo } from "react";
|
||||
|
||||
type MotionHTMLProps = MotionProps & Record<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 {
|
||||
children: string;
|
||||
as?: ElementType;
|
||||
@ -38,9 +23,7 @@ const ShimmerComponent = ({
|
||||
duration = 2,
|
||||
spread = 2,
|
||||
}: TextShimmerProps) => {
|
||||
const MotionComponent = getMotionComponent(
|
||||
Component as keyof JSX.IntrinsicElements
|
||||
);
|
||||
const MotionComponent = motion[Component as keyof typeof motion] as React.ComponentType<MotionHTMLProps>;
|
||||
|
||||
const dynamicSpread = useMemo(
|
||||
() => (children?.length ?? 0) * spread,
|
||||
|
||||
@ -16,7 +16,6 @@ export function EditHistoryOverlay({ messageId, roomId, onClose }: Props) {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
messageEditHistory(roomId, messageId)
|
||||
.then((res) => {
|
||||
if (!cancelled) setHistory(res.data?.data?.history ?? []);
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Edit2, Trash2, Reply, MessageSquare, Pin } from 'lucide-react';
|
||||
import { MentionRenderer } from '@/components/channel/mention/MentionRenderer';
|
||||
import { IrRenderer } from '@/lib/ir/renderer';
|
||||
import { extractIrNodes } from '@/lib/ir/parser';
|
||||
import type { Message } from '@/contexts/room';
|
||||
import { formatRelativeTime } from '@/contexts/room';
|
||||
import { Avatar } from './Avatar';
|
||||
@ -148,7 +149,7 @@ export function MessageItem({
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
<MentionRenderer content={msg.content} />
|
||||
<IrRenderer nodes={extractIrNodes(msg.content)} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useMemo, useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { useMemo, useRef, useEffect, useState, useCallback, forwardRef } from 'react';
|
||||
import { Loader2, ChevronDown } from 'lucide-react';
|
||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
|
||||
import type { Message } from '@/contexts/room';
|
||||
@ -8,6 +8,25 @@ import { MessageItem } from './MessageItem';
|
||||
import { MESSAGE_LIST } from '@/css/channel/styles';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
|
||||
const VirtuosoScroller = forwardRef<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 {
|
||||
messages: Message[];
|
||||
isLoadingHistory: boolean;
|
||||
@ -93,8 +112,6 @@ export function MessageList({
|
||||
useEffect(() => {
|
||||
initialScrollDoneRef.current = false;
|
||||
prevLengthRef.current = 0;
|
||||
setIsAtBottom(true);
|
||||
setNewMsgCount(0);
|
||||
|
||||
if (renderedItems.length > 0) {
|
||||
const el = scrollerRef.current;
|
||||
@ -120,6 +137,7 @@ export function MessageList({
|
||||
if (isAtBottom) {
|
||||
const el = scrollerRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setNewMsgCount(0);
|
||||
} else {
|
||||
setNewMsgCount(prev => prev + (renderedItems.length - prevLen));
|
||||
@ -132,7 +150,7 @@ export function MessageList({
|
||||
if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||||
setNewMsgCount(0);
|
||||
setIsAtBottom(true);
|
||||
}, [renderedItems.length]);
|
||||
}, []);
|
||||
|
||||
if (displayMessages.length === 0 && !isLoadingHistory) {
|
||||
return (
|
||||
@ -148,6 +166,7 @@ export function MessageList({
|
||||
key={roomId}
|
||||
ref={virtuosoRef}
|
||||
style={{ flex: 1 }}
|
||||
components={{ Scroller: VirtuosoScroller }}
|
||||
data={renderedItems}
|
||||
initialTopMostItemIndex={
|
||||
renderedItems.length > 0
|
||||
|
||||
@ -43,11 +43,13 @@ export function MentionBottomSheet({
|
||||
|
||||
// Sync external query changes
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSearch(query);
|
||||
}, [query]);
|
||||
|
||||
// Reset selection when filtered list changes
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSelectedIndex(0);
|
||||
}, [search]);
|
||||
|
||||
@ -248,7 +250,7 @@ export function MentionBottomSheet({
|
||||
className="rounded-md text-[10px] font-bold"
|
||||
style={{
|
||||
backgroundColor: hashColor(item.label),
|
||||
color: "#ffffff",
|
||||
color: "var(--text-inverse)",
|
||||
}}
|
||||
>
|
||||
{item.label[0]?.toUpperCase() || "?"}
|
||||
|
||||
23
src/components/chat/CodePreviewContext.tsx
Normal file
23
src/components/chat/CodePreviewContext.tsx
Normal 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);
|
||||
}
|
||||
130
src/components/chat/CodePreviewPanel.tsx
Normal file
130
src/components/chat/CodePreviewPanel.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
98
src/components/chat/ToolCallBlock.tsx
Normal file
98
src/components/chat/ToolCallBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/components/ir/DataCardRenderer.tsx
Normal file
30
src/components/ir/DataCardRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
src/components/ir/MentionChipRenderer.tsx
Normal file
52
src/components/ir/MentionChipRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/components/ir/MermaidRenderer.tsx
Normal file
25
src/components/ir/MermaidRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -45,8 +45,8 @@ export const ChannelSidebar = memo(function ChannelSidebar({onCollapse}: Channel
|
||||
const {data: projectInfo} = useProjectInfo(projectName);
|
||||
const [isCreateMenuOpen, setIsCreateMenuOpen] = useState(false);
|
||||
|
||||
const rooms = roomsData?.rooms ?? [];
|
||||
const categories = roomsData?.categories ?? [];
|
||||
const rooms = useMemo(() => roomsData?.rooms ?? [], [roomsData?.rooms]);
|
||||
const categories = useMemo(() => roomsData?.categories ?? [], [roomsData?.categories]);
|
||||
|
||||
const pathParts = location.pathname.split("/").filter(Boolean);
|
||||
const isActive = useCallback((path: string) => {
|
||||
|
||||
@ -40,7 +40,9 @@ export const ServerIconRail = memo(function ServerIconRail() {
|
||||
const handleLogout = useCallback(async () => {
|
||||
try {
|
||||
await logoutMutation.mutateAsync();
|
||||
} catch { }
|
||||
} catch {
|
||||
// Intentionally empty - navigation proceeds on error
|
||||
}
|
||||
navigate("/auth/login", { replace: true });
|
||||
}, [logoutMutation, navigate]);
|
||||
|
||||
@ -85,7 +87,7 @@ export const ServerIconRail = memo(function ServerIconRail() {
|
||||
>
|
||||
<Avatar className="w-full h-full rounded-2xl">
|
||||
<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>
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
@ -8,7 +8,8 @@ import {
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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";
|
||||
|
||||
function relativeTime(dateStr: string) {
|
||||
@ -104,7 +105,7 @@ export function InlineCommentThread({
|
||||
|
||||
{/* Body */}
|
||||
<div className="text-sm text-foreground">
|
||||
<MarkdownRenderer content={root.body} />
|
||||
<IrRenderer nodes={extractIrNodes(root.body)} />
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
@ -165,7 +166,7 @@ export function InlineCommentThread({
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-foreground">
|
||||
<MarkdownRenderer content={reply.body} />
|
||||
<IrRenderer nodes={extractIrNodes(reply.body)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import {
|
||||
FileCode,
|
||||
FileBox,
|
||||
@ -331,15 +331,19 @@ export function PullRequestDiff({
|
||||
|
||||
const { data: commentData } = usePRCommentListQuery(namespace, repo, prNumber);
|
||||
|
||||
const files = diffData?.files || [];
|
||||
const threads = commentData?.threads || [];
|
||||
const files = useMemo(() => diffData?.files || [], [diffData?.files]);
|
||||
const threads = useMemo(() => commentData?.threads || [], [commentData?.threads]);
|
||||
|
||||
// Auto-select first file
|
||||
useMemo(() => {
|
||||
if (!activeFile && files.length > 0) {
|
||||
setActiveFile(files[0].path);
|
||||
}
|
||||
}, [files, activeFile]);
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setActiveFile((prev) => {
|
||||
if (!prev && files.length > 0) {
|
||||
return files[0].path;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, [files]);
|
||||
|
||||
const activeFileData = useMemo(() => {
|
||||
return files.find((f) => f.path === activeFile);
|
||||
|
||||
@ -14,7 +14,8 @@ import {
|
||||
import { useRepoCommitDetailQuery, useRepoCommitDiffQuery } from "@/hooks/useRepoDetailQuery";
|
||||
import { LoadingState } from "@/components/ui/LoadingState";
|
||||
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) {
|
||||
if (!dateStr) return '';
|
||||
@ -70,18 +71,21 @@ export function CommitDetail() {
|
||||
|
||||
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
|
||||
useMemo(() => {
|
||||
if (!activeFilePath && deltas.length > 0) {
|
||||
const firstPath = deltas[0].new_file.path || deltas[0].old_file.path;
|
||||
setActiveFilePath(firstPath);
|
||||
}
|
||||
}, [deltas, activeFilePath]);
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setActiveFilePath((prev) => {
|
||||
if (!prev && deltas.length > 0) {
|
||||
return deltas[0].new_file.path || deltas[0].old_file.path;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, [deltas]);
|
||||
|
||||
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]);
|
||||
|
||||
if (isLoadingCommit || isLoadingDiff) {
|
||||
@ -118,6 +122,7 @@ export function CommitDetail() {
|
||||
{/* 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">
|
||||
<GitPullRequest className="w-3 h-3" />
|
||||
{/* eslint-disable-next-line react-hooks/purity */}
|
||||
<span>#{Math.floor(Math.random() * 900) + 100}</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -161,7 +166,7 @@ export function CommitDetail() {
|
||||
Files Changed · <span className="text-muted-foreground font-normal">{deltas.length}</span>
|
||||
</div>
|
||||
<div className={REPO_DIFF.sidebarList}>
|
||||
{deltas.map((delta: any) => {
|
||||
{deltas.map((delta: DiffDeltaResponse) => {
|
||||
const path = delta.new_file.path || delta.old_file.path;
|
||||
const isActive = path === activeFilePath;
|
||||
return (
|
||||
@ -211,7 +216,7 @@ export function CommitDetail() {
|
||||
<div className={REPO_DIFF.diffContainer}>
|
||||
<table className={REPO_DIFF.diffTable}>
|
||||
<tbody>
|
||||
{activeDelta.lines.map((line: any, idx: number) => {
|
||||
{activeDelta.lines.map((line: DiffLineResponse, idx: number) => {
|
||||
const isAdded = line.origin === '+';
|
||||
const isRemoved = line.origin === '-';
|
||||
const isHunk = line.origin === 'H';
|
||||
|
||||
@ -27,10 +27,10 @@ export function RepoBranchesTab() {
|
||||
|
||||
const defaultBranch = "main";
|
||||
const activeBranches = repoBranches.filter(
|
||||
(b: any) => !b.is_remote
|
||||
(b: BranchInfoResponse) => !b.is_remote
|
||||
);
|
||||
const remoteBranches = repoBranches.filter(
|
||||
(b: any) => b.is_remote
|
||||
(b: BranchInfoResponse) => b.is_remote
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@ -62,8 +62,9 @@ export function RepoCodeTab() {
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (repoBranches.length > 0 && !selectedBranch) {
|
||||
useEffect(() => {
|
||||
if (repoBranches.length > 0 && !repoBranches.some((b) => b.name === selectedBranch)) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSelectedBranch(defaultBranch);
|
||||
}
|
||||
}, [repoBranches, defaultBranch, selectedBranch]);
|
||||
|
||||
@ -29,7 +29,8 @@ export function RepoTagsTab() {
|
||||
</span>
|
||||
</div>
|
||||
<div className={REPO_TAGS.list}>
|
||||
{repoTags.map((tag: any) => (
|
||||
{repoTags.map((tag: // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
any) => (
|
||||
<div
|
||||
key={tag.name || tag}
|
||||
className={REPO_TAGS.item}
|
||||
|
||||
@ -54,4 +54,5 @@ export function SettingsDataCacheProvider({ children }: { children: ReactNode })
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const useSettingsDataCache = () => useContext(SettingsDataCacheContext);
|
||||
@ -143,7 +143,7 @@ export function SettingsModal() {
|
||||
}
|
||||
|
||||
requestAnimationFrame(frame);
|
||||
}, [closeSettingsModal, navigate]);
|
||||
}, [closeSettingsModal, navigate, location.pathname]);
|
||||
|
||||
const ActiveComponent = SECTIONS[activeSection];
|
||||
|
||||
|
||||
@ -10,34 +10,32 @@ interface CustomTheme {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
interface ThemeCustomizationProps {
|
||||
className?: string;
|
||||
function applyThemeVars(vars: CustomTheme) {
|
||||
const root = document.documentElement;
|
||||
Object.entries(vars).forEach(([key, value]) => {
|
||||
root.style.setProperty(`--${key}`, value);
|
||||
});
|
||||
}
|
||||
|
||||
export function useThemeCustomization() {
|
||||
const [customVars, setCustomVars] = useState<CustomTheme>({});
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const [customVars, setCustomVars] = useState<CustomTheme>(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
try {
|
||||
setCustomVars(JSON.parse(stored));
|
||||
} catch {}
|
||||
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 applyThemeVars = (vars: CustomTheme) => {
|
||||
const root = document.documentElement;
|
||||
Object.entries(vars).forEach(([key, value]) => {
|
||||
root.style.setProperty(`--${key}`, value);
|
||||
});
|
||||
};
|
||||
|
||||
const updateVar = (key: string, value: string) => {
|
||||
setCustomVars((prev) => {
|
||||
const next = { ...prev, [key]: value };
|
||||
@ -73,7 +71,7 @@ export function useThemeCustomization() {
|
||||
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 [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
@ -224,6 +222,8 @@ export function loadThemeVars() {
|
||||
Object.entries(vars).forEach(([key, value]) => {
|
||||
document.documentElement.style.setProperty(`--${key}`, value);
|
||||
});
|
||||
} catch {}
|
||||
} catch {
|
||||
console.error("Failed to parse stored theme vars");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,38 +1,190 @@
|
||||
import { memo, useRef, useEffect } from "react";
|
||||
import {memo, useEffect, useId, useState} from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
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 {
|
||||
content: string;
|
||||
className?: string;
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Sanitize raw HTML: strip <script> and event handlers. */
|
||||
function sanitizeHtml(raw: string): string {
|
||||
return raw
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "")
|
||||
.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
||||
/** Extract text content from React children (handles string, array, or element). */
|
||||
function extractText(children: React.ReactNode): string {
|
||||
if (typeof children === "string") return children;
|
||||
if (Array.isArray(children)) return children.map(extractText).join("");
|
||||
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. */
|
||||
function HtmlBlock({ html }: { html: string }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const INLINE_CODE_LINE_LIMIT = 36;
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const shadow = el.shadowRoot || el.attachShadow({ mode: "open" });
|
||||
shadow.innerHTML = sanitizeHtml(html);
|
||||
}, [html]);
|
||||
/** 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();
|
||||
|
||||
return <div ref={ref} className="my-2" />;
|
||||
// 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(() => {
|
||||
if (opensPanel && activePreviewId === previewId) {
|
||||
openCodePreview?.({
|
||||
id: previewId,
|
||||
code: content,
|
||||
language: displayLanguage,
|
||||
lineCount,
|
||||
});
|
||||
}
|
||||
}, [opensPanel, activePreviewId, openCodePreview, previewId, content, displayLanguage, lineCount]);
|
||||
|
||||
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) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<style>{`
|
||||
export const MarkdownRenderer = memo(function MarkdownRenderer({content, className}: MarkdownRendererProps) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<style>{`
|
||||
.markdown-table-wrapper table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
@ -64,6 +216,8 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, classN
|
||||
overflow-x: auto;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.markdown-code-block code {
|
||||
background: none !important;
|
||||
@ -73,67 +227,59 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, classN
|
||||
color: var(--text-primary);
|
||||
}
|
||||
`}</style>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeSanitize]}
|
||||
components={{
|
||||
a: ({ href, children, ...props }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...props}
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeSanitize]}
|
||||
components={{
|
||||
a: ({href, children, ...props}) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
img: ({src, alt, ...props}) => {
|
||||
const safeAlt = alt || "";
|
||||
return <img src={src} alt={safeAlt} loading="lazy" {...props} />;
|
||||
},
|
||||
table: ({children}) => (
|
||||
<div className="overflow-x-auto my-2 markdown-table-wrapper">
|
||||
<table>{children}</table>
|
||||
</div>
|
||||
),
|
||||
code: ({children, className, ...props}) => {
|
||||
const cls = Array.isArray(className) ? className.join(" ") : (className || "");
|
||||
const match = /language-(\w+)/.exec(cls);
|
||||
const isInline = !match;
|
||||
|
||||
// Inline code → keep existing style
|
||||
if (isInline) {
|
||||
return (
|
||||
<code
|
||||
className="px-1.5 py-0.5 rounded text-[13px]"
|
||||
style={{
|
||||
backgroundColor: "var(--surface-elevated)",
|
||||
border: "0.5px solid var(--border-subtle)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
// Fenced code block with copy + line numbers.
|
||||
// All languages, including html, use the same collapsed/code-panel flow.
|
||||
return <CodeBlock className={className}>{children}</CodeBlock>;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
img: ({ src, alt, ...props }) => {
|
||||
const safeAlt = alt || "";
|
||||
return <img src={src} alt={safeAlt} loading="lazy" {...props} />;
|
||||
},
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto my-2 markdown-table-wrapper">
|
||||
<table>{children}</table>
|
||||
</div>
|
||||
),
|
||||
code: ({ children, className, ...props }) => {
|
||||
const cls = Array.isArray(className) ? className.join(" ") : (className || "");
|
||||
const match = /language-(\w+)/.exec(cls);
|
||||
const isInline = !match;
|
||||
|
||||
// ````html` blocks: render inside Shadow DOM to scope CSS
|
||||
if (match?.[1] === "html") {
|
||||
const raw = typeof children === "string" ? children : "";
|
||||
return <HtmlBlock html={raw} />;
|
||||
}
|
||||
|
||||
if (isInline) {
|
||||
return (
|
||||
<code
|
||||
className="px-1.5 py-0.5 rounded text-[13px]"
|
||||
style={{
|
||||
backgroundColor: "var(--surface-elevated)",
|
||||
border: "0.5px solid var(--border-subtle)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<pre className="markdown-code-block">
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@ -46,4 +46,5 @@ function Badge({
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { Badge, badgeVariants }
|
||||
|
||||
@ -75,9 +75,11 @@ function ButtonGroupSeparator({
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { buttonGroupVariants }
|
||||
|
||||
export {
|
||||
ButtonGroup,
|
||||
ButtonGroupSeparator,
|
||||
ButtonGroupText,
|
||||
buttonGroupVariants,
|
||||
}
|
||||
|
||||
@ -64,4 +64,5 @@ function Button({
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { Button, buttonVariants }
|
||||
|
||||
@ -56,14 +56,40 @@ function Carousel({
|
||||
},
|
||||
plugins
|
||||
)
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||
const subscribeToCarousel = React.useCallback(
|
||||
(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) => {
|
||||
if (!api) return
|
||||
setCanScrollPrev(api.canScrollPrev())
|
||||
setCanScrollNext(api.canScrollNext())
|
||||
}, [])
|
||||
const getCanScrollPrev = React.useCallback(
|
||||
() => api?.canScrollPrev() ?? false,
|
||||
[api]
|
||||
)
|
||||
|
||||
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(() => {
|
||||
api?.scrollPrev()
|
||||
@ -91,17 +117,6 @@ function Carousel({
|
||||
setApi(api)
|
||||
}, [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 (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
@ -229,6 +244,9 @@ function CarouselNext({
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { useCarousel }
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
@ -236,5 +254,4 @@ export {
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
useCarousel,
|
||||
}
|
||||
|
||||
@ -279,6 +279,9 @@ function useComboboxAnchor() {
|
||||
return React.useRef<HTMLDivElement | null>(null)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { useComboboxAnchor }
|
||||
|
||||
export {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
@ -295,5 +298,4 @@ export {
|
||||
ComboboxChipsInput,
|
||||
ComboboxTrigger,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
}
|
||||
|
||||
@ -19,4 +19,5 @@ function DirectionProvider({
|
||||
|
||||
const useDirection = Direction.useDirection
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { DirectionProvider, useDirection }
|
||||
|
||||
@ -151,6 +151,9 @@ function NavigationMenuIndicator({
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { navigationMenuTriggerStyle }
|
||||
|
||||
export {
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
@ -160,5 +163,4 @@ export {
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
navigationMenuTriggerStyle,
|
||||
}
|
||||
|
||||
@ -672,6 +672,9 @@ function SidebarMenuSubButton({
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { useSidebar }
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@ -696,5 +699,4 @@ export {
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
||||
|
||||
@ -87,4 +87,5 @@ function TabsContent({
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
|
||||
@ -42,4 +42,5 @@ function Toggle({
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { Toggle, toggleVariants }
|
||||
|
||||
@ -11,6 +11,13 @@ import {
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useWsEvent, useWsStatus, getWsClient, useRoomSubscription } from '@/ws';
|
||||
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 { db } from '@/lib/db';
|
||||
import { MessageRepository } from '@/lib/db/repository';
|
||||
@ -62,8 +69,8 @@ export interface RoomContextValue {
|
||||
streamingChunks: Map<RoomId, Array<{ type: string; content: string; seq?: number }>>;
|
||||
activeAiStream: ActiveAiStream | null;
|
||||
cancelAiStream: () => void;
|
||||
getAiList: () => Promise<any>;
|
||||
upsertAi: (config: any) => Promise<any>;
|
||||
getAiList: () => Promise<unknown>;
|
||||
upsertAi: (config: Record<string, unknown>) => Promise<unknown>;
|
||||
deleteAi: (agentId: string) => void;
|
||||
|
||||
/** Pin management */
|
||||
@ -79,12 +86,12 @@ export interface RoomContextValue {
|
||||
voiceLeave: () => void;
|
||||
|
||||
/** General */
|
||||
searchMessages: (query: string, opts?: any) => Promise<any>;
|
||||
createInvite: (opts?: any) => Promise<void>;
|
||||
searchMessages: (query: string, opts?: Record<string, unknown>) => Promise<unknown>;
|
||||
createInvite: (opts?: Record<string, unknown>) => Promise<void>;
|
||||
updatePresence: (status: string) => void;
|
||||
grantAccess: (targetUserId: string, role: string) => void;
|
||||
banUser: (userId: string, reason?: string) => void;
|
||||
createThread: (parentSeq: number) => Promise<any> | undefined;
|
||||
createThread: (parentSeq: number) => Promise<unknown> | undefined;
|
||||
|
||||
/** UI state */
|
||||
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 currentUserId = user?.uid ?? null;
|
||||
|
||||
const roomIdRef = useRef(roomId);
|
||||
roomIdRef.current = roomId;
|
||||
|
||||
// ── Messages ──
|
||||
|
||||
const {
|
||||
@ -179,18 +183,17 @@ export function RoomProvider({ roomId, projectName, children }: RoomProviderProp
|
||||
|
||||
// ── Exposed actions ──
|
||||
|
||||
|
||||
const getAiList = useCallback(async () => {
|
||||
const client = safeGetClient();
|
||||
if (client && roomId) return client.getAiList(roomId);
|
||||
return null;
|
||||
}, [roomId]);
|
||||
|
||||
const upsertAi = useCallback(async (config: any) => {
|
||||
const upsertAi = useCallback(async (config: Record<string, unknown>) => {
|
||||
const client = safeGetClient();
|
||||
if (client && roomId) return client.upsertAi(roomId, config);
|
||||
return null;
|
||||
}, [roomId]);
|
||||
|
||||
const deleteAi = useCallback((agentId: string) => {
|
||||
const client = safeGetClient();
|
||||
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 });
|
||||
}, []);
|
||||
|
||||
const searchMessages = useCallback(async (query: string, opts?: any) => {
|
||||
const searchMessages = useCallback(async (query: string, opts?: Record<string, unknown>) => {
|
||||
const client = safeGetClient();
|
||||
if (client) return client.search(query, { room: roomId, ...opts });
|
||||
return null;
|
||||
@ -235,7 +238,7 @@ export function RoomProvider({ roomId, projectName, children }: RoomProviderProp
|
||||
if (client && roomId) client.emitRaw('voice_leave', { room: roomId });
|
||||
}, [roomId]);
|
||||
|
||||
const createInvite = useCallback(async (options: any) => {
|
||||
const createInvite = useCallback(async (options: Record<string, unknown>) => {
|
||||
const client = safeGetClient();
|
||||
if (client && roomId) client.emitRaw('invite_create', { room: roomId, ...options });
|
||||
}, [roomId]);
|
||||
@ -260,17 +263,24 @@ export function RoomProvider({ roomId, projectName, children }: RoomProviderProp
|
||||
|
||||
// ── Room switch cleanup ──
|
||||
|
||||
useEffect(() => {
|
||||
if (!roomId) return;
|
||||
clearMessages();
|
||||
cleanupStream();
|
||||
// Reset state during render when roomId changes
|
||||
const [prevRoomId, setPrevRoomId] = useState(roomId);
|
||||
if (roomId !== prevRoomId) {
|
||||
setPrevRoomId(roomId);
|
||||
setCurrentRoom(null);
|
||||
setMembers([]);
|
||||
setPinnedMessages([]);
|
||||
setThreads([]);
|
||||
setTypingUsers(new Map());
|
||||
}
|
||||
|
||||
// Imperative cleanup when roomId changes
|
||||
useEffect(() => {
|
||||
if (!roomId) return;
|
||||
clearMessages();
|
||||
cleanupStream();
|
||||
mergePendingMessages();
|
||||
}, [roomId]);
|
||||
}, [roomId, clearMessages, cleanupStream, mergePendingMessages]);
|
||||
|
||||
// ── Load room info ──
|
||||
|
||||
@ -286,7 +296,7 @@ export function RoomProvider({ roomId, projectName, children }: RoomProviderProp
|
||||
]);
|
||||
|
||||
if (roomRes.status === 'fulfilled') {
|
||||
const data = (roomRes.value as any).data?.data;
|
||||
const data = (roomRes.value as AxiosResponse<ApiResponseRoomResponse>).data?.data;
|
||||
if (data) {
|
||||
setCurrentRoom({ id: data.id, room_name: data.room_name, public: data.public });
|
||||
// Update presence to online when joining a room
|
||||
@ -294,15 +304,16 @@ export function RoomProvider({ roomId, projectName, children }: RoomProviderProp
|
||||
}
|
||||
}
|
||||
if (membersRes.status === 'fulfilled') {
|
||||
const participants = (membersRes.value as any).data?.data?.participants ?? [];
|
||||
setMembers(participants.map((p: any) => mapParticipantToMember(p, 'online')));
|
||||
const membersData = (membersRes.value as AxiosResponse<ApiResponseRoomParticipantListResponse>).data?.data;
|
||||
const participants = membersData?.participants ?? [];
|
||||
setMembers(participants.map((p) => mapParticipantToMember(p, 'online')));
|
||||
}
|
||||
if (pinsRes.status === 'fulfilled') {
|
||||
setPinnedMessages((pinsRes.value as any).data?.data ?? []);
|
||||
setPinnedMessages((pinsRes.value as AxiosResponse<ApiResponseVecRoomPinResponse>).data?.data ?? []);
|
||||
}
|
||||
if (threadsRes.status === 'fulfilled') {
|
||||
const threadData = (threadsRes.value as any).data?.data ?? [];
|
||||
const mapped: ThreadState[] = threadData.map((t: any) => ({
|
||||
const threadData = (threadsRes.value as AxiosResponse<ApiResponseVecRoomThreadResponse>).data?.data ?? [];
|
||||
const mapped: ThreadState[] = threadData.map((t) => ({
|
||||
...t,
|
||||
messages: [],
|
||||
isOpen: false,
|
||||
@ -320,14 +331,17 @@ export function RoomProvider({ roomId, projectName, children }: RoomProviderProp
|
||||
// ── WS: message events ──
|
||||
|
||||
useWsEvent('message_new', (event) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
handleNewMessage(event as any);
|
||||
});
|
||||
|
||||
useWsEvent('message_edited', (event) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
handleEditedMessage(event as any);
|
||||
});
|
||||
|
||||
useWsEvent('message_revoked', (event) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
handleRevokedMessage(event as any);
|
||||
});
|
||||
|
||||
@ -391,6 +405,7 @@ export function RoomProvider({ roomId, projectName, children }: RoomProviderProp
|
||||
// ── WS: Reaction ──
|
||||
|
||||
useWsEvent('reaction_batch_updated', (event) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
handleReactionUpdate(event as any);
|
||||
});
|
||||
|
||||
@ -479,7 +494,7 @@ export function RoomProvider({ roomId, projectName, children }: RoomProviderProp
|
||||
|
||||
useWsEvent('room_settings_updated', (event) => {
|
||||
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 ──
|
||||
@ -488,7 +503,7 @@ export function RoomProvider({ roomId, projectName, children }: RoomProviderProp
|
||||
if (event.room_id !== roomId) return;
|
||||
setThreads((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];
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -45,12 +45,14 @@ const [isHistoryLoaded, setIsHistoryLoaded] = useState(false);
|
||||
const loadMessagesAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// ── Room switch state reset ──
|
||||
const lastRoomIdRef = useRef<RoomId | null>(roomId);
|
||||
if (roomId !== lastRoomIdRef.current) {
|
||||
setIsHistoryLoaded(false);
|
||||
setNextCursor(null);
|
||||
lastRoomIdRef.current = roomId;
|
||||
}
|
||||
const lastRoomIdRef = useRef<RoomId | null>(null);
|
||||
useEffect(() => {
|
||||
if (roomId !== lastRoomIdRef.current) {
|
||||
setIsHistoryLoaded(false);
|
||||
setNextCursor(null);
|
||||
lastRoomIdRef.current = roomId;
|
||||
}
|
||||
}, [roomId]);
|
||||
|
||||
// ── Core operations ──
|
||||
|
||||
@ -93,6 +95,7 @@ const [isHistoryLoaded, setIsHistoryLoaded] = useState(false);
|
||||
room: roomId,
|
||||
before_seq: useCursor ?? null,
|
||||
limit,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any;
|
||||
if (resp?.messages) rawMsgs = resp.messages;
|
||||
} catch { /* WS failed */ }
|
||||
@ -110,7 +113,8 @@ const [isHistoryLoaded, setIsHistoryLoaded] = useState(false);
|
||||
const json = await res.json();
|
||||
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;
|
||||
throw err;
|
||||
}
|
||||
@ -148,6 +152,7 @@ const [isHistoryLoaded, setIsHistoryLoaded] = useState(false);
|
||||
room: roomId,
|
||||
after_seq: meta.maxSeq,
|
||||
limit: 100
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any;
|
||||
|
||||
if (resp?.messages && resp.messages.length > 0) {
|
||||
@ -236,7 +241,8 @@ const [isHistoryLoaded, setIsHistoryLoaded] = useState(false);
|
||||
|
||||
const newMsg = mapToMessage(msg);
|
||||
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();
|
||||
|
||||
if (optimistic) {
|
||||
@ -312,15 +318,21 @@ function mapToMessage(r: RoomMessageResponse): Message {
|
||||
in_reply_to: r.in_reply_to,
|
||||
content: r.content,
|
||||
content_type: r.content_type,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
edited_at: (r as any).edited_at,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
revoked: (r as any).revoked,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
revoked_by: (r as any).revoked_by,
|
||||
send_at: r.send_at,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
_localReactions: (r as any).reactions ?? [],
|
||||
isOptimistic: false,
|
||||
isOptimisticError: false,
|
||||
is_streaming: false,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
thinking_content: (r as any).thinking_content ?? null,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
attachment_ids: (r as any).attachment_ids,
|
||||
};
|
||||
}
|
||||
|
||||
@ -39,16 +39,22 @@ export function shouldGroup(prev: Message, curr: Message): boolean {
|
||||
export function mapHttpMessage(r: RoomMessageResponse): Message {
|
||||
return {
|
||||
...r,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
thread: (r as any).thread ?? (r as any).thread_id,
|
||||
_localReactions: [],
|
||||
is_streaming: false,
|
||||
isOptimistic: false,
|
||||
isOptimisticError: false,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
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 {
|
||||
uid: 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 {
|
||||
message_id: data.message_id ?? data.id,
|
||||
content: data.content ?? '',
|
||||
|
||||
@ -26,7 +26,7 @@ export const CHANNEL_PAGE = {
|
||||
panel: 'channel-panel',
|
||||
reconnectBanner: 'flex items-center gap-2 px-4 py-2 text-[13px] font-medium',
|
||||
reconnectBannerBg: 'var(--warning)',
|
||||
reconnectBannerColor: '#000',
|
||||
reconnectBannerColor: 'var(--text-primary)',
|
||||
typingIndicator: 'flex items-center gap-2 px-4 py-1 text-[12px]',
|
||||
typingDots: 'typing-dots',
|
||||
} as const;
|
||||
|
||||
@ -9,12 +9,15 @@ import {
|
||||
listMessages,
|
||||
createMessage as apiCreateMessage,
|
||||
stopMessage as apiStopMessage,
|
||||
resendMessage as apiResendMessage,
|
||||
streamChat,
|
||||
editMessage as apiEditMessage,
|
||||
listMessageVersions as apiListMessageVersions,
|
||||
switchMessageVersion as apiSwitchMessageVersion,
|
||||
listMessageForks as apiListMessageForks,
|
||||
forkMessage as apiForkMessage,
|
||||
} from "@/client/aiChatApi";
|
||||
import { useStreamingStore } from "@/store/streaming";
|
||||
|
||||
const CONVERSATIONS_KEY = "ai-conversations";
|
||||
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 type { MessageResponse };
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ export function useCurrentUserQuery() {
|
||||
return (res.data?.data as ContextMe) ?? null;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
retry: (failureCount, error: any) => {
|
||||
// Don't retry on unauthorized
|
||||
if (error?.status === 401 || error?.response?.status === 401 || error?.message === "Unauthorized") {
|
||||
|
||||
@ -73,7 +73,8 @@ export function useRepoBranchesQuery({ namespace, repo }: RepoParams) {
|
||||
queryKey: [REPO_BRANCHES_QUERY_KEY, namespace, repo],
|
||||
queryFn: async () => {
|
||||
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,
|
||||
});
|
||||
@ -84,7 +85,8 @@ export function useRepoTagsQuery({ namespace, repo }: RepoParams) {
|
||||
queryKey: [REPO_TAGS_QUERY_KEY, namespace, repo],
|
||||
queryFn: async () => {
|
||||
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,
|
||||
});
|
||||
@ -95,7 +97,8 @@ export function useRepoBranchSummaryQuery({ namespace, repo }: RepoParams) {
|
||||
queryKey: [REPO_BRANCH_SUMMARY_QUERY_KEY, namespace, repo],
|
||||
queryFn: async () => {
|
||||
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,
|
||||
});
|
||||
@ -106,7 +109,8 @@ export function useRepoPullsQuery({ namespace, repo }: RepoParams) {
|
||||
queryKey: [REPO_PULLS_QUERY_KEY, namespace, repo],
|
||||
queryFn: async () => {
|
||||
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,
|
||||
});
|
||||
@ -121,7 +125,8 @@ export function useRepoTreeQuery({ namespace, repo, oid }: RepoTreeParams) {
|
||||
queryKey: [REPO_TREE_QUERY_KEY, namespace, repo, oid],
|
||||
queryFn: async () => {
|
||||
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,
|
||||
});
|
||||
@ -135,6 +140,7 @@ export function useRepoCommitDetailQuery({ namespace, repo, oid }: RepoTreeParam
|
||||
queryKey: [REPO_COMMIT_DETAIL_QUERY_KEY, namespace, repo, oid],
|
||||
queryFn: async () => {
|
||||
const res = await gitCommitGet(namespace, repo, oid);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return res.data.data as any;
|
||||
},
|
||||
enabled: !!namespace && !!repo && !!oid,
|
||||
@ -149,6 +155,7 @@ export function useRepoCommitDiffQuery({ namespace, repo, oid, baseOid }: RepoTr
|
||||
old_tree: baseOid || "",
|
||||
new_tree: oid,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return res.data.data as any;
|
||||
},
|
||||
enabled: !!namespace && !!repo && !!oid,
|
||||
@ -195,7 +202,8 @@ export function useBranchProtectionQuery({ namespace, repo }: RepoParams) {
|
||||
queryKey: [BRANCH_PROTECTION_QUERY_KEY, namespace, repo],
|
||||
queryFn: async () => {
|
||||
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,
|
||||
});
|
||||
|
||||
@ -84,7 +84,8 @@ export function useUserFollowerCountQuery(username: string) {
|
||||
queryKey: [USER_QUERY_KEY, username, "followers-count"],
|
||||
queryFn: async (): Promise<number> => {
|
||||
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,
|
||||
});
|
||||
@ -95,7 +96,8 @@ export function useUserFollowingCountQuery(username: string) {
|
||||
queryKey: [USER_QUERY_KEY, username, "following-count"],
|
||||
queryFn: async (): Promise<number> => {
|
||||
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,
|
||||
});
|
||||
@ -229,7 +231,8 @@ export function useIsSubscribedQuery(username: string) {
|
||||
queryFn: async (): Promise<boolean> => {
|
||||
try {
|
||||
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 {
|
||||
return false;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user