feat(frontend): update UI components, skill pages, and hooks

Refactor ChatMessageList, ChannelSidebar, and skill detail/pages.
Add CreateSkillDialog and DeleteSkillDialog. Update MarkdownRenderer,
use-mobile hook, and useSkillsQuery.
This commit is contained in:
ZhenYi 2026-05-11 17:06:13 +08:00
parent de85417053
commit ac9ffb2a7a
14 changed files with 661 additions and 161 deletions

View File

@ -1,4 +1,4 @@
import { createContext, useContext, useState, type ReactNode } from "react";
import { createContext, useContext, useState, useMemo, useCallback, type ReactNode } from "react";
import { Outlet } from "react-router-dom";
import { ChevronRight } from "lucide-react";
import { ServerIconRail } from "@/components/layout/ServerIconRail";
@ -26,8 +26,18 @@ export function ChannelLayout({ children }: { children?: ReactNode }) {
const isTablet = useIsTablet();
const canShowMembers = !isMobile && !isTablet;
const contextValue = useMemo(
() => ({ showMembers, setShowMembers }),
[showMembers],
);
const toggleSidebar = useCallback(
() => setIsSidebarCollapsed((v) => !v),
[],
);
return (
<ChannelContext.Provider value={{ showMembers, setShowMembers }}>
<ChannelContext.Provider value={contextValue}>
<div className="flex h-screen overflow-hidden" style={{ backgroundColor: "var(--surface-ground)" }}>
{!isMobile && <ServerIconRail />}
@ -44,7 +54,7 @@ export function ChannelLayout({ children }: { children?: ReactNode }) {
{!isSidebarCollapsed && <ChannelSidebar />}
</div>
<button
onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
onClick={toggleSidebar}
className="absolute flex items-center justify-center w-5 h-10 cursor-pointer transition-colors"
style={{
color: "var(--text-muted)",

View File

@ -1,5 +1,5 @@
import { Copy, Check, Sparkles, ClipboardList, Pencil, RefreshCw, GitFork } from "lucide-react";
import { useState } from "react";
import { memo, useState } from "react";
import { useCurrentUserQuery } from "@/hooks/useAuth";
import { useEditMessageMutation, useMessageVersionsQuery, useSwitchMessageVersionMutation } from "@/hooks/useAiChatQuery";
import { MarkdownRenderer } from "@/components/ui/MarkdownRenderer";
@ -136,7 +136,7 @@ function ThinkingSection({ content, isOpen, onToggle }: { content: string; isOpe
);
}
export function ChatMessageBubble({ message, conversationId, onRegenerate }: ChatMessageBubbleProps) {
export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conversationId, onRegenerate }: ChatMessageBubbleProps) {
const isUser = message.role === "user";
const [copied, setCopied] = useState<"answer" | "full" | false>(false);
const [openThinking, setOpenThinking] = useState<Record<number, boolean>>({});
@ -472,4 +472,4 @@ export function ChatMessageBubble({ message, conversationId, onRegenerate }: Cha
</div>
</div>
);
}
});

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
import { useEffect, useRef, useState, useCallback } from "react";
import { Loader2, Code, FileText, GitPullRequest, Brain, ChevronDown } from "lucide-react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useQueryClient } from "@tanstack/react-query";
@ -78,24 +78,13 @@ export function ChatMessageList({ conversationId }: ChatMessageListProps) {
if (!atBottom) setUserScrolledUp(true);
}, []);
// Streaming bubble (always counted for virtualization)
// 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 totalItems = messages.length + (hasStreamingBubble ? 1 : 0);
// Build item data array: messages + optional streaming bubble
const items = useMemo(() => {
const result: Array<{ type: "message"; message: typeof messages[0] } | { type: "streaming" }> = [];
for (const m of messages) {
result.push({ type: "message", message: m });
}
if (hasStreamingBubble) {
result.push({ type: "streaming" });
}
return result;
}, [messages, hasStreamingBubble]);
const virtualizer = useVirtualizer({
count: totalItems,
count: messages.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => ESTIMATED_SIZE,
overscan: OVERSCAN,
@ -220,7 +209,7 @@ export function ChatMessageList({ conversationId }: ChatMessageListProps) {
</div>
)}
{/* Virtualized message list */}
{/* Virtualized message list — persisted messages only */}
<div
ref={scrollRef}
className="flex-1 overflow-y-auto"
@ -232,8 +221,8 @@ export function ChatMessageList({ conversationId }: ChatMessageListProps) {
style={{ height: `${virtualizer.getTotalSize()}px` }}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const item = items[virtualItem.index];
if (!item) return null;
const message = messages[virtualItem.index];
if (!message) return null;
return (
<div
@ -245,22 +234,27 @@ export function ChatMessageList({ conversationId }: ChatMessageListProps) {
transform: `translateY(${virtualItem.start}px)`,
}}
>
{item.type === "message" ? (
<ChatMessageBubble
message={item.message}
conversationId={conversationId}
onRegenerate={(_newMsgId: string) => {
queryClient.invalidateQueries({ queryKey: ["ai-messages", conversationId] });
queryClient.invalidateQueries({ queryKey: ["ai-conversations", conversationId] });
}}
/>
) : (
<StreamingBubble parts={stream!.parts} isDone={stream!.isDone} />
)}
<ChatMessageBubble
message={message}
conversationId={conversationId}
onRegenerate={(_newMsgId: string) => {
queryClient.invalidateQueries({ queryKey: ["ai-messages", conversationId] });
queryClient.invalidateQueries({ queryKey: ["ai-conversations", conversationId] });
}}
/>
</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} />
</div>
)}
</div>
</div>
);
@ -268,8 +262,41 @@ export function ChatMessageList({ conversationId }: ChatMessageListProps) {
function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boolean }) {
const [openThinking, setOpenThinking] = useState<Record<number, boolean>>({});
// 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 };
// Start rAF sync loop when streaming begins
const hasParts = parts.length > 0;
useEffect(() => {
if (!hasParts) return;
const sync = () => {
const { parts: p, isDone: d } = latestRef.current;
setDisplayParts([...p]);
setDisplayDone(d);
if (!d) {
rafRef.current = requestAnimationFrame(sync);
}
};
rafRef.current = requestAnimationFrame(sync);
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";
@ -279,13 +306,13 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole
}
});
}
}, [parts.length]);
}, [displayParts.length]);
const toggleThinking = (idx: number) => {
setOpenThinking((prev) => ({ ...prev, [idx]: !prev[idx] }));
};
const firstThinkingIdx = parts.findIndex((p) => p.type === "thinking");
const firstThinkingIdx = displayParts.findIndex((p) => p.type === "thinking");
return (
<div ref={contentRef} className="flex gap-4 px-4 py-3 max-w-3xl mx-auto w-full">
@ -305,22 +332,23 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole
<span className="text-xs font-semibold" style={{ color: "var(--text-primary)" }}>
Assistant
</span>
{!isDone && (
{!displayDone && (
<span className="text-[11px] animate-pulse" style={{ color: "var(--text-muted)" }}>
responding
</span>
)}
</div>
{/* Interleaved rendering — thinking (collapsible) + token in order */}
{/* 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)" }}>
{parts.map((part, i) => {
{displayParts.map((part, i) => {
if (part.type === "thinking") {
const isOpen = openThinking[i] ?? false;
const isActivelyThinking = !isDone && i === firstThinkingIdx;
const isActivelyThinking = !displayDone && i === firstThinkingIdx;
return (
<div key={i} className="mb-3" style={{ whiteSpace: "normal" }}>
{/* DeepSeek-style horizontal separator */}
<div
className="flex items-center gap-2 py-1.5 cursor-pointer select-none"
onClick={() => toggleThinking(i)}
@ -342,7 +370,7 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole
</div>
{isOpen && (
<div
className="mt-1 p-3 rounded-xl text-sm leading-relaxed max-h-[400px] overflow-y-auto"
className="mt-1 p-3 rounded-xl text-sm leading-relaxed max-h-[400px] overflow-y-auto whitespace-pre-wrap"
style={{
backgroundColor: "var(--surface-elevated)",
border: "1px solid var(--border-subtle)",
@ -356,16 +384,35 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole
</div>
);
}
// 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).
const isLast = i === displayParts.length - 1;
return (
<MarkdownRenderer
key={i}
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"
/>
<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"
/>
{isLast && !displayDone && <StreamingCursor />}
</div>
);
})}
</div>
</div>
</div>
);
}
/** Blinking cursor for typing feel during streaming. */
function StreamingCursor() {
return (
<span
className="inline-block w-[2px] h-[1.1em] ml-[1px] align-text-bottom animate-pulse rounded-sm"
style={{
backgroundColor: "var(--accent)",
animationDuration: "0.8s",
}}
/>
);
}

