+
{children}
diff --git a/echo/frontend/src/components/participant/ParticipantConversationAudio.tsx b/echo/frontend/src/components/participant/ParticipantConversationAudio.tsx
index 1af0f4a9e..7099561e6 100644
--- a/echo/frontend/src/components/participant/ParticipantConversationAudio.tsx
+++ b/echo/frontend/src/components/participant/ParticipantConversationAudio.tsx
@@ -938,8 +938,10 @@ export const ParticipantConversationAudio = () => {
}`}
>
+ scrollTargetRef.current?.scrollIntoView({ behavior: "smooth" })
+ }
/>
diff --git a/echo/frontend/src/routes/project/chat/NewChatRoute.tsx b/echo/frontend/src/routes/project/chat/NewChatRoute.tsx
index 4967220de..21b510673 100644
--- a/echo/frontend/src/routes/project/chat/NewChatRoute.tsx
+++ b/echo/frontend/src/routes/project/chat/NewChatRoute.tsx
@@ -3,7 +3,6 @@ import { Plural, Trans } from "@lingui/react/macro";
import {
ActionIcon,
Alert,
- Anchor,
Badge,
Box,
Button,
@@ -14,28 +13,44 @@ import {
Stack,
Text,
Textarea,
+ TextInput,
Title,
} from "@mantine/core";
-import { useDisclosure, useDocumentTitle } from "@mantine/hooks";
-import { IconAlertCircle, IconArrowUp } from "@tabler/icons-react";
+import {
+ useDebouncedValue,
+ useDisclosure,
+ useDocumentTitle,
+} from "@mantine/hooks";
+import {
+ IconAlertCircle,
+ IconSearch,
+ IconSend,
+ IconX,
+} from "@tabler/icons-react";
import { formatRelative } from "date-fns";
import posthog from "posthog-js";
-import { Suspense, useEffect, useMemo, useRef, useState } from "react";
+import { Suspense, useEffect, useRef, useState } from "react";
import { useInView } from "react-intersection-observer";
-import { useLocation, useParams } from "react-router";
+import { useLocation, useParams, useSearchParams } from "react-router";
+import { AgenticIntroModal } from "@/components/chat/AgenticIntroModal";
import {
ChatAccordionItemMenu,
ChatModeIndicator,
} from "@/components/chat/ChatAccordion";
+import {
+ ChatComposerShell,
+ ConversationFocusChips,
+ ConversationPickerButton,
+} from "@/components/chat/ChatComposer";
import { ChatModeSelector } from "@/components/chat/ChatModeSelector";
import { ChatUpgradeModal } from "@/components/chat/FreeTierChatGate";
import {
useInfiniteProjectChats,
useInitializeChatModeMutation,
usePrefetchSuggestions,
+ useProjectChatSearch,
useProjectChatsCount,
} from "@/components/chat/hooks";
-import { InsertTemplateMenu } from "@/components/chat/InsertTemplateMenu";
import { consumeChatPrefill } from "@/components/chat/prefill";
import { BaseSkeleton } from "@/components/common/BaseSkeleton";
import { NavigationButton } from "@/components/common/NavigationButton";
@@ -45,11 +60,7 @@ import {
useAttachChatConversationsMutation,
useCreateChatMutation,
} from "@/components/project/hooks";
-import {
- AGENTIC_CHAT_IS_DEFAULT,
- ASK_DOCS_URL,
- ENABLE_AGENTIC_CHAT,
-} from "@/config";
+import { AGENTIC_CHAT_IS_DEFAULT, ENABLE_AGENTIC_CHAT } from "@/config";
import { useI18nNavigate } from "@/hooks/useI18nNavigate";
import { useLanguage } from "@/hooks/useLanguage";
import { useWorkspace } from "@/hooks/useWorkspace";
@@ -57,6 +68,7 @@ import { useWorkspaceUsage } from "@/hooks/useWorkspaceUsage";
import type { ChatMode } from "@/lib/api";
import { isFreeTierLimitError } from "@/lib/freeTier";
import { isReadOnlyRole } from "@/lib/roles";
+import { testId } from "@/lib/testUtils";
const CHATS_PAGE_SIZE = 10;
@@ -70,11 +82,9 @@ const ChatsSectionSkeleton = () => (
);
const ProjectChatsSection = ({
- filter,
projectId,
workspaceId,
}: {
- filter?: string;
projectId: string;
workspaceId: string;
}) => {
@@ -87,6 +97,24 @@ const ProjectChatsSection = ({
initialLimit: CHATS_PAGE_SIZE,
});
+ const [searchParams, setSearchParams] = useSearchParams();
+ const rawSearch = searchParams.get("chatq") ?? "";
+ const [debouncedSearch] = useDebouncedValue(rawSearch, 300);
+ const searchQuery = useProjectChatSearch(projectId, debouncedSearch);
+ const isSearching = debouncedSearch.trim().length > 0;
+
+ const setSearch = (next: string) => {
+ setSearchParams(
+ (current) => {
+ const params = new URLSearchParams(current);
+ if (next) params.set("chatq", next);
+ else params.delete("chatq");
+ return params;
+ },
+ { replace: true },
+ );
+ };
+
useEffect(() => {
if (inView && chatsQuery.hasNextPage && !chatsQuery.isFetchingNextPage) {
chatsQuery.fetchNextPage();
@@ -106,37 +134,69 @@ const ProjectChatsSection = ({
}>
)?.flatMap((page) => page.chats) ?? [];
- // The bar above doubles as a filter over your chats: typing narrows the
- // list in place (gently, no layout jumps) while Enter still starts a new
- // chat with the typed question.
- const normalizedFilter = filter?.trim().toLowerCase() ?? "";
- const visibleChats = useMemo(() => {
- if (!normalizedFilter) return allChats;
- return allChats.filter((chat) =>
- (chat.name ?? "").toLowerCase().includes(normalizedFilter),
- );
- }, [allChats, normalizedFilter]);
+ const listedChats = isSearching ? (searchQuery.data?.chats ?? []) : allChats;
+ const shownCount = isSearching
+ ? (searchQuery.data?.total ?? 0)
+ : (chatsCountQuery.data ?? 0);
- const totalChats = chatsCountQuery.data ?? 0;
- if (totalChats === 0) return null;
+ // Hide the whole section only when the project genuinely has no chats and
+ // the host is not searching; otherwise the empty states below do the talking.
+ if (!isSearching && (chatsCountQuery.data ?? 0) === 0) return null;
return (
-
-
- Chats
-
- {totalChats}
+
+
+
+ Chats
+
+ {shownCount}
+
+ setSearch(event.currentTarget.value)}
+ placeholder={t`Search chats`}
+ aria-label={t`Search chats`}
+ leftSection={}
+ rightSection={
+ rawSearch ? (
+ setSearch("")}
+ >
+
+
+ ) : null
+ }
+ {...testId("chats-search-input")}
+ />
- {normalizedFilter && visibleChats.length === 0 && (
+ {isSearching && listedChats.length === 0 && !searchQuery.isFetching && (
+
+ No chats match your search.
+
+ )}
+ {!isSearching && listedChats.length === 0 && (
- No chats match. Press Enter to ask this as a new chat.
+ No chats yet.
+
+ )}
+ {isSearching && shownCount > listedChats.length && (
+
+
+ Showing the first {listedChats.length} of {shownCount} matches.
+ Narrow the search to see the rest.
+
)}
- {visibleChats.map((item, index) => {
+ {listedChats.map((item, index) => {
const chatMode = (item as ProjectChat & { chat_mode?: string })
.chat_mode as
| "overview"
@@ -154,7 +214,11 @@ const ProjectChatsSection = ({
}
- ref={index === visibleChats.length - 1 ? loadMoreRef : undefined}
+ ref={
+ !isSearching && index === listedChats.length - 1
+ ? loadMoreRef
+ : undefined
+ }
>
@@ -222,9 +286,9 @@ export const NewChatRoute = () => {
const [selectedConversationIds, setSelectedConversationIds] = useState<
string[]
>(() => {
- const state = location.state as
- | { selectedConversationIds?: unknown }
- | null;
+ const state = location.state as {
+ selectedConversationIds?: unknown;
+ } | null;
return Array.isArray(state?.selectedConversationIds)
? state.selectedConversationIds.filter(
(id): id is string => typeof id === "string",
@@ -232,6 +296,7 @@ export const NewChatRoute = () => {
: [];
});
const [pickerOpened, pickerHandlers] = useDisclosure(false);
+ const [agenticIntroOpened, agenticIntroHandlers] = useDisclosure(false);
const handleModeSelected = async (
mode: ChatMode,
@@ -405,120 +470,96 @@ export const NewChatRoute = () => {
Where would you like to start?
-
{/* Body */}
-
+
{
))}
+ {pendingContextAnnouncement.length > 0 &&
+ messages.length > 0 &&
+ (messages[messages.length - 1].role === "user" || isLoading) && (
+ // biome-ignore lint/a11y/useValidAriaRole: role is a component prop for styling, not an ARIA attribute
+