Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 184 additions & 5 deletions src/app/(route)/chat/ChatPage.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,192 @@
'use client';

import QueryBox from '@/app/(route)/home/_components/HomeQueryBox/HomeQueryBox';
import HomeQueryBox from '@/app/(route)/home/_components/HomeQueryBox/HomeQueryBox';
import CardList from '@/components/basics/CardList/CardList';
import LinkCard from '@/components/basics/LinkCard/LinkCard';
import Tab from '@/components/basics/Tab/Tab';
import UserChatBox from '@/components/wrappers/UserChatBox/UserChatBox';
import { chatHistoryById, chatLinksById, chatReasoningById, mockChats } from '@/mocks';
import { useChatRightPanelStore } from '@/stores/chatRightPanelStore';
import { useChatStore } from '@/stores/chatStore';
import { useSearchParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';

export default function Chat() {
const params = useSearchParams();
const { setChats, setActiveChat } = useChatStore();
const { setSelectedLink } = useChatRightPanelStore();
const [message, setMessage] = useState('');
const useMockData = process.env.NEXT_PUBLIC_USE_MOCKS === 'true';
Comment on lines 14 to +19
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd page.tsx src/app/\(route\)/chat

Repository: Team-SoFa/linkiving

Length of output: 126


🏁 Script executed:

cat -n src/app/\(route\)/chat/page.tsx

Repository: Team-SoFa/linkiving

Length of output: 246


🏁 Script executed:

head -5 src/app/\(route\)/chat/ChatPage.tsx

Repository: Team-SoFa/linkiving

Length of output: 286


🏁 Script executed:

head -20 src/app/\(route\)/chat/ChatPage.tsx

Repository: Team-SoFa/linkiving

Length of output: 1016


useSearchParams() 사용 시 Suspense 경계 필요

프로덕션 빌드 시 Client Component에서 useSearchParams()를 호출하는 정적 페이지는 Suspense 경계로 감싸야 하며, 그렇지 않으면 빌드가 실패합니다. 정적으로 렌더링되는 경로에서 useSearchParams() 호출 시 가장 가까운 Suspense 경계까지의 Client Component 트리가 클라이언트 사이드 렌더링되어, 페이지의 일부는 정적으로 렌더링되고 useSearchParams()를 사용하는 동적 부분만 클라이언트 사이드 렌더링될 수 있습니다. page.tsx에서 <Chat /> 컴포넌트를 Suspense로 감싸거나, ChatPage에서 useSearchParams 사용 부분을 더 작은 자식 컴포넌트로 추출하여 Suspense로 감싸야 합니다.

🤖 Prompt for AI Agents
In src/app/(route)/chat/ChatPage.tsx around lines 14-19, useSearchParams() is
being called in a Client Component that can be rendered on a static page and
thus must be surrounded by a Suspense boundary to avoid production build
failures; fix it by either (A) moving the useSearchParams() logic into a smaller
child Client Component with "use client" at the top and render that child inside
a <Suspense fallback={...}> wrapper, or (B) wrap the <Chat /> component
invocation in the parent page.tsx with <Suspense fallback={...}> so the tree up
to the nearest Suspense becomes client-rendered; ensure the child component only
uses client hooks and provide a sensible fallback UI.


const activeId = useMemo(() => {
const param = params.get('chatId');
const parsed = param ? Number(param) : NaN;
if (!Number.isNaN(parsed)) return parsed;
return useMockData ? (mockChats[0]?.id ?? null) : null;
}, [params, useMockData]);

useEffect(() => {
setChats(useMockData ? mockChats : []);
}, [setChats, useMockData]);

useEffect(() => {
if (activeId !== null) {
setActiveChat(activeId);
}
}, [activeId, setActiveChat]);

const messages = useMemo(() => {
if (!useMockData || activeId === null) return [];
return chatHistoryById[activeId] ?? [];
}, [activeId, useMockData]);
const chatPairs = useMemo(() => {
const pairs: {
user: (typeof messages)[number];
assistant?: (typeof messages)[number];
}[] = [];
let pendingUser: (typeof messages)[number] | null = null;

messages.forEach(message => {
if (message.role === 'user') {
if (pendingUser) {
pairs.push({ user: pendingUser });
}
pendingUser = message;
} else if (message.role === 'assistant') {
if (pendingUser) {
pairs.push({ user: pendingUser, assistant: message });
pendingUser = null;
}
}
});

if (pendingUser) {
pairs.push({ user: pendingUser });
}

return pairs;
}, [messages]);
const linkCards = activeId && useMockData ? (chatLinksById[activeId] ?? []) : [];
const reasoning = activeId && useMockData ? chatReasoningById[activeId] : undefined;
return (
<div className="relative flex h-screen w-full items-center">
<div className="absolute bottom-10 left-1/2 flex -translate-x-1/2">
<QueryBox />
</div>
<div className="min-h-screen w-full px-6 py-6">
<section className="mx-auto flex w-full max-w-200 min-w-0 flex-1 flex-col">
<div className="flex-1 overflow-y-auto px-6 py-6">
{chatPairs.length === 0 ? (
<p className="text-gray400 text-sm">요청을 입력해 보세요.</p>
) : (
<div className="flex flex-col gap-4">
{chatPairs.map(({ user, assistant }) => (
<div key={user.id} className="space-y-6">
<div className="flex justify-end">
<div className="bg-blue50 text-gray900 max-w-[560px] rounded-2xl px-4 py-3 text-sm leading-[160%]">
{user.content}
</div>
</div>
<Tab
tabs={['답변', '링크', '단계']}
contents={{
답변: (
<div className="space-y-6">
<div className="text-gray900 space-y-4 text-sm leading-[160%]">
<p>
{reasoning?.answer ?? assistant?.content ?? '응답을 생성 중입니다.'}
</p>
</div>
<div>
{linkCards.length === 0 ? (
<p className="text-gray400 text-sm">표시할 링크가 없습니다.</p>
) : (
<CardList>
{linkCards.map(link => (
<LinkCard
key={`${user.id}-${link.id}`}
title={link.title}
link={link.url}
summary={link.summary}
imageUrl={link.imageUrl ?? ''}
onClick={() => setSelectedLink(link)}
/>
))}
</CardList>
)}
</div>
</div>
),
링크: (
<div>
{linkCards.length === 0 ? (
<p className="text-gray400 text-sm">표시할 링크가 없습니다.</p>
) : (
<CardList>
{linkCards.map(link => (
<LinkCard
key={`${user.id}-link-${link.id}`}
title={link.title}
link={link.url}
summary={link.summary}
imageUrl={link.imageUrl ?? ''}
onClick={() => setSelectedLink(link)}
/>
))}
</CardList>
)}
</div>
),
단계: (
<ol className="text-gray700 space-y-4 text-sm">
{reasoning?.reasoningSteps?.length
? reasoning.reasoningSteps.map((step, index) => {
const relatedLinks = linkCards.filter(link =>
step.linkIds.includes(link.id)
);

return (
<li key={`${user.id}-step-${index}`} className="space-y-2">
<p className="text-gray900 font-medium">
{index + 1}. {step.step}
</p>
{relatedLinks.length > 0 && (
<div className="flex flex-wrap gap-2">
{relatedLinks.map(link => (
<button
key={`${user.id}-step-link-${link.id}`}
type="button"
onClick={() => setSelectedLink(link)}
className="text-gray500 hover:text-gray900 border-gray200 rounded-full border px-3 py-1 text-xs"
>
{link.title}
</button>
))}
</div>
)}
</li>
);
})
: null}
</ol>
),
}}
/>
</div>
))}
</div>
)}
</div>

<div className="border-gray200 bg-white px-6 py-4">
<div className="hidden xl:flex">
<HomeQueryBox />
</div>
<div className="flex xl:hidden">
<UserChatBox
value={message}
onChange={e => setMessage(e.target.value)}
onSubmit={() => setMessage('')}
/>
</div>
</div>
</section>
</div>
);
}
2 changes: 2 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import ReactQueryProvider from '@/components/ReactQueryProvider';
import ToastContainer from '@/components/basics/Toast/ToastContainer';
import SideNavigation from '@/components/layout/SideNavigation/SideNavigation';
import ChatRightSidebar from '@/components/layout/SideNavigation/components/ChatRightSidebar/ChatRightSidebar';
import '@/styles/globals.css';
import type { Metadata } from 'next';

Expand Down Expand Up @@ -60,6 +61,7 @@ export default function RootLayout({
<main className="min-h-screen flex-1 overflow-x-hidden">
<ReactQueryProvider>{children}</ReactQueryProvider>
</main>
<ChatRightSidebar />
<ToastContainer />
</body>
</html>
Expand Down
14 changes: 14 additions & 0 deletions src/components/layout/SideNavigation/SideNavigation.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,36 @@
'use client';

import { mockChats } from '@/mocks';
import { useChatStore } from '@/stores/chatStore';
import { useModalStore } from '@/stores/modalStore';
import { useSideNavStore } from '@/stores/sideNavStore';
import { motion } from 'framer-motion';

import AddLinkButton from './components/AddLink/AddLinkButton';
import AddLinkModal from './components/AddLinkModal/AddLinkModal';
import AllLinkButton from './components/AllLink/AllLinkButton';
import ChatRoomList from './components/ChatRoomList/ChatRoomList';
import NewChatButton from './components/NewChat/NewChatButton';
import SideNavHeaderIconButton from './components/SideNavToggle/SideNavToggle';

export default function SideNavigation() {
const { type, open } = useModalStore();
const { isOpen, toggle } = useSideNavStore();
const { chats, activeChatId } = useChatStore();
const useMockData = process.env.NEXT_PUBLIC_USE_MOCKS === 'true';

const MENU_ITEMS = [
{ id: 'new-chat', item: <NewChatButton /> },
{ id: 'add-link', item: <AddLinkButton onClick={() => open('ADD_LINK')} /> },
{ id: 'all-link', item: <AllLinkButton /> },
];

const resolvedChats = useMockData && chats.length === 0 ? mockChats : chats;
const chatRooms = resolvedChats.map(chat => ({
id: chat.id,
title: chat.title,
href: `/chat?chatId=${chat.id}`,
}));
return (
<>
<motion.div
Expand Down Expand Up @@ -55,6 +67,8 @@ export default function SideNavigation() {
</div>
))}
</nav>

<ChatRoomList items={chatRooms} activeId={activeChatId} />
</div>
</motion.div>
{type === 'ADD_LINK' && <AddLinkModal />}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
'use client';

import LinkCardDetailPanel from '@/components/wrappers/LinkCardDetailPanel/LinkCardDetailPanel';
import { useChatRightPanelStore } from '@/stores/chatRightPanelStore';
import { usePathname } from 'next/navigation';

export default function ChatRightSidebar() {
const pathname = usePathname();
const { selectedLink, clearSelectedLink } = useChatRightPanelStore();

if (pathname !== '/chat') return null;

if (!selectedLink) return null;

return (
<aside className="border-gray200 bg-gray50 hidden h-screen w-130 shrink-0 border-l xl:block">
<LinkCardDetailPanel
url={selectedLink.url}
title={selectedLink.title}
summary={selectedLink.summary}
memo=""
imageUrl={selectedLink.imageUrl}
onClose={clearSelectedLink}
/>
</aside>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import LinkButton from '@/components/wrappers/LinkButton/LinkButton';
import { useSideNavStore } from '@/stores/sideNavStore';

type ChatRoomItem = {
id: number;
title: string;
href: string;
};

type ChatRoomListProps = {
items: ChatRoomItem[];
activeId?: number | null;
};

export default function ChatRoomList({ items, activeId }: ChatRoomListProps) {
const { isOpen } = useSideNavStore();

if (!isOpen) return null;

return (
<section className="mt-10">
<p className="text-gray500 mb-3 text-xs font-semibold">채팅방</p>
{items.length === 0 ? (
<p className="text-gray400 text-xs">채팅방이 없습니다.</p>
) : (
<ul className="flex flex-col gap-2">
{items.map(item => {
const isActive = activeId === item.id;
return (
<li key={item.id}>
<LinkButton
href={item.href}
label={item.title}
size="sm"
variant={isActive ? 'secondary' : 'tertiary_subtle'}
contextStyle="onPanel"
radius="md"
className="flex h-9 w-50 justify-start truncate px-3"
/>
</li>
);
})}
</ul>
)}
</section>
);
}
Loading