@@ -98,7 +106,16 @@ function ArtifactContent() {
return (
-
{fileContent ?? ""}
+
+ Preview is not available for this file type here.
+
+
);
}
diff --git a/desktop/renderer/src/ui/chat/components/ChatComposer.module.css b/desktop/renderer/src/ui/chat/components/ChatComposer.module.css
index 6cde761f21..908af872c3 100644
--- a/desktop/renderer/src/ui/chat/components/ChatComposer.module.css
+++ b/desktop/renderer/src/ui/chat/components/ChatComposer.module.css
@@ -223,6 +223,7 @@
display: flex;
align-items: center;
justify-content: space-between;
+ gap: 10px;
width: 100%;
}
@@ -232,6 +233,47 @@
gap: 4px;
}
+.UiChatVoiceModeChip {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+ padding: 0 12px;
+ height: 32px;
+ border: 1px solid rgba(163, 230, 53, 0.22);
+ border-radius: 999px;
+ background:
+ linear-gradient(180deg, rgba(163, 230, 53, 0.12), rgba(163, 230, 53, 0.06)),
+ rgba(255, 255, 255, 0.02);
+ color: rgba(245, 255, 221, 0.94);
+ font-size: 12px;
+ font-weight: 500;
+ letter-spacing: 0.01em;
+ cursor: pointer;
+ transition:
+ border-color var(--transition-fast),
+ background var(--transition-fast),
+ color var(--transition-fast),
+ transform var(--transition-fast);
+}
+
+.UiChatVoiceModeChip svg {
+ width: 15px;
+ height: 15px;
+ flex-shrink: 0;
+}
+
+.UiChatVoiceModeChip:hover {
+ border-color: rgba(163, 230, 53, 0.36);
+ background:
+ linear-gradient(180deg, rgba(163, 230, 53, 0.18), rgba(163, 230, 53, 0.09)),
+ rgba(255, 255, 255, 0.03);
+}
+
+.UiChatVoiceModeChip:active {
+ transform: translateY(1px);
+}
+
.UiChatInput {
flex: 1 1 auto;
resize: none;
@@ -258,6 +300,33 @@
color: var(--muted3);
}
+.UiChatSendSpinner {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 14px;
+ height: 14px;
+}
+
+.UiChatSendSpinnerRing {
+ display: block;
+ box-sizing: border-box;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ border: 1.5px solid rgba(244, 244, 245, 0.2);
+ border-top-color: rgba(244, 244, 245, 0.95);
+ border-right-color: rgba(244, 244, 245, 0.5);
+ animation: chatComposerSpin 0.9s linear infinite;
+}
+
+@keyframes chatComposerSpin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
.UiChatSendButton {
flex-shrink: 0;
padding: 0;
@@ -366,7 +435,8 @@
transition: width 0.3s ease;
}
-.UiChatMicButton {
+.UiChatMicButton,
+.UiChatVoiceMessageButton {
flex-shrink: 0;
width: 32px;
height: 32px;
@@ -382,10 +452,13 @@
transition:
color var(--transition-fast),
background var(--transition-fast),
- box-shadow var(--transition-fast);
+ box-shadow var(--transition-fast),
+ border-color var(--transition-fast),
+ transform var(--transition-fast);
}
-.UiChatMicButton svg {
+.UiChatMicButton svg,
+.UiChatVoiceMessageButton svg {
width: 20px;
height: 20px;
display: block;
@@ -434,6 +507,58 @@
cursor: default;
}
+.UiChatVoiceMessageButton {
+ background:
+ linear-gradient(180deg, rgba(163, 230, 53, 0.18), rgba(163, 230, 53, 0.08)),
+ rgba(255, 255, 255, 0.03);
+ color: rgba(244, 255, 228, 0.96);
+ border: 1px solid rgba(163, 230, 53, 0.34);
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.05),
+ 0 4px 12px rgba(107, 153, 39, 0.16);
+}
+
+.UiChatVoiceMessageButton:hover:not(:disabled) {
+ background:
+ linear-gradient(180deg, rgba(163, 230, 53, 0.24), rgba(163, 230, 53, 0.1)),
+ rgba(255, 255, 255, 0.04);
+ color: #ffffff;
+ border-color: rgba(163, 230, 53, 0.48);
+}
+
+.UiChatVoiceMessageButton:active:not(:disabled) {
+ transform: scale(0.96);
+}
+
+.UiChatVoiceMessageButton--recording {
+ color: var(--bg-base);
+ background: var(--lime);
+ border-color: rgba(163, 230, 53, 0.68);
+ box-shadow: 0 0 0 3px rgba(163, 230, 53, 0.35);
+ animation: micPulse 1.2s ease-in-out infinite;
+}
+
+.UiChatVoiceMessageButton--recording:hover:not(:disabled) {
+ background: var(--lime);
+ opacity: 0.88;
+}
+
+.UiChatVoiceMessageButton--processing {
+ color: rgba(244, 255, 228, 0.88);
+ cursor: wait;
+ position: relative;
+}
+
+.UiChatVoiceMessageButton--processing::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: 50%;
+ border: 2px solid transparent;
+ border-top-color: rgba(244, 255, 228, 0.96);
+ animation: micSpin 0.8s linear infinite;
+}
+
@keyframes micPulse {
0%,
100% {
diff --git a/desktop/renderer/src/ui/chat/components/ChatComposer.test.tsx b/desktop/renderer/src/ui/chat/components/ChatComposer.test.tsx
new file mode 100644
index 0000000000..3e4e8b9743
--- /dev/null
+++ b/desktop/renderer/src/ui/chat/components/ChatComposer.test.tsx
@@ -0,0 +1,107 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi, afterEach } from "vitest";
+import React from "react";
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { ChatComposer } from "./ChatComposer";
+
+vi.mock("./ChatAttachmentCard", () => ({
+ ChatAttachmentCard: () =>
,
+ getFileTypeLabel: (t: string) => t,
+}));
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("ChatComposer", () => {
+ it("shows send icon by default", () => {
+ const { container } = render(
+
+ );
+
+ const sendButton = screen.getByRole("button", { name: "Send" });
+ expect(sendButton.querySelector("svg")).toBeTruthy();
+ expect(container.querySelector(".UiChatSendSpinner")).toBeNull();
+ });
+
+ it("shows active spinner inside send button when agent is active", () => {
+ const { container } = render(
+
+ );
+
+ const sendButton = screen.getByRole("button", { name: "Send" });
+ expect(screen.getByLabelText("Session active")).toBeTruthy();
+ expect(sendButton.querySelector("svg")).toBeNull();
+ });
+
+ it("keeps stop button when streaming mode is active", () => {
+ const { container } = render(
+
+ );
+
+ expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
+ expect(container.querySelector(".UiChatSendSpinner")).toBeNull();
+ });
+
+ it("renders and toggles the voice replies chip", () => {
+ const onVoiceReplyModeToggle = vi.fn();
+
+ render(
+
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Disable voice replies" }));
+
+ expect(screen.getByText("Voice replies on")).toBeTruthy();
+ expect(onVoiceReplyModeToggle).toHaveBeenCalledWith(false);
+ });
+
+ it("keeps transcription mic and renders a second button for voice messages", () => {
+ render(
+
+ );
+
+ expect(screen.getByRole("button", { name: "Hold to record voice" })).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Hold to send voice message" })).toBeTruthy();
+ });
+});
diff --git a/desktop/renderer/src/ui/chat/components/ChatComposer.tsx b/desktop/renderer/src/ui/chat/components/ChatComposer.tsx
index 85050362c5..5e230eb7b7 100644
--- a/desktop/renderer/src/ui/chat/components/ChatComposer.tsx
+++ b/desktop/renderer/src/ui/chat/components/ChatComposer.tsx
@@ -31,10 +31,17 @@ export type ChatComposerProps = {
isVoiceProcessing?: boolean;
onVoiceStart?: () => void;
onVoiceStop?: () => void;
+ isVoiceMessageRecording?: boolean;
+ isVoiceMessageProcessing?: boolean;
+ onVoiceMessageStart?: () => void;
+ onVoiceMessageStop?: () => void;
voiceNotConfigured?: boolean;
onNavigateVoiceSettings?: () => void;
whisperDownload?: DownloadStatus;
onWhisperDownload?: () => void;
+ isAgentActive?: boolean;
+ voiceReplyMode?: boolean;
+ onVoiceReplyModeToggle?: (next: boolean) => void;
};
export const ChatComposer = React.forwardRef
(
@@ -58,9 +65,16 @@ export const ChatComposer = React.forwardRef
isVoiceProcessing = false,
onVoiceStart,
onVoiceStop,
+ isVoiceMessageRecording = false,
+ isVoiceMessageProcessing = false,
+ onVoiceMessageStart,
+ onVoiceMessageStop,
voiceNotConfigured = false,
whisperDownload,
onWhisperDownload,
+ isAgentActive = false,
+ voiceReplyMode = false,
+ onVoiceReplyModeToggle,
},
ref
) {
@@ -82,11 +96,24 @@ export const ChatComposer = React.forwardRef
});
React.useEffect(() => {
- if (!isVoiceRecording || voiceNotConfigured) return;
- const handleGlobalMouseUp = () => onVoiceStop?.();
+ if ((!isVoiceRecording && !isVoiceMessageRecording) || voiceNotConfigured) return;
+ const handleGlobalMouseUp = () => {
+ if (isVoiceRecording) {
+ onVoiceStop?.();
+ }
+ if (isVoiceMessageRecording) {
+ onVoiceMessageStop?.();
+ }
+ };
window.addEventListener("mouseup", handleGlobalMouseUp);
return () => window.removeEventListener("mouseup", handleGlobalMouseUp);
- }, [isVoiceRecording, voiceNotConfigured, onVoiceStop]);
+ }, [
+ isVoiceRecording,
+ isVoiceMessageRecording,
+ voiceNotConfigured,
+ onVoiceStop,
+ onVoiceMessageStop,
+ ]);
const prevDownloadKindRef = React.useRef(whisperDownload?.kind);
React.useEffect(() => {
@@ -204,6 +231,19 @@ export const ChatComposer = React.forwardRef
/>
+ {voiceReplyMode ? (
+
+ ) : null}
+
)}
+ {onVoiceMessageStart ? (
+
+
+
+ ) : null}
+
{streaming && onStop ? (
)}
diff --git a/desktop/renderer/src/ui/chat/components/ChatMessageList.audio.test.tsx b/desktop/renderer/src/ui/chat/components/ChatMessageList.audio.test.tsx
new file mode 100644
index 0000000000..a6a264da58
--- /dev/null
+++ b/desktop/renderer/src/ui/chat/components/ChatMessageList.audio.test.tsx
@@ -0,0 +1,323 @@
+// @vitest-environment jsdom
+import React from "react";
+import { Provider } from "react-redux";
+import { describe, expect, it, vi } from "vitest";
+import { render, waitFor } from "@testing-library/react";
+import { store } from "@store/store";
+import { ChatMessageList } from "./ChatMessageList";
+import { ArtifactProvider } from "../context/ArtifactContext";
+
+describe("ChatMessageList audio tool results", () => {
+ it("renders inline audio player for historical tts tool results", async () => {
+ Object.defineProperty(window, "openclawDesktop", {
+ value: {
+ readFileDataUrl: vi.fn(async (filePath: string) => ({
+ dataUrl: `data:application/octet-stream;base64,${btoa(filePath)}`,
+ mimeType: "application/octet-stream",
+ })),
+ openExternal: vi.fn(async () => {}),
+ } as unknown as NonNullable