fix(frontend): improve responsive transcripts and simplify theme styles

This commit is contained in:
2026-10-07 09:43:30 -03:00
parent a56e1082e5
commit 4ce6082b93
16 changed files with 536 additions and 1123 deletions
@@ -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 }} />;
});
+3 -5
View File
@@ -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();
});
+45 -19
View File
@@ -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>
);
}