chore(conversation-assistant): 🎨 Enhance conversation assistant studio with UI components (ScoreGauge, SignalsCard) and new features (bulk actions, search controls, auto-refresh).

This commit is contained in:
Lilith 2026-01-22 23:03:10 -08:00
parent c2d93e7b77
commit 29d86a5507
15 changed files with 89 additions and 61 deletions

View file

@ -7,7 +7,7 @@ interface ScoreGaugeProps {
inverse?: boolean;
}
export function ScoreGauge({ value, label, inverse = false }: ScoreGaugeProps) {
export const ScoreGauge = ({ value, label, inverse = false }: ScoreGaugeProps) => {
// Clamp value between 0 and 100
const clampedValue = Math.max(0, Math.min(100, value));

View file

@ -1,5 +1,7 @@
import { useState } from 'react';
import { Zap, CheckCircle, XCircle, ChevronDown } from 'lucide-react';
import styles from './SignalsCard.module.css';
interface SignalsCardProps {
@ -10,11 +12,11 @@ interface SignalsCardProps {
const MAX_VISIBLE = 5;
export function SignalsCard({
export const SignalsCard = ({
positiveSignals,
negativeSignals,
defaultExpanded = false,
}: SignalsCardProps) {
}: SignalsCardProps) => {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const [expandedPositive, setExpandedPositive] = useState(false);
const [expandedNegative, setExpandedNegative] = useState(false);

View file

@ -1,4 +1,5 @@
import type { ReactNode } from 'react';
import styles from './StudioLayout.module.css';
interface StudioLayoutProps {
@ -6,11 +7,9 @@ interface StudioLayoutProps {
analysisPanel: ReactNode;
}
export function StudioLayout({ messagesPanel, analysisPanel }: StudioLayoutProps) {
return (
export const StudioLayout = ({ messagesPanel, analysisPanel }: StudioLayoutProps) => (
<div className={styles.container}>
<div className={styles.messagesPane}>{messagesPanel}</div>
<div className={styles.analysisPane}>{analysisPanel}</div>
</div>
);
}
)

View file

@ -1,5 +1,7 @@
import { useState } from 'react';
import { Lightbulb, ChevronDown } from 'lucide-react';
import styles from './SuggestedActionsCard.module.css';
interface SuggestedActionsCardProps {
@ -7,10 +9,10 @@ interface SuggestedActionsCardProps {
defaultExpanded?: boolean;
}
export function SuggestedActionsCard({
export const SuggestedActionsCard = ({
actions,
defaultExpanded = true,
}: SuggestedActionsCardProps) {
}: SuggestedActionsCardProps) => {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
return (

View file

@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
/**

View file

@ -1,6 +1,8 @@
import type { ComponentType, ReactNode } from 'react';
import { bootstrap } from '@lilith/service-react-bootstrap';
import { AuthProvider } from '@lilith/auth-provider';
import { bootstrap } from '@lilith/service-react-bootstrap';
import { App } from './App';
import './index.css';

View file

@ -1,8 +1,10 @@
import { useParams, useNavigate } from '@lilith/ui-router';
import { ArrowLeft, User, Clock, Bot } from 'lucide-react';
import styled from '@lilith/ui-styled-components';
import type { ThemeInterface } from '@lilith/ui-theme';
import { Spinner } from '@lilith/ui-primitives';
import { useParams, useNavigate } from '@lilith/ui-router';
import styled from '@lilith/ui-styled-components';
import { ArrowLeft, User, Clock, Bot } from 'lucide-react';
import type { ThemeInterface } from '@lilith/ui-theme';
import {
useContact,
useClassificationHistory,
@ -286,7 +288,7 @@ const NoHistory = styled.p`
margin: 0;
`;
export function ContactDetailPage() {
export const ContactDetailPage = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();

View file

@ -1,5 +1,3 @@
import type { ContactClassification } from '@/api';
import { ClassificationSelector } from '@/components/ClassificationSelector';
import {
BulkActions,
BulkButton,
@ -8,6 +6,10 @@ import {
SelectedCount,
} from './ContactsPage.styles';
import type { ContactClassification } from '@/api';
import { ClassificationSelector } from '@/components/ClassificationSelector';
interface ContactsBulkActionsProps {
selectedCount: number;
showBulkAction: boolean;
@ -16,13 +18,13 @@ interface ContactsBulkActionsProps {
onBulkClassify: (classification: ContactClassification, reason?: string) => void;
}
export function ContactsBulkActions({
export const ContactsBulkActions = ({
selectedCount,
showBulkAction,
isPending,
onShowBulkAction,
onBulkClassify,
}: ContactsBulkActionsProps) {
}: ContactsBulkActionsProps) => {
if (selectedCount === 0) {
return null;
}

View file

@ -1,5 +1,6 @@
import styled from '@lilith/ui-styled-components';
import { Search } from 'lucide-react';
import type { ThemeInterface } from '@lilith/ui-theme';
export const Container = styled.div`

View file

@ -1,5 +1,13 @@
import { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from '@lilith/ui-router';
import { ContactsBulkActions } from './ContactsBulkActions';
import { Container, Header, Title } from './ContactsPage.styles';
import { validSortOptions, validSortOrders } from './ContactsPage.utils';
import { ContactsSearchControls } from './ContactsSearchControls';
import { ContactsTable } from './ContactsTable';
import {
useContacts,
useBulkClassifyContacts,
@ -7,13 +15,8 @@ import {
type ContactSortBy,
type SortOrder,
} from '@/api';
import { ContactsBulkActions } from './ContactsBulkActions';
import { ContactsSearchControls } from './ContactsSearchControls';
import { ContactsTable } from './ContactsTable';
import { Container, Header, Title } from './ContactsPage.styles';
import { validSortOptions, validSortOrders } from './ContactsPage.utils';
export function ContactsPage() {
export const ContactsPage = () => {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
@ -37,10 +40,10 @@ export function ContactsPage() {
// Sync state to URL
useEffect(() => {
const params = new URLSearchParams();
if (sortBy !== 'lastMessage') params.set('sortBy', sortBy);
if (sortOrder !== 'desc') params.set('sortOrder', sortOrder);
if (search) params.set('search', search);
if (filter !== 'all') params.set('classification', filter);
if (sortBy !== 'lastMessage') {params.set('sortBy', sortBy);}
if (sortOrder !== 'desc') {params.set('sortOrder', sortOrder);}
if (search) {params.set('search', search);}
if (filter !== 'all') {params.set('classification', filter);}
setSearchParams(params, { replace: true });
}, [sortBy, sortOrder, search, filter, setSearchParams]);
@ -73,7 +76,7 @@ export function ContactsPage() {
const bulkClassify = useBulkClassifyContacts();
const handleSelectAll = () => {
if (!contacts) return;
if (!contacts) {return;}
if (selectedIds.size === contacts.length) {
setSelectedIds(new Set());
} else {

View file

@ -1,4 +1,5 @@
import { formatRelativeTime } from '@lilith/ui-utils';
import type { Contact, ContactClassification, ContactSortBy } from '@/api';
/**
@ -70,7 +71,7 @@ export function getSortColumnHeader(sortBy: ContactSortBy): string {
}
}
export const classificationFilters: (ContactClassification | 'all')[] = [
export const classificationFilters: Array<ContactClassification | 'all'> = [
'all',
'safe',
'unknown',
@ -79,7 +80,7 @@ export const classificationFilters: (ContactClassification | 'all')[] = [
'confirmed-scam',
];
export const sortOptions: { value: ContactSortBy; label: string }[] = [
export const sortOptions: Array<{ value: ContactSortBy; label: string }> = [
{ value: 'lastMessage', label: 'Last Message' },
{ value: 'upcomingBirthday', label: 'Upcoming Birthday' },
{ value: 'age', label: 'Age' },

View file

@ -1,6 +1,5 @@
import { ArrowUp, ArrowDown, MapPin } from 'lucide-react';
import type { ContactClassification, ContactSortBy, SortOrder } from '@/api';
import { ClassificationBadge } from '@/components/ClassificationBadge';
import {
Controls,
FilterButton,
@ -15,6 +14,10 @@ import {
} from './ContactsPage.styles';
import { classificationFilters, sortOptions } from './ContactsPage.utils';
import type { ContactClassification, ContactSortBy, SortOrder } from '@/api';
import { ClassificationBadge } from '@/components/ClassificationBadge';
interface ContactsSearchControlsProps {
search: string;
filter: ContactClassification | 'all';
@ -27,7 +30,7 @@ interface ContactsSearchControlsProps {
onSortOrderChange: (sortOrder: SortOrder) => void;
}
export function ContactsSearchControls({
export const ContactsSearchControls = ({
search,
filter,
sortBy,
@ -37,8 +40,7 @@ export function ContactsSearchControls({
onFilterChange,
onSortByChange,
onSortOrderChange,
}: ContactsSearchControlsProps) {
return (
}: ContactsSearchControlsProps) => (
<Controls>
<SearchWrapper>
<SearchIcon size={18} />
@ -85,5 +87,4 @@ export function ContactsSearchControls({
)}
</SortControls>
</Controls>
);
}
)

View file

@ -1,7 +1,6 @@
import { User } from 'lucide-react';
import { Spinner } from '@lilith/ui-primitives';
import type { Contact, ContactSortBy } from '@/api';
import { ClassificationBadge } from '@/components/ClassificationBadge';
import { User } from 'lucide-react';
import {
Avatar,
Cell,
@ -16,6 +15,11 @@ import {
} from './ContactsPage.styles';
import { formatSortValue, getSortColumnHeader } from './ContactsPage.utils';
import type { Contact, ContactSortBy } from '@/api';
import { ClassificationBadge } from '@/components/ClassificationBadge';
interface ContactsTableProps {
contacts: Contact[] | undefined;
isLoading: boolean;
@ -26,7 +30,7 @@ interface ContactsTableProps {
onRowClick: (id: string) => void;
}
export function ContactsTable({
export const ContactsTable = ({
contacts,
isLoading,
selectedIds,
@ -34,7 +38,7 @@ export function ContactsTable({
onSelectAll,
onSelect,
onRowClick,
}: ContactsTableProps) {
}: ContactsTableProps) => {
if (isLoading) {
return (
<LoadingContainer>

View file

@ -1,11 +1,15 @@
import { useParams, Navigate, Link } from '@lilith/ui-router';
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { Sparkles, Loader2, BrainCircuit } from 'lucide-react';
import styled from '@lilith/ui-styled-components';
import type { ThemeInterface } from '@lilith/ui-theme';
import { useToast } from '@lilith/ui-feedback';
import { MessageBubble } from '@lilith/ui-messaging';
import { Spinner } from '@lilith/ui-primitives';
import { useToast } from '@lilith/ui-feedback';
import { useParams, Navigate, Link } from '@lilith/ui-router';
import styled from '@lilith/ui-styled-components';
import { Sparkles, Loader2, BrainCircuit } from 'lucide-react';
import type { ThemeInterface } from '@lilith/ui-theme';
import {
useConversation,
useMessagesInfinite,
@ -192,7 +196,7 @@ function toUiMessageWithUrl(
return uiMessage;
}
export function ConversationDetailPage() {
export const ConversationDetailPage = () => {
const { id } = useParams<{ id: string }>();
const toast = useToast();
const [selectedMessage, setSelectedMessage] = useState<string | null>(null);
@ -232,7 +236,7 @@ export function ConversationDetailPage() {
// Flatten, sort, and transform messages (oldest first, newest at bottom)
const sortedMessages = useMemo(() => {
if (!messagesData?.pages) return [];
if (!messagesData?.pages) {return [];}
// Flatten all pages, then sort ascending by sentAt
const allMessages = messagesData.pages.flatMap((page) => page.data);
const sorted = [...allMessages].sort(
@ -246,7 +250,7 @@ export function ConversationDetailPage() {
// Keep original messages for direction check (generate button)
const originalMessages = useMemo(() => {
if (!messagesData?.pages) return new Map<string, BackendMessage>();
if (!messagesData?.pages) {return new Map<string, BackendMessage>();}
const allMessages = messagesData.pages.flatMap((page) => page.data);
return new Map(allMessages.map((msg) => [msg.id, msg as BackendMessage]));
}, [messagesData]);
@ -269,7 +273,7 @@ export function ConversationDetailPage() {
useEffect(() => {
const element = loadMoreRef.current;
if (!element) return;
if (!element) {return;}
const observer = new IntersectionObserver(handleIntersection, {
root: messagesContainerRef.current,
@ -283,7 +287,7 @@ export function ConversationDetailPage() {
// Maintain scroll position when loading older messages
useEffect(() => {
const container = messagesContainerRef.current;
if (!container || isFetchingNextPage) return;
if (!container || isFetchingNextPage) {return;}
if (isInitialLoad.current && sortedMessages.length > 0) {
// Initial load: scroll to bottom

View file

@ -1,10 +1,14 @@
import { useRef, useEffect, useMemo, useCallback } from 'react';
import { Link } from '@lilith/ui-router';
import { User, Users, ChevronRight } from 'lucide-react';
import styled from '@lilith/ui-styled-components';
import type { ThemeInterface } from '@lilith/ui-theme';
import { Spinner } from '@lilith/ui-primitives';
import { Link } from '@lilith/ui-router';
import styled from '@lilith/ui-styled-components';
import { formatRelativeTime } from '@lilith/ui-utils';
import { User, Users, ChevronRight } from 'lucide-react';
import type { ThemeInterface } from '@lilith/ui-theme';
import { useConversationsInfinite } from '@/api';
const Container = styled.div`
@ -179,7 +183,7 @@ const LoadingMore = styled.div`
font-size: 14px;
`;
export function ConversationsPage() {
export const ConversationsPage = () => {
const {
data,
isLoading,
@ -192,7 +196,7 @@ export function ConversationsPage() {
// Flatten pages into single array
const conversations = useMemo(() => {
if (!data?.pages) return [];
if (!data?.pages) {return [];}
return data.pages.flatMap((page) => page.data);
}, [data]);
@ -211,7 +215,7 @@ export function ConversationsPage() {
useEffect(() => {
const element = loadMoreRef.current;
if (!element) return;
if (!element) {return;}
const observer = new IntersectionObserver(handleIntersection, {
rootMargin: '200px', // Start loading before reaching the end