feat: refactor chat input handling and add BoardChatComposer component for improved chat functionality

This commit is contained in:
Abhimanyu Saharan
2026-02-06 20:28:48 +05:30
parent e786763250
commit e7dc2d0f8b
2 changed files with 114 additions and 52 deletions

View File

@@ -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>

View 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";