feat: refactor chat input handling and add BoardChatComposer component for improved chat functionality
This commit is contained in:
@@ -6,11 +6,13 @@ import { useParams, useRouter } from "next/navigation";
|
|||||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
||||||
import { Activity, MessageSquare, Pencil, Settings, X } from "lucide-react";
|
import { Activity, MessageSquare, Pencil, Settings, X } from "lucide-react";
|
||||||
import ReactMarkdown, { type Components } from "react-markdown";
|
import ReactMarkdown, { type Components } from "react-markdown";
|
||||||
|
import remarkBreaks from "remark-breaks";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
|
|
||||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||||
import { TaskBoard } from "@/components/organisms/TaskBoard";
|
import { TaskBoard } from "@/components/organisms/TaskBoard";
|
||||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||||
|
import { BoardChatComposer } from "@/components/BoardChatComposer";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -201,7 +203,6 @@ export default function BoardDetailPage() {
|
|||||||
);
|
);
|
||||||
const [isChatOpen, setIsChatOpen] = useState(false);
|
const [isChatOpen, setIsChatOpen] = useState(false);
|
||||||
const [chatMessages, setChatMessages] = useState<BoardChatMessage[]>([]);
|
const [chatMessages, setChatMessages] = useState<BoardChatMessage[]>([]);
|
||||||
const [chatInput, setChatInput] = useState("");
|
|
||||||
const [isChatSending, setIsChatSending] = useState(false);
|
const [isChatSending, setIsChatSending] = useState(false);
|
||||||
const [chatError, setChatError] = useState<string | null>(null);
|
const [chatError, setChatError] = useState<string | null>(null);
|
||||||
const chatMessagesRef = useRef<BoardChatMessage[]>([]);
|
const chatMessagesRef = useRef<BoardChatMessage[]>([]);
|
||||||
@@ -235,11 +236,36 @@ export default function BoardDetailPage() {
|
|||||||
const [isSavingTask, setIsSavingTask] = useState(false);
|
const [isSavingTask, setIsSavingTask] = useState(false);
|
||||||
const [saveTaskError, setSaveTaskError] = useState<string | null>(null);
|
const [saveTaskError, setSaveTaskError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isSidePanelOpen = isDetailOpen || isChatOpen || isLiveFeedOpen;
|
||||||
|
|
||||||
const titleLabel = useMemo(
|
const titleLabel = useMemo(
|
||||||
() => (board ? `${board.name} board` : "Board"),
|
() => (board ? `${board.name} board` : "Board"),
|
||||||
[board],
|
[board],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSidePanelOpen) return;
|
||||||
|
|
||||||
|
const { body, documentElement } = document;
|
||||||
|
const originalHtmlOverflow = documentElement.style.overflow;
|
||||||
|
const originalBodyOverflow = body.style.overflow;
|
||||||
|
const originalBodyPaddingRight = body.style.paddingRight;
|
||||||
|
|
||||||
|
const scrollbarWidth = window.innerWidth - documentElement.clientWidth;
|
||||||
|
|
||||||
|
documentElement.style.overflow = "hidden";
|
||||||
|
body.style.overflow = "hidden";
|
||||||
|
if (scrollbarWidth > 0) {
|
||||||
|
body.style.paddingRight = `${scrollbarWidth}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
documentElement.style.overflow = originalHtmlOverflow;
|
||||||
|
body.style.overflow = originalBodyOverflow;
|
||||||
|
body.style.paddingRight = originalBodyPaddingRight;
|
||||||
|
};
|
||||||
|
}, [isSidePanelOpen]);
|
||||||
|
|
||||||
const latestTaskTimestamp = (items: Task[]) => {
|
const latestTaskTimestamp = (items: Task[]) => {
|
||||||
let latestTime = 0;
|
let latestTime = 0;
|
||||||
items.forEach((task) => {
|
items.forEach((task) => {
|
||||||
@@ -889,10 +915,10 @@ export default function BoardDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSendChat = async () => {
|
const handleSendChat = useCallback(async (content: string): Promise<boolean> => {
|
||||||
if (!isSignedIn || !boardId) return;
|
if (!isSignedIn || !boardId) return false;
|
||||||
const trimmed = chatInput.trim();
|
const trimmed = content.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return false;
|
||||||
setIsChatSending(true);
|
setIsChatSending(true);
|
||||||
setChatError(null);
|
setChatError(null);
|
||||||
try {
|
try {
|
||||||
@@ -917,15 +943,16 @@ export default function BoardDetailPage() {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setChatInput("");
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setChatError(
|
setChatError(
|
||||||
err instanceof Error ? err.message : "Unable to send message.",
|
err instanceof Error ? err.message : "Unable to send message.",
|
||||||
);
|
);
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setIsChatSending(false);
|
setIsChatSending(false);
|
||||||
}
|
}
|
||||||
};
|
}, [boardId, isSignedIn]);
|
||||||
|
|
||||||
const assigneeById = useMemo(() => {
|
const assigneeById = useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
@@ -1444,14 +1471,16 @@ export default function BoardDetailPage() {
|
|||||||
</SignedOut>
|
</SignedOut>
|
||||||
<SignedIn>
|
<SignedIn>
|
||||||
<DashboardSidebar />
|
<DashboardSidebar />
|
||||||
<main className="flex-1 overflow-y-auto bg-gradient-to-br from-slate-50 to-slate-100">
|
<main
|
||||||
|
className={cn(
|
||||||
|
"flex-1 bg-gradient-to-br from-slate-50 to-slate-100",
|
||||||
|
isSidePanelOpen ? "overflow-hidden" : "overflow-y-auto",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="sticky top-0 z-30 border-b border-slate-200 bg-white shadow-sm">
|
<div className="sticky top-0 z-30 border-b border-slate-200 bg-white shadow-sm">
|
||||||
<div className="px-8 py-6">
|
<div className="px-8 py-6">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
|
||||||
<span>{board?.name ?? "Board"}</span>
|
|
||||||
</div>
|
|
||||||
<h1 className="mt-2 text-2xl font-semibold text-slate-900 tracking-tight">
|
<h1 className="mt-2 text-2xl font-semibold text-slate-900 tracking-tight">
|
||||||
{board?.name ?? "Board"}
|
{board?.name ?? "Board"}
|
||||||
</h1>
|
</h1>
|
||||||
@@ -1724,7 +1753,7 @@ export default function BoardDetailPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed right-0 top-0 z-50 h-full w-[760px] max-w-[99vw] transform bg-white shadow-2xl transition-transform",
|
"fixed right-0 top-0 z-50 h-full w-[max(760px,45vw)] max-w-[99vw] transform bg-white shadow-2xl transition-transform",
|
||||||
isDetailOpen ? "translate-x-0" : "translate-x-full",
|
isDetailOpen ? "translate-x-0" : "translate-x-full",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -1949,26 +1978,26 @@ export default function BoardDetailPage() {
|
|||||||
<span>{formatCommentTimestamp(comment.created_at)}</span>
|
<span>{formatCommentTimestamp(comment.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
{comment.message?.trim() ? (
|
{comment.message?.trim() ? (
|
||||||
<div className="mt-2 text-sm text-slate-900 whitespace-pre-wrap break-words">
|
<div className="mt-2 select-text cursor-text text-sm text-slate-900 break-words">
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||||
components={{
|
components={{
|
||||||
...MARKDOWN_TABLE_COMPONENTS,
|
...MARKDOWN_TABLE_COMPONENTS,
|
||||||
p: ({ node: _node, ...props }) => (
|
p: ({ node: _node, ...props }) => (
|
||||||
<p
|
<p
|
||||||
className="text-sm text-slate-900 whitespace-pre-wrap break-words"
|
className="text-sm text-slate-900 break-words"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
ul: ({ node: _node, ...props }) => (
|
ul: ({ node: _node, ...props }) => (
|
||||||
<ul
|
<ul
|
||||||
className="list-disc pl-5 text-sm text-slate-900 whitespace-pre-wrap break-words"
|
className="list-disc pl-5 text-sm text-slate-900 break-words"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
li: ({ node: _node, ...props }) => (
|
li: ({ node: _node, ...props }) => (
|
||||||
<li
|
<li
|
||||||
className="mb-1 text-sm text-slate-900 whitespace-pre-wrap break-words"
|
className="mb-1 text-sm text-slate-900 break-words"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -2073,31 +2102,7 @@ export default function BoardDetailPage() {
|
|||||||
)}
|
)}
|
||||||
<div ref={chatEndRef} />
|
<div ref={chatEndRef} />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 space-y-2">
|
<BoardChatComposer isSending={isChatSending} onSend={handleSendChat} />
|
||||||
<Textarea
|
|
||||||
value={chatInput}
|
|
||||||
onChange={(event) => setChatInput(event.target.value)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key !== "Enter") return;
|
|
||||||
if (event.nativeEvent.isComposing) return;
|
|
||||||
if (event.shiftKey) return;
|
|
||||||
event.preventDefault();
|
|
||||||
if (isChatSending) return;
|
|
||||||
if (!chatInput.trim()) return;
|
|
||||||
void handleSendChat();
|
|
||||||
}}
|
|
||||||
placeholder="Message the board lead. Tag agents with @name."
|
|
||||||
className="min-h-[120px]"
|
|
||||||
/>
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button
|
|
||||||
onClick={handleSendChat}
|
|
||||||
disabled={isChatSending || !chatInput.trim()}
|
|
||||||
>
|
|
||||||
{isChatSending ? "Sending…" : "Send"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
57
frontend/src/components/BoardChatComposer.tsx
Normal file
57
frontend/src/components/BoardChatComposer.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo, useCallback, useState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
type BoardChatComposerProps = {
|
||||||
|
placeholder?: string;
|
||||||
|
isSending?: boolean;
|
||||||
|
onSend: (content: string) => Promise<boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function BoardChatComposerImpl({
|
||||||
|
placeholder = "Message the board lead. Tag agents with @name.",
|
||||||
|
isSending = false,
|
||||||
|
onSend,
|
||||||
|
}: BoardChatComposerProps) {
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
|
||||||
|
const send = useCallback(async () => {
|
||||||
|
if (isSending) return;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
const ok = await onSend(trimmed);
|
||||||
|
if (ok) {
|
||||||
|
setValue("");
|
||||||
|
}
|
||||||
|
}, [isSending, onSend, value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<Textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => setValue(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== "Enter") return;
|
||||||
|
if (event.nativeEvent.isComposing) return;
|
||||||
|
if (event.shiftKey) return;
|
||||||
|
event.preventDefault();
|
||||||
|
void send();
|
||||||
|
}}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="min-h-[120px]"
|
||||||
|
disabled={isSending}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={() => void send()} disabled={isSending || !value.trim()}>
|
||||||
|
{isSending ? "Sending…" : "Send"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BoardChatComposer = memo(BoardChatComposerImpl);
|
||||||
|
BoardChatComposer.displayName = "BoardChatComposer";
|
||||||
Reference in New Issue
Block a user