fix(frontend): improve responsive transcripts and simplify theme styles
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
import { memo, useMemo } from "react";
|
||||
|
||||
import { renderMarkdown } from "../lib/messages";
|
||||
|
||||
export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
text,
|
||||
className = "",
|
||||
}: {
|
||||
text: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const html = useMemo(() => renderMarkdown(text), [text]);
|
||||
return <div className={`markdown-content ${className}`} dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { memo, useEffect, useState } from "react";
|
||||
|
||||
import { renderMarkdown, type Message } from "../lib/messages";
|
||||
import type { Message } from "../lib/messages";
|
||||
import { AssistantMarkdown } from "./AssistantMarkdown";
|
||||
|
||||
function Bubble({ message, userName }: { message: Message; userName: string }) {
|
||||
const [expiredText, setExpiredText] = useState<string | null>(null);
|
||||
@@ -24,10 +25,7 @@ function Bubble({ message, userName }: { message: Message; userName: string }) {
|
||||
{message.role === "user" ? userName : message.role === "tool" ? "Herramienta" : "Vocero"}
|
||||
</div>
|
||||
{message.role === "assistant" ? (
|
||||
<div
|
||||
className="bubble-body markdown-content"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.text) }}
|
||||
/>
|
||||
<AssistantMarkdown text={message.text} className="bubble-body" />
|
||||
) : (
|
||||
<div className="bubble-body">{message.role === "tool" ? message.toolName : message.text}</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import type { Message } from "../lib/messages";
|
||||
import { Conversation } from "./Conversation";
|
||||
|
||||
const message: Message = {
|
||||
id: "1",
|
||||
role: "assistant",
|
||||
text: "Una respuesta con [enlace](https://example.test)",
|
||||
timestamp: "2026-10-07T12:00:00Z",
|
||||
partial: false,
|
||||
};
|
||||
it("renders accessible names, a real timestamp, and sanitized assistant content", () => {
|
||||
render(<Conversation messages={[message]} userName="Ana" status="listening" white muted={false} />);
|
||||
expect(screen.getByText("Vocero")).toBeVisible();
|
||||
expect(screen.getByText(/^09:00/).closest("time")).toHaveAttribute("datetime", message.timestamp);
|
||||
expect(screen.getByRole("link", { name: "enlace" })).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
it("preserves a reader's scroll position during updates and offers a jump to the latest message", async () => {
|
||||
const user = userEvent.setup();
|
||||
const view = render(<Conversation messages={[message]} userName="Ana" status="listening" white muted={false} />);
|
||||
const history = screen.getByRole("log");
|
||||
Object.defineProperties(history, { scrollHeight: { configurable: true, value: 1000 }, clientHeight: { value: 200 } });
|
||||
history.scrollTop = 200;
|
||||
fireEvent.scroll(history);
|
||||
view.rerender(
|
||||
<Conversation
|
||||
messages={[message, { ...message, id: "2", text: "Nueva respuesta" }]}
|
||||
userName="Ana"
|
||||
status="listening"
|
||||
white
|
||||
muted={false}
|
||||
/>,
|
||||
);
|
||||
expect(history.scrollTop).toBe(200);
|
||||
await user.click(screen.getByRole("button", { name: "Ir al último mensaje" }));
|
||||
expect(history.scrollTop).toBe(1000);
|
||||
expect(history).toHaveFocus();
|
||||
expect(screen.queryByRole("button", { name: "Ir al último mensaje" })).not.toBeInTheDocument();
|
||||
});
|
||||
it("gives useful empty-state instructions during a live session and when muted", () => {
|
||||
const view = render(<Conversation messages={[]} userName="Ana" status="listening" white muted={false} />);
|
||||
expect(screen.getByText("Habla cuando quieras. Tu conversación aparecerá aquí.")).toBeVisible();
|
||||
view.rerender(<Conversation messages={[]} userName="Ana" status="listening" white muted />);
|
||||
expect(screen.getByText("Activa el micrófono para hablar")).toBeVisible();
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Download, MessageCircle, Wrench } from "lucide-react";
|
||||
import { ArrowDown, Download, MessageCircle, Wrench } from "lucide-react";
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { initials } from "../lib/context";
|
||||
import type { ConnectionStatus } from "../lib/contracts";
|
||||
import { displayTime, exportConversation, renderMarkdown, type Message } from "../lib/messages";
|
||||
import { displayTime, exportConversation, type Message } from "../lib/messages";
|
||||
import { LIVE_STATES } from "../realtime/controller";
|
||||
import { AssistantMarkdown } from "./AssistantMarkdown";
|
||||
|
||||
const MessageRow = memo(function MessageRow({ message, userName }: { message: Message; userName: string }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
@@ -33,25 +34,19 @@ const MessageRow = memo(function MessageRow({ message, userName }: { message: Me
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const data = {
|
||||
"data-user-name": userName,
|
||||
"data-user-initials": initials(userName),
|
||||
"data-asst-name": "Vocero",
|
||||
"data-timestamp": time,
|
||||
};
|
||||
return (
|
||||
<div className={`hist-msg ${message.role} ${message.interrupted ? "interrupted" : ""}`} {...data}>
|
||||
<div className="hist-role">{message.role === "user" ? userName : "Vocero"}</div>
|
||||
<div
|
||||
className={`hist-msg ${message.role} ${message.interrupted ? "interrupted" : ""}`}
|
||||
data-user-initials={initials(userName)}
|
||||
>
|
||||
<div className="hist-meta">
|
||||
<span>{message.role === "user" ? userName : "Vocero"}</span>
|
||||
<time dateTime={message.timestamp}>{time}</time>
|
||||
</div>
|
||||
{message.role === "assistant" ? (
|
||||
<div
|
||||
className={`hist-body markdown-content ${message.partial ? "partial" : ""}`}
|
||||
{...data}
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.text) }}
|
||||
/>
|
||||
<AssistantMarkdown text={message.text} className={`hist-body ${message.partial ? "partial" : ""}`} />
|
||||
) : (
|
||||
<div className={`hist-body ${message.partial ? "partial" : ""}`} {...data}>
|
||||
{message.text}
|
||||
</div>
|
||||
<div className={`hist-body ${message.partial ? "partial" : ""}`}>{message.text}</div>
|
||||
)}
|
||||
{message.interrupted && <div className="hist-note">Interrumpido</div>}
|
||||
</div>
|
||||
@@ -62,14 +57,17 @@ export const Conversation = memo(function Conversation({
|
||||
userName,
|
||||
status,
|
||||
white,
|
||||
muted,
|
||||
}: {
|
||||
messages: Message[];
|
||||
userName: string;
|
||||
status: ConnectionStatus;
|
||||
white: boolean;
|
||||
muted: boolean;
|
||||
}) {
|
||||
const history = useRef<HTMLDivElement>(null);
|
||||
const atBottom = useRef(true);
|
||||
const [showLatest, setShowLatest] = useState(false);
|
||||
useEffect(() => {
|
||||
const element = history.current;
|
||||
if (element && atBottom.current) {
|
||||
@@ -115,12 +113,15 @@ export const Conversation = memo(function Conversation({
|
||||
id="chat-history"
|
||||
className="chat-history"
|
||||
role="log"
|
||||
tabIndex={0}
|
||||
aria-label="Mensajes de la conversación"
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
onScroll={() => {
|
||||
const element = history.current;
|
||||
if (element) {
|
||||
atBottom.current = element.scrollHeight - element.scrollTop - element.clientHeight < 60;
|
||||
setShowLatest(!atBottom.current);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -130,10 +131,35 @@ export const Conversation = memo(function Conversation({
|
||||
<div className="chat-empty">
|
||||
<MessageCircle aria-hidden="true" />
|
||||
<span className="chat-empty-title">Aún no hay mensajes</span>
|
||||
<span className="chat-empty-hint">Toca el círculo y empieza a hablar</span>
|
||||
<span className="chat-empty-hint">
|
||||
{muted
|
||||
? "Activa el micrófono para hablar"
|
||||
: live
|
||||
? "Habla cuando quieras. Tu conversación aparecerá aquí."
|
||||
: waiting
|
||||
? "Tu conversación aparecerá aquí al conectar."
|
||||
: "Toca el círculo y empieza a hablar"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showLatest && (
|
||||
<button
|
||||
className="jump-to-latest btn secondary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (history.current) {
|
||||
history.current.scrollTop = history.current.scrollHeight;
|
||||
}
|
||||
atBottom.current = true;
|
||||
setShowLatest(false);
|
||||
history.current?.focus({ preventScroll: true });
|
||||
}}
|
||||
>
|
||||
<ArrowDown size={16} aria-hidden="true" />
|
||||
Ir al último mensaje
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { reportSuggestions } from "../lib/reports";
|
||||
import { Dialog } from "./Dialog";
|
||||
|
||||
function ReportContent({ reports }: { reports: Reports }) {
|
||||
const suggestions = reportSuggestions(reports);
|
||||
return (
|
||||
<div className="reports-content">
|
||||
<div className="reports-hint">
|
||||
@@ -14,14 +15,16 @@ function ReportContent({ reports }: { reports: Reports }) {
|
||||
</ul>
|
||||
{reports.note && <p>{reports.note}</p>}
|
||||
</div>
|
||||
<div className="reports-suggestions">
|
||||
<h3 className="reports-suggestions-title">Puedes preguntar</h3>
|
||||
<ul className="reports-suggestions-list">
|
||||
{reportSuggestions(reports).map((suggestion) => (
|
||||
<li key={suggestion}>{suggestion}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{suggestions.length > 0 && (
|
||||
<div className="reports-suggestions">
|
||||
<h3 className="reports-suggestions-title">Puedes preguntar</h3>
|
||||
<ul className="reports-suggestions-list">
|
||||
{suggestions.map((suggestion) => (
|
||||
<li key={suggestion}>{suggestion}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,9 +15,7 @@ class Recognition {
|
||||
onend: (() => void) | null = null;
|
||||
onerror = null;
|
||||
onresult = null;
|
||||
start = vi.fn(() => {
|
||||
this.onstart?.();
|
||||
});
|
||||
start = vi.fn();
|
||||
abort = vi.fn();
|
||||
constructor() {
|
||||
Recognition.instances.push(this);
|
||||
@@ -39,11 +37,19 @@ it("requires opt-in, displays actual listening, and releases recognition on Stop
|
||||
expect(Recognition.instances).toHaveLength(0);
|
||||
await user.click(screen.getByRole("checkbox"));
|
||||
expect(Recognition.instances).toHaveLength(1);
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Preparando activación");
|
||||
act(() => {
|
||||
Recognition.instances[0]?.onstart?.();
|
||||
});
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Micrófono activo");
|
||||
await user.click(screen.getByRole("checkbox"));
|
||||
await user.click(screen.getByRole("checkbox"));
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Preparando activación");
|
||||
await act(async () => {
|
||||
await controller.stop();
|
||||
});
|
||||
expect(Recognition.instances[0]?.abort).toHaveBeenCalledOnce();
|
||||
expect(Recognition.instances[1]?.abort).toHaveBeenCalledOnce();
|
||||
expect(screen.getByRole("checkbox")).not.toBeChecked();
|
||||
expect(Recognition.instances).toHaveLength(1);
|
||||
expect(Recognition.instances).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { useWakeWord } from "../hooks/use-wake-word";
|
||||
import type { VoiceController } from "../realtime/controller";
|
||||
|
||||
function WakeWordStatus({ controller }: { controller: VoiceController }) {
|
||||
const status = useWakeWord(true, controller);
|
||||
return (
|
||||
<span role="status">
|
||||
{status === "listening"
|
||||
? "Micrófono activo: escuchando la frase de activación."
|
||||
: status === "blocked"
|
||||
? "No se pudo activar. Revisa el permiso del micrófono y vuelve a marcar la opción."
|
||||
: "Preparando activación por voz…"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
export function WakeWordControl({ enabled, controller }: { enabled: boolean; controller: VoiceController }) {
|
||||
const status = useWakeWord(enabled, controller);
|
||||
if (status === "unsupported") {
|
||||
if (!(window.SpeechRecognition ?? window.webkitSpeechRecognition)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
@@ -18,15 +29,11 @@ export function WakeWordControl({ enabled, controller }: { enabled: boolean; con
|
||||
/>
|
||||
Activar con «hola vocero»
|
||||
</label>
|
||||
<span role="status">
|
||||
{status === "listening"
|
||||
? "Micrófono activo: escuchando la frase de activación."
|
||||
: status === "blocked"
|
||||
? "No se pudo activar. Revisa el permiso del micrófono y vuelve a marcar la opción."
|
||||
: status === "starting"
|
||||
? "Preparando activación por voz…"
|
||||
: "Opcional. Se desactiva al detener la conversación."}
|
||||
</span>
|
||||
{enabled ? (
|
||||
<WakeWordStatus controller={controller} />
|
||||
) : (
|
||||
<span role="status">Opcional. Se desactiva al detener la conversación.</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user