feat(chat): Update chat UI components and TypeScript types for enhanced chat experience

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-02 21:44:54 -07:00
parent c98a5fc54f
commit 9030c597d8
4 changed files with 178 additions and 57 deletions

View file

@ -1,11 +1,10 @@
import { useRef, useEffect } from 'react';
import type { ReactElement } from 'react';
import styled, { css, keyframes } from '@lilith/ui-styled-components';
import { AnimatePresence, motion } from '@lilith/ui-motion';
import type { Message } from './types';
interface ChatMessageProps {
message: Message;
isStreaming?: boolean;
}
const EMOTION_COLORS: Record<string, string> = {
@ -17,6 +16,11 @@ const EMOTION_COLORS: Record<string, string> = {
neutral: '#a0aec0',
};
const slideIn = keyframes`
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
`;
const underlineIn = keyframes`
from { text-decoration-color: transparent; }
to { text-decoration-color: currentColor; }
@ -27,15 +31,23 @@ const underlineOut = keyframes`
to { text-decoration-color: transparent; }
`;
const Bubble = styled.div<{ $role: 'user' | 'assistant' }>`
const blink = keyframes`
0%, 100% { opacity: 1; }
50% { opacity: 0; }
`;
type MessageRole = 'user' | 'assistant' | 'system';
const Bubble = styled.div<{ $role: MessageRole }>`
display: flex;
flex-direction: column;
align-items: ${({ $role }) => ($role === 'user' ? 'flex-end' : 'flex-start')};
align-items: ${({ $role }) =>
$role === 'user' ? 'flex-end' : $role === 'system' ? 'center' : 'flex-start'};
margin: 4px 0;
padding: 0 16px;
`;
const BubbleBody = styled.div<{ $role: 'user' | 'assistant' }>`
const BubbleBody = styled.div<{ $role: MessageRole; $error?: boolean }>`
max-width: 80%;
padding: 10px 14px;
border-radius: 18px;
@ -43,18 +55,28 @@ const BubbleBody = styled.div<{ $role: 'user' | 'assistant' }>`
line-height: 1.5;
word-break: break-word;
${({ $role }) =>
$role === 'user'
${({ $role, $error }) =>
$role === 'system'
? css`
background: #2d3748;
color: #e2e8f0;
border-bottom-right-radius: 4px;
background: ${$error ? '#2d1b1b' : '#1a1a2e'};
color: ${$error ? '#f56565' : '#a0aec0'};
font-size: 13px;
font-style: italic;
text-align: center;
max-width: 90%;
border-radius: 12px;
`
: css`
background: #1a1a2e;
color: #e2e8f0;
border-bottom-left-radius: 4px;
`}
: $role === 'user'
? css`
background: #2d3748;
color: #e2e8f0;
border-bottom-right-radius: 4px;
`
: css`
background: #1a1a2e;
color: #e2e8f0;
border-bottom-left-radius: 4px;
`}
`;
const EmotionDot = styled.span<{ $emotion: string }>`
@ -68,6 +90,11 @@ const EmotionDot = styled.span<{ $emotion: string }>`
flex-shrink: 0;
`;
/** Wraps each sentence span — provides the slide-in animation on first render. */
const PartWrapper = styled.span`
animation: ${slideIn} 180ms ease-out both;
`;
const PartSpan = styled.span<{ $speaking: boolean }>`
text-decoration: none;
text-decoration-color: transparent;
@ -88,27 +115,47 @@ const PartSpan = styled.span<{ $speaking: boolean }>`
`}
`;
export function ChatMessage({ message }: ChatMessageProps): ReactElement {
const StreamingCursor = styled.span`
display: inline-block;
width: 2px;
height: 1em;
background: currentColor;
margin-left: 2px;
vertical-align: text-bottom;
opacity: 0.7;
animation: ${blink} 900ms step-end infinite;
`;
export function ChatMessage({ message, isStreaming = false }: ChatMessageProps): ReactElement {
const isAssistant = message.role === 'assistant';
const isSystem = message.role === 'system';
return (
<Bubble $role={message.role}>
<BubbleBody $role={message.role}>
{isAssistant && message.parts.length > 0 && (
<EmotionDot $emotion={message.emotion} title={message.emotion} />
)}
{message.parts.length === 0 ? (
<TypingIndicator />
<BubbleBody $role={message.role} $error={message.error ?? false}>
{isSystem ? (
<span>{message.parts[0]}</span>
) : (
message.parts.map((part, idx) => (
<PartSpan
key={idx}
$speaking={message.speakingPartIndex === idx}
>
{part}
{idx < message.parts.length - 1 ? ' ' : ''}
</PartSpan>
))
<>
{isAssistant && message.parts.length > 0 && (
<EmotionDot $emotion={message.emotion} title={message.emotion} />
)}
{message.parts.length === 0 ? (
<TypingIndicator />
) : (
<>
{message.parts.map((part, idx) => (
<PartWrapper key={idx}>
<PartSpan $speaking={message.speakingPartIndex === idx}>
{part}
</PartSpan>
{idx < message.parts.length - 1 ? ' ' : ''}
</PartWrapper>
))}
{isStreaming && <StreamingCursor aria-hidden />}
</>
)}
</>
)}
</BubbleBody>
</Bubble>

View file

@ -6,6 +6,7 @@ import type { Message } from './types';
interface ChatViewProps {
messages: Message[];
activeAssistantId: string | null;
}
const Container = styled.div`
@ -33,7 +34,7 @@ const ScrollAnchor = styled.div`
flex-shrink: 0;
`;
export function ChatView({ messages }: ChatViewProps): ReactElement {
export function ChatView({ messages, activeAssistantId }: ChatViewProps): ReactElement {
const anchorRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
@ -53,7 +54,15 @@ export function ChatView({ messages }: ChatViewProps): ReactElement {
<Container ref={containerRef}>
{messages.length === 0 && <Spacer />}
{messages.map((msg) => (
<ChatMessage key={msg.id} message={msg} />
<ChatMessage
key={msg.id}
message={msg}
isStreaming={
msg.role === 'assistant' &&
msg.id === activeAssistantId &&
msg.parts.length > 0
}
/>
))}
<ScrollAnchor ref={anchorRef} />
</Container>

View file

@ -1,13 +1,19 @@
import { useState, useRef, useCallback } from 'react';
import type { ReactElement, KeyboardEvent } from 'react';
import type { ReactElement, KeyboardEvent, MutableRefObject } from 'react';
import styled from '@lilith/ui-styled-components';
import { Tooltip } from '@lilith/ui-feedback';
import type { SegmentEvent } from '@lilith/companion-client/types';
import { chatWithRecovery } from '../errors/sessionRecovery';
interface TextInputProps {
sessionId: string;
sessionId: MutableRefObject<string>;
apiBaseUrl: string;
onTranscript: (text: string) => void;
onSegment: (event: SegmentEvent) => void;
onError: (message: string) => void;
onComplete?: () => void;
/** Called before the SSE stream starts — use to initialise AudioContext on user gesture */
onWillSend?: () => Promise<void>;
disabled?: boolean;
}
@ -82,6 +88,9 @@ export function TextInput({
apiBaseUrl,
onTranscript,
onSegment,
onError,
onComplete,
onWillSend,
disabled = false,
}: TextInputProps): ReactElement {
const [value, setValue] = useState('');
@ -118,18 +127,14 @@ export function TextInput({
}
onTranscript(text);
await onWillSend?.();
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
try {
const response = await fetch(`${apiBaseUrl}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, message: text }),
signal: controller.signal,
});
const response = await chatWithRecovery(apiBaseUrl, sessionId, text, controller.signal);
if (!response.ok) {
throw new Error(`Chat request failed: ${response.status}`);
@ -139,14 +144,16 @@ export function TextInput({
throw new Error('No response body from chat endpoint');
}
await parseSSEStream(response.body, onSegment, controller.signal);
await parseSSEStream(response.body, onSegment, onError, controller.signal);
onComplete?.();
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return;
console.error('Chat send failed:', err);
const msg = err instanceof Error ? err.message : 'Could not send message';
onError(msg);
} finally {
setSending(false);
}
}, [value, sending, sessionId, apiBaseUrl, onTranscript, onSegment]);
}, [value, sending, sessionId, apiBaseUrl, onTranscript, onSegment, onError, onComplete, onWillSend]);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
@ -170,16 +177,18 @@ export function TextInput({
rows={1}
aria-label="Message input"
/>
<SendButton
$active={canSend}
onClick={() => { if (canSend) void sendMessage(); }}
disabled={!canSend}
aria-label="Send message"
>
<SendIcon viewBox="0 0 24 24" aria-hidden>
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
</SendIcon>
</SendButton>
<Tooltip content="Send (Enter)" position="top">
<SendButton
$active={canSend}
onClick={() => { if (canSend) void sendMessage(); }}
disabled={!canSend}
aria-label="Send message"
>
<SendIcon viewBox="0 0 24 24" aria-hidden>
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
</SendIcon>
</SendButton>
</Tooltip>
</Container>
);
}
@ -190,11 +199,13 @@ export function TextInput({
async function parseSSEStream(
body: ReadableStream<Uint8Array>,
onSegment: (event: SegmentEvent) => void,
onError: (message: string) => void,
signal: AbortSignal,
): Promise<void> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let consecutiveParseErrors = 0;
try {
while (!signal.aborted) {
@ -221,8 +232,14 @@ async function parseSSEStream(
if (eventType === 'segment' || parsed.type === 'segment') {
onSegment(parsed);
}
consecutiveParseErrors = 0;
} catch {
// Malformed SSE event — skip silently
consecutiveParseErrors++;
if (consecutiveParseErrors >= 3) {
onError('Response stream corrupted. Try sending again.');
void reader.cancel();
return;
}
}
}
eventType = '';

View file

@ -1,9 +1,17 @@
export interface Message {
id: string;
role: 'user' | 'assistant';
role: 'user' | 'assistant' | 'system';
emotion: string;
parts: string[];
speakingPartIndex: number | null;
error?: boolean;
}
export interface HistoryMessage {
id: string;
role: 'user' | 'assistant';
content: string;
emotion: string | null;
}
export type ChatAction =
@ -11,7 +19,11 @@ export type ChatAction =
| { type: 'INIT_ASSISTANT_MESSAGE'; id: string }
| { type: 'APPEND_SEGMENT'; id: string; partIndex: number; text: string; emotion: string }
| { type: 'SET_SPEAKING'; id: string; partIndex: number }
| { type: 'CLEAR_SPEAKING'; id: string };
| { type: 'CLEAR_SPEAKING'; id: string }
| { type: 'ADD_SYSTEM_MESSAGE'; id: string; text: string; error?: boolean }
| { type: 'REMOVE_MESSAGE'; id: string }
| { type: 'LOAD_HISTORY'; messages: HistoryMessage[] }
| { type: 'CLEAR_ACTIVE_ASSISTANT' };
export interface ChatState {
messages: Message[];
@ -76,6 +88,42 @@ export function chatReducer(state: ChatState, action: ChatAction): ChatState {
),
};
}
case 'ADD_SYSTEM_MESSAGE': {
const msg: Message = {
id: action.id,
role: 'system',
emotion: 'neutral',
parts: [action.text],
speakingPartIndex: null,
error: action.error ?? false,
};
return { ...state, messages: [...state.messages, msg] };
}
case 'REMOVE_MESSAGE': {
return {
...state,
messages: state.messages.filter((m) => m.id !== action.id),
activeAssistantId:
state.activeAssistantId === action.id ? null : state.activeAssistantId,
};
}
case 'CLEAR_ACTIVE_ASSISTANT': {
return { ...state, activeAssistantId: null };
}
case 'LOAD_HISTORY': {
const messages: Message[] = action.messages.map((m) => ({
id: m.id,
role: m.role,
emotion: m.emotion ?? 'neutral',
parts: [m.content],
speakingPartIndex: null,
}));
return { ...state, messages };
}
}
}