feat(project): enhance channel and issues pages

Update ChannelPage with message list integration, enhance
IssuesPage with drag-and-drop support, add NewIssuePage.
This commit is contained in:
ZhenYi 2026-05-14 23:14:59 +08:00
parent f4653f2399
commit c308fc044d
3 changed files with 237 additions and 49 deletions

View File

@ -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<string | null>(null);
const [emojiPickerMessageId, setEmojiPickerMessageId] = useState<string | null>(null);
const [activeThread, setActiveThread] = useState<ThreadState | null>(null);
const [sidePanel, setSidePanel] = useState<'pins' | 'threads' | null>(null);
const [editHistoryMessageId, setEditHistoryMessageId] = useState<string | null>(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<HTMLTextAreaElement>(null);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(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<HTMLTextAreaElement>) => {
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<string, Message>();
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<HTMLInputElement>) => {
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() {
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, padding: '8px 16px', borderBottom: '1px solid var(--border-subtle)' }}>
<button
onClick={() => { setActiveThread(null); setSidePanel((prev) => (prev === 'pins' ? null : 'pins')); }}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '5px 10px',
border: '1px solid var(--border-default)',
borderRadius: 8,
background: sidePanel === 'pins' ? 'var(--surface-elevated)' : 'transparent',
color: 'var(--text-secondary)',
fontSize: 12,
cursor: 'pointer',
}}
>
<Pin className="w-3 h-3" />
Pins {pinnedMessages.length}
</button>
<button
onClick={() => { setActiveThread(null); setSidePanel((prev) => (prev === 'threads' ? null : 'threads')); }}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '5px 10px',
border: '1px solid var(--border-default)',
borderRadius: 8,
background: sidePanel === 'threads' ? 'var(--surface-elevated)' : 'transparent',
color: 'var(--text-secondary)',
fontSize: 12,
cursor: 'pointer',
}}
>
<MessageSquare className="w-3 h-3" />
Threads {threads.length}
</button>
</div>
<MessageList
messages={messages}
isLoadingHistory={isLoadingMore}
@ -438,6 +547,7 @@ function ChannelPageInner() {
onShowEditHistory={setEditHistoryMessageId}
onStartReached={handleStartReached}
roomId={roomIdParam}
readOnly={isProjectPreview}
/>
{mentionOpen && (
@ -463,36 +573,98 @@ function ChannelPageInner() {
</div>
)}
<MessageInput
value={inputValue}
roomName={currentRoom?.room_name ?? roomIdParam}
isConnected={isConnected}
isEditing={!!editingMessageId}
replyToMessageId={replyToMessageId}
uploading={uploading}
inputRef={inputRef}
fileInputRef={fileInputRef}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onSend={handleSendMessage}
onFileSelect={handleFileSelect}
onCancelReply={() => setReplyToMessageId(null)}
onCancelEdit={() => { setEditingMessageId(null); setInputValue(''); pendingMentionsRef.current.clear(); }}
onMention={handleOpenMention}
/>
{isProjectPreview ? (
<div className="shrink-0 p-4">
<ProjectJoinBanner compact message="Join this project to send messages in channels." />
</div>
) : (
<MessageInput
value={inputValue}
roomName={currentRoom?.room_name ?? roomIdParam}
isConnected={isConnected}
isEditing={!!editingMessageId}
replyToMessageId={replyToMessageId}
uploading={uploading}
inputRef={inputRef}
fileInputRef={fileInputRef}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onSend={handleSendMessage}
onFileSelect={handleFileSelect}
onCancelReply={() => setReplyToMessageId(null)}
onCancelEdit={() => { setEditingMessageId(null); setInputValue(''); pendingMentionsRef.current.clear(); }}
onMention={handleOpenMention}
/>
)}
</div>
{activeThread && (
<ThreadPanel
thread={activeThread}
thread={displayedThread ?? activeThread}
typingUsers={typingUsersList}
onClose={closeThread}
sendMessage={(content: string, opts?: { contentType?: string; thread?: string; inReplyTo?: string; attachmentIds?: string[] }) => sendMessage(content, opts)}
onTypingStart={() => { const c = safeGetClient(); if (c) c.sendTypingStart(roomIdParam); }}
onTypingStop={() => { const c = safeGetClient(); if (c) c.sendTypingStop(roomIdParam); }}
readOnly={isProjectPreview}
/>
)}
{!activeThread && sidePanel === 'pins' && (
<PinPanel
pins={pinnedMessages}
messages={messages}
onClose={() => setSidePanel(null)}
onGotoMessage={gotoMessage}
onUnpin={isProjectPreview ? undefined : (messageId) => {
removePin(messageId).catch((err) => console.error('[ChannelPage] failed to unpin message:', err));
}}
/>
)}
{!activeThread && sidePanel === 'threads' && (
<div className="thread-panel">
<div className="thread-panel-header">
<div className="thread-panel-title">
<MessageSquare className="w-4 h-4" style={{ color: 'var(--text-primary)' }} />
<span style={{ color: 'var(--text-primary)', fontWeight: 600, fontSize: 14 }}>Threads</span>
</div>
<button onClick={() => setSidePanel(null)} className="thread-close-btn" title="Close Threads">
<X className="w-4 h-4" style={{ color: 'var(--text-muted)' }} />
</button>
</div>
<div className="thread-messages">
{threads.length === 0 ? (
<div style={{ color: 'var(--text-muted)', fontSize: 13, padding: 12 }}>
No threads yet. Use the message action menu to start one.
</div>
) : (
threads.map((thread) => {
const parent = messages.find((msg) => msg.seq === thread.parent) ?? null;
return (
<button
key={thread.id}
onClick={() => openThreadByState(thread, parent)}
className="thread-list-item"
disabled={isProjectPreview}
>
<span style={{ fontSize: 13, fontWeight: 600 }}>
{parent?.display_name ?? `Message #${thread.parent}`}
</span>
<span style={{ color: 'var(--text-secondary)', fontSize: 12, lineHeight: 1.4 }}>
{thread.last_message_preview ?? parent?.content ?? 'Thread has no replies yet.'}
</span>
<span style={{ color: 'var(--text-muted)', fontSize: 11 }}>
Updated {new Date(thread.last_message_at).toLocaleString()}
</span>
</button>
);
})
)}
</div>
</div>
)}
{editHistoryMessageId && (
<EditHistoryOverlay
messageId={editHistoryMessageId}

View File

@ -23,6 +23,7 @@ import { memo, useState, useMemo, useDeferredValue, useRef, useCallback } from "
import { useVirtualizer } from "@tanstack/react-virtual";
import { stripMarkdown, truncate } from "@/lib/utils";
import type { IssueResponse, IssueLabelResponse } from "@/client/model";
import { useProjectLayout } from "@/app/project/layout";
interface IssueRowProps {
issue: IssueResponse;
@ -108,6 +109,7 @@ const OVERSCAN = 5;
export function IssuesPage() {
const { projectName } = useParams<{ projectName: string }>();
const navigate = useNavigate();
const { isProjectPreview } = useProjectLayout();
const [activeTab, setActiveTab] = useState<'open' | 'closed'>('open');
const [searchQuery, setSearchQuery] = useState('');
@ -199,13 +201,15 @@ export function IssuesPage() {
<h1 className={ISSUES_PAGE.pageTitle}>Issues</h1>
<p className={ISSUES_PAGE.pageSub}>Track and manage project tasks and bugs</p>
</div>
<button
onClick={() => navigate(`/${projectName}/issues/new`)}
className={ISSUES_PAGE.newBtn}
>
<Plus className="w-4 h-4" />
New issue
</button>
{!isProjectPreview && (
<button
onClick={() => navigate(`/${projectName}/issues/new`)}
className={ISSUES_PAGE.newBtn}
>
<Plus className="w-4 h-4" />
New issue
</button>
)}
</div>
{/* Toolbar */}
@ -268,7 +272,7 @@ export function IssuesPage() {
<p className="text-sm text-muted-foreground mt-1 mb-6 text-center max-w-[300px]">
{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."}
</p>
{!searchQuery && activeTab === 'open' && (
{!isProjectPreview && !searchQuery && activeTab === 'open' && (
<button
onClick={() => navigate(`/${projectName}/issues/new`)}
className={ISSUES_PAGE.newBtn}
@ -307,4 +311,4 @@ export function IssuesPage() {
);
}
export default IssuesPage;
export default IssuesPage;

View File

@ -11,10 +11,12 @@ import {
ArrowLeft,
Info
} from "lucide-react";
import { ProjectJoinBanner, useProjectLayout } from "@/app/project/layout";
export function NewIssuePage() {
const { projectName } = useParams<{ projectName: string }>();
const navigate = useNavigate();
const { isProjectPreview } = useProjectLayout();
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
@ -22,6 +24,16 @@ export function NewIssuePage() {
const createMutation = useCreateIssueMutation(projectName);
if (isProjectPreview) {
return (
<div className={ISSUES_PAGE.container}>
<div className="max-w-[800px] w-full mx-auto mt-8">
<ProjectJoinBanner message="Join this project before creating issues." />
</div>
</div>
);
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) {