View File

@ -1,22 +1,78 @@
import { useParams } from "react-router-dom";
import { useSkillDetailQuery } from "@/hooks/useSkillDetailQuery";
import { useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useSkillDetailQuery, useUpdateSkillMutation } from "@/hooks/useSkillsQuery";
import { PageHeader } from "@/components/ui/PageHeader";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { LoadingState } from "@/components/ui/LoadingState";
import { ErrorState } from "@/components/ui/ErrorState";
import { Lightbulb, Calendar, Code } from "lucide-react";
import { Lightbulb, Calendar, Code, Pencil, Trash2 } from "lucide-react";
import { DeleteSkillDialog } from "@/app/project/skills/DeleteSkillDialog";
export function SkillDetailPage() {
const { projectName, skillSlug } = useParams<{
projectName: string;
skillSlug: string;
}>();
const navigate = useNavigate();
const {
data: skillDetail,
isLoading,
error,
refetch,
} = useSkillDetailQuery({ projectName: projectName!, slug: skillSlug! });
} = useSkillDetailQuery(projectName, skillSlug);
const { mutate: updateSkill, isPending: isSaving } = useUpdateSkillMutation(projectName);
const [editing, setEditing] = useState(false);
const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editContent, setEditContent] = useState("");
const [editEnabled, setEditEnabled] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const handleStartEdit = () => {
if (!skillDetail) return;
setEditName(skillDetail.name);
setEditDescription(skillDetail.description ?? "");
setEditContent(skillDetail.content);
setEditEnabled(skillDetail.enabled);
setSaveError(null);
setEditing(true);
};
const handleCancelEdit = () => {
setEditing(false);
setSaveError(null);
};
const handleSave = () => {
if (!projectName || !skillSlug) return;
setSaveError(null);
updateSkill(
{
slug: skillSlug,
req: {
name: editName || undefined,
description: editDescription || undefined,
content: editContent,
enabled: editEnabled,
},
},
{
onSuccess: () => {
setEditing(false);
refetch();
},
onError: (err) =>
setSaveError(err instanceof Error ? err.message : "Save failed"),
}
);
};
const handleRetry = () => {
refetch();
@ -45,10 +101,45 @@ export function SkillDetailPage() {
return (
<div className="p-6 h-full overflow-auto">
<PageHeader
title={skillDetail.name}
description={skillDetail.description || "Skill details"}
title={editing ? "Edit skill" : skillDetail.name}
description={
editing ? "Modify skill details" : (skillDetail.description || "Skill details")
}
actions={
editing ? (
<>
<Button variant="outline" size="sm" onClick={handleCancelEdit} disabled={isSaving}>
Cancel
</Button>
<Button size="sm" onClick={handleSave} disabled={isSaving}>
{isSaving ? "Saving..." : "Save"}
</Button>
</>
) : (
<>
<Button variant="outline" size="sm" onClick={handleStartEdit}>
<Pencil className="w-4 h-4 mr-1.5" />
Edit
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="w-4 h-4 mr-1.5" />
Delete
</Button>
</>
)
}
/>
{saveError && (
<div className="mb-4 p-3 rounded-md bg-destructive/10 text-destructive text-sm">
{saveError}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<div className="bg-card rounded-lg border border-border p-4" style={{ backgroundColor: "var(--surface-elevated)" }}>
@ -56,9 +147,17 @@ export function SkillDetailPage() {
<Code className="w-4 h-4" />
Skill Content
</div>
<pre className="text-sm whitespace-pre-wrap font-mono rounded p-3 overflow-auto max-h-[600px]" style={{ backgroundColor: "var(--hover-bg)", color: "var(--text-primary)" }}>
{skillDetail.content}
</pre>
{editing ? (
<Textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className="font-mono text-sm min-h-[400px]"
/>
) : (
<pre className="text-sm whitespace-pre-wrap font-mono rounded p-3 overflow-auto max-h-[600px]" style={{ backgroundColor: "var(--hover-bg)", color: "var(--text-primary)" }}>
{skillDetail.content}
</pre>
)}
</div>
</div>
@ -73,26 +172,75 @@ export function SkillDetailPage() {
<dt style={{ color: "var(--text-muted)" }}>Slug</dt>
<dd className="font-mono" style={{ color: "var(--text-primary)" }}>{skillDetail.slug}</dd>
</div>
<div className="flex justify-between">
<dt style={{ color: "var(--text-muted)" }}>Status</dt>
<dd style={{ color: "var(--text-primary)" }}>
<span
className="px-2 py-0.5 rounded-full text-xs"
style={{
backgroundColor: skillDetail.enabled ? "var(--success-alpha10)" : "var(--hover-bg)",
color: skillDetail.enabled ? "var(--success)" : "var(--text-muted)"
}}
>
{skillDetail.enabled ? "Enabled" : "Disabled"}
</span>
</dd>
</div>
{skillDetail.created_at && (
<div className="flex justify-between">
<dt style={{ color: "var(--text-muted)" }}>Created</dt>
<dd className="flex items-center gap-1" style={{ color: "var(--text-primary)" }}>
<Calendar className="w-3 h-3" />
{new Date(skillDetail.created_at).toLocaleDateString()}
{editing ? (
<>
<div className="flex flex-col gap-2 pt-2">
<Label htmlFor="edit-name" style={{ color: "var(--text-muted)" }}>
Name
</Label>
<Input
id="edit-name"
value={editName}
onChange={(e) => setEditName(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2 pt-2">
<Label htmlFor="edit-desc" style={{ color: "var(--text-muted)" }}>
Description
</Label>
<Input
id="edit-desc"
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
/>
</div>
</>
) : (
<>
<div className="flex justify-between">
<dt style={{ color: "var(--text-muted)" }}>Status</dt>
<dd style={{ color: "var(--text-primary)" }}>
<span
className="px-2 py-0.5 rounded-full text-xs"
style={{
backgroundColor: skillDetail.enabled ? "var(--success-alpha10)" : "var(--hover-bg)",
color: skillDetail.enabled ? "var(--success)" : "var(--text-muted)"
}}
>
{skillDetail.enabled ? "Enabled" : "Disabled"}
</span>
</dd>
</div>
{skillDetail.created_at && (
<div className="flex justify-between">
<dt style={{ color: "var(--text-muted)" }}>Created</dt>
<dd className="flex items-center gap-1" style={{ color: "var(--text-primary)" }}>
<Calendar className="w-3 h-3" />
{new Date(skillDetail.created_at).toLocaleDateString()}
</dd>
</div>
)}
</>
)}
{editing && (
<div className="flex items-center justify-between pt-2">
<dt style={{ color: "var(--text-muted)" }}>Enabled</dt>
<dd>
<button
type="button"
onClick={() => setEditEnabled(!editEnabled)}
className={`px-2 py-0.5 rounded-full text-xs transition-colors ${
editEnabled
? "bg-success-alpha10 text-success"
: "bg-hover-bg text-muted-foreground"
}`}
style={{
backgroundColor: editEnabled ? "var(--success-alpha10)" : "var(--hover-bg)",
color: editEnabled ? "var(--success)" : "var(--text-muted)"
}}
>
{editEnabled ? "Enabled" : "Disabled"}
</button>
</dd>
</div>
)}
@ -100,6 +248,13 @@ export function SkillDetailPage() {
</div>
</div>
</div>
<DeleteSkillDialog
skill={skillDetail}
open={deleteOpen}
onOpenChange={setDeleteOpen}
onDeleted={() => navigate(`/${projectName}/skills`)}
/>
</div>
);
}

View File

@ -0,0 +1,132 @@
import { useState } from "react";
import { useParams } from "react-router-dom";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useCreateSkillMutation } from "@/hooks/useSkillsQuery";
interface CreateSkillDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function CreateSkillDialog({ open, onOpenChange }: CreateSkillDialogProps) {
const { projectName } = useParams<{ projectName: string }>();
const { mutate: createSkill, isPending } = useCreateSkillMutation(projectName);
const [slug, setSlug] = useState("");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [content, setContent] = useState("");
const [error, setError] = useState<string | null>(null);
const reset = () => {
setSlug("");
setName("");
setDescription("");
setContent("");
setError(null);
};
const handleOpenChange = (open: boolean) => {
if (!open) reset();
onOpenChange(open);
};
const handleSubmit = () => {
if (!slug.trim()) {
setError("Slug is required");
return;
}
if (!content.trim()) {
setError("Content is required");
return;
}
setError(null);
createSkill(
{
slug: slug.trim(),
name: name.trim() || undefined,
description: description.trim() || undefined,
content: content.trim(),
},
{
onSuccess: () => handleOpenChange(false),
onError: (err) =>
setError(err instanceof Error ? err.message : "Create failed"),
}
);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Create skill</DialogTitle>
<DialogDescription>
Add a new AI skill to the project.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label htmlFor="slug">Slug *</Label>
<Input
id="slug"
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="my-skill"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My Skill"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Description</Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What this skill does"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="content">Content *</Label>
<Textarea
id="content"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Skill content or prompt..."
rows={6}
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isPending}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={isPending}>
{isPending ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,64 @@
import { useState } from "react";
import { useParams } from "react-router-dom";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useDeleteSkillMutation } from "@/hooks/useSkillsQuery";
import type { SkillResponse } from "@/client/model";
interface DeleteSkillDialogProps {
skill: SkillResponse;
open: boolean;
onOpenChange: (open: boolean) => void;
onDeleted?: () => void;
}
export function DeleteSkillDialog({ skill, open, onOpenChange, onDeleted }: DeleteSkillDialogProps) {
const { projectName } = useParams<{ projectName: string }>();
const { mutate: deleteSkill, isPending } = useDeleteSkillMutation(projectName);
const [error, setError] = useState<string | null>(null);
const handleDelete = () => {
setError(null);
deleteSkill(skill.slug, {
onSuccess: () => {
onOpenChange(false);
onDeleted?.();
},
onError: (err) => setError(err instanceof Error ? err.message : "Delete failed"),
});
};
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete skill</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete <strong>{skill.name}</strong>? This action cannot be undone.
</AlertDialogDescription>
{error && (
<p className="text-sm text-destructive mt-2">{error}</p>
)}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={handleDelete}
disabled={isPending}
>
{isPending ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@ -1,16 +1,24 @@
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useSkillsQuery } from "@/hooks/useSkillsQuery";
import { useSkillsQuery, useScanSkillsMutation } from "@/hooks/useSkillsQuery";
import { PageHeader } from "@/components/ui/PageHeader";
import { Button } from "@/components/ui/button";
import { LoadingState } from "@/components/ui/LoadingState";
import { EmptyState } from "@/components/ui/EmptyState";
import { ErrorState } from "@/components/ui/ErrorState";
import { Lightbulb, Calendar } from "lucide-react";
import { Lightbulb, Calendar, Plus, RefreshCw, Trash2 } from "lucide-react";
import type { SkillResponse } from "@/client/model";
import { CreateSkillDialog } from "./CreateSkillDialog";
import { DeleteSkillDialog } from "./DeleteSkillDialog";
export function SkillsPage() {
const { projectName } = useParams<{ projectName: string }>();
const navigate = useNavigate();
const { data: skills = [], isLoading, error, refetch } = useSkillsQuery(projectName);
const { mutate: scanSkills, isPending: isScanning } = useScanSkillsMutation(projectName);
const [createOpen, setCreateOpen] = useState(false);
const [deleteSkill, setDeleteSkill] = useState<SkillResponse | null>(null);
if (!projectName) {
return (
@ -46,31 +54,60 @@ export function SkillsPage() {
return (
<div className="p-6 h-full overflow-auto">
<PageHeader title="Skills" description="Project capabilities and AI skills" />
<PageHeader
title="Skills"
description="Project capabilities and AI skills"
actions={
<>
<Button
variant="outline"
size="sm"
onClick={() => scanSkills()}
disabled={isScanning}
>
<RefreshCw className={`w-4 h-4 mr-1.5 ${isScanning ? "animate-spin" : ""}`} />
{isScanning ? "Scanning..." : "Scan"}
</Button>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="w-4 h-4 mr-1.5" />
Create
</Button>
</>
}
/>
{skills.length === 0 ? (
<EmptyState
icon={<Lightbulb className="w-6 h-6" />}
title="No skills"
description="Configure project skills to enable AI assistance"
description="Create or scan skills to enable AI assistance"
/>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{skills.map((skill: SkillResponse) => (
<div
key={skill.id}
onClick={() =>
navigate(`/${projectName}/skills/${skill.slug}`)
}
className="bg-card rounded-lg border border-border p-4 hover:border-primary/50 transition-colors cursor-pointer"
className="bg-card rounded-lg border border-border p-4 hover:border-primary/50 transition-colors cursor-pointer group relative"
style={{ backgroundColor: "var(--surface-elevated)" }}
onClick={() => navigate(`/${projectName}/skills/${skill.slug}`)}
>
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity h-8 w-8"
onClick={(e) => {
e.stopPropagation();
setDeleteSkill(skill);
}}
>
<Trash2 className="w-4 h-4 text-muted-foreground hover:text-destructive" />
</Button>
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<Lightbulb className="w-5 h-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-card-foreground mb-1 truncate">
<h3 className="text-base font-semibold text-card-foreground mb-1 truncate pr-6">
{skill.name}
</h3>
{skill.description && (
@ -90,6 +127,15 @@ export function SkillsPage() {
))}
</div>
)}
<CreateSkillDialog open={createOpen} onOpenChange={setCreateOpen} />
{deleteSkill && (
<DeleteSkillDialog
skill={deleteSkill}
open={!!deleteSkill}
onOpenChange={(open) => { if (!open) setDeleteSkill(null); }}
/>
)}
</div>
);
}

View File

@ -1,9 +1,9 @@
import { memo, useCallback, useMemo, useState } from "react";
import {Link, useLocation, useParams} from "react-router-dom";
import {useRoomsQuery} from "@/hooks/useRoomsQuery";
import {useProjectInfo} from "@/hooks/useProjectInfo";
import {Hash, PanelLeftClose, Plus, Search, Settings} from "lucide-react";
import {CHANNEL_SIDEBAR} from "@/css/layout/styles";
import {useState} from "react";
import {ProjectCreateMenuModal} from "@/app/project";
const NAV_ITEMS = [
@ -38,7 +38,7 @@ interface ChannelSidebarProps {
onCollapse?: () => void;
}
export function ChannelSidebar({onCollapse}: ChannelSidebarProps) {
export const ChannelSidebar = memo(function ChannelSidebar({onCollapse}: ChannelSidebarProps) {
const location = useLocation();
const {projectName} = useParams<{ projectName: string }>();
const {data: roomsData, isLoading} = useRoomsQuery(projectName);
@ -49,29 +49,32 @@ export function ChannelSidebar({onCollapse}: ChannelSidebarProps) {
const categories = roomsData?.categories ?? [];
const pathParts = location.pathname.split("/").filter(Boolean);
const isActive = (path: string) => {
if (pathParts.length >= 2) {
return pathParts[1] === path;
}
return false;
};
const isActive = useCallback((path: string) => {
return pathParts.length >= 2 && pathParts[1] === path;
}, [pathParts]);
const isRoomActive = (roomId: string) => {
const isRoomActive = useCallback((roomId: string) => {
return pathParts.length >= 3 && pathParts[2] === roomId;
};
}, [pathParts]);
const isSettingsActive = isActive("settings");
const showSettings = projectInfo?.role === "Owner" || projectInfo?.role === "Admin";
const uncategorizedRooms = rooms.filter((r) => !r.isMuted && !r.category);
const categorizedRooms = [...categories]
.sort((a, b) => a.position - b.position)
.map((cat) => ({
...cat,
rooms: rooms.filter((r) => !r.isMuted && r.category === cat.id),
}))
.filter((cat) => cat.rooms.length > 0);
const uncategorizedRooms = useMemo(
() => rooms.filter((r) => !r.isMuted && !r.category),
[rooms],
);
const categorizedRooms = useMemo(
() => [...categories]
.sort((a, b) => a.position - b.position)
.map((cat) => ({
...cat,
rooms: rooms.filter((r) => !r.isMuted && r.category === cat.id),
}))
.filter((cat) => cat.rooms.length > 0),
[rooms, categories],
);
return (
<div
@ -291,4 +294,4 @@ export function ChannelSidebar({onCollapse}: ChannelSidebarProps) {
)}
</div>
);
}
});

View File

@ -1,9 +1,9 @@
import { memo, useCallback, useState } from "react";
import { Link, useLocation, useParams } from "react-router-dom";
import { ChevronRight, Home, Settings } from "lucide-react";
import { useProjectLayout } from "@/app/project/layout";
import { useProjectsQuery } from "@/hooks/useProjectsQuery";
import { useOptionalRoom } from "@/contexts/room";
import { useState } from "react";
import { RoomSettingsModal } from "@/app/project/channel/RoomSettingsModal";
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@ -87,17 +87,17 @@ const TOOLBAR_ICONS = [
},
];
export function Header() {
export const Header = memo(function Header() {
const location = useLocation();
const segments = useBreadcrumbs();
const { showMembers, setShowMembers } = useProjectLayout();
const [showSettings, setShowSettings] = useState(false);
const roomContext = useOptionalRoom();
const handleCopy = (e: React.MouseEvent, text: string) => {
const handleCopy = useCallback((e: React.MouseEvent, text: string) => {
e.preventDefault();
navigator.clipboard.writeText(text);
};
}, []);
return (
<>
@ -219,4 +219,4 @@ export function Header() {
<RoomSettingsModal open={showSettings} onOpenChange={setShowSettings} />
</>
);
}
});

View File

@ -1,7 +1,7 @@
import { useEffect, useState, useMemo } from 'react';
import { memo, useEffect, useState, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { Avatar } from '@/components/channel/Avatar';
import { useRoom } from '@/contexts/room';
import { useOptionalRoom } from '@/contexts/room';
import { projectMembersGrouped, projectInfo, projectPresence } from '@/client/api';
import type { MemberGroup } from '@/client/model';
import type { PresenceStatus } from '@/client/model';
@ -19,17 +19,9 @@ const ROLE_COLORS: Record<string, string> = {
member: 'var(--role-blue)',
};
function useRoomSafe() {
try {
return useRoom();
} catch {
return null;
}
}
export function MemberList() {
export const MemberList = memo(function MemberList() {
const { projectName } = useParams<{ projectName: string }>();
const room = useRoomSafe();
const room = useOptionalRoom();
const [groups, setGroups] = useState<MemberGroup[]>([]);
const [total, setTotal] = useState(0);
@ -217,4 +209,4 @@ export function MemberList() {
})}
</div>
);
}
});

View File

@ -1,3 +1,4 @@
import { memo, useCallback, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import {useCurrentUserQuery, useLogoutMutation} from "@/hooks/useAuth";
import {useProjectsQuery} from "@/hooks/useProjectsQuery";
@ -5,7 +6,6 @@ import {Popover, PopoverContent, PopoverTrigger,} from "@/components/ui/popover"
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
import {useSettingsModal} from "@/components/settings/SettingsModalContext";
import {LogOut, Settings, Home, Plus} from "lucide-react";
import { useState } from "react";
import { CreateProjectModal } from "@/app/me/components/CreateProjectModal";
const AVATAR_COLORS = [
@ -22,7 +22,7 @@ function hashColor(str: string): string {
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
}
export function ServerIconRail() {
export const ServerIconRail = memo(function ServerIconRail() {
const location = useLocation();
const navigate = useNavigate();
const { data: user } = useCurrentUserQuery();
@ -36,12 +36,12 @@ export function ServerIconRail() {
const pathParts = location.pathname.split("/").filter(Boolean);
const currentProjectName = pathParts.length > 0 ? pathParts[0] : "";
const handleLogout = async () => {
const handleLogout = useCallback(async () => {
try {
await logoutMutation.mutateAsync();
} catch { }
navigate("/auth/login", { replace: true });
};
}, [logoutMutation, navigate]);
return (
<div
@ -178,4 +178,4 @@ export function ServerIconRail() {
</Popover>
</div>
);
}
});

View File

@ -1,16 +1,20 @@
import { memo } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeRaw from "rehype-raw";
import rehypeSanitize from "rehype-sanitize";
interface MarkdownRendererProps {
content: string;
className?: string;
}
export function MarkdownRenderer({ content, className }: MarkdownRendererProps) {
export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className }: MarkdownRendererProps) {
return (
<div className={className}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
components={{
a: ({ href, children, ...props }) => (
<a
@ -32,4 +36,4 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps)
</ReactMarkdown>
</div>
);
}
});

View File

@ -4,49 +4,52 @@ const MOBILE_BREAKPOINT = 768
const TABLET_BREAKPOINT = 1024
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
const [isMobile, setIsMobile] = React.useState<boolean>(
() => window.innerWidth < MOBILE_BREAKPOINT,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile
return isMobile;
}
export function useIsTablet() {
const [isTablet, setIsTablet] = React.useState<boolean | undefined>(undefined)
const [isTablet, setIsTablet] = React.useState<boolean>(
() => window.innerWidth >= MOBILE_BREAKPOINT && window.innerWidth < TABLET_BREAKPOINT,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${TABLET_BREAKPOINT - 1}px)`)
const mql = window.matchMedia(`(max-width: ${TABLET_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsTablet(window.innerWidth >= MOBILE_BREAKPOINT && window.innerWidth < TABLET_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsTablet(window.innerWidth >= MOBILE_BREAKPOINT && window.innerWidth < TABLET_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
setIsTablet(window.innerWidth >= MOBILE_BREAKPOINT && window.innerWidth < TABLET_BREAKPOINT);
};
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isTablet
return isTablet;
}
export function useIsDesktop() {
const [isDesktop, setIsDesktop] = React.useState<boolean | undefined>(undefined)
const [isDesktop, setIsDesktop] = React.useState<boolean>(
() => window.innerWidth >= TABLET_BREAKPOINT,
);
React.useEffect(() => {
const mql = window.matchMedia(`(min-width: ${TABLET_BREAKPOINT}px)`)
const mql = window.matchMedia(`(min-width: ${TABLET_BREAKPOINT}px)`);
const onChange = () => {
setIsDesktop(window.innerWidth >= TABLET_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsDesktop(window.innerWidth >= TABLET_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
setIsDesktop(window.innerWidth >= TABLET_BREAKPOINT);
};
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isDesktop
return isDesktop;
}

View File

@ -1,6 +1,6 @@
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query";
import { skillList, skillGet, skillCreate } from "@/client/api";
import type { SkillResponse, CreateSkillRequest } from "@/client/model";
import { skillList, skillGet, skillCreate, skillUpdate, skillDelete, skillScan } from "@/client/api";
import type { SkillResponse, CreateSkillRequest, UpdateSkillRequest } from "@/client/model";
const QUERY_KEY = "skills";
@ -51,3 +51,47 @@ export function useCreateSkillMutation(projectName: string | undefined) {
},
});
}
export function useUpdateSkillMutation(projectName: string | undefined) {
const invalidate = useInvalidateSkills();
return useMutation({
mutationFn: async ({ slug, req }: { slug: string; req: UpdateSkillRequest }) => {
if (!projectName) throw new Error("No project selected");
const res = await skillUpdate(projectName, slug, req);
return res.data?.data;
},
onSuccess: () => {
if (projectName) invalidate(projectName);
},
});
}
export function useDeleteSkillMutation(projectName: string | undefined) {
const invalidate = useInvalidateSkills();
return useMutation({
mutationFn: async (slug: string) => {
if (!projectName) throw new Error("No project selected");
await skillDelete(projectName, slug);
},
onSuccess: () => {
if (projectName) invalidate(projectName);
},
});
}
export function useScanSkillsMutation(projectName: string | undefined) {
const invalidate = useInvalidateSkills();
return useMutation({
mutationFn: async () => {
if (!projectName) throw new Error("No project selected");
const res = await skillScan(projectName);
return res.data?.data;
},
onSuccess: () => {
if (projectName) invalidate(projectName);
},
});
}