Skip to content
Merged
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
325 changes: 133 additions & 192 deletions echo/frontend/src/components/chat/AgenticChatPanel.tsx

Large diffs are not rendered by default.

90 changes: 90 additions & 0 deletions echo/frontend/src/components/chat/AgenticIntroModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { Badge, Button, Group, List, Modal, Stack, Text } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { MODE_COLORS } from "@/components/chat/ChatModeSelector";
import { testId } from "@/lib/testUtils";

export const AgenticIntroModal = ({
onClose,
onConfirm,
opened,
}: {
onClose: () => void;
onConfirm: () => void;
opened: boolean;
}) => (
<Modal
opened={opened}
onClose={onClose}
title={
<Group gap="xs" align="center">
<Text size="lg">
<Trans>Agentic chat</Trans>
</Text>
<Badge size="sm" color="mauve" c="graphite">
<Trans>Beta</Trans>
</Badge>
</Group>
}
size="md"
padding="lg"
{...testId("agentic-intro-modal")}
>
<Stack gap="md">
<List
spacing="md"
size="sm"
icon={<IconSparkles size={14} color={MODE_COLORS.agentic.primary} />}
styles={{ itemWrapper: { alignItems: "baseline" } }}
>
<List.Item>
<Stack gap={2}>
<Text size="sm" fw={500}>
<Trans>Multi-step analysis.</Trans>
</Text>
<Text size="xs">
<Trans>
Plans, gathers, and checks its work across many conversations.
</Trans>
</Text>
</Stack>
</List.Item>
<List.Item>
<Stack gap={2}>
<Text size="sm" fw={500}>
<Trans>Help setting up your project.</Trans>
</Text>
<Text size="xs">
<Trans>Walks you through setup and suggests next steps.</Trans>
</Text>
</Stack>
</List.Item>
<List.Item>
<Stack gap={2}>
<Text size="sm" fw={500}>
<Trans>Ask about the app.</Trans>
</Text>
<Text size="xs">
<Trans>
Questions about how dembrane works, not only about your data.
</Trans>
</Text>
</Stack>
</List.Item>
</List>
<Group justify="flex-end" gap="sm">
<Button variant="subtle" onClick={onClose}>
<Trans>Not now</Trans>
</Button>
<Button
onClick={onConfirm}
aria-label={t`Switch to Agentic`}
{...testId("agentic-intro-modal-confirm")}
>
<Trans>Switch to Agentic</Trans>
</Button>
</Group>
</Stack>
</Modal>
);
181 changes: 181 additions & 0 deletions echo/frontend/src/components/chat/ChatComposer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
// @vitest-environment jsdom
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { MantineProvider } from "@mantine/core";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { afterEach, beforeAll, expect, it, vi } from "vitest";
import {
ChatComposerShell,
ConversationFocusChips,
ConversationPickerButton,
} from "./ChatComposer";

beforeAll(() => {
i18n.load("en", {});
i18n.activate("en");

// MantineProvider reads the OS color scheme on mount; jsdom has no
// matchMedia, so stub a minimal (always non-matching) implementation.
window.matchMedia =
window.matchMedia ||
((query: string) => ({
addEventListener: () => {},
addListener: () => {},
dispatchEvent: () => false,
matches: false,
media: query,
onchange: null,
removeEventListener: () => {},
removeListener: () => {},
}));
});

afterEach(() => {
cleanup();
});

const renderWithProviders = (ui: React.ReactNode) =>
render(
<I18nProvider i18n={i18n}>
<MantineProvider>
<MemoryRouter>{ui}</MemoryRouter>
</MantineProvider>
</I18nProvider>,
);

it("renders the textarea slot with the footer controls around it", () => {
renderWithProviders(
<ChatComposerShell
chips={<span>chips</span>}
footerLeft={<span>left</span>}
footerRight={<span>right</span>}
>
<textarea aria-label="message" />
</ChatComposerShell>,
);
expect(screen.getByLabelText("message")).toBeTruthy();
expect(screen.getByText("chips")).toBeTruthy();
expect(screen.getByText("left")).toBeTruthy();
expect(screen.getByText("right")).toBeTruthy();
});

it("omits the chips row when no chips are passed", () => {
renderWithProviders(
<ChatComposerShell>
<textarea aria-label="message" />
</ChatComposerShell>,
);
expect(screen.queryByTestId("chat-composer-chips")).toBeNull();
});

it("clears every conversation from one control", () => {
const onClearAll = vi.fn();
renderWithProviders(
<ConversationFocusChips
conversations={[
{ id: "a", participant_name: "Ada" },
{ id: "b", participant_name: "Bo" },
]}
label="Focusing on 2 conversations:"
onClearAll={onClearAll}
/>,
);
fireEvent.click(screen.getByTestId("chat-composer-clear-focus"));
expect(onClearAll).toHaveBeenCalledTimes(1);
});

it("renders no clear control when clearing is not available", () => {
renderWithProviders(
<ConversationFocusChips
conversations={[{ id: "a", participant_name: "Ada" }]}
label="Using 1 conversation:"
/>,
);
expect(screen.queryByTestId("chat-composer-clear-focus")).toBeNull();
});

it("shows the overflow notice instead of chips when one is given", () => {
renderWithProviders(
<ConversationFocusChips
conversations={[{ id: "a", participant_name: "Ada" }]}
label="Focusing on 1 conversation:"
onClearAll={() => {}}
overflowNotice={<span>too many to list</span>}
/>,
);
expect(screen.getByText("too many to list")).toBeTruthy();
expect(screen.queryByText("Ada")).toBeNull();
});

