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

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

View File

@ -82,6 +82,7 @@ export default function App() {
<Route path="/me/projects" element={<MePage />} />
<Route path="/me/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" />} />

View File

@ -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 {

View File

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

View File

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

View File

@ -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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
import { Loader2, Code, FileText, GitPullRequest, Brain, ChevronDown, Sparkles } from "lucide-react";
import { 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>
);
}
}

View File

@ -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() || "?"}

View File

@ -1,4 +1,4 @@
import { useState, useCallback, useEffect } from "react";
import { useState, useCallback, useEffect, useMemo } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { 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>
);
}

View File

@ -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">

View File

@ -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 () => {

View File

@ -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,

View File

@ -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)"}

View File

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

View File

@ -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 (

View File

@ -73,7 +73,7 @@ export function ProfileHeader({ user, isMe, isLoading, starsCount: starsCountPro
<div className="flex flex-col md:flex-row gap-6 items-start">
<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>

View File

@ -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">

View File

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

View File

@ -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}
/>
))}

View File

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

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState } from "react";
import { Settings, Trash2, Loader2, Globe, Lock } from "lucide-react";
import { 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)" }}>

View File

@ -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 () => {

View File

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

View File

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

View File

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

View File

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

View File

@ -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.");
}
};

View File

@ -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 />

View File

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

View File

@ -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" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,11 @@
/**
* Generated by orval v8.9.0 🍺
* Do not edit manually.
* api
* OpenAPI spec version: 0.2.9
*/
export interface ActivityBreakdownItem {
event_type: string;
count: number;
}

View File

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

View File

@ -0,0 +1,13 @@
/**
* Generated by orval v8.9.0 🍺
* Do not edit manually.
* api
* OpenAPI spec version: 0.2.9
*/
import type { ApiResponseForkConversationResponseData } from './apiResponseForkConversationResponseData';
export interface ApiResponseForkConversationResponse {
code: number;
message: string;
data?: ApiResponseForkConversationResponseData;
}

View File

@ -0,0 +1,14 @@
/**
* Generated by orval v8.9.0 🍺
* Do not edit manually.
* api
* OpenAPI spec version: 0.2.9
*/
export type ApiResponseForkConversationResponseData = {
id: string;
/** @nullable */
title?: string | null;
model: string;
created_at: string;
};

View File

@ -0,0 +1,14 @@
/**
* Generated by orval v8.9.0 🍺
* Do not edit manually.
* api
* OpenAPI spec version: 0.2.9
*/
import type { ApiResponseProjectStatsResponseData } from './apiResponseProjectStatsResponseData';
export interface ApiResponseProjectStatsResponse {
code: number;
message: string;
/** Aggregated project statistics for dashboard display. */
data?: ApiResponseProjectStatsResponseData;
}

View File

@ -0,0 +1,35 @@
/**
* Generated by orval v8.9.0 🍺
* Do not edit manually.
* api
* OpenAPI spec version: 0.2.9
*/
import type { ActivityBreakdownItem } from './activityBreakdownItem';
import type { ProjectStatsActivityItem } from './projectStatsActivityItem';
/**
* Aggregated project statistics for dashboard display.
*/
export type ApiResponseProjectStatsResponseData = {
project_id: string;
project_name: string;
member_count: number;
/** @nullable */
my_role?: string | null;
repo_count: number;
issue_total: number;
issue_open: number;
issue_closed: number;
pr_total: number;
pr_open: number;
pr_merged: number;
pr_closed: number;
room_count: number;
ai_call_count: number;
ai_input_tokens: number;
ai_output_tokens: number;
/** @nullable */
ai_cost_usd?: string | null;
activity_last_30d: ActivityBreakdownItem[];
recent_activities: ProjectStatsActivityItem[];
};

View File

@ -0,0 +1,14 @@
/**
* Generated by orval v8.9.0 🍺
* Do not edit manually.
* api
* OpenAPI spec version: 0.2.9
*/
export interface ForkConversationResponse {
id: string;
/** @nullable */
title?: string | null;
model: string;
created_at: string;
}

View File

@ -7,6 +7,7 @@
export * from './accessKeyListResponse';
export * from './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';

View File

@ -0,0 +1,16 @@
/**
* Generated by orval v8.9.0 🍺
* Do not edit manually.
* api
* OpenAPI spec version: 0.2.9
*/
export interface ProjectStatsActivityItem {
id: number;
event_type: string;
title: string;
actor_name: string;
/** @nullable */
actor_avatar?: string | null;
created_at: string;
}

View File

@ -0,0 +1,35 @@
/**
* Generated by orval v8.9.0 🍺
* Do not edit manually.
* api
* OpenAPI spec version: 0.2.9
*/
import type { ActivityBreakdownItem } from './activityBreakdownItem';
import type { ProjectStatsActivityItem } from './projectStatsActivityItem';
/**
* Aggregated project statistics for dashboard display.
*/
export interface ProjectStatsResponse {
project_id: string;
project_name: string;
member_count: number;
/** @nullable */
my_role?: string | null;
repo_count: number;
issue_total: number;
issue_open: number;
issue_closed: number;
pr_total: number;
pr_open: number;
pr_merged: number;
pr_closed: number;
room_count: number;
ai_call_count: number;
ai_input_tokens: number;
ai_output_tokens: number;
/** @nullable */
ai_cost_usd?: string | null;
activity_last_30d: ActivityBreakdownItem[];
recent_activities: ProjectStatsActivityItem[];
}

View File

