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
17 changes: 17 additions & 0 deletions dashboard/src/v2/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ const buildComposerStatus = (input: {
pendingDashboardMessages: number;
selectedProject: boolean;
sending: boolean;
speechError: string | null;
trimmedInput: string;
}): ComposerStatusViewModel => {
const disabledReason = !input.selectedProject
Expand Down Expand Up @@ -148,6 +149,15 @@ const buildComposerStatus = (input: {
};
}

if (input.speechError) {
return {
tone: "failed",
visibleText: `Voice playback failed: ${input.speechError} The transcript is still available.`,
liveText: `Voice playback failed: ${input.speechError}`,
disabledReason,
};
}

if (input.pendingDashboardMessages > 0) {
const queuedLabel = `${input.pendingDashboardMessages} message${input.pendingDashboardMessages === 1 ? "" : "s"} queued for delivery.`;
return {
Expand Down Expand Up @@ -371,6 +381,7 @@ export const ChatPage: FunctionComponent = () => {
pendingDashboardMessages,
selectedProject: Boolean(selectedProject),
sending,
speechError: transcriptSpeech.error,
trimmedInput: trimmedComposerInput,
}), [
activeConnection?.displayName,
Expand All @@ -379,6 +390,7 @@ export const ChatPage: FunctionComponent = () => {
pendingDashboardMessages,
selectedProject,
sending,
transcriptSpeech.error,
trimmedComposerInput,
]);
const sendDisabled = Boolean(composerStatus.disabledReason);
Expand Down Expand Up @@ -1155,6 +1167,11 @@ export const ChatPage: FunctionComponent = () => {
Transcript could not update: {error}. Use the invocation actions above when available, or switch to Threads to continue the conversation.
</div>
)}
{transcriptSpeech.error && (
<div role="alert" aria-live="assertive" className="rounded-xl border border-status-red/25 bg-status-red/[0.08] px-3 py-2 text-xs font-semibold leading-relaxed text-status-red">
Voice playback failed: {transcriptSpeech.error} The transcript is still available.
</div>
)}
{invocationMessagesLoading && invocationMessages.length > 0 && (
<div role="status" aria-live="polite" className="rounded-xl border border-black/[0.06] bg-white/75 px-3 py-2 text-xs font-medium text-slate-500 shadow-sm dark:border-white/[0.06] dark:bg-white/[0.04] dark:text-slate-300">
Refreshing transcript while keeping the current messages visible.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ const speechButtonMock = vi.hoisted(() => ({
}));

const synthesisMock = vi.hoisted(() => ({
synthesizeSpeech: vi.fn<() => Promise<Blob>>(),
synthesizeSpeech: vi.fn<(
text: string,
projectId?: string | null,
voice?: string | null,
signal?: AbortSignal,
) => Promise<Blob>>(),
}));

const mocks = vi.hoisted(() => {
Expand Down Expand Up @@ -329,7 +334,12 @@ describe("ChatPage speech input", () => {
);

await waitFor(() => expect(synthesisMock.synthesizeSpeech).toHaveBeenCalledTimes(1));
expect(synthesisMock.synthesizeSpeech).toHaveBeenCalledWith("Fresh agent reply", "p1");
expect(synthesisMock.synthesizeSpeech).toHaveBeenCalledWith(
"Fresh agent reply",
"p1",
undefined,
expect.any(AbortSignal),
);
});

it("replays the staged agent message only after its explicit replay control is clicked", async () => {
Expand All @@ -354,7 +364,12 @@ describe("ChatPage speech input", () => {
expect(synthesisMock.synthesizeSpeech).not.toHaveBeenCalled();
fireEvent.click(replay);

await waitFor(() => expect(synthesisMock.synthesizeSpeech).toHaveBeenCalledWith("Replay this reply", "p1"));
await waitFor(() => expect(synthesisMock.synthesizeSpeech).toHaveBeenCalledWith(
"Replay this reply",
"p1",
undefined,
expect.any(AbortSignal),
));
});

it("reports a 3D voice synthesis failure instead of silently discarding it", async () => {
Expand Down Expand Up @@ -383,6 +398,33 @@ describe("ChatPage speech input", () => {
);
});

it("reports thread replay failures through the existing composer status without hiding the transcript", async () => {
synthesisMock.synthesizeSpeech.mockRejectedValueOnce(new Error("Speech provider timed out."));
mocks.data = {
...mocks.data,
chatMode: "threads",
messages: [{
id: "thread-replay-error",
threadId: "thread1",
direction: "connection_to_dashboard",
authorType: "connection",
authorConnectionId: "connection-1",
bodyMarkdown: "The transcript remains visible.",
deliveryStatus: "delivered",
createdAt: "2026-03-10T12:00:00.000Z",
metadata: null,
}],
};

renderChatPage();
fireEvent.click(await screen.findByRole("button", { name: "Replay message from Assistant" }));

expect(await screen.findByRole("alert")).toHaveTextContent(
"Voice playback failed: Speech provider timed out. The transcript is still available.",
);
expect(screen.getByText("The transcript remains visible.")).toBeInTheDocument();
});

it("auto-plays the first reply after sending in a brand-new empty 3D thread", async () => {
mocks.data = {
...mocks.data,
Expand Down Expand Up @@ -439,7 +481,12 @@ describe("ChatPage speech input", () => {
);

await waitFor(() => expect(synthesisMock.synthesizeSpeech).toHaveBeenCalledTimes(1));
expect(synthesisMock.synthesizeSpeech).toHaveBeenCalledWith("First answer", "p1");
expect(synthesisMock.synthesizeSpeech).toHaveBeenCalledWith(
"First answer",
"p1",
undefined,
expect.any(AbortSignal),
);
});

it("keeps loaded thread and invocation transcripts silent until replay is requested", async () => {
Expand Down Expand Up @@ -496,5 +543,12 @@ describe("ChatPage speech input", () => {

expect(await screen.findByRole("button", { name: "Replay message from Assistant" })).toBeInTheDocument();
expect(synthesisMock.synthesizeSpeech).not.toHaveBeenCalled();

synthesisMock.synthesizeSpeech.mockRejectedValueOnce(new Error("Invocation replay failed."));
fireEvent.click(screen.getByRole("button", { name: "Replay message from Assistant" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
"Voice playback failed: Invocation replay failed. The transcript is still available.",
);
expect(screen.getByText("Loaded invocation reply")).toBeInTheDocument();
});
});
Loading
Loading