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:
parent
f4653f2399
commit
c308fc044d
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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()) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user