fix(frontend): Discord UI polish, room streaming and kanban improvements

- DiscordChatPanel: update threading layout, message list padding
- DiscordMemberList: role colors, online status indicators
- RoomThreadPanel, MessageBubble, MessageInput: UI refinements
- IMEditor: editor state improvements
- KanbanCard, KanbanColumn: card layout, column styling
- room-context.tsx, room-ws-client.ts, ws-protocol.ts: streaming
- pull-request-detail.tsx: PR review improvements
- Add RoomPinPanel component
This commit is contained in:
ZhenYi 2026-04-21 22:31:48 +08:00
parent aeb765d2ac
commit 4d3afc5e71
14 changed files with 558 additions and 32 deletions

View File

@ -1,4 +1,4 @@
import { pullRequestClose, pullRequestGet, pullRequestReopen } from "@/client";
import { generatePrDescription, pullRequestClose, pullRequestGet, pullRequestReopen, pullRequestUpdate, triggerCodeReview } from "@/client";
import { RepoHeader } from "@/components/repository/header";
import { PRConversation } from "@/components/repository/PRConversation";
import { PRDiffViewer } from "@/components/repository/PRDiffViewer";
@ -7,7 +7,8 @@ import { PRCommitList } from "@/components/repository/PRCommitList";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Calendar, Check, GitBranch, GitMerge, GitPullRequest, Loader2, X } from "lucide-react";
import { Calendar, Check, GitBranch, GitMerge, GitPullRequest, Loader2, Sparkles, X } from "lucide-react";
import { useState } from "react";
import { useParams } from "react-router-dom";
import { toast } from "sonner";
@ -45,6 +46,8 @@ export const RepoPullRequestDetail = () => {
}>();
const queryClient = useQueryClient();
const [generatedBody, setGeneratedBody] = useState<string | null>(null);
const { data: pr, isLoading, error } = useQuery({
queryKey: ["pull-request", namespace, repoName, prNumber],
queryFn: async () => {
@ -90,6 +93,60 @@ export const RepoPullRequestDetail = () => {
},
});
const generateDescMutation = useMutation({
mutationFn: async () => {
if (!namespace || !repoName || !prNumber) return;
const resp = await generatePrDescription({
path: { namespace, repo: repoName },
body: { pr_number: parseInt(prNumber, 10) },
});
return resp.data?.markdown_body ?? null;
},
onSuccess: (body) => {
if (body) {
setGeneratedBody(body);
toast.success("Description generated");
}
},
onError: () => {
toast.error("Failed to generate description");
},
});
const triggerReviewMutation = useMutation({
mutationFn: async () => {
if (!namespace || !repoName || !prNumber) return;
await triggerCodeReview({
path: { namespace, repo: repoName },
body: { pr_number: parseInt(prNumber, 10) },
});
},
onSuccess: () => {
toast.success("AI review requested");
},
onError: () => {
toast.error("Failed to request AI review");
},
});
const applyDescMutation = useMutation({
mutationFn: async (body: string) => {
if (!namespace || !repoName || !prNumber) return;
await pullRequestUpdate({
path: { namespace, repo: repoName, number: parseInt(prNumber, 10) },
body: { body },
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["pull-request", namespace, repoName, prNumber] });
setGeneratedBody(null);
toast.success("Description updated");
},
onError: () => {
toast.error("Failed to update description");
},
});
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
@ -156,6 +213,20 @@ export const RepoPullRequestDetail = () => {
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => triggerReviewMutation.mutate()}
disabled={triggerReviewMutation.isPending || !isOpen}
title="Request AI code review"
>
{triggerReviewMutation.isPending ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Sparkles className="h-4 w-4 mr-1" />
)}
AI Review
</Button>
{isOpen && (
<Button
variant="outline"
@ -192,12 +263,58 @@ export const RepoPullRequestDetail = () => {
</div>
{/* Description */}
{pr.body && (
<div className="mt-4">
<h3 className="text-sm font-medium mb-2">Description</h3>
<div className="text-sm text-muted-foreground whitespace-pre-wrap">{pr.body}</div>
<div className="mt-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium">Description</h3>
<Button
variant="ghost"
size="sm"
onClick={() => generateDescMutation.mutate()}
disabled={generateDescMutation.isPending || !isOpen}
className="h-7 text-xs gap-1"
>
{generateDescMutation.isPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Sparkles className="h-3 w-3" />
)}
Generate with AI
</Button>
</div>
)}
{generatedBody ? (
<div className="space-y-2">
<div className="text-sm bg-primary/5 border border-primary/20 rounded-md p-3 whitespace-pre-wrap text-muted-foreground">
{generatedBody}
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => applyDescMutation.mutate(generatedBody)}
disabled={applyDescMutation.isPending}
className="h-7 text-xs"
>
{applyDescMutation.isPending ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : null}
Apply to PR
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setGeneratedBody(null)}
className="h-7 text-xs"
>
Discard
</Button>
</div>
</div>
) : pr.body ? (
<div className="text-sm text-muted-foreground whitespace-pre-wrap">{pr.body}</div>
) : (
<div className="text-sm text-muted-foreground italic">
No description provided. Click "Generate with AI" to create one.
</div>
)}
</div>
</div>
{/* Merge box + branch info */}

