chore: further improvements

This commit is contained in:
Lluis Agusti
2026-01-20 20:50:51 +07:00
parent 451191c73c
commit 4696766b52
15 changed files with 382 additions and 534 deletions

View File

@@ -1,24 +0,0 @@
"use client";
import { Chat } from "@/components/contextual/Chat/Chat";
import { useCopilotChatPage } from "./useCopilotChatPage";
export default function CopilotChatPage() {
const { isFlagReady, isChatEnabled, sessionId, prompt } =
useCopilotChatPage();
if (!isFlagReady || isChatEnabled === false) {
return null;
}
return (
<div className="flex h-full flex-col">
<Chat
className="flex-1"
urlSessionId={sessionId}
initialPrompt={prompt}
showNewChatButton={false}
/>
</div>
);
}

View File

@@ -1,60 +0,0 @@
"use client";
import { getHomepageRoute } from "@/lib/constants";
import {
Flag,
type FlagValues,
useGetFlag,
} from "@/services/feature-flags/use-get-flag";
import { useFlags } from "launchdarkly-react-client-sdk";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
export function useCopilotChatPage() {
const router = useRouter();
const searchParams = useSearchParams();
const isChatEnabled = useGetFlag(Flag.CHAT);
const flags = useFlags<FlagValues>();
const homepageRoute = getHomepageRoute(isChatEnabled);
const envEnabled = process.env.NEXT_PUBLIC_LAUNCHDARKLY_ENABLED === "true";
const clientId = process.env.NEXT_PUBLIC_LAUNCHDARKLY_CLIENT_ID;
const isLaunchDarklyConfigured = envEnabled && Boolean(clientId);
const isFlagReady =
!isLaunchDarklyConfigured || flags[Flag.CHAT] !== undefined;
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() {
if (!isFlagReady) return;
if (isChatEnabled === false) {
router.replace(homepageRoute);
}
},
[homepageRoute, isChatEnabled, isFlagReady, router],
);
return {
isFlagReady,
isChatEnabled,
sessionId,
prompt: prompt ?? storedPrompt,
};
}

View File

