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
62 changes: 59 additions & 3 deletions components/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import OmpWebLogo from "./OmpWebLogo";
import { CHAT_COLUMN_MAX_WIDTH, MINIMAP_WIDTH } from "@/lib/chat-layout";
import { useAgentSession, type AgentPhase, type NoticeItem, type SubagentInfo } from "@/hooks/useAgentSession";
import { useAudio } from "@/hooks/useAudio";
import { useSpeechSynthesis, SpeechSynthesisProvider } from "@/hooks/useSpeechSynthesis";
import { useDragDrop } from "@/hooks/useDragDrop";
import { useIsMobile } from "@/hooks/useIsMobile";
import type { SessionStatsInfo, GenerationSpeedInfo } from "@/lib/pi-types";
Expand Down Expand Up @@ -92,6 +93,44 @@ function getUserInputText(message: AgentMessage): string | null {
return text.length > 0 ? text : null;
}

/**
* Text of the newest assistant reply for read-aloud. The live streaming
* message wins when present; otherwise the last committed assistant row is
* used, keyed by its entry id so the row's own speaker button lights up.
*/
function assistantSpeech(
messages: AgentMessage[],
entryIds: string[],
streaming: Partial<AgentMessage> | null,
): { id: string; text: string } | null {
const textOf = (content: unknown): string => {
if (!Array.isArray(content)) return "";
return content
.filter((block: unknown): block is { type: "text"; text: string } => {
if (!block || typeof block !== "object") return false;
if (!("type" in block) || block.type !== "text") return false;
return "text" in block && typeof block.text === "string";
})
.map((block) => block.text)
.join("\n\n");
};

if (streaming && streaming.role === "assistant") {
const text = textOf(streaming.content);
if (text.trim()) {
return { id: streaming.timestamp ? String(streaming.timestamp) : "msg", text };
}
}
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message.role !== "assistant") continue;
const text = textOf(message.content);
if (!text.trim()) break;
return { id: entryIds[i] ?? (message.timestamp ? String(message.timestamp) : "msg"), text };
}
return null;
}