View File

@ -1,11 +1,13 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { AlignLeft, Calendar, ChevronUp, ChevronRight, ChevronDown } from 'lucide-react';
import type { CardResponse } from '@/client';
import type { CardResponse, MemberInfo } from '@/client';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
interface KanbanCardProps {
card: CardResponse;
onEdit?: (card: CardResponse) => void;
members?: MemberInfo[];
}
const PRIORITY = {
@ -32,7 +34,7 @@ const PRIORITY = {
},
} as const;
export function KanbanCard({ card, onEdit }: KanbanCardProps) {
export function KanbanCard({ card, onEdit, members = [] }: KanbanCardProps) {
const {
attributes,
listeners,
@ -42,6 +44,10 @@ export function KanbanCard({ card, onEdit }: KanbanCardProps) {
isDragging,
} = useSortable({ id: card.id, data: { type: 'Card', card } });
const assignee = card.assignee_id
? members.find((m) => m.user_id === card.assignee_id)
: undefined;
const style = {
transform: CSS.Transform.toString(transform),
transition,
@ -108,11 +114,17 @@ export function KanbanCard({ card, onEdit }: KanbanCardProps) {
{formatDate(card.due_date)}
</span>
)}
{card.assignee_id && (
{assignee && (
<span className="inline-flex items-center gap-1">
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM2 14a6 6 0 0 1 12 0H2Z"/>
</svg>
<Avatar className="h-3.5 w-3.5">
{assignee.avatar_url ? (
<AvatarImage src={assignee.avatar_url} className="h-3.5 w-3.5" />
) : null}
<AvatarFallback className="h-3.5 w-3.5 text-[9px]">
{(assignee.display_name ?? assignee.username).charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="leading-none">{assignee.display_name ?? assignee.username}</span>
</span>
)}
</div>

View File

@ -2,8 +2,9 @@ import { useDroppable } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { useState } from 'react';
import { MoreHorizontal, Pencil, Plus, Trash2, ChevronUp, ChevronRight, ChevronDown } from 'lucide-react';
import type { CardResponse, ColumnWithCardsResponse } from '@/client';
import { cardCreate, cardDelete, cardUpdate, columnDelete, columnUpdate } from '@/client';
import { useQuery } from '@tanstack/react-query';
import type { CardResponse, ColumnWithCardsResponse, MemberInfo } from '@/client';
import { cardCreate, cardDelete, cardUpdate, columnDelete, columnUpdate, projectMembers } from '@/client';
import { useProject } from '@/contexts';
import {
Dialog,
@ -29,12 +30,14 @@ function CardDialog({
position: _position,
onClose,
onSaved,
members,
}: {
card?: CardResponse;
columnId: string;
position: number;
onClose: () => void;
onSaved: () => void;
members: MemberInfo[];
}) {
const { project } = useProject();
const [title, setTitle] = useState(card?.title ?? '');
@ -42,6 +45,9 @@ function CardDialog({
const [priority, setPriority] = useState<'high' | 'medium' | 'low' | ''>(
(card?.priority as 'high' | 'medium' | 'low') ?? '',
);
const [assigneeId, setAssigneeId] = useState<string>(card?.assignee_id ?? '');
const [dueDate, setDueDate] = useState<string>(card?.due_date ? card.due_date.slice(0, 10) : '');
const [issueId, setIssueId] = useState<string>(card?.issue_id ? String(card.issue_id) : '');
const [loading, setLoading] = useState(false);
const isEdit = !!card;
@ -50,6 +56,7 @@ function CardDialog({
if (!project?.name || !title.trim()) return;
setLoading(true);
try {
const parsedIssueId = issueId ? parseInt(issueId, 10) : undefined;
if (isEdit) {
await cardUpdate({
path: { project_name: project.name, card_id: card.id },
@ -57,12 +64,22 @@ function CardDialog({
title: title.trim(),
description: description.trim() || undefined,
priority: priority || undefined,
assignee_id: assigneeId || undefined,
due_date: dueDate ? dueDate + 'T00:00:00Z' : undefined,
},
});
} else {
await cardCreate({
path: { project_name: project.name },
body: { title: title.trim(), column_id: columnId },
body: {
title: title.trim(),
column_id: columnId,
description: description.trim() || undefined,
priority: priority || undefined,
assignee_id: assigneeId || undefined,
due_date: dueDate ? dueDate + 'T00:00:00Z' : undefined,
issue_id: parsedIssueId || undefined,
},
});
}
onSaved();
@ -135,6 +152,54 @@ function CardDialog({
/>
</div>
{/* Assignee */}
<div className="space-y-1.5">
<Label className="text-[12px] font-semibold uppercase tracking-[0.08em] text-[#71717A]">
Assignee
</Label>
<select
value={assigneeId}
onChange={(e) => setAssigneeId(e.target.value)}
className="flex h-10 w-full rounded-md border border-[#E4E4E7] bg-white px-3 py-2 text-[13px] text-[#27272A] focus:border-[#D4D4D8] focus:outline-none focus:ring-1 focus:ring-[#D4D4D8] cursor-pointer"
>
<option value="">Unassigned</option>
{members.map((m) => (
<option key={m.user_id} value={m.user_id}>
{m.display_name ?? m.username}
</option>
))}
</select>
</div>
{/* Due Date */}
<div className="space-y-1.5">
<Label className="text-[12px] font-semibold uppercase tracking-[0.08em] text-[#71717A]">
Due Date
</Label>
<Input
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="h-10 border-[#E4E4E7] focus:border-[#D4D4D8] text-[13px]"
/>
</div>
{/* Issue ID — only shown when creating (API doesn't support update) */}
{!isEdit && (
<div className="space-y-1.5">
<Label className="text-[12px] font-semibold uppercase tracking-[0.08em] text-[#71717A]">
Linked Issue ID
</Label>
<Input
type="number"
value={issueId}
onChange={(e) => setIssueId(e.target.value)}
placeholder="e.g. 42"
className="h-10 border-[#E4E4E7] focus:border-[#D4D4D8] text-[13px]"
/>
</div>
)}
{/* Priority */}
<div className="space-y-1.5">
<Label className="text-[12px] font-semibold uppercase tracking-[0.08em] text-[#71717A]">
@ -262,6 +327,17 @@ export function KanbanColumn({ columnData, onCardAdded }: KanbanColumnProps) {
const [newColName, setNewColName] = useState(column.name);
const [renaming, setRenaming] = useState(false);
const { data: membersData } = useQuery({
queryKey: ['projectMembers', project?.name],
queryFn: async () => {
if (!project?.name) return { members: [] as MemberInfo[] };
const resp = await projectMembers({ path: { project_name: project.name } });
return resp.data?.data ?? { members: [] as MemberInfo[] };
},
enabled: !!project?.name,
});
const members = membersData?.members ?? [];
const { setNodeRef, isOver } = useDroppable({
id: column.id,
data: { type: 'Column', column },
@ -358,6 +434,7 @@ export function KanbanColumn({ columnData, onCardAdded }: KanbanColumnProps) {
key={card.id}
card={card}
onEdit={setEditingCard}
members={members}
/>
))}
</SortableContext>
@ -386,6 +463,7 @@ export function KanbanColumn({ columnData, onCardAdded }: KanbanColumnProps) {
position={cards.findIndex((c) => c.id === editingCard.id)}
onClose={() => setEditingCard(undefined)}
onSaved={onCardAdded ?? (() => {})}
members={members}
/>
)}
@ -396,6 +474,7 @@ export function KanbanColumn({ columnData, onCardAdded }: KanbanColumnProps) {
position={cards.length}
onClose={() => setAddingCard(false)}
onSaved={onCardAdded ?? (() => {})}
members={members}
/>
)}

View File

@ -29,6 +29,7 @@ import { RoomThreadPanel } from './RoomThreadPanel';
import { RoomSettingsPanel } from './RoomSettingsPanel';
import { DiscordMemberList } from './DiscordMemberList';
import { RoomMessageSearch } from './RoomMessageSearch';
import { RoomPinPanel } from './RoomPinPanel';
import { useRoom } from '@/contexts';
// ─── Main Panel ──────────────────────────────────────────────────────────
@ -57,6 +58,7 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
threads,
refreshThreads,
roomAiConfigs,
presence,
} = useRoom();
const messagesEndRef = useRef<HTMLDivElement>(null);
@ -71,6 +73,7 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
const [showMentions, setShowMentions] = useState(false);
const [showMemberList, setShowMemberList] = useState(false);
const [showSearch, setShowSearch] = useState(false);
const [showPins, setShowPins] = useState(false);
const [activeThread, setActiveThread] = useState<{ thread: RoomThreadResponse; parentMessage: MessageWithMeta } | null>(null);
const [isUpdatingRoom, setIsUpdatingRoom] = useState(false);
@ -169,6 +172,12 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
setShowSearch(false);
}, []);
const handleJumpToMessage = useCallback((messageId: string) => {
const el = document.getElementById(`msg-${messageId}`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
setShowPins(false);
}, []);
const handleUpdateRoom = useCallback(
async (name: string, isPublic: boolean) => {
setIsUpdatingRoom(true);
@ -196,6 +205,7 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
setShowSettings(false);
setShowMentions(false);
setShowSearch(false);
setShowPins(false);
setActiveThread(null);
}, [room.id]);
@ -264,7 +274,11 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
<button
className="flex h-8 w-8 items-center justify-center rounded-md transition-colors"
style={{ color: 'var(--room-text-muted)' }}
style={{
color: showPins ? 'var(--room-accent)' : 'var(--room-text-muted)',
background: showPins ? 'var(--room-channel-active)' : 'transparent',
}}
onClick={() => { setShowPins(v => !v); setShowSettings(false); setShowMentions(false); setShowMemberList(false); }}
title="Pinned messages"
>
<Pin className="h-4 w-4" />
@ -353,6 +367,7 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
membersLoading={membersLoading}
onMemberClick={handleMemberClick}
aiConfigs={roomAiConfigs}
presence={presence}
/>
)}
</div>
@ -409,6 +424,17 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
/>
)}
{showPins && (
<RoomPinPanel
roomId={room.id}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
messages={messages as any}
members={members}
onClose={() => setShowPins(false)}
onJumpToMessage={handleJumpToMessage}
/>
)}
<RoomMessageEditDialog
open={editDialogOpen}
onOpenChange={setEditDialogOpen}

View File

@ -5,6 +5,7 @@ import type { RoomMemberResponse } from '@/client';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Loader2, Shield, UserRound, Bot } from 'lucide-react';
import { useTheme } from '@/contexts';
import type { PresenceMap } from '@/contexts/room-context';
import { cn } from '@/lib/utils';
import type { RoomAiConfig } from '@/contexts';
@ -14,6 +15,7 @@ interface DiscordMemberListProps {
onMemberClick?: (member: RoomMemberResponse) => void;
onMemberMention?: (id: string, label: string) => void;
aiConfigs?: RoomAiConfig[];
presence?: PresenceMap;
}
// Role color mapping — AI Studio clean palette
@ -25,16 +27,41 @@ const ROLE_COLORS: Record<string, string> = {
guest: '#9ca3af',
};
// Presence dot colors
const PRESENCE_COLORS: Record<string, string> = {
online: '#3ba55c',
away: '#faa61a',
dnd: '#ed4245',
offline: '#747f8d',
};
function getRoleColor(role: string): string {
return ROLE_COLORS[role] ?? ROLE_COLORS['member'];
}
function PresenceDot({ status }: { status: string }) {
const color = PRESENCE_COLORS[status] ?? PRESENCE_COLORS['offline'];
return (
<span
className="absolute bottom-0 right-0 rounded-full border-2"
style={{
background: color,
width: 10,
height: 10,
borderColor: 'var(--room-sidebar)',
}}
/>
);
}
function MemberItem({
member,
onClick,
presenceStatus,
}: {
member: RoomMemberResponse;
onClick?: (member: RoomMemberResponse) => void;
presenceStatus?: string;
}) {
const { resolvedTheme } = useTheme();
const displayName = (member.user_info as { username?: string } | null | undefined)?.username ?? member.user.slice(0, 8);
@ -47,7 +74,7 @@ function MemberItem({
onClick={() => onClick?.(member)}
title={`@${displayName}${member.role ?? 'member'}`}
>
<div className="discord-member-avatar-wrap">
<div className="discord-member-avatar-wrap relative">
<Avatar className="h-8 w-8">
{member.user_info?.avatar_url ? (
<AvatarImage src={member.user_info.avatar_url} alt={displayName} />
@ -59,7 +86,7 @@ function MemberItem({
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="discord-member-status-dot online" />
<PresenceDot status={presenceStatus ?? 'offline'} />
</div>
<div className="flex-1 min-w-0">
@ -108,6 +135,7 @@ export const DiscordMemberList = memo(function DiscordMemberList({
membersLoading,
onMemberClick,
aiConfigs = [],
presence = {},
}: DiscordMemberListProps) {
useTheme(); // theme consumed via CSS variables
const { admins, moderators, regularMembers } = useMemo(() => {
@ -199,6 +227,7 @@ export const DiscordMemberList = memo(function DiscordMemberList({
key={member.user}
member={member}
onClick={onMemberClick}
presenceStatus={presence[member.user]}
/>
))}
</MemberSection>
@ -214,6 +243,7 @@ export const DiscordMemberList = memo(function DiscordMemberList({
key={member.user}
member={member}
onClick={onMemberClick}
presenceStatus={presence[member.user]}
/>
))}
</MemberSection>

View File

@ -0,0 +1,183 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Pin, X, Loader2 } from 'lucide-react';
import { pinList, pinRemove, type RoomPinResponse, type RoomMemberResponse } from '@/client';
import { toast } from 'sonner';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { getSenderDisplayName } from './sender';
import { formatMessageTime } from './shared/formatters';
interface RoomPinPanelProps {
roomId: string;
messages: Array<{
id: string;
content: string;
sender_id: string;
sender_type: string;
send_at: string;
}>;
members: RoomMemberResponse[];
onClose: () => void;
onJumpToMessage: (messageId: string) => void;
}
export function RoomPinPanel({ roomId, messages, members, onClose, onJumpToMessage }: RoomPinPanelProps) {
const queryClient = useQueryClient();
const { data: pins, isLoading } = useQuery({
queryKey: ['roomPins', roomId],
queryFn: async () => {
const resp = await pinList({ path: { room_id: roomId } });
return resp.data?.data ?? ([] as RoomPinResponse[]);
},
});
const unpinMutation = useMutation({
mutationFn: async (messageId: string) => {
await pinRemove({ path: { room_id: roomId, message_id: messageId } });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roomPins', roomId] });
toast.success('Message unpinned');
},
onError: () => {
toast.error('Failed to unpin message');
},
});
// Build a map of message_id -> message content from local messages
const messageMap = new Map(messages.map(m => [m.id, m]));
return (
<aside
className="flex flex-col border-l z-20 animate-in slide-in-from-right duration-200"
style={{
width: 380,
borderColor: 'var(--room-border)',
background: 'var(--room-bg)',
top: '48px',
bottom: 0,
right: 0,
position: 'absolute',
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3 border-b shrink-0"
style={{ borderColor: 'var(--room-border)' }}
>
<div className="flex items-center gap-2">
<Pin className="h-4 w-4" style={{ color: 'var(--room-accent)' }} />
<span className="text-sm font-semibold" style={{ color: 'var(--room-text)' }}>
Pinned Messages
</span>
{pins && (
<span
className="text-[11px] px-1.5 py-0.5 rounded-full"
style={{ background: 'var(--room-channel-active)', color: 'var(--room-text-muted)' }}
>
{pins.length}
</span>
)}
</div>
<button
onClick={onClose}
className="flex h-6 w-6 items-center justify-center rounded-md transition-colors cursor-pointer bg-transparent border-0"
style={{ color: 'var(--room-text-muted)' }}
>
<X className="h-4 w-4" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center h-24">
<Loader2 className="h-5 w-5 animate-spin" style={{ color: 'var(--room-text-muted)' }} />
</div>
) : pins && pins.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 gap-2">
<Pin className="h-8 w-8 opacity-30" style={{ color: 'var(--room-text-muted)' }} />
<p className="text-sm" style={{ color: 'var(--room-text-muted)' }}>No pinned messages</p>
</div>
) : (
<div className="divide-y" style={{ borderColor: 'var(--room-border)' }}>
{pins?.map((pin) => {
const localMsg = messageMap.get(pin.message);
return (
<div
key={pin.message}
className="group px-4 py-3 hover:bg-[var(--room-channel-active)] transition-colors cursor-pointer"
onClick={() => {
onJumpToMessage(pin.message);
onClose();
}}
>
{/* Sender row */}
<div className="flex items-center gap-2 mb-1">
<Avatar className="h-5 w-5">
{(() => {
const member = members.find(m => m.user === pin.pinned_by);
return (
<>
{member?.user_info?.avatar_url ? (
<AvatarImage src={member.user_info.avatar_url} className="h-5 w-5" />
) : null}
<AvatarFallback className="h-5 w-5 text-[10px]">
{(member?.user_info?.username ?? pin.pinned_by).charAt(0).toUpperCase()}
</AvatarFallback>
</>
);
})()}
</Avatar>
<span className="text-xs font-medium" style={{ color: 'var(--room-text)' }}>
{(() => {
const member = members.find(m => m.user === pin.pinned_by);
return member?.user_info?.username ?? pin.pinned_by;
})()}
</span>
<span className="text-[11px]" style={{ color: 'var(--room-text-muted)' }}>
pinned {formatMessageTime(pin.pinned_at).split(':').slice(0, 2).join(':')}
</span>
<button
onClick={(e) => {
e.stopPropagation();
unpinMutation.mutate(pin.message);
}}
className="ml-auto opacity-0 group-hover:opacity-100 transition-opacity flex h-5 w-5 items-center justify-center rounded text-[var(--room-text-muted)] hover:text-red-400 cursor-pointer bg-transparent border-0"
title="Unpin"
>
<X className="h-3 w-3" />
</button>
</div>
{/* Message preview */}
<p
className="text-[13px] line-clamp-2 pl-7"
style={{ color: 'var(--room-text-secondary)' }}
>
{localMsg
? localMsg.content.length > 120
? localMsg.content.slice(0, 120) + '…'
: localMsg.content
: `[Message #${pin.message}]`}
</p>
{/* Original sender */}
{localMsg && (
<div className="mt-0.5 pl-7">
<span className="text-[11px]" style={{ color: 'var(--room-text-muted)' }}>
{getSenderDisplayName(localMsg as any)} {formatMessageTime(localMsg.send_at)}
</span>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</aside>
);
}

View File

@ -189,20 +189,24 @@ export function ThreadIndicator({ threadId, onClick }: ThreadIndicatorProps) {
// Find thread info from threads list
const threadInfo = threads.find(t => t.id === threadId);
// Count replies - we'll estimate based on last_message_preview presence
// A proper implementation would need a reply_count field from backend
const hasReplies = !!threadInfo?.last_message_preview;
// Count replies — participants array gives us a count
const participantCount = Array.isArray(threadInfo?.participants)
? threadInfo.participants.length
: 0;
return (
<button
type="button"
onClick={onClick}
className="mt-1 flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-muted/50 hover:text-foreground transition-colors"
className="mt-1 flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-[var(--room-accent)] hover:bg-[var(--room-channel-active)] transition-colors"
>
<MessageSquare className="h-3.5 w-3.5" />
<span>Thread</span>
{hasReplies && (
<span className="text-[10px]"> has replies</span>
{participantCount > 0 && (
<span className="flex items-center gap-1 text-[11px]" style={{ color: 'var(--room-text-muted)' }}>
<span className="font-medium">{participantCount}</span>
<span>replies</span>
</span>
)}
</button>
);

View File

@ -83,7 +83,7 @@ export const MessageBubble = memo(function MessageBubble({
const isEdited = !!message.edited_at;
useTheme();
const { user } = useUser();
const { wsClient, streamingMessages, members } = useRoom();
const { wsClient, streamingMessages, members, pins, pinMessage, unpinMessage } = useRoom();
const avatarUrl = (() => {
if (message.sender_type === 'ai') return undefined;
const member = members.find(m => m.user === message.sender_id);
@ -93,6 +93,7 @@ export const MessageBubble = memo(function MessageBubble({
const isRevoked = !!message.revoked;
const isFailed = message.isOptimisticError === true;
const isPending = message.isOptimistic === true || message.id.startsWith('temp-') || message.id.startsWith('optimistic-');
const isPinned = pins.some(p => p.message === message.id);
const displayContent = isStreaming && streamingMessages?.has(message.id)
? streamingMessages.get(message.id)!
@ -378,6 +379,20 @@ export const MessageBubble = memo(function MessageBubble({
style={{ background: 'var(--card)', border: '1px solid var(--room-border)', borderRadius: 6 }}
>
<ReactionPicker onReact={handleReaction} />
<button
className="flex h-7 w-7 items-center justify-center rounded-md transition-colors"
style={{ color: isPinned ? 'var(--room-accent)' : 'var(--room-text-muted)' }}
onClick={() => {
if (isPinned) {
unpinMessage?.(message.id);
} else {
pinMessage?.(message.id);
}
}}
title={isPinned ? 'Unpin' : 'Pin'}
>
📌
</button>
{onReply && (
<button
className="flex h-7 w-7 items-center justify-center rounded-md transition-colors"

View File

@ -45,6 +45,14 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
getAttachmentIds: () => innerEditorRef.current?.getAttachmentIds() ?? [],
}), []);
// Slash commands available in the editor
const SLASH_COMMANDS = [
{ id: 'ai', label: '/ai', description: 'Ask AI a question', type: 'command' as const },
{ id: 'remind', label: '/remind', description: 'Set a reminder (e.g. /remind 10m Check CI)', type: 'command' as const },
{ id: 'poll', label: '/poll', description: 'Create a poll (e.g. /poll "Question?" A B C)', type: 'command' as const },
{ id: 'code-review', label: '/code-review', description: 'Request AI code review', type: 'command' as const },
];
// Transform room data into MentionItems — memoized to prevent IMEditor re-creation
const mentionItems = useMemo(() => ({
users: members.map((m) => ({
@ -55,7 +63,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
})),
channels: [] as { id: string; label: string; type: 'channel'; avatar?: string }[],
ai: [] as { id: string; label: string; type: 'ai'; avatar?: string }[],
commands: [] as { id: string; label: string; type: 'command'; avatar?: string }[],
commands: SLASH_COMMANDS,
}), [members]);
// File upload handler — POST to /rooms/{room_id}/upload

View File

@ -269,9 +269,14 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
const selectMention = useCallback((item: MentionItem) => {
if (!editor) return;
// Use backend-parseable format: @[type:id:label]
const mentionStr = `@[${item.type}:${item.id}:${item.label}] `;
editor.chain().focus().insertContent(mentionStr).run();
if (item.type === 'command') {
// Replace the / prefix with the full command label
editor.chain().focus().insertContent(item.label + ' ').run();
} else {
// Use backend-parseable format: @[type:id:label]
const mentionStr = `@[${item.type}:${item.id}:${item.label}] `;
editor.chain().focus().insertContent(mentionStr).run();
}
setMentionOpen(false);
}, []);
@ -309,7 +314,7 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
let ts = from;
for (let i = from - 1; i >= 1; i--) {
const c = text[i - 1];
if (c === '@') {
if (c === '@' || c === '/') {
ts = i;
break;
}
@ -324,6 +329,22 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
setMentionIdx(0);
setMentionOpen(true);
if (wrapRef.current) {
const sel = window.getSelection();
if (sel?.rangeCount) {
const r = sel.getRangeAt(0).getBoundingClientRect();
const cr = wrapRef.current.getBoundingClientRect();
setMentionPos({top: r.bottom - cr.top + 6, left: Math.max(0, r.left - cr.left)});
}
}
} else if (q.startsWith('/') && q.length > 1) {
// Filter commands by query (e.g. "/ai" matches "ai")
const results = filterMentionItems(mentionItems.commands, q.slice(1));
setMentionQuery(q.slice(1));
setMentionItems2(results);
setMentionIdx(0);
setMentionOpen(true);
if (wrapRef.current) {
const sel = window.getSelection();
if (sel?.rangeCount) {

View File

@ -2,5 +2,5 @@ export { UserProvider, useUser } from './user-context';
export { ProjectProvider, useProject } from './project-context';
export { WorkspaceProvider, useWorkspace, tryUseWorkspace } from './workspace-context';
export { RepositoryContextProvider, useRepo, type RepoInfo } from './repository-context';
export { RoomProvider, useRoom, type RoomWithCategory, type MessageWithMeta, type UiMessage, type ReactionGroup, type RoomAiConfig } from './room-context';
export { RoomProvider, useRoom, type RoomWithCategory, type MessageWithMeta, type UiMessage, type ReactionGroup, type RoomAiConfig, type PresenceMap, type PresenceStatus } from './room-context';
export { ThemeProvider, useTheme } from './theme-context';

View File

@ -33,6 +33,9 @@ import { useUser } from '@/contexts';
export type { RoomWsStatus, RoomWsClient } from '@/lib/room-ws-client';
export type PresenceStatus = 'online' | 'away' | 'dnd' | 'offline';
export type PresenceMap = Record<string, PresenceStatus>; // keyed by user_id
export interface RoomAiConfig {
model: string;
modelName?: string;
@ -98,6 +101,7 @@ interface RoomContextValue {
wsClient: RoomWsClient | null;
connectWs: () => Promise<void>;
disconnectWs: () => void;
presence: PresenceMap;
rooms: RoomWithCategory[];
roomsLoading: boolean;
@ -406,6 +410,9 @@ export function RoomProvider({
const [threads, setThreads] = useState<RoomThreadResponse[]>([]);
// User presence map: user_id -> status
const [presence, setPresence] = useState<PresenceMap>({});
const [streamingContent, setStreamingContent] = useState<Map<string, string>>(new Map());
// Project repos for @repository: mention suggestions
@ -627,6 +634,10 @@ export function RoomProvider({
if (payload.room_id !== activeRoomIdRef.current) return;
setPins((prev) => prev.filter((p) => p.message !== payload.message_id));
},
onUserPresence: (payload) => {
if (payload.room_id !== activeRoomIdRef.current) return;
setPresence((prev) => ({ ...prev, [payload.user_id]: payload.status }));
},
onStatusChange: (status) => {
setWsStatus(status);
if (status === 'closed' || status === 'error') {
@ -1260,6 +1271,7 @@ export function RoomProvider({
refreshPins: fetchPins,
pinMessage,
unpinMessage,
presence,
threads,
refreshThreads: fetchThreads,
createThread,
@ -1312,6 +1324,7 @@ export function RoomProvider({
fetchPins,
pinMessage,
unpinMessage,
presence,
threads,
fetchThreads,
createThread,

View File

@ -28,6 +28,7 @@ import type {
SubscribeData,
UserInfo,
RoomReactionUpdatedPayload,
UserPresencePayload,
} from './ws-protocol';
export type {
@ -51,6 +52,7 @@ export type {
MessageEditHistoryResponse,
UserInfo,
RoomReactionUpdatedPayload,
UserPresencePayload,
};
export interface WsTokenResponse {
@ -75,6 +77,7 @@ export interface RoomWsCallbacks {
onMessageRevoked?: (payload: import('./ws-protocol').MessageRevokedPayload) => void;
onMessagePinned?: (payload: import('./ws-protocol').MessagePinnedPayload) => void;
onMessageUnpinned?: (payload: import('./ws-protocol').MessageUnpinnedPayload) => void;
onUserPresence?: (payload: UserPresencePayload) => void;
onStatusChange?: (status: RoomWsStatus) => void;
onError?: (error: Error) => void;
/** Called each time the client sends a heartbeat ping */
@ -1022,6 +1025,14 @@ export class RoomWsClient {
room_id: event.room_id ?? '',
});
break;
case 'user.presence':
case 'user_presence':
this.callbacks.onUserPresence?.({
user_id: (event.data as { user_id?: string })?.user_id ?? '',
room_id: event.room_id ?? '',
status: ((event.data as { status?: string })?.status ?? 'offline') as 'online' | 'away' | 'dnd' | 'offline',
});
break;
default:
// Unknown event type - ignore silently
break;

View File

@ -154,6 +154,7 @@ export type WsEventPayload =
| { type: 'message_revoked'; data: MessageRevokedPayload }
| { type: 'message_pinned'; data: MessagePinnedPayload }
| { type: 'message_unpinned'; data: MessageUnpinnedPayload }
| { type: 'user_presence'; data: UserPresencePayload }
| { type: string; data: unknown }; // catch-all for unknown events
export interface RoomMessagePayload {
@ -316,6 +317,12 @@ export interface MessageUnpinnedPayload {
room_id: string;
}
export interface UserPresencePayload {
user_id: string;
room_id: string;
status: 'online' | 'away' | 'dnd' | 'offline';
}
export interface SearchResultData {
messages: RoomMessageResponse[];
total: number;