@ -210,6 +210,7 @@ const ProviderAttachmentsContext = createContext<AttachmentsContext | null>(
null
);
// 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);

View File

@ -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) {

View File

@ -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,

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { useMemo, useRef, useEffect, useState, useCallback } from 'react';
import { useMemo, useRef, useEffect, useState, useCallback, forwardRef } from 'react';
import { Loader2, ChevronDown } from 'lucide-react';
import { 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

View File

@ -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() || "?"}

View File

@ -0,0 +1,23 @@
import { createContext, useContext } from "react";
export interface CodePreviewPayload {
id: string;
code: string;
language: string;
lineCount: number;
previewMode?: "code" | "preview";
}
interface CodePreviewContextValue {
activeCode: CodePreviewPayload | null;
openCodePreview: (payload: CodePreviewPayload) => void;
closeCodePreview: () => void;
}
const CodePreviewContext = createContext<CodePreviewContextValue | null>(null);
export const CodePreviewProvider = CodePreviewContext.Provider;
export function useCodePreview() {
return useContext(CodePreviewContext);
}

View File

@ -0,0 +1,130 @@
import { memo, useCallback, useEffect, useMemo, useState } from "react";
import { Check, Copy, PanelRightClose } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { CodePreviewPayload } from "@/components/chat/CodePreviewContext";
interface CodePreviewPanelProps {
code: CodePreviewPayload | null;
onClose: () => void;
}
export const CodePreviewPanel = memo(function CodePreviewPanel({ code, onClose }: CodePreviewPanelProps) {
const [copied, setCopied] = useState(false);
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
const lines = useMemo(() => code?.code.replace(/\n$/, "").split("\n") ?? [], [code?.code]);
const canPreview = code?.language === "html";
const handleCopy = useCallback(() => {
if (!code) return;
navigator.clipboard.writeText(code.code).then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1600);
});
}, [code]);
// Sync viewMode when code changes
useEffect(() => {
if (code?.previewMode) {
setViewMode(code.previewMode);
}
}, [code?.previewMode]);
return (
<aside
className="h-full shrink-0 overflow-hidden border-l transition-[width,opacity,transform] duration-300 ease-out"
style={{
width: code ? "min(48vw, 760px)" : 0,
opacity: code ? 1 : 0,
transform: code ? "translateX(0)" : "translateX(18px)",
borderColor: "var(--border-subtle)",
backgroundColor: "var(--surface-ground)",
}}
aria-hidden={!code}
>
<div className="flex h-full w-[min(48vw,760px)] flex-col">
<div
className="flex h-12 shrink-0 items-center justify-between gap-3 border-b px-3"
style={{ borderColor: "var(--border-subtle)" }}
>
<div className="min-w-0">
<div className="truncate text-sm font-medium" style={{ color: "var(--text-primary)" }}>
{code?.language || "text"}
</div>
<div className="text-xs" style={{ color: "var(--text-muted)" }}>
{code?.lineCount ?? 0} lines
</div>
</div>
<div className="flex items-center gap-1">
{canPreview && (
<div className="flex items-center rounded-md border overflow-hidden mr-2" style={{ borderColor: "var(--border-default)" }}>
<button
onClick={() => setViewMode("code")}
className="px-3 py-1 text-xs font-medium transition-colors"
style={{
backgroundColor: viewMode === "code" ? "var(--accent)" : "transparent",
color: viewMode === "code" ? "var(--accent-fg)" : "var(--text-secondary)",
}}
>
Code
</button>
<button
onClick={() => setViewMode("preview")}
className="px-3 py-1 text-xs font-medium transition-colors"
style={{
backgroundColor: viewMode === "preview" ? "var(--accent)" : "transparent",
color: viewMode === "preview" ? "var(--accent-fg)" : "var(--text-secondary)",
}}
>
Preview
</button>
</div>
)}
<Button variant="ghost" size="sm" onClick={handleCopy} disabled={!code}>
{copied ? <Check data-icon="inline-start" /> : <Copy data-icon="inline-start" />}
{copied ? "Copied" : "Copy"}
</Button>
<Button variant="ghost" size="icon-sm" onClick={onClose} aria-label="Close code preview">
<PanelRightClose />
</Button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto">
{viewMode === "preview" && canPreview ? (
<iframe
title="HTML Preview"
srcDoc={code?.code ?? ""}
className="w-full h-full border-0"
sandbox="allow-scripts"
/>
) : (
<div className="grid min-w-max grid-cols-[auto_1fr] text-[13px] leading-[1.65]">
<div
className="select-none border-r px-3 py-3 text-right font-mono tabular-nums"
style={{
borderColor: "var(--border-subtle)",
color: "var(--text-muted)",
backgroundColor: "var(--surface-elevated)",
}}
aria-hidden="true"
>
{lines.map((_, index) => (
<div key={index}>{index + 1}</div>
))}
</div>
<pre
className="m-0 min-h-full px-4 py-3 font-mono outline-none"
style={{
color: "var(--text-primary)",
backgroundColor: "var(--surface-ground)",
}}
>
<code>{code?.code ?? ""}</code>
</pre>
</div>
)}
</div>
</div>
</aside>
);
});

View File

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

View File

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

View File

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

View File

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

View File

@ -45,8 +45,8 @@ export const ChannelSidebar = memo(function ChannelSidebar({onCollapse}: Channel
const {data: projectInfo} = useProjectInfo(projectName);
const [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) => {

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (

View File

@ -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]);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 ?? '',

View File

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

View File

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

View File

@ -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") {

View File

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

View File

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