function withAssistantBlocks(
message: AssistantMessage,
content: AssistantContentBlock[],
Expand Down Expand Up @@ -530,11 +569,21 @@ export function ChatWindow({ session, newSessionCwd, newSessionWorkspace, toolCa
// checks the sound preference itself.
const playDoneSoundRef = useRef(playDoneSound);
playDoneSoundRef.current = playDoneSound;
const tts = useSpeechSynthesis();
const ttsRef = useRef(tts);
useEffect(() => {
ttsRef.current = tts;
}, [tts]);
// omp calls onAgentEnd in the same tick as the state update that commits the
// finished reply, so reading the transcript here would still see the previous
// one. Flag it instead and speak from the render that carries it.
const autoplayPendingRef = useRef(false);

const wrappedOnAgentEnd = useCallback(() => {
playDoneSoundRef.current();
if (ttsRef.current.autoPlayEnabled) autoplayPendingRef.current = true;
onAgentEnd?.();
}, [onAgentEnd]);

// Stabilize the onEditContent ref; pairs with React.memo to avoid re-rendering history messages
const handleEditContent = useCallback((content: string) => {
chatInputRef?.current?.insertIfEmpty(content);
Expand Down Expand Up @@ -566,6 +615,12 @@ export function ChatWindow({ session, newSessionCwd, newSessionWorkspace, toolCa
modelsRefreshKey, chatInputRef, onBranchDataChange, onSystemPromptChange, onSystemPromptLoaderChange, onSessionStatsPanelOpen,
onOpenFile,
});
useEffect(() => {
if (!autoplayPendingRef.current) return;
autoplayPendingRef.current = false;
const speech = assistantSpeech(messages, entryIds, streamState.streamingMessage);
if (speech) ttsRef.current.speak(speech.id, speech.text);
}, [messages, entryIds, streamState, agentRunning]);
const sessionBusy = agentRunning || bashRunning;
const modelCapacity = useMemo(() => {
if (!displayModelValue) return null;
Expand Down Expand Up @@ -1078,8 +1133,8 @@ export function ChatWindow({ session, newSessionCwd, newSessionWorkspace, toolCa
</div>
);
}

return (
<SpeechSynthesisProvider value={tts}>
<div
className="relative flex h-full flex-col overflow-hidden"
onDragEnter={handleDragEnter}
Expand Down Expand Up @@ -1372,7 +1427,8 @@ export function ChatWindow({ session, newSessionCwd, newSessionWorkspace, toolCa
</div>
</>
)}
</div>
</div>
</SpeechSynthesisProvider>
);
}

Expand Down
28 changes: 26 additions & 2 deletions components/MessageView.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"use client";

import { memo, useState, useId, useRef, useEffect, useMemo, useCallback, type ComponentProps } from "react";
import { Copy, Check, GitFork, CornerUpLeft, ChevronRight, ChevronDown, Brain, EyeOff, CircleAlert, CircleSlash, LoaderCircle, FileText, Search, FileEdit, Terminal, CheckSquare, Bot, Code2, Globe, MessagesSquare, Wrench } from "lucide-react";
import { Copy, Check, GitFork, CornerUpLeft, ChevronRight, ChevronDown, Brain, EyeOff, CircleAlert, CircleSlash, LoaderCircle, FileText, Search, FileEdit, Terminal, CheckSquare, Bot, Code2, Globe, MessagesSquare, Wrench, Volume2, Square } from "lucide-react";
import { MarkdownBody } from "./MarkdownBody";
import { MessageCopyActions } from "./MessageCopyActions";
import { useSpeechContext } from "@/hooks/useSpeechSynthesis";
import { ClickableImage } from "./ImageLightbox";
import { translate, useI18n, type Locale } from "@/lib/i18n";
import { parseCompactionSummary } from "@/lib/compaction-summary";
Expand Down Expand Up @@ -510,6 +511,15 @@ function AssistantMessageView({
liveTokensPerSecond?: number | null;
}) {
const { t, locale } = useI18n();
const { isSupported: ttsSupported, isSpeaking: ttsSpeaking, speakingId: ttsSpeakingId, toggle: ttsToggle } = useSpeechContext();
const speakableText = useMemo(() => {
return (message.content ?? [])
.filter((b): b is TextContent => b.type === "text" && typeof b.text === "string")
.map((b) => b.text)
.join("\n\n");
}, [message.content]);
const messageSpeechId = entryId ?? (message.timestamp ? String(message.timestamp) : "msg");
const isThisSpeaking = ttsSpeaking && ttsSpeakingId === messageSpeechId;
const time = showTimestamp ? formatTime(message.timestamp, locale) : null;
const bodyRef = useRef<HTMLDivElement>(null);
const texts = (message.content ?? []).filter((block): block is TextContent => block.type === "text").map((block) => block.text);
Expand Down Expand Up @@ -735,10 +745,24 @@ function AssistantMessageView({
)}
</div>

{!isStreaming && (texts.some((text) => text.trim()) || time || canFork) && (
{!isStreaming && (texts.some((text) => text.trim()) || time || canFork || (ttsSupported && speakableText.trim().length > 0)) && (
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", justifyContent: "space-between", gap: 6, marginTop: 3 }}>
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 3 }}>
<MessageCopyActions texts={texts} bodyRef={bodyRef} />
{ttsSupported && speakableText.trim().length > 0 && (
<Tooltip content={isThisSpeaking ? t("messageView.stopSpeech") : t("messageView.readAloud")}>
<button
type="button"
className="message-copy-action"
onClick={() => ttsToggle(messageSpeechId, speakableText)}
aria-label={isThisSpeaking ? t("messageView.stopSpeech") : t("messageView.readAloud")}
style={isThisSpeaking ? { color: "var(--accent)", background: "var(--bg-hover)" } : undefined}
>
{isThisSpeaking ? <Square size={13} aria-hidden="true" /> : <Volume2 size={13} aria-hidden="true" />}
<span>{isThisSpeaking ? t("messageView.stopSpeech") : t("messageView.readAloud")}</span>
</button>
</Tooltip>
)}
{canFork && <ForkSessionButton entryId={forkEntryId!} onFork={onFork!} forking={forking} />}
</div>
{time && <span style={{ fontSize: 10, color: "var(--text-dim)", marginLeft: "auto" }}>{time}</span>}
Expand Down
43 changes: 43 additions & 0 deletions components/SettingsConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { copyText } from "@/lib/clipboard";
import type { AppUpdateInfo } from "./AppUpdateDialog";
import { useFontSize, type FontSizePreference } from "@/hooks/useFontSize";
import { useUiScale, type UiScalePreference } from "@/hooks/useUiScale";
import { useSpeechSynthesis } from "@/hooks/useSpeechSynthesis";
const SettingsTabLoading = () => {
const { t } = useI18n();
return <div role="status" style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", color: "var(--text-muted)", fontSize: 12 }}>{t("settingsConfig.loadingSettings")}</div>;
Expand Down Expand Up @@ -131,6 +132,8 @@ const SETTING_INDEX: SettingIndexEntry[] = [
{ id: "completion-sound", tab: "general", sectionKey: "settingsConfig.interfaceBehavior", labelKey: "settingsConfig.completionSound", descKey: "settingsConfig.completionSoundDesc", fallbackSection: "Interface & Behavior", fallbackLabel: "Completion sound", fallbackDesc: "Play a tone when the agent completes a run.", scope: "UI" },
{ id: "keep-tool-calls-collapsed", tab: "general", sectionKey: "settingsConfig.interfaceBehavior", labelKey: "settingsConfig.keepToolCallsCollapsed", descKey: "settingsConfig.keepToolCallsCollapsedDesc", fallbackSection: "Interface & Behavior", fallbackLabel: "Keep tool calls collapsed", fallbackDesc: "Show only compact headers while tools execute.", scope: "UI" },
{ id: "scope-native-select-all", tab: "general", sectionKey: "settingsConfig.interfaceBehavior", labelKey: "settingsConfig.scopeNativeSelectAll", descKey: "settingsConfig.scopeNativeSelectAllDesc", fallbackSection: "Interface & Behavior", fallbackLabel: "Scope native Select All (experimental)", fallbackDesc: "Limit whole-page selections from browser or touch menus to the active message, chat, or file. May also narrow deliberate whole-page selections. Turn off if selection handles or menus misbehave. Keyboard shortcuts are unaffected.", scope: "UI" },
{ id: "tts-autoplay", tab: "general", sectionKey: "settingsConfig.interfaceBehavior", labelKey: "settingsConfig.ttsAutoplay", descKey: "settingsConfig.ttsAutoplayDesc", fallbackSection: "Interface & Behavior", fallbackLabel: "Auto-read assistant responses", fallbackDesc: "Automatically read aloud new assistant replies when completed.", scope: "UI" },
{ id: "tts-voice", tab: "general", sectionKey: "settingsConfig.interfaceBehavior", labelKey: "settingsConfig.ttsVoice", descKey: "settingsConfig.ttsVoiceDesc", fallbackSection: "Interface & Behavior", fallbackLabel: "Speech Voice", fallbackDesc: "Select the browser voice for text-to-speech reading.", scope: "UI" },
{ id: "provider-usage", tab: "general", sectionKey: "settingsConfig.interfaceBehavior", labelKey: "settingsConfig.providerUsage", descKey: "settingsConfig.providerUsageDesc", fallbackSection: "Interface & Behavior", fallbackLabel: "Provider usage limits", fallbackDesc: "Show provider usage in the sidebar, above Settings.", scope: "UI" },
{ id: "chat-font-size", tab: "general", sectionKey: "settingsConfig.interfaceBehavior", labelKey: "settingsConfig.chatFontSize", descKey: "settingsConfig.chatFontSizeDesc", fallbackSection: "Interface & Behavior", fallbackLabel: "Chat Font Size", fallbackDesc: "Adjust text size for conversation messages, code blocks, and markdown output.", scope: "UI" },
{ id: "ui-scale", tab: "general", sectionKey: "settingsConfig.interfaceBehavior", labelKey: "settingsConfig.uiScale", descKey: "settingsConfig.uiScaleDesc", fallbackSection: "Interface & Behavior", fallbackLabel: "Interface Scale", fallbackDesc: "Adjust overall UI zoom and display density across sidebars, dialogs, buttons, and toolbars.", scope: "UI" },
Expand Down Expand Up @@ -380,6 +383,14 @@ export function SettingsConfig({ activeTab, toolCallsDefaultCollapsed, onToolCal
const workspaceReady = cwd !== null;
const { fontSize, setFontSize } = useFontSize();
const { uiScale, setUiScale } = useUiScale();
const {
isSupported: ttsSupported,
autoPlayEnabled: ttsAutoPlay,
setAutoPlay: setTtsAutoPlay,
voices: ttsVoices,
selectedVoiceURI: ttsVoiceURI,
setSelectedVoiceURI: setTtsVoiceURI,
} = useSpeechSynthesis();
const [searchQuery, setSearchQuery] = useState("");
const [highlightId, setHighlightId] = useState<string | null>(null);
const [submitBehavior, setSubmitBehavior] = useState<SubmitDuringRunBehavior>(() => getSubmitDuringRunBehavior());
Expand Down Expand Up @@ -776,6 +787,38 @@ export function SettingsConfig({ activeTab, toolCallsDefaultCollapsed, onToolCal
}}
/>
</NativeSetting>
<NativeSetting
searchId="tts-autoplay"
label={t("settingsConfig.ttsAutoplay") || "Auto-read assistant responses"}
description={ttsSupported ? (t("settingsConfig.ttsAutoplayDesc") || "Automatically read aloud new assistant replies when completed.") : `${t("settingsConfig.ttsAutoplayDesc") || "Automatically read aloud new assistant replies when completed."} (${t("settingsConfig.ttsNotSupported") || "Not supported in this browser"})`}
scope="UI"
>
<ToggleSwitch
checked={ttsSupported ? ttsAutoPlay : false}
disabled={!ttsSupported}
onChange={setTtsAutoPlay}
/>
</NativeSetting>
<NativeSetting
searchId="tts-voice"
label={t("settingsConfig.ttsVoice") || "Speech Voice"}
description={ttsSupported ? (t("settingsConfig.ttsVoiceDesc") || "Select the browser voice for text-to-speech reading.") : `${t("settingsConfig.ttsVoiceDesc") || "Select the browser voice for text-to-speech reading."} (${t("settingsConfig.ttsNotSupported") || "Not supported in this browser"})`}
scope="UI"
>
<select
style={nativeSelectStyle}
value={ttsVoiceURI || ""}
disabled={!ttsSupported || ttsVoices.length === 0}
onChange={(e) => setTtsVoiceURI(e.target.value || null)}
>
<option value="">{t("settingsConfig.defaultVoice") || "Default system voice"}</option>
{ttsVoices.map((v) => (
<option key={v.voiceURI} value={v.voiceURI}>
{v.name} ({v.lang})
</option>
))}
</select>
</NativeSetting>
<NativeSetting searchId="provider-usage" label={t("settingsConfig.providerUsage")} description={t("settingsConfig.providerUsageDesc")} scope="UI">
<ToggleSwitch checked={providerUsageVisible} onChange={onProviderUsageVisibleChange} />
</NativeSetting>
Expand Down
Loading
Loading