From c308fc044d75d082aba1dd5a1d44aec5d0c98320 Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Thu, 14 May 2026 23:14:59 +0800 Subject: [PATCH] feat(project): enhance channel and issues pages Update ChannelPage with message list integration, enhance IssuesPage with drag-and-drop support, add NewIssuePage. --- src/app/project/channel/ChannelPage.tsx | 252 ++++++++++++++++++++---- src/app/project/issues/IssuesPage.tsx | 22 ++- src/app/project/issues/NewIssuePage.tsx | 12 ++ 3 files changed, 237 insertions(+), 49 deletions(-) diff --git a/src/app/project/channel/ChannelPage.tsx b/src/app/project/channel/ChannelPage.tsx index d59cbeb..5ea59ba 100644 --- a/src/app/project/channel/ChannelPage.tsx +++ b/src/app/project/channel/ChannelPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import { useParams } from 'react-router-dom'; -import { AlertCircle } from 'lucide-react'; +import { AlertCircle, MessageSquare, Pin, X } from 'lucide-react'; import { useWsConnected, getWsClient, @@ -8,17 +8,18 @@ import { import { useRoom, } from '@/contexts/room'; -import { useProjectLayout } from '@/app/project/layout'; +import { ProjectJoinBanner, useProjectLayout } from '@/app/project/layout'; import type { Message, ReactionGroup, Member, ThreadState } from '@/contexts/room'; import { ThreadPanel, + PinPanel, EditHistoryOverlay, MessageList, MessageInput, } from '@/components/channel'; import { MentionBottomSheet } from '@/components/channel/mention'; import type { MentionSelection, MentionEntityType } from '@/components/channel/mention/types'; -import { projectRepos, aiList, skillList } from '@/client/api'; +import { projectRepos, aiList, skillList, threadCreate, threadMessages } from '@/client/api'; function safeGetClient() { try { return getWsClient(); } catch { return null; } @@ -31,6 +32,8 @@ function ChannelPageInner() { wsStatus, currentRoom, members, + pinnedMessages, + threads, messages, isHistoryLoaded, isLoadingMore, @@ -38,11 +41,13 @@ function ChannelPageInner() { sendMessage, editMessage, revokeMessage, + removePin, + setThreads, typingUsers, } = useRoom(); const isConnected = useWsConnected(); - const { setCurrentRoomName } = useProjectLayout(); + const { isProjectPreview, setCurrentRoomName } = useProjectLayout(); // Sync room name to layout Header useEffect(() => { @@ -55,6 +60,7 @@ function ChannelPageInner() { const [replyToMessageId, setReplyToMessageId] = useState(null); const [emojiPickerMessageId, setEmojiPickerMessageId] = useState(null); const [activeThread, setActiveThread] = useState(null); + const [sidePanel, setSidePanel] = useState<'pins' | 'threads' | null>(null); const [editHistoryMessageId, setEditHistoryMessageId] = useState(null); const [uploading, setUploading] = useState(false); @@ -75,7 +81,7 @@ function ChannelPageInner() { // Fetch AI agents, repos, and skills in parallel when room opens useEffect(() => { - if (!roomIdParam) return; + if (!roomIdParam || isProjectPreview) return; const projectName = window.location.pathname.split('/')[1]; Promise.all([ @@ -100,7 +106,7 @@ function ChannelPageInner() { } }) .catch(() => {}); - }, [roomIdParam]); + }, [isProjectPreview, roomIdParam]); const inputRef = useRef(null); const typingTimeoutRef = useRef(null); @@ -113,10 +119,10 @@ function ChannelPageInner() { // Sync room name to layout Header useEffect(() => { - if (wsStatus === 'connected' && isConnected) { + if (!isProjectPreview && wsStatus === 'connected' && isConnected) { loadHistory(); } - }, [wsStatus, isConnected, loadHistory]); + }, [isProjectPreview, wsStatus, isConnected, loadHistory]); // Load older messages when scrolling to top const handleStartReached = useCallback(() => { @@ -137,6 +143,7 @@ function ChannelPageInner() { }, []); const handleInputChange = (e: React.ChangeEvent) => { + if (isProjectPreview) return; const currentValue = e.target.value; setInputValue(currentValue); @@ -200,7 +207,7 @@ function ChannelPageInner() { }; const handleSendMessage = () => { - if (!inputValue.trim()) return; + if (isProjectPreview || !inputValue.trim()) return; const content = resolveContent(inputValue); if (editingMessageId) { editMessage(editingMessageId, content); @@ -346,35 +353,98 @@ function ChannelPageInner() { // ── Thread ── - const openThread = useCallback( - async (msg: Message) => { - if (!roomIdParam || !msg.thread) return; + const normalizeThreadMessages = useCallback((parent: Message | null, threadMsgs: Message[]) => { + const map = new Map(); + if (parent) map.set(parent.id, parent); + for (const msg of threadMsgs) map.set(msg.id, msg); + return [...map.values()].sort((a, b) => a.seq - b.seq); + }, []); + + const openThreadByState = useCallback( + async (thread: ThreadState, parentMessage?: Message | null) => { + if (isProjectPreview || !roomIdParam) return; 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, + const res = await threadMessages(roomIdParam, thread.id, { limit: 100 }); + const threadMsgs: Message[] = (res.data?.data?.messages ?? []).map((r) => ({ + ...r, + _localReactions: [], + is_streaming: false, + isOptimistic: false, + isOptimisticError: false, + thinking_content: null, })); + const parent = parentMessage ?? messages.find((m) => m.seq === thread.parent) ?? null; setActiveThread({ - id: msg.thread, parent: msg.seq, created_by: '', participants: [], - last_message_at: new Date().toISOString(), last_message_preview: null, - created_at: '', messages: threadMsgs, isOpen: true, + ...thread, + messages: normalizeThreadMessages(parent, threadMsgs), + isOpen: true, }); + setSidePanel(null); } catch (err) { console.error('[ChannelPage] failed to open thread:', err); } }, - [roomIdParam], + [isProjectPreview, messages, normalizeThreadMessages, roomIdParam], ); + const openThread = useCallback( + async (msg: Message) => { + if (isProjectPreview || !roomIdParam) return; + const existing = threads.find((t) => t.id === msg.thread || t.parent === msg.seq); + if (existing) { + await openThreadByState(existing, msg); + return; + } + + try { + const res = await threadCreate(roomIdParam, { parent_seq: msg.seq }); + const data = res.data?.data; + if (!data) return; + const nextThread: ThreadState = { + id: data.id, + parent: data.parent, + created_by: data.created_by, + participants: data.participants, + last_message_at: data.last_message_at, + last_message_preview: data.last_message_preview ?? null, + created_at: data.created_at, + messages: [msg], + isOpen: true, + }; + setThreads((prev) => (prev.some((t) => t.id === nextThread.id) ? prev : [...prev, nextThread])); + setActiveThread(nextThread); + setSidePanel(null); + } catch (err) { + console.error('[ChannelPage] failed to create thread:', err); + } + }, + [isProjectPreview, openThreadByState, roomIdParam, setThreads, threads], + ); + + const displayedThread = useMemo(() => { + if (!activeThread) return null; + const parent = messages.find((m) => m.seq === activeThread.parent) ?? null; + const liveThreadMessages = messages.filter((m) => m.thread === activeThread.id); + const merged = normalizeThreadMessages(parent, [...activeThread.messages, ...liveThreadMessages]); + return { ...activeThread, messages: merged }; + }, [activeThread, messages, normalizeThreadMessages]); + const closeThread = () => setActiveThread(null); + const gotoMessage = useCallback((messageId: string) => { + setSidePanel(null); + requestAnimationFrame(() => { + const escape = window.CSS?.escape ?? ((value: string) => value.replace(/"/g, '\\"')); + const el = document.querySelector(`[data-message-id="${escape(messageId)}"]`); + el?.scrollIntoView({ block: 'center', behavior: 'smooth' }); + }); + }, []); + // ── File upload ── const handleFileSelect = async (e: React.ChangeEvent) => { const files = e.target.files; - if (!files || files.length === 0 || !roomIdParam) return; + if (isProjectPreview || !files || files.length === 0 || !roomIdParam) return; setUploading(true); try { const formData = new FormData(); @@ -424,6 +494,45 @@ function ChannelPageInner() { )} +
+ + +
+ {mentionOpen && ( @@ -463,36 +573,98 @@ function ChannelPageInner() { )} - setReplyToMessageId(null)} - onCancelEdit={() => { setEditingMessageId(null); setInputValue(''); pendingMentionsRef.current.clear(); }} - onMention={handleOpenMention} - /> + {isProjectPreview ? ( +
+ +
+ ) : ( + setReplyToMessageId(null)} + onCancelEdit={() => { setEditingMessageId(null); setInputValue(''); pendingMentionsRef.current.clear(); }} + onMention={handleOpenMention} + /> + )} {activeThread && ( sendMessage(content, opts)} onTypingStart={() => { const c = safeGetClient(); if (c) c.sendTypingStart(roomIdParam); }} onTypingStop={() => { const c = safeGetClient(); if (c) c.sendTypingStop(roomIdParam); }} + readOnly={isProjectPreview} /> )} + {!activeThread && sidePanel === 'pins' && ( + setSidePanel(null)} + onGotoMessage={gotoMessage} + onUnpin={isProjectPreview ? undefined : (messageId) => { + removePin(messageId).catch((err) => console.error('[ChannelPage] failed to unpin message:', err)); + }} + /> + )} + + {!activeThread && sidePanel === 'threads' && ( +
+
+
+ + Threads +
+ +
+
+ {threads.length === 0 ? ( +
+ No threads yet. Use the message action menu to start one. +
+ ) : ( + threads.map((thread) => { + const parent = messages.find((msg) => msg.seq === thread.parent) ?? null; + return ( + + ); + }) + )} +
+
+ )} + {editHistoryMessageId && ( (); const navigate = useNavigate(); + const { isProjectPreview } = useProjectLayout(); const [activeTab, setActiveTab] = useState<'open' | 'closed'>('open'); const [searchQuery, setSearchQuery] = useState(''); @@ -199,13 +201,15 @@ export function IssuesPage() {

Issues

Track and manage project tasks and bugs

- + {!isProjectPreview && ( + + )} {/* Toolbar */} @@ -268,7 +272,7 @@ export function IssuesPage() {

{searchQuery ? "Try adjusting your search or filters to find what you're looking for." : "You're all caught up! Create an issue to track new tasks."}

- {!searchQuery && activeTab === 'open' && ( + {!isProjectPreview && !searchQuery && activeTab === 'open' && (