@@ -1,13 +1,14 @@
"use client";
import {
postV2CreateSession,
getGetV2ListSessionsQueryKey,
useGetV2GetSession,
} from "@/app/api/__generated__/endpoints/chat/chat";
import { okData } from "@/app/api/helpers";
import { useBreakpoint } from "@/lib/hooks/useBreakpoint";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { Key, storage } from "@/services/storage/local-storage";
import { useQueryClient } from "@tanstack/react-query";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useMobileDrawer } from "./components/MobileDrawer/useMobileDrawer";
@@ -17,13 +18,13 @@ import {
filterVisibleSessions,
getCurrentSessionId,
mergeCurrentSessionIntoList,
shouldAutoSelectSession,
} from "./helpers";
export function useCopilotShell() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const breakpoint = useBreakpoint();
const { isLoggedIn } = useSupabase();
const isMobile =
@@ -47,7 +48,6 @@ export function useCopilotShell() {
isFetching: isSessionsFetching,
hasNextPage,
areAllSessionsLoaded,
totalCount,
fetchNextPage,
reset: resetPagination,
} = useSessionsPagination({
@@ -66,104 +66,34 @@ export function useCopilotShell() {
});
const [hasAutoSelectedSession, setHasAutoSelectedSession] = useState(false);
const hasCreatedSessionRef = useRef(false);
const hasAutoSelectedRef = useRef(false);
const paramPrompt = searchParams.get("prompt");
// Mark as auto-selected when sessionId is in URL
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}`,
);
hasAutoSelectedRef.current = true;
setHasAutoSelectedSession(true);
}
})
.catch(() => {
hasCreatedSessionRef.current = false;
});
}
// 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 || 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;
runCreateSession();
return;
}
const visibleSessions = filterVisibleSessions(accumulatedSessions);
const autoSelect = shouldAutoSelectSession(
areAllSessionsLoaded,
hasAutoSelectedRef.current,
paramSessionId,
visibleSessions,
accumulatedSessions,
isSessionsLoading,
totalCount,
);
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;
runCreateSession();
} else if (totalCount === 0) {
hasAutoSelectedRef.current = true;
setHasAutoSelectedSession(true);
}
}, [
isOnHomepage,
areAllSessionsLoaded,
accumulatedSessions,
paramSessionId,
paramPrompt,
router,
isSessionsLoading,
totalCount,
]);
useEffect(() => {
if (paramSessionId) {
if (paramSessionId && !hasAutoSelectedRef.current) {
hasAutoSelectedRef.current = true;
setHasAutoSelectedSession(true);
}
}, [paramSessionId]);
// Reset pagination and auto-selection when query becomes disabled
// On homepage without sessionId, mark as ready immediately
useEffect(() => {
if (isOnHomepage && !paramSessionId && !hasAutoSelectedRef.current) {
hasAutoSelectedRef.current = true;
setHasAutoSelectedSession(true);
}
}, [isOnHomepage, paramSessionId]);
// Invalidate sessions list when navigating to homepage (to show newly created sessions)
useEffect(() => {
if (isOnHomepage && !paramSessionId) {
queryClient.invalidateQueries({
queryKey: getGetV2ListSessionsQueryKey(),
});
}
}, [isOnHomepage, paramSessionId, queryClient]);
// Reset pagination when query becomes disabled
const prevPaginationEnabledRef = useRef(paginationEnabled);
useEffect(() => {
if (prevPaginationEnabledRef.current && !paginationEnabled) {
@@ -171,7 +101,7 @@ export function useCopilotShell() {
resetAutoSelect();
}
prevPaginationEnabledRef.current = paginationEnabled;
}, [paginationEnabled]);
}, [paginationEnabled, resetPagination]);
const sessions = mergeCurrentSessionIntoList(
accumulatedSessions,
@@ -179,6 +109,8 @@ export function useCopilotShell() {
currentSessionData,
);
const visibleSessions = filterVisibleSessions(sessions);
const sidebarSelectedSessionId =
isOnHomepage && !paramSessionId ? null : currentSessionId;
@@ -194,21 +126,28 @@ export function useCopilotShell() {
);
function handleSelectSession(sessionId: string) {
router.push(`/copilot/chat?sessionId=${sessionId}`);
// Navigate using replaceState to avoid full page reload
window.history.replaceState(null, "", `/copilot?sessionId=${sessionId}`);
// Force a re-render by updating the URL through router
router.replace(`/copilot?sessionId=${sessionId}`);
if (isMobile) handleCloseDrawer();
}
function handleNewChat() {
storage.clean(Key.CHAT_SESSION_ID);
resetAutoSelect();
router.push("/copilot");
// Invalidate sessions list to ensure newly created sessions appear
queryClient.invalidateQueries({
queryKey: getGetV2ListSessionsQueryKey(),
});
window.history.replaceState(null, "", "/copilot");
router.replace("/copilot");
if (isMobile) handleCloseDrawer();
}
function resetAutoSelect() {
hasAutoSelectedRef.current = false;
setHasAutoSelectedSession(false);
hasCreatedSessionRef.current = false;
}
return {
@@ -216,9 +155,9 @@ export function useCopilotShell() {
isDrawerOpen,
isLoggedIn,
hasActiveSession:
Boolean(currentSessionId) && (!isOnHomepage || paramSessionId),
Boolean(currentSessionId) && (!isOnHomepage || Boolean(paramSessionId)),
isLoading: isSessionsLoading || !areAllSessionsLoaded,
sessions,
sessions: visibleSessions,
currentSessionId: sidebarSelectedSessionId,
handleSelectSession,
handleOpenDrawer,

View File

@@ -1,28 +1,160 @@
"use client";
import { postV2CreateSession } from "@/app/api/__generated__/endpoints/chat/chat";
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { Text } from "@/components/atoms/Text/Text";
import { Chat } from "@/components/contextual/Chat/Chat";
import { getHomepageRoute } from "@/lib/constants";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import {
Flag,
type FlagValues,
useGetFlag,
} from "@/services/feature-flags/use-get-flag";
import { ArrowUpIcon } from "@phosphor-icons/react";
import { useEffect } from "react";
import { useCopilotHome } from "./useCopilotHome";
import { useFlags } from "launchdarkly-react-client-sdk";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { getGreetingName, getQuickActions } from "./helpers";
type PageState =
| { type: "welcome" }
| { type: "creating"; prompt: string }
| { type: "chat"; sessionId: string; initialPrompt?: string };
export default function CopilotPage() {
const {
greetingName,
value,
quickActions,
isFlagReady,
isChatEnabled,
isUserLoading,
isLoggedIn,
handleChange,
handleSubmit,
handleKeyDown,
handleQuickAction,
} = useCopilotHome();
const router = useRouter();
const searchParams = useSearchParams();
const { user, isLoggedIn, isUserLoading } = useSupabase();
const isChatEnabled = useGetFlag(Flag.CHAT);
const flags = useFlags<FlagValues>();
const homepageRoute = getHomepageRoute(isChatEnabled);
const envEnabled = process.env.NEXT_PUBLIC_LAUNCHDARKLY_ENABLED === "true";
const clientId = process.env.NEXT_PUBLIC_LAUNCHDARKLY_CLIENT_ID;
const isLaunchDarklyConfigured = envEnabled && Boolean(clientId);
const isFlagReady =
!isLaunchDarklyConfigured || flags[Flag.CHAT] !== undefined;
const [inputValue, setInputValue] = useState("");
const [pageState, setPageState] = useState<PageState>({ type: "welcome" });
const initialPromptRef = useRef<Map<string, string>>(new Map());
const urlSessionId = searchParams.get("sessionId");
// Sync with URL sessionId (preserve initialPrompt from ref)
useEffect(
function syncSessionFromUrl() {
if (urlSessionId) {
// If we're already in chat state with this sessionId, don't overwrite
if (
pageState.type === "chat" &&
pageState.sessionId === urlSessionId
) {
return;
}
// Get initialPrompt from ref or current state
const storedInitialPrompt = initialPromptRef.current.get(urlSessionId);
const currentInitialPrompt =
storedInitialPrompt ||
(pageState.type === "creating"
? pageState.prompt
: pageState.type === "chat"
? pageState.initialPrompt
: undefined);
if (currentInitialPrompt) {
initialPromptRef.current.set(urlSessionId, currentInitialPrompt);
}
setPageState({
type: "chat",
sessionId: urlSessionId,
initialPrompt: currentInitialPrompt,
});
} else if (pageState.type === "chat") {
setPageState({ type: "welcome" });
}
},
[urlSessionId],
);
useEffect(
function ensureAccess() {
if (!isFlagReady) return;
if (isChatEnabled === false) {
router.replace(homepageRoute);
}
},
[homepageRoute, isChatEnabled, isFlagReady, router],
);
const greetingName = useMemo(
function getName() {
return getGreetingName(user);
},
[user],
);
const quickActions = useMemo(function getActions() {
return getQuickActions();
}, []);
async function startChatWithPrompt(prompt: string) {
if (!prompt?.trim()) return;
if (pageState.type === "creating") return;
const trimmedPrompt = prompt.trim();
setPageState({ type: "creating", prompt: trimmedPrompt });
setInputValue("");
try {
// Create session
const sessionResponse = await postV2CreateSession({
body: JSON.stringify({}),
});
if (sessionResponse.status !== 200 || !sessionResponse.data?.id) {
throw new Error("Failed to create session");
}
const sessionId = sessionResponse.data.id;
// Store initialPrompt in ref so it persists across re-renders
initialPromptRef.current.set(sessionId, trimmedPrompt);
// Update URL and show Chat with initial prompt
// Chat will handle sending the message and streaming
window.history.replaceState(null, "", `/copilot?sessionId=${sessionId}`);
setPageState({ type: "chat", sessionId, initialPrompt: trimmedPrompt });
} catch (error) {
console.error("[CopilotPage] Failed to start chat:", error);
setPageState({ type: "welcome" });
}
}
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!inputValue.trim()) return;
startChatWithPrompt(inputValue.trim());
}
function handleKeyDown(
event: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>,
) {
if (event.key !== "Enter") return;
if (event.shiftKey) return;
event.preventDefault();
if (!inputValue.trim()) return;
startChatWithPrompt(inputValue.trim());
}
function handleQuickAction(action: string) {
startChatWithPrompt(action);
}
// Auto-grow textarea
useEffect(() => {
const textarea = document.getElementById(
"copilot-prompt",
@@ -39,12 +171,39 @@ export default function CopilotPage() {
textarea.style.height = `${newHeight}px`;
textarea.style.overflowY =
textarea.scrollHeight > maxHeight ? "auto" : "hidden";
}, [value]);
}, [inputValue]);
if (!isFlagReady || isChatEnabled === false || !isLoggedIn) {
return null;
}
// Show Chat when we have an active session
if (pageState.type === "chat") {
return (
<div className="flex h-full flex-col">
<Chat
key={pageState.sessionId ?? "welcome"}
className="flex-1"
urlSessionId={pageState.sessionId}
initialPrompt={pageState.initialPrompt}
/>
</div>
);
}
// Show loading state while creating session and sending first message
if (pageState.type === "creating") {
return (
<div className="flex h-full flex-1 flex-col items-center justify-center bg-[#f8f8f9] px-6 py-10">
<LoadingSpinner size="large" />
<Text variant="body" className="mt-4 text-zinc-500">
Starting your chat...
</Text>
</div>
);
}
// Show Welcome screen
const isLoading = isUserLoading;
return (
@@ -83,8 +242,8 @@ export default function CopilotPage() {
label="Copilot prompt"
hideLabel
type="textarea"
value={value}
onChange={handleChange}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
rows={1}
placeholder='You can search or just ask - e.g. "create a blog post outline"'
@@ -97,7 +256,7 @@ export default function CopilotPage() {
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()}
disabled={!inputValue.trim()}
>
<ArrowUpIcon className="h-4 w-4" weight="bold" />
</Button>
@@ -108,6 +267,7 @@ export default function CopilotPage() {
{quickActions.map((action) => (
<Button
key={action}
type="button"
variant="outline"
size="small"
onClick={() => handleQuickAction(action)}

View File

@@ -1,105 +0,0 @@
"use client";
import { postV2CreateSession } from "@/app/api/__generated__/endpoints/chat/chat";
import { getHomepageRoute } from "@/lib/constants";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import {
Flag,
type FlagValues,
useGetFlag,
} from "@/services/feature-flags/use-get-flag";
import { useFlags } from "launchdarkly-react-client-sdk";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { getGreetingName, getQuickActions } from "./helpers";
export function useCopilotHome() {
const router = useRouter();
const { user, isLoggedIn, isUserLoading } = useSupabase();
const [value, setValue] = useState("");
const isChatEnabled = useGetFlag(Flag.CHAT);
const flags = useFlags<FlagValues>();
const homepageRoute = getHomepageRoute(isChatEnabled);
const envEnabled = process.env.NEXT_PUBLIC_LAUNCHDARKLY_ENABLED === "true";
const clientId = process.env.NEXT_PUBLIC_LAUNCHDARKLY_CLIENT_ID;
const isLaunchDarklyConfigured = envEnabled && Boolean(clientId);
const isFlagReady =
!isLaunchDarklyConfigured || flags[Flag.CHAT] !== undefined;
const greetingName = useMemo(
function getName() {
return getGreetingName(user);
},
[user],
);
const quickActions = useMemo(function getActions() {
return getQuickActions();
}, []);
useEffect(
function ensureAccess() {
if (!isFlagReady) return;
if (isChatEnabled === false) {
router.replace(homepageRoute);
}
},
[homepageRoute, isChatEnabled, isFlagReady, router],
);
function handleChange(
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) {
setValue(event.target.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);
}
}
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;
await createSessionAndNavigate(value.trim());
}
async function handleQuickAction(action: string) {
await createSessionAndNavigate(action);
}
return {
greetingName,
value,
quickActions,
isFlagReady,
isChatEnabled,
isLoggedIn,
isUserLoading,
handleChange,
handleSubmit,
handleKeyDown,
handleQuickAction,
};
}

View File

@@ -1,9 +1,7 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { cn } from "@/lib/utils";
import type { ReactNode } from "react";
import { ChatContainer } from "./components/ChatContainer/ChatContainer";
import { ChatErrorState } from "./components/ChatErrorState/ChatErrorState";
import { ChatLoader } from "./components/ChatLoader/ChatLoader";
@@ -11,22 +9,12 @@ import { useChat } from "./useChat";
export interface ChatProps {
className?: string;
showHeader?: boolean;
showSessionInfo?: boolean;
showNewChatButton?: boolean;
onNewChat?: () => void;
headerActions?: ReactNode;
urlSessionId?: string | null;
initialPrompt?: string | null;
initialPrompt?: string;
}
export function Chat({
className,
showHeader = true,
showSessionInfo = true,
showNewChatButton = true,
onNewChat,
headerActions,
urlSessionId,
initialPrompt,
}: ChatProps) {
@@ -37,44 +25,14 @@ export function Chat({
error,
sessionId,
createSession,
clearSession,
showLoader,
} = useChat({ urlSessionId });
function handleNewChat() {
clearSession();
onNewChat?.();
}
return (
<div className={cn("flex h-full flex-col", className)}>
{/* Header */}
{showHeader && (
<header className="shrink-0 bg-[#f8f8f9] p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{showSessionInfo && sessionId && (
<>
{showNewChatButton && (
<Button
variant="outline"
size="small"
onClick={handleNewChat}
>
New Chat
</Button>
)}
</>
)}
{headerActions}
</div>
</div>
</header>
)}
{/* Main Content */}
<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) */}
{/* Loading State */}
{showLoader && (isLoading || isCreating) && (
<div className="flex flex-1 items-center justify-center">
<div className="flex flex-col items-center gap-4">

View File

@@ -1,7 +1,7 @@
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 { useCallback } from "react";
import { usePageContext } from "../../usePageContext";
import { ChatInput } from "../ChatInput/ChatInput";
import { MessageList } from "../MessageList/MessageList";
@@ -10,29 +10,28 @@ import { useChatContainer } from "./useChatContainer";
export interface ChatContainerProps {
sessionId: string | null;
initialMessages: SessionDetailResponse["messages"];
initialPrompt?: string;
className?: string;
initialPrompt?: string | null;
}
export function ChatContainer({
sessionId,
initialMessages,
className,
initialPrompt,
className,
}: ChatContainerProps) {
const { messages, streamingChunks, isStreaming, sendMessage } =
useChatContainer({
sessionId,
initialMessages,
initialPrompt,
});
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(
async (content: string, isUserMessage: boolean = true) => {
const context = capturePageContext();
@@ -41,18 +40,6 @@ export function ChatContainer({
[sendMessage, capturePageContext],
);
useEffect(
function handleInitialPrompt() {
if (!initialPrompt) return;
if (hasSentInitialRef.current) return;
if (!sessionId) return;
if (messages.length > 0) return;
hasSentInitialRef.current = true;
void sendMessageWithContext(initialPrompt);
},
[initialPrompt, messages.length, sendMessageWithContext, sessionId],
);
return (
<div
className={cn(
@@ -60,7 +47,7 @@ export function ChatContainer({
className,
)}
>
{/* Messages or Welcome Screen - Scrollable */}
{/* Messages - Scrollable */}
<div className="relative flex min-h-0 flex-1 flex-col">
<div className="flex min-h-full flex-col justify-end">
<MessageList
@@ -74,7 +61,7 @@ export function ChatContainer({
</div>
{/* Input - Fixed at bottom */}
<div className="relative px-3 pb-4 pt-2">
<div className="relative px-3 pb-6 pt-2">
<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}
@@ -82,7 +69,7 @@ export function ChatContainer({
placeholder={
isMobile
? "You can search or just ask"
: "You can search or just ask — e.g. create a blog post outline"
: "You can search or just ask — e.g. \"create a blog post outline\""
}
/>
</div>

View File

@@ -1,6 +1,33 @@
import {
SessionKey,
sessionStorage,
} from "@/services/storage/session-storage";
import type { ToolResult } from "@/types/chat";
import type { ChatMessageData } from "../ChatMessage/useChatMessage";
export function hasSentInitialPrompt(sessionId: string): boolean {
try {
const sent = JSON.parse(
sessionStorage.get(SessionKey.CHAT_SENT_INITIAL_PROMPTS) || "{}",
);
return sent[sessionId] === true;
} catch {
return false;
}
}
export function markInitialPromptSent(sessionId: string): void {
try {
const sent = JSON.parse(
sessionStorage.get(SessionKey.CHAT_SENT_INITIAL_PROMPTS) || "{}",
);
sent[sessionId] = true;
sessionStorage.set(SessionKey.CHAT_SENT_INITIAL_PROMPTS, JSON.stringify(sent));
} catch {
// Ignore storage errors
}
}
export function removePageContext(content: string): string {
// Remove "Page URL: ..." pattern at start of line (case insensitive, handles various formats)
let cleaned = content.replace(/^\s*Page URL:\s*[^\n\r]*/gim, "");

View File

@@ -30,18 +30,9 @@ export function handleTextEnded(
_chunk: StreamChunk,
deps: HandlerDependencies,
) {
console.log("[Text Ended] Saving streamed text as assistant message");
const completedText = deps.streamingChunksRef.current.join("");
if (completedText.trim()) {
deps.setMessages((prev) => {
const lastMessage = prev[prev.length - 1];
console.log("[Text Ended] Previous message:", {
type: lastMessage?.type,
toolName:
lastMessage?.type === "tool_call" ? lastMessage.toolName : undefined,
content: completedText.substring(0, 200),
});
const assistantMessage: ChatMessageData = {
type: "message",
role: "assistant",
@@ -68,22 +59,12 @@ export function handleToolCallStart(
timestamp: new Date(),
};
deps.setMessages((prev) => [...prev, toolCallMessage]);
console.log("[Tool Call Start]", {
toolId: toolCallMessage.toolId,
toolName: toolCallMessage.toolName,
timestamp: new Date().toISOString(),
});
}
export function handleToolResponse(
chunk: StreamChunk,
deps: HandlerDependencies,
) {
console.log("[Tool Response] Received:", {
toolId: chunk.tool_id,
toolName: chunk.tool_name,
timestamp: new Date().toISOString(),
});
let toolName = chunk.tool_name || "unknown";
if (!chunk.tool_name || chunk.tool_name === "unknown") {
deps.setMessages((prev) => {
@@ -140,19 +121,8 @@ export function handleToolResponse(
if (toolCallIndex !== -1) {
const newMessages = [...prev];
newMessages[toolCallIndex] = responseMessage;
console.log(
"[Tool Response] Replaced tool_call with matching tool_id:",
chunk.tool_id,
"at index:",
toolCallIndex,
);
return newMessages;
}
console.warn(
"[Tool Response] No tool_call found with tool_id:",
chunk.tool_id,
"appending instead",
);
return [...prev, responseMessage];
});
}
@@ -177,50 +147,19 @@ export function handleStreamEnd(
deps: HandlerDependencies,
) {
const completedContent = deps.streamingChunksRef.current.join("");
// Only save message if there are uncommitted chunks
// (text_ended already saved if there were tool calls)
if (completedContent.trim()) {
console.log(
"[Stream End] Saving remaining streamed text as assistant message",
);
const assistantMessage: ChatMessageData = {
type: "message",
role: "assistant",
content: completedContent,
timestamp: new Date(),
};
deps.setMessages((prev) => {
const updated = [...prev, assistantMessage];
console.log("[Stream End] Final state:", {
localMessages: updated.map((m) => ({
type: m.type,
...(m.type === "message" && {
role: m.role,
contentLength: m.content.length,
}),
...(m.type === "tool_call" && {
toolId: m.toolId,
toolName: m.toolName,
}),
...(m.type === "tool_response" && {
toolId: m.toolId,
toolName: m.toolName,
success: m.success,
}),
})),
streamingChunks: deps.streamingChunksRef.current,
timestamp: new Date().toISOString(),
});
return updated;
});
} else {
console.log("[Stream End] No uncommitted chunks, message already saved");
deps.setMessages((prev) => [...prev, assistantMessage]);
}
deps.setStreamingChunks([]);
deps.streamingChunksRef.current = [];
deps.setHasTextChunks(false);
deps.setIsStreamingInitiated(false);
console.log("[Stream End] Stream complete, messages in local state");
}
export function handleError(chunk: StreamChunk, deps: HandlerDependencies) {

View File

@@ -1,14 +1,17 @@
import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
import { useCallback, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { useChatStream } from "../../useChatStream";
import { usePageContext } from "../../usePageContext";
import type { ChatMessageData } from "../ChatMessage/useChatMessage";
import { createStreamEventDispatcher } from "./createStreamEventDispatcher";
import {
createUserMessage,
filterAuthMessages,
hasSentInitialPrompt,
isToolCallArray,
isValidMessage,
markInitialPromptSent,
parseToolResponse,
removePageContext,
} from "./helpers";
@@ -16,9 +19,10 @@ import {
interface Args {
sessionId: string | null;
initialMessages: SessionDetailResponse["messages"];
initialPrompt?: string;
}
export function useChatContainer({ sessionId, initialMessages }: Args) {
export function useChatContainer({ sessionId, initialMessages, initialPrompt }: Args) {
const [messages, setMessages] = useState<ChatMessageData[]>([]);
const [streamingChunks, setStreamingChunks] = useState<string[]>([]);
const [hasTextChunks, setHasTextChunks] = useState(false);
@@ -29,7 +33,6 @@ export function useChatContainer({ sessionId, initialMessages }: Args) {
const allMessages = useMemo(() => {
const processedInitialMessages: ChatMessageData[] = [];
// Map to track tool calls by their ID so we can look up tool names for tool responses
const toolCallMap = new Map<string, string>();
for (const msg of initialMessages) {
@@ -45,13 +48,9 @@ export function useChatContainer({ sessionId, initialMessages }: Args) {
? new Date(msg.timestamp as string)
: undefined;
// Remove page context from user messages when loading existing sessions
if (role === "user") {
content = removePageContext(content);
// Skip user messages that become empty after removing page context
if (!content.trim()) {
continue;
}
if (!content.trim()) continue;
processedInitialMessages.push({
type: "message",
role: "user",
@@ -61,19 +60,15 @@ export function useChatContainer({ sessionId, initialMessages }: Args) {
continue;
}
// Handle assistant messages first (before tool messages) to build tool call map
if (role === "assistant") {
// Strip <thinking> tags from content
content = content
.replace(/<thinking>[\s\S]*?<\/thinking>/gi, "")
.trim();
// If assistant has tool calls, create tool_call messages for each
if (toolCalls && isToolCallArray(toolCalls) && toolCalls.length > 0) {
for (const toolCall of toolCalls) {
const toolName = toolCall.function.name;
const toolId = toolCall.id;
// Store tool name for later lookup
toolCallMap.set(toolId, toolName);
try {
@@ -96,7 +91,6 @@ export function useChatContainer({ sessionId, initialMessages }: Args) {
});
}
}
// Only add assistant message if there's content after stripping thinking tags
if (content.trim()) {
processedInitialMessages.push({
type: "message",
@@ -106,7 +100,6 @@ export function useChatContainer({ sessionId, initialMessages }: Args) {
});
}
} else if (content.trim()) {
// Assistant message without tool calls, but with content
processedInitialMessages.push({
type: "message",
role: "assistant",
@@ -117,7 +110,6 @@ export function useChatContainer({ sessionId, initialMessages }: Args) {
continue;
}
// Handle tool messages - look up tool name from tool call map
if (role === "tool") {
const toolCallId = (msg.tool_call_id as string) || "";
const toolName = toolCallMap.get(toolCallId) || "unknown";
@@ -133,7 +125,6 @@ export function useChatContainer({ sessionId, initialMessages }: Args) {
continue;
}
// Handle other message types (system, etc.)
if (content.trim()) {
processedInitialMessages.push({
type: "message",
@@ -154,7 +145,7 @@ export function useChatContainer({ sessionId, initialMessages }: Args) {
context?: { url: string; content: string },
) {
if (!sessionId) {
console.error("Cannot send message: no session ID");
console.error("[useChatContainer] Cannot send message: no session ID");
return;
}
if (isUserMessage) {
@@ -167,6 +158,7 @@ export function useChatContainer({ sessionId, initialMessages }: Args) {
streamingChunksRef.current = [];
setHasTextChunks(false);
setIsStreamingInitiated(true);
const dispatcher = createStreamEventDispatcher({
setHasTextChunks,
setStreamingChunks,
@@ -175,6 +167,7 @@ export function useChatContainer({ sessionId, initialMessages }: Args) {
sessionId,
setIsStreamingInitiated,
});
try {
await sendStreamMessage(
sessionId,
@@ -184,8 +177,12 @@ export function useChatContainer({ sessionId, initialMessages }: Args) {
context,
);
} catch (err) {
console.error("Failed to send message:", err);
console.error("[useChatContainer] Failed to send message:", err);
setIsStreamingInitiated(false);
// Don't show error toast for AbortError (expected during cleanup)
if (err instanceof Error && err.name === "AbortError") return;
const errorMessage =
err instanceof Error ? err.message : "Failed to send message";
toast.error("Failed to send message", {
@@ -196,6 +193,22 @@ export function useChatContainer({ sessionId, initialMessages }: Args) {
[sessionId, sendStreamMessage],
);
const { capturePageContext } = usePageContext();
// Send initial prompt if provided (for new sessions from homepage)
useEffect(
function handleInitialPrompt() {
if (!initialPrompt || !sessionId) return;
if (initialMessages.length > 0) return;
if (hasSentInitialPrompt(sessionId)) return;
markInitialPromptSent(sessionId);
const context = capturePageContext();
sendMessage(initialPrompt, true, context);
},
[initialPrompt, sessionId, initialMessages.length, sendMessage, capturePageContext],
);
return {
messages: allMessages,
streamingChunks,

View File

@@ -1,3 +1,4 @@
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input";
import { cn } from "@/lib/utils";
import { ArrowUpIcon } from "@phosphor-icons/react";
@@ -24,41 +25,43 @@ export function ChatInput({
inputId,
});
return (
<div className={cn("relative flex-1", className)}>
<Input
id={inputId}
label="Chat message input"
hideLabel
type="textarea"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
rows={1}
wrapperClassName="mb-0 relative"
className="pr-12"
/>
<span id="chat-input-hint" className="sr-only">
Press Enter to send, Shift+Enter for new line
</span>
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
handleSend();
}
<button
onClick={handleSend}
disabled={disabled || !value.trim()}
className={cn(
"absolute right-3 top-1/2 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full",
"border border-zinc-800 bg-zinc-800 text-white",
"hover:border-zinc-900 hover:bg-zinc-900",
"disabled:border-zinc-200 disabled:bg-zinc-200 disabled:text-white disabled:opacity-50",
"transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950",
"disabled:pointer-events-none",
)}
aria-label="Send message"
>
<ArrowUpIcon className="h-3 w-3" weight="bold" />
</button>
</div>
return (
<form onSubmit={handleSubmit} className={cn("relative flex-1", className)}>
<div className="relative">
<Input
id={inputId}
label="Chat message input"
hideLabel
type="textarea"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
rows={1}
wrapperClassName="mb-0"
className="!rounded-full border-transparent !py-5 pr-12 !text-[1rem] resize-none [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
/>
<span id="chat-input-hint" className="sr-only">
Press Enter to send, Shift+Enter for new line
</span>
<Button
type="submit"
variant="icon"
size="icon"
aria-label="Send message"
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={disabled || !value.trim()}
>
<ArrowUpIcon className="h-4 w-4" weight="bold" />
</Button>
</div>
</form>
);
}

View File

@@ -45,29 +45,8 @@ export function useMessageItem({
success: true,
timestamp: message.timestamp,
} as ChatMessageData;
console.log(
"[MessageItem] Converting assistant message to tool output:",
{
content: message.content.substring(0, 100),
prevToolName: prevMessage.toolName,
},
);
}
}
// Log for debugging
if (message.type === "message" && message.role === "assistant") {
const prevMessageToolName =
prevMessage?.type === "tool_call" ? prevMessage.toolName : undefined;
console.log("[MessageItem] Assistant message:", {
index,
content: message.content.substring(0, 200),
fullContent: message.content,
prevMessageType: prevMessage?.type,
prevMessageToolName,
});
}
}
const isFinalMessage =

View File

@@ -189,10 +189,7 @@ export function useChatSession({
const refreshSession = useCallback(
async function refreshSession() {
if (!sessionId) {
console.log("[refreshSession] Skipping - no session ID");
return;
}
if (!sessionId) return;
try {
setError(null);
await refetch();

View File

@@ -1,5 +1,5 @@
import type { ToolArguments, ToolResult } from "@/types/chat";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { toast } from "sonner";
const MAX_RETRIES = 3;
@@ -151,13 +151,14 @@ export function useChatStream() {
const abortControllerRef = useRef<AbortController | null>(null);
const stopStreaming = useCallback(() => {
if (abortControllerRef.current) {
const controller = abortControllerRef.current;
if (controller) {
try {
if (!abortControllerRef.current.signal.aborted) {
abortControllerRef.current.abort();
if (!controller.signal.aborted) {
controller.abort();
}
} catch {
// Ignore abort errors - signal may already be aborted or invalid
// Ignore abort errors
}
abortControllerRef.current = null;
}
@@ -168,12 +169,6 @@ export function useChatStream() {
setIsStreaming(false);
}, []);
useEffect(() => {
return () => {
stopStreaming();
};
}, [stopStreaming]);
const sendMessage = useCallback(
async (
sessionId: string,
@@ -238,11 +233,9 @@ export function useChatStream() {
onChunk({ type: "stream_end" });
}
const cleanup = () => {
reader.cancel().catch(() => {
// Ignore cancel errors
});
};
function cleanup() {
reader.cancel().catch(() => {});
}
async function readStream() {
try {
@@ -283,10 +276,8 @@ export function useChatStream() {
continue;
}
// Call the chunk handler
onChunk(chunk);
// Handle stream lifecycle
if (chunk.type === "stream_end") {
didDispatchStreamEnd = true;
cleanup();
@@ -303,9 +294,8 @@ export function useChatStream() {
);
return;
}
} catch (err) {
} catch {
// Skip invalid JSON lines
console.warn("Failed to parse SSE chunk:", err, data);
}
}
}
@@ -313,6 +303,9 @@ export function useChatStream() {
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
cleanup();
dispatchStreamEnd();
stopStreaming();
resolve();
return;
}
@@ -336,9 +329,7 @@ export function useChatStream() {
isUserMessage,
context,
true,
).catch((_err) => {
// Retry failed
});
).catch(() => {});
}, retryDelay);
} else {
setError(streamError);
@@ -358,6 +349,10 @@ export function useChatStream() {
readStream();
});
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
setIsStreaming(false);
return Promise.resolve();
}
const streamError =
err instanceof Error ? err : new Error("Failed to start stream");
setError(streamError);

View File

@@ -0,0 +1,40 @@
import * as Sentry from "@sentry/nextjs";
import { environment } from "../environment";
export enum SessionKey {
CHAT_SENT_INITIAL_PROMPTS = "chat_sent_initial_prompts",
}
function get(key: SessionKey) {
if (environment.isServerSide()) {
Sentry.captureException(new Error("Session storage is not available"));
return;
}
try {
return window.sessionStorage.getItem(key);
} catch {
return;
}
}
function set(key: SessionKey, value: string) {
if (environment.isServerSide()) {
Sentry.captureException(new Error("Session storage is not available"));
return;
}
return window.sessionStorage.setItem(key, value);
}
function clean(key: SessionKey) {
if (environment.isServerSide()) {
Sentry.captureException(new Error("Session storage is not available"));
return;
}
return window.sessionStorage.removeItem(key);
}
export const sessionStorage = {
clean,
get,
set,
};