diff --git a/src/app/repository/pull-request-detail.tsx b/src/app/repository/pull-request-detail.tsx index 0b9234c..cdec1d2 100644 --- a/src/app/repository/pull-request-detail.tsx +++ b/src/app/repository/pull-request-detail.tsx @@ -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(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 (
@@ -156,6 +213,20 @@ export const RepoPullRequestDetail = () => {
+ {isOpen && (
{/* Description */} - {pr.body && ( -
-

Description

-
{pr.body}
+
+
+

Description

+
- )} + + {generatedBody ? ( +
+
+ {generatedBody} +
+
+ + +
+
+ ) : pr.body ? ( +
{pr.body}
+ ) : ( +
+ No description provided. Click "Generate with AI" to create one. +
+ )} +
{/* Merge box + branch info */} diff --git a/src/components/project/KanbanCard.tsx b/src/components/project/KanbanCard.tsx index e50285e..4a42184 100644 --- a/src/components/project/KanbanCard.tsx +++ b/src/components/project/KanbanCard.tsx @@ -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)} )} - {card.assignee_id && ( + {assignee && ( - - - + + {assignee.avatar_url ? ( + + ) : null} + + {(assignee.display_name ?? assignee.username).charAt(0).toUpperCase()} + + + {assignee.display_name ?? assignee.username} )} diff --git a/src/components/project/KanbanColumn.tsx b/src/components/project/KanbanColumn.tsx index 2b7f10e..eb04f27 100644 --- a/src/components/project/KanbanColumn.tsx +++ b/src/components/project/KanbanColumn.tsx @@ -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(card?.assignee_id ?? ''); + const [dueDate, setDueDate] = useState(card?.due_date ? card.due_date.slice(0, 10) : ''); + const [issueId, setIssueId] = useState(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({ /> + {/* Assignee */} +
+ + +
+ + {/* Due Date */} +
+ + setDueDate(e.target.value)} + className="h-10 border-[#E4E4E7] focus:border-[#D4D4D8] text-[13px]" + /> +
+ + {/* Issue ID — only shown when creating (API doesn't support update) */} + {!isEdit && ( +
+ + setIssueId(e.target.value)} + placeholder="e.g. 42" + className="h-10 border-[#E4E4E7] focus:border-[#D4D4D8] text-[13px]" + /> +
+ )} + {/* Priority */}
@@ -409,6 +424,17 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha /> )} + {showPins && ( + setShowPins(false)} + onJumpToMessage={handleJumpToMessage} + /> + )} + 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 = { guest: '#9ca3af', }; +// Presence dot colors +const PRESENCE_COLORS: Record = { + 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 ( + + ); +} + 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'}`} > -
+
{member.user_info?.avatar_url ? ( @@ -59,7 +86,7 @@ function MemberItem({ {displayName.slice(0, 2).toUpperCase()} - +
@@ -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]} /> ))} @@ -214,6 +243,7 @@ export const DiscordMemberList = memo(function DiscordMemberList({ key={member.user} member={member} onClick={onMemberClick} + presenceStatus={presence[member.user]} /> ))} diff --git a/src/components/room/RoomPinPanel.tsx b/src/components/room/RoomPinPanel.tsx new file mode 100644 index 0000000..d556392 --- /dev/null +++ b/src/components/room/RoomPinPanel.tsx @@ -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 ( + + ); +} diff --git a/src/components/room/RoomThreadPanel.tsx b/src/components/room/RoomThreadPanel.tsx index b00ee29..31ec80f 100644 --- a/src/components/room/RoomThreadPanel.tsx +++ b/src/components/room/RoomThreadPanel.tsx @@ -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 ( ); diff --git a/src/components/room/message/MessageBubble.tsx b/src/components/room/message/MessageBubble.tsx index 5f7174c..9eb7237 100644 --- a/src/components/room/message/MessageBubble.tsx +++ b/src/components/room/message/MessageBubble.tsx @@ -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 }} > + {onReply && (