chore: more wip

This commit is contained in:
Lluis Agusti
2026-01-20 18:56:02 +07:00
parent 2fc3516473
commit f51e50fe10
14 changed files with 232 additions and 170 deletions

View File

@@ -175,6 +175,8 @@ While server components and actions are cool and cutting-edge, they introduce a
- Prefer [React Query](https://tanstack.com/query/latest/docs/framework/react/overview) for server state, colocated near consumers (see [state colocation](https://kentcdodds.com/blog/state-colocation-will-make-your-react-app-faster))
- Co-locate UI state inside components/hooks; keep global state minimal
- Avoid `useMemo` and `useCallback` unless you have a measured performance issue
- Do not abuse `useEffect`; prefer state colocation and derive values directly when possible
### Styling and components

View File

@@ -8,7 +8,7 @@ import {
} from "@/services/feature-flags/use-get-flag";
import { useFlags } from "launchdarkly-react-client-sdk";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { useEffect, useState } from "react";
export function useCopilotChatPage() {
const router = useRouter();
@@ -24,6 +24,22 @@ export function useCopilotChatPage() {
const sessionId = searchParams.get("sessionId");
const prompt = searchParams.get("prompt");
const [storedPrompt, setStoredPrompt] = useState<string | null>(null);
useEffect(
function loadStoredPrompt() {
if (prompt) return;
try {
const storedValue = sessionStorage.getItem("copilot_initial_prompt");
if (!storedValue) return;
sessionStorage.removeItem("copilot_initial_prompt");
setStoredPrompt(storedValue);
} catch {
// Ignore storage errors (private mode, etc.)
}
},
[prompt],
);
useEffect(
function guardAccess() {
@@ -39,6 +55,6 @@ export function useCopilotChatPage() {
isFlagReady,
isChatEnabled,
sessionId,
prompt,
prompt: prompt ?? storedPrompt,
};
}

View File

@@ -19,6 +19,7 @@ export function CopilotShell({ children }: Props) {
isDrawerOpen,
isLoading,
isLoggedIn,
hasActiveSession,
sessions,
currentSessionId,
handleSelectSession,
@@ -38,7 +39,7 @@ export function CopilotShell({ children }: Props) {
return (
<div
className="flex overflow-hidden bg-zinc-50"
className="flex overflow-hidden bg-[#EFEFF0]"
style={{ height: `calc(100vh - ${NAVBAR_HEIGHT_PX}px)` }}
>
{!isMobile && (
@@ -51,10 +52,11 @@ export function CopilotShell({ children }: Props) {
onSelectSession={handleSelectSession}
onFetchNextPage={fetchNextPage}
onNewChat={handleNewChat}
hasActiveSession={Boolean(hasActiveSession)}
/>
)}
<div className="flex min-h-0 flex-1 flex-col">
<div className="relative flex min-h-0 flex-1 flex-col">
{isMobile && <MobileHeader onOpenDrawer={handleOpenDrawer} />}
<div className="flex min-h-0 flex-1 flex-col">
{isReadyToShowContent ? children : <LoadingState />}
@@ -74,6 +76,7 @@ export function CopilotShell({ children }: Props) {
onNewChat={handleNewChat}
onClose={handleCloseDrawer}
onOpenChange={handleDrawerOpenChange}
hasActiveSession={Boolean(hasActiveSession)}
/>
)}
</div>

View File

@@ -15,6 +15,7 @@ interface Props {
onSelectSession: (sessionId: string) => void;
onFetchNextPage: () => void;
onNewChat: () => void;
hasActiveSession: boolean;
}
export function DesktopSidebar({
@@ -26,9 +27,10 @@ export function DesktopSidebar({
onSelectSession,
onFetchNextPage,
onNewChat,
hasActiveSession,
}: Props) {
return (
<aside className="flex h-full w-80 flex-col border-r border-zinc-100 bg-white">
<aside className="flex h-full w-80 flex-col border-r border-zinc-100 bg-zinc-50">
<div className="shrink-0 px-6 py-4">
<Text variant="h3" size="body-medium">
Your chats
@@ -50,17 +52,19 @@ export function DesktopSidebar({
onFetchNextPage={onFetchNextPage}
/>
</div>
<div className="shrink-0 bg-white p-3 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)]">
<Button
variant="primary"
size="small"
onClick={onNewChat}
className="w-full"
leftIcon={<Plus width="1rem" height="1rem" />}
>
New Chat
</Button>
</div>
{hasActiveSession && (
<div className="shrink-0 bg-zinc-50 p-3 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)]">
<Button
variant="primary"
size="small"
onClick={onNewChat}
className="w-full"
leftIcon={<Plus width="1rem" height="1rem" />}
>
New Chat
</Button>
</div>
)}
</aside>
);
}

View File

@@ -2,7 +2,7 @@ import type { SessionSummaryResponse } from "@/app/api/__generated__/models/sess
import { Button } from "@/components/atoms/Button/Button";
import { scrollbarStyles } from "@/components/styles/scrollbars";
import { cn } from "@/lib/utils";
import { Plus, X } from "@phosphor-icons/react";
import { PlusIcon, X } from "@phosphor-icons/react";
import { Drawer } from "vaul";
import { SessionsList } from "../SessionsList/SessionsList";
@@ -18,6 +18,7 @@ interface Props {
onNewChat: () => void;
onClose: () => void;
onOpenChange: (open: boolean) => void;
hasActiveSession: boolean;
}
export function MobileDrawer({
@@ -32,12 +33,13 @@ export function MobileDrawer({
onNewChat,
onClose,
onOpenChange,
hasActiveSession,
}: Props) {
return (
<Drawer.Root open={isOpen} onOpenChange={onOpenChange} direction="left">
<Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 z-[60] bg-black/10 backdrop-blur-sm" />
<Drawer.Content className="fixed left-0 top-0 z-[70] flex h-full w-80 flex-col border-r border-zinc-200 bg-white">
<Drawer.Content className="fixed left-0 top-0 z-[70] flex h-full w-80 flex-col border-r border-zinc-200 bg-zinc-50">
<div className="shrink-0 border-b border-zinc-200 p-4">
<div className="flex items-center justify-between">
<Drawer.Title className="text-lg font-semibold text-zinc-800">
@@ -69,17 +71,19 @@ export function MobileDrawer({
onFetchNextPage={onFetchNextPage}
/>
</div>
<div className="shrink-0 bg-white p-3 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)]">
<Button
variant="primary"
size="small"
onClick={onNewChat}
className="w-full"
leftIcon={<Plus width="1rem" height="1rem" />}
>
New Chat
</Button>
</div>
{hasActiveSession && (
<div className="shrink-0 bg-white p-3 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)]">
<Button
variant="primary"
size="small"
onClick={onNewChat}
className="w-full"
leftIcon={<PlusIcon width="1rem" height="1rem" />}
>
New Chat
</Button>
</div>
)}
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>

View File

@@ -1,5 +1,6 @@
import { Button } from "@/components/atoms/Button/Button";
import { List } from "@phosphor-icons/react";
import { NAVBAR_HEIGHT_PX } from "@/lib/constants";
import { ListIcon } from "@phosphor-icons/react";
interface Props {
onOpenDrawer: () => void;
@@ -7,15 +8,15 @@ interface Props {
export function MobileHeader({ onOpenDrawer }: Props) {
return (
<header className="flex items-center justify-between px-4 py-3">
<Button
variant="icon"
size="icon"
aria-label="Open sessions"
onClick={onOpenDrawer}
>
<List width="1.25rem" height="1.25rem" />
</Button>
</header>
<Button
variant="icon"
size="icon"
aria-label="Open sessions"
onClick={onOpenDrawer}
className="fixed z-50 bg-white shadow-md"
style={{ left: "1rem", top: `${NAVBAR_HEIGHT_PX + 20}px` }}
>
<ListIcon width="1.25rem" height="1.25rem" />
</Button>
);
}

View File

@@ -9,7 +9,7 @@ import { useBreakpoint } from "@/lib/hooks/useBreakpoint";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { Key, storage } from "@/services/storage/local-storage";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useMobileDrawer } from "./components/MobileDrawer/useMobileDrawer";
import { useSessionsPagination } from "./components/SessionsList/useSessionsPagination";
import {
@@ -30,6 +30,7 @@ export function useCopilotShell() {
breakpoint === "base" || breakpoint === "sm" || breakpoint === "md";
const isOnHomepage = pathname === "/copilot";
const paramSessionId = searchParams.get("sessionId");
const {
isDrawerOpen,
@@ -38,7 +39,7 @@ export function useCopilotShell() {
handleDrawerOpenChange,
} = useMobileDrawer();
const paginationEnabled = !isMobile || isDrawerOpen;
const paginationEnabled = !isMobile || isDrawerOpen || !!paramSessionId;
const {
sessions: accumulatedSessions,
@@ -54,54 +55,56 @@ export function useCopilotShell() {
});
const storedSessionId = storage.get(Key.CHAT_SESSION_ID) ?? null;
const currentSessionId = useMemo(
() => getCurrentSessionId(searchParams, storedSessionId),
[searchParams, storedSessionId],
);
const currentSessionId = getCurrentSessionId(searchParams, storedSessionId)
const { data: currentSessionData, isLoading: isCurrentSessionLoading } =
useGetV2GetSession(currentSessionId || "", {
query: {
enabled: !!currentSessionId && paginationEnabled,
enabled: !!currentSessionId,
select: okData,
},
});
const [hasAutoSelectedSession, setHasAutoSelectedSession] = useState(false);
const hasCreatedSessionRef = useRef(false);
const paramSessionId = searchParams.get("sessionId");
const hasAutoSelectedRef = useRef(false);
const paramPrompt = searchParams.get("prompt");
const createSessionAndNavigate = useCallback(
function createSessionAndNavigate() {
useEffect(() => {
function runCreateSession() {
postV2CreateSession({ body: JSON.stringify({}) })
.then((response) => {
if (response.status === 200 && response.data) {
const promptParam = paramPrompt ? `&prompt=${encodeURIComponent(paramPrompt)}` : "";
router.push(`/copilot/chat?sessionId=${response.data.id}${promptParam}`);
const promptParam = paramPrompt
? `&prompt=${encodeURIComponent(paramPrompt)}`
: "";
router.push(
`/copilot/chat?sessionId=${response.data.id}${promptParam}`,
);
hasAutoSelectedRef.current = true;
setHasAutoSelectedSession(true);
}
})
.catch(() => {
hasCreatedSessionRef.current = false;
});
},
[router, paramPrompt],
);
}
useEffect(() => {
// Don't auto-select or auto-create sessions on homepage
if (isOnHomepage) {
setHasAutoSelectedSession(true);
// Don't auto-select or auto-create sessions on homepage without an explicit sessionId
if (isOnHomepage && !paramSessionId) {
if (!hasAutoSelectedRef.current) {
hasAutoSelectedRef.current = true;
setHasAutoSelectedSession(true);
}
return;
}
if (!areAllSessionsLoaded || hasAutoSelectedSession) return;
if (!areAllSessionsLoaded || hasAutoSelectedRef.current) return;
// If there's a prompt parameter, create a new session (don't auto-select existing)
if (paramPrompt && !paramSessionId && !hasCreatedSessionRef.current) {
hasCreatedSessionRef.current = true;
createSessionAndNavigate();
runCreateSession();
return;
}
@@ -109,7 +112,7 @@ export function useCopilotShell() {
const autoSelect = shouldAutoSelectSession(
areAllSessionsLoaded,
hasAutoSelectedSession,
hasAutoSelectedRef.current,
paramSessionId,
visibleSessions,
accumulatedSessions,
@@ -118,24 +121,28 @@ export function useCopilotShell() {
);
if (paramSessionId) {
hasAutoSelectedRef.current = true;
setHasAutoSelectedSession(true);
return;
}
// Don't auto-select existing sessions if there's a prompt (user wants new session)
if (paramPrompt) {
hasAutoSelectedRef.current = true;
setHasAutoSelectedSession(true);
return;
}
if (autoSelect.shouldSelect && autoSelect.sessionIdToSelect) {
hasAutoSelectedRef.current = true;
setHasAutoSelectedSession(true);
router.push(`/copilot/chat?sessionId=${autoSelect.sessionIdToSelect}`);
} else if (autoSelect.shouldCreate && !hasCreatedSessionRef.current) {
// Only auto-create on chat page when no sessions exist, not homepage
hasCreatedSessionRef.current = true;
createSessionAndNavigate();
runCreateSession();
} else if (totalCount === 0) {
hasAutoSelectedRef.current = true;
setHasAutoSelectedSession(true);
}
}, [
@@ -144,43 +151,48 @@ export function useCopilotShell() {
accumulatedSessions,
paramSessionId,
paramPrompt,
hasAutoSelectedSession,
router,
isSessionsLoading,
totalCount,
createSessionAndNavigate,
]);
useEffect(() => {
if (paramSessionId) {
hasAutoSelectedRef.current = true;
setHasAutoSelectedSession(true);
}
}, [paramSessionId]);
function resetAutoSelect() {
setHasAutoSelectedSession(false);
hasCreatedSessionRef.current = false;
}
// Reset pagination and auto-selection when query becomes disabled
const prevPaginationEnabledRef = useRef(paginationEnabled);
useEffect(() => {
if (!paginationEnabled) {
if (prevPaginationEnabledRef.current && !paginationEnabled) {
resetPagination();
resetAutoSelect();
}
}, [paginationEnabled, resetPagination]);
prevPaginationEnabledRef.current = paginationEnabled;
}, [paginationEnabled]);
const sessions = useMemo(
function getSessions() {
return mergeCurrentSessionIntoList(
accumulatedSessions,
currentSessionId,
currentSessionData,
);
},
[accumulatedSessions, currentSessionId, currentSessionData],
const sessions = mergeCurrentSessionIntoList(
accumulatedSessions,
currentSessionId,
currentSessionData,
);
const sidebarSelectedSessionId =
isOnHomepage && !paramSessionId ? null : currentSessionId;
const isReadyToShowContent = isOnHomepage
? true
: checkReadyToShowContent(
areAllSessionsLoaded,
paramSessionId,
accumulatedSessions,
isCurrentSessionLoading,
currentSessionData,
hasAutoSelectedSession,
);
function handleSelectSession(sessionId: string) {
router.push(`/copilot/chat?sessionId=${sessionId}`);
if (isMobile) handleCloseDrawer();
@@ -189,39 +201,24 @@ export function useCopilotShell() {
function handleNewChat() {
storage.clean(Key.CHAT_SESSION_ID);
resetAutoSelect();
createSessionAndNavigate();
router.push("/copilot");
if (isMobile) handleCloseDrawer();
}
const isReadyToShowContent = useMemo(() => {
// On homepage, always show content (welcome screen) immediately
if (isOnHomepage) return true;
return checkReadyToShowContent(
areAllSessionsLoaded,
paramSessionId,
accumulatedSessions,
isCurrentSessionLoading,
currentSessionData,
hasAutoSelectedSession,
);
}, [
isOnHomepage,
areAllSessionsLoaded,
paramSessionId,
accumulatedSessions,
isCurrentSessionLoading,
currentSessionData,
hasAutoSelectedSession,
]);
function resetAutoSelect() {
hasAutoSelectedRef.current = false;
setHasAutoSelectedSession(false);
hasCreatedSessionRef.current = false;
}
return {
isMobile,
isDrawerOpen,
isLoggedIn,
hasActiveSession: Boolean(currentSessionId) && (!isOnHomepage || paramSessionId),
isLoading: isSessionsLoading || !areAllSessionsLoaded,
sessions,
currentSessionId,
currentSessionId: sidebarSelectedSessionId,
handleSelectSession,
handleOpenDrawer,
handleCloseDrawer,

View File

@@ -46,66 +46,67 @@ export default function CopilotPage() {
const isLoading = isUserLoading;
return (
<div className="flex h-full flex-1 items-center justify-center overflow-y-auto px-6 py-10">
<div className="w-full max-w-2xl text-center">
<div className="flex h-full flex-1 items-center justify-center overflow-y-auto px-6 py-10 bg-[#f8f8f9]">
<div className="w-full text-center">
{isLoading ? (
<>
<div className="max-w-2xl mx-auto">
<Skeleton className="mx-auto mb-3 h-8 w-64" />
<Skeleton className="mx-auto mb-8 h-6 w-80" />
<div className="mb-8">
<Skeleton className="mx-auto h-14 w-full max-w-2xl rounded-lg" />
<Skeleton className="mx-auto h-14 w-full rounded-lg" />
</div>
<div className="flex flex-wrap items-center justify-center gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-9 w-48 rounded-md" />
))}
</div>
</>
</div>
) : (
<>
<Text variant="h3" className="mb-3 text-zinc-700 text-[1.5rem]">
<div className="max-w-2xl mx-auto">
<Text variant="h3" className="mb-3 text-zinc-700 !text-[1.375rem]">
Hey, <span className="text-violet-600">{greetingName}</span>
</Text>
<Text variant="h3" className="mb-8">
<Text variant="h3" className="mb-8 !font-normal">
What do you want to automate?
</Text>
<form onSubmit={handleSubmit} className="mb-8">
<div className="relative">
<Input
id="copilot-prompt"
label="Copilot prompt"
hideLabel
type="textarea"
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
rows={1}
placeholder='You can search or just ask - e.g. "create a blog post outline"'
wrapperClassName="mb-0"
className="!py-5 !rounded-full pr-12 !text-[1rem]"
/>
<Button
type="submit"
variant="icon"
size="icon"
aria-label="Submit prompt"
className="absolute right-2 top-1/2 -translate-y-1/2 border-zinc-800 bg-zinc-800 text-white hover:border-zinc-900 hover:bg-zinc-900"
disabled={!value.trim()}
>
<ArrowUpIcon className="h-4 w-4" weight="bold" />
</Button>
</div>
</form>
<div className="flex flex-wrap items-center justify-center gap-3">
<form onSubmit={handleSubmit} className="mb-6">
<div className="relative">
<Input
id="copilot-prompt"
label="Copilot prompt"
hideLabel
type="textarea"
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
rows={1}
placeholder='You can search or just ask - e.g. "create a blog post outline"'
wrapperClassName="mb-0"
className="!py-5 !rounded-full pr-12 !text-[1rem] border-transparent [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
/>
<Button
type="submit"
variant="icon"
size="icon"
aria-label="Submit prompt"
className="absolute right-2 top-1/2 -translate-y-1/2 border-zinc-800 bg-zinc-800 text-white hover:border-zinc-900 hover:bg-zinc-900"
disabled={!value.trim()}
>
<ArrowUpIcon className="h-4 w-4" weight="bold" />
</Button>
</div>
</form>
</div>
<div className="flex flex-nowrap items-center justify-center gap-3 overflow-x-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
{quickActions.map((action) => (
<Button
key={action}
variant="outline"
size="small"
onClick={() => handleQuickAction(action)}
className="border-zinc-600 text-zinc-600 text-[1rem] !py-2 !px-4 h-auto"
className="border-zinc-600 text-zinc-600 text-[1rem] !py-2 !px-4 h-auto shrink-0"
>
{action}
</Button>

View File

@@ -1,5 +1,6 @@
"use client";
import { postV2CreateSession } from "@/app/api/__generated__/endpoints/chat/chat";
import { getHomepageRoute } from "@/lib/constants";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import {
@@ -11,7 +12,6 @@ import { useFlags } from "launchdarkly-react-client-sdk";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import {
buildCopilotChatUrl,
getGreetingName,
getQuickActions,
} from "./helpers";
@@ -56,24 +56,40 @@ export function useCopilotHome() {
setValue(event.target.value);
}
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!value.trim()) return;
router.push(buildCopilotChatUrl(value));
async function createSessionAndNavigate(prompt: string) {
try {
const response = await postV2CreateSession({ body: JSON.stringify({}) });
if (response.status === 200 && response.data) {
try {
sessionStorage.setItem("copilot_initial_prompt", prompt);
} catch {
// Ignore storage errors (private mode, etc.)
}
router.push(`/copilot/chat?sessionId=${response.data.id}`);
}
} catch (error) {
console.error("Failed to create session:", error);
}
}
function handleKeyDown(
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!value.trim()) return;
await createSessionAndNavigate(value.trim());
}
async function handleKeyDown(
event: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>,
) {
if (event.key !== "Enter") return;
if (event.shiftKey) return;
event.preventDefault();
if (!value.trim()) return;
router.push(buildCopilotChatUrl(value));
await createSessionAndNavigate(value.trim());
}
function handleQuickAction(action: string) {
router.push(buildCopilotChatUrl(action));
async function handleQuickAction(action: string) {
await createSessionAndNavigate(action);
}
return {

View File

@@ -73,7 +73,7 @@ export function Chat({
)}
{/* Main Content */}
<main className="flex min-h-0 w-full flex-1 flex-col overflow-hidden">
<main className="flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-[#f8f8f9]">
{/* Loading State - show loader when loading or creating a session (with 300ms delay) */}
{showLoader && (isLoading || isCreating) && (
<div className="flex flex-1 items-center justify-center">

View File

@@ -1,4 +1,5 @@
import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
import { useBreakpoint } from "@/lib/hooks/useBreakpoint";
import { cn } from "@/lib/utils";
import { useCallback, useEffect, useRef } from "react";
import { usePageContext } from "../../usePageContext";
@@ -24,8 +25,11 @@ export function ChatContainer({
sessionId,
initialMessages,
});
const { capturePageContext } = usePageContext();
const hasSentInitialRef = useRef(false);
const breakpoint = useBreakpoint();
const isMobile = breakpoint === "base" || breakpoint === "sm" || breakpoint === "md";
// Wrap sendMessage to automatically capture page context
const sendMessageWithContext = useCallback(
@@ -56,7 +60,7 @@ export function ChatContainer({
)}
>
{/* Messages or Welcome Screen - Scrollable */}
<div className="relative flex min-h-0 flex-1 flex-col overflow-y-auto">
<div className="relative flex min-h-0 flex-1 flex-col">
<div className="flex min-h-full flex-col justify-end">
<MessageList
messages={messages}
@@ -69,12 +73,12 @@ export function ChatContainer({
</div>
{/* Input - Fixed at bottom */}
<div className="relative pb-4 pt-2">
<div className="relative pb-4 pt-2 px-3">
<div className="pointer-events-none absolute top-[-18px] z-10 h-6 w-full bg-gradient-to-b from-transparent to-[#f8f8f9]" />
<ChatInput
onSend={sendMessageWithContext}
disabled={isStreaming || !sessionId}
placeholder="You can search or just ask — e.g. “create a blog post outline”"
placeholder={isMobile ? "You can search or just ask" : "You can search or just ask — e.g. “create a blog post outline”"}
/>
</div>
</div>

View File

@@ -4,10 +4,10 @@ import { Button } from "@/components/atoms/Button/Button";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { cn } from "@/lib/utils";
import {
ArrowClockwise,
ArrowsClockwiseIcon,
CheckCircleIcon,
CheckIcon,
CopyIcon,
CopyIcon
} from "@phosphor-icons/react";
import { useRouter } from "next/navigation";
import { useCallback, useState } from "react";
@@ -258,7 +258,7 @@ export function ChatMessage({
)}
<div
className={cn(
"flex gap-1",
"flex gap-0",
isUser ? "justify-end" : "justify-start",
)}
>
@@ -269,7 +269,7 @@ export function ChatMessage({
onClick={handleTryAgain}
aria-label="Try again"
>
<ArrowClockwise className="size-3 text-neutral-500" />
<ArrowsClockwiseIcon className="size-4 text-zinc-600" />
</Button>
)}
{(isUser || isFinalMessage) && (
@@ -280,9 +280,9 @@ export function ChatMessage({
aria-label="Copy message"
>
{copied ? (
<CheckIcon className="size-3 text-green-600" />
<CheckIcon className="size-4 text-green-600" />
) : (
<CopyIcon className="size-3 text-neutral-500" />
<CopyIcon className="size-4 text-zinc-600" />
)}
</Button>
)}

View File

@@ -32,15 +32,19 @@ export function MessageList({
});
return (
<div
ref={messagesContainerRef}
className={cn(
"flex-1 overflow-y-auto overflow-x-hidden",
"scrollbar-thin scrollbar-track-transparent scrollbar-thumb-zinc-300",
className,
)}
>
<div className="mx-auto flex min-w-0 flex-col hyphens-auto break-words py-4">
<div className="relative flex min-h-0 flex-1 flex-col">
{/* Top fade shadow */}
<div className="pointer-events-none absolute top-0 z-10 h-8 w-full bg-gradient-to-b from-[#f8f8f9] to-transparent" />
<div
ref={messagesContainerRef}
className={cn(
"flex-1 overflow-y-auto overflow-x-hidden",
"scrollbar-thin scrollbar-track-transparent scrollbar-thumb-zinc-300",
className,
)}
>
<div className="mx-auto flex min-w-0 flex-col hyphens-auto break-words py-4">
{/* Render all persisted messages */}
{(() => {
const lastAssistantMessageIndex = findLastMessageIndex(
@@ -99,7 +103,11 @@ export function MessageList({
{/* Invisible div to scroll to */}
<div ref={messagesEndRef} />
</div>
</div>
{/* Bottom fade shadow */}
<div className="pointer-events-none absolute bottom-0 z-10 h-8 w-full bg-gradient-to-t from-[#f8f8f9] to-transparent" />
</div>
);
}

View File

@@ -152,7 +152,13 @@ export function useChatStream() {
const stopStreaming = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
try {
if (!abortControllerRef.current.signal.aborted) {
abortControllerRef.current.abort();
}
} catch {
// Ignore abort errors - signal may already be aborted or invalid
}
abortControllerRef.current = null;
}
if (retryTimeoutRef.current) {