it("renders nothing when count is zero and no conversations are given", () => {
renderWithProviders(
<ConversationFocusChips count={0} label="Using 0 conversations:" />,
);
expect(screen.queryByText("Using 0 conversations:")).toBeNull();
});

it("renders the label without ConversationLinks in count-only mode", () => {
renderWithProviders(
<ConversationFocusChips count={3} label="Using 3 conversations:" />,
);
expect(screen.getByText("Using 3 conversations:")).toBeTruthy();
// No conversation objects given, so ConversationLinks has nothing to render.
expect(screen.queryByRole("link")).toBeNull();
});

it("disables the clear button independently of the clearing spinner", () => {
const onClearAll = vi.fn();
renderWithProviders(
<ConversationFocusChips
count={2}
disabled
label="Using 2 conversations:"
onClearAll={onClearAll}
/>,
);
const button = screen.getByTestId("chat-composer-clear-focus");
expect(button.hasAttribute("data-disabled")).toBe(true);
fireEvent.click(button);
expect(onClearAll).not.toHaveBeenCalled();
});

it("prefers conversations over count when both are given", () => {
renderWithProviders(
<ConversationFocusChips
conversations={[{ id: "a", participant_name: "Ada" }]}
count={99}
label="Using 1 conversation:"
/>,
);
// Real conversation list renders, not the unrelated `count` value.
expect(screen.getByText("Ada")).toBeTruthy();
});

it("prefers the overflow notice over ConversationLinks even in count-only mode", () => {
renderWithProviders(
<ConversationFocusChips
count={50}
label="Using 50 conversations:"
overflowNotice={<span>too many to list</span>}
/>,
);
expect(screen.getByText("too many to list")).toBeTruthy();
});

it("labels the picker button from its prop", () => {
const onClick = vi.fn();
renderWithProviders(
<ConversationPickerButton
ariaLabel="Focus on conversations"
label="Focus on conversations"
onClick={onClick}
testId="agentic-select-conversations-button"
/>,
);
const button = screen.getByTestId("agentic-select-conversations-button");
expect(button.textContent).toContain("Focus on conversations");
expect(button.getAttribute("aria-label")).toBe("Focus on conversations");
fireEvent.click(button);
expect(onClick).toHaveBeenCalledTimes(1);
});
126 changes: 126 additions & 0 deletions echo/frontend/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { Trans } from "@lingui/react/macro";
import { Box, Button, Group, Text } from "@mantine/core";
import { ChatCircleText as ChatCircleTextIcon } from "@phosphor-icons/react";
import { ConversationLinks } from "@/components/conversation/ConversationLinks";
import { testId } from "@/lib/testUtils";

/** The one owner of composer appearance. Every chat surface renders through
* this so the three bottom bars cannot drift apart again. */
export const ChatComposerShell = ({
children,
chips,
footerLeft,
footerRight,
}: {
children: React.ReactNode;
chips?: React.ReactNode;
footerLeft?: React.ReactNode;
footerRight?: React.ReactNode;
}) => (
<Box
className="rounded-xl border px-3 pb-2 pt-2 shadow-sm transition-colors"
style={{
backgroundColor: "var(--app-background)",
borderColor: "var(--mantine-color-primary-light)",
}}
>
{chips && <Box {...testId("chat-composer-chips")}>{chips}</Box>}
{children}
<Group justify="space-between" align="center" gap="xs">
<Group gap="xs">{footerLeft}</Group>
<Group gap="xs" wrap="nowrap">
{footerRight}
</Group>
</Group>
</Box>
);

export const ConversationFocusChips = ({
conversations,
count,
disabled,
isClearing,
label,
onClearAll,
overflowNotice,
}: {
/** Omit if only a count is known (e.g. before the chat exists); use `count`. */
conversations?: Array<{ id: string; participant_name?: string | null }>;
/** Count-only mode: same shell, no `ConversationLinks`. */
count?: number;
/** Disables "Clear all" for reasons unrelated to clearing (unlike
* `isClearing`, which is the clear action's own loading state). */
disabled?: boolean;
isClearing?: boolean;
label: React.ReactNode;
/** Absent means this surface has no way to clear, so no control renders.
* Never wire this to something that opens a picker; "Clear all" must clear. */
onClearAll?: () => void;
overflowNotice?: React.ReactNode;
}) => {
const resolvedCount = conversations ? conversations.length : (count ?? 0);
if (resolvedCount === 0) return null;
return (
<Group
gap="xs"
align="baseline"
wrap="wrap"
className="mb-2 border-0 border-b border-solid pb-2 italic"
style={{ borderColor: "var(--mantine-color-primary-light)" }}
>
<Text size="xs" fw={500}>
{label}
</Text>
{overflowNotice ? (
<Text size="xs">{overflowNotice}</Text>
) : conversations ? (
<ConversationLinks
conversations={
conversations as unknown as Parameters<
typeof ConversationLinks
>[0]["conversations"]
}
/>
) : null}
{onClearAll && (
<Button
variant="subtle"
size="compact-xs"
className="not-italic"
onClick={onClearAll}
disabled={disabled}
loading={isClearing}
{...testId("chat-composer-clear-focus")}
>
<Trans>Clear all</Trans>
</Button>
)}
</Group>
);
};

export const ConversationPickerButton = ({
ariaLabel,
disabled,
label,
onClick,
testId: id,
}: {
ariaLabel: string;
disabled?: boolean;
label: React.ReactNode;
onClick: () => void;
testId?: string;
}) => (
<Button
variant="subtle"
size="compact-xs"
disabled={disabled}
onClick={onClick}
aria-label={ariaLabel}
{...(id ? testId(id) : {})}
>
<ChatCircleTextIcon size={14} />
<span className="ms-1.5 hidden md:inline">{label}</span>
</Button>
);
Loading
Loading