Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
53e0096
feat(read-aloud): speak selected text on web and desktop
kaspesi Jul 30, 2026
1c05ef3
fix(read-aloud): speak selections only on their own host
kaspesi Jul 30, 2026
6a2f186
Merge branch 'main' into text-selection-speech-modal
kaspesi Jul 30, 2026
c85fd3b
test(agent-manager): retry the interrupt-fixture teardown on Windows
kaspesi Jul 30, 2026
c3e3214
feat(read-aloud): move read aloud from text selection to the turn footer
kaspesi Aug 4, 2026
74905ef
Merge branch 'main' into text-selection-speech-modal
kaspesi Aug 4, 2026
d082854
fix(read-aloud): use the outlined stop icon while speaking
kaspesi Aug 4, 2026
3351497
fix(read-aloud): accept the optional hovered flag from Pressable
kaspesi Aug 4, 2026
cb2f1e5
fix(read-aloud): stop playback when the route leaves the owning host
kaspesi Aug 4, 2026
13db05c
ci: re-run checks
kaspesi Aug 4, 2026
ce1d1b9
fix(read-aloud): drop segments that finish decoding after a stop
kaspesi Aug 4, 2026
d08ab97
ci: re-run checks
kaspesi Aug 4, 2026
fee0f78
refactor(read-aloud): drop unused playback speed, surface failures in…
kaspesi Aug 4, 2026
a116cf6
Merge branch 'main' into text-selection-speech-modal
kaspesi Aug 4, 2026
a09ba25
docs: move read-aloud dev notes into development.md, drop the scratch…
kaspesi Aug 4, 2026
7d98acf
Merge branch 'text-selection-speech-modal' of https://github.com/kasp…
kaspesi Aug 4, 2026
50f529a
docs: revert unrelated development.md notes
kaspesi Aug 4, 2026
9f00149
fix(read-aloud): drop a segment cancelled during engine initialization
kaspesi Aug 4, 2026
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
112 changes: 112 additions & 0 deletions packages/app/src/agent-stream/render-strategy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream";
import {
collectAssistantTurnContentForStreamRenderStrategy,
collectAssistantTurnSpeechForStreamRenderStrategy,
getBottomOffsetForStreamRenderStrategy,
getFrameChildOrderForStreamRenderStrategy,
getHistoryLiveBoundaryIndexForStreamRenderStrategy,
Expand Down Expand Up @@ -38,6 +39,24 @@ function assistantMessage(id: string, text: string, seed: number): StreamItem {
};
}

function toolCall(id: string, seed: number): StreamItem {
return {
kind: "tool_call",
id,
timestamp: createTimestamp(seed),
payload: {
source: "orchestrator",
data: {
toolCallId: id,
toolName: "bash",
arguments: "cmd",
result: null,
status: "completed",
},
},
};
}

describe("resolveStreamRenderStrategy", () => {
it("uses forward_stream on web", () => {
const strategy = resolveStreamRenderStrategy({
Expand Down Expand Up @@ -196,6 +215,99 @@ describe("neighbor and traversal semantics", () => {
).toBe("assistant-1\n\nassistant-2");
});

it("speaks only the prose after the last tool call", () => {
const chronological: StreamItem[] = [
userMessage("u1", "user-1", 1),
assistantMessage("a1", "before the tool", 2),
toolCall("t1", 3),
assistantMessage("a2", "after the tool", 4),
];

const forward = resolveStreamRenderStrategy({ platform: "web", isMobileBreakpoint: false });
const startIndex = chronological.findIndex((item) => item.id === "a2");

// Copy takes the whole turn; speech stops at the tool call. Reading a long
// turn from the top would replay narration the user already watched.
expect(
collectAssistantTurnContentForStreamRenderStrategy({
strategy: forward,
items: chronological,
startIndex,
}),
).toBe("before the tool\n\nafter the tool");
expect(
collectAssistantTurnSpeechForStreamRenderStrategy({
strategy: forward,
items: chronological,
startIndex,
}),
).toBe("after the tool");
});

it("speaks the whole turn when it has no tool calls", () => {
const chronological: StreamItem[] = [
userMessage("u1", "user-1", 1),
assistantMessage("a1", "assistant-1", 2),
assistantMessage("a2", "assistant-2", 3),
];

const forward = resolveStreamRenderStrategy({ platform: "web", isMobileBreakpoint: false });

expect(
collectAssistantTurnSpeechForStreamRenderStrategy({
strategy: forward,
items: chronological,
startIndex: chronological.findIndex((item) => item.id === "a2"),
}),
).toBe("assistant-1\n\nassistant-2");
});

it("speaks nothing when the turn ends on a tool call", () => {
const chronological: StreamItem[] = [
userMessage("u1", "user-1", 1),
assistantMessage("a1", "before the tool", 2),
toolCall("t1", 3),
];

const forward = resolveStreamRenderStrategy({ platform: "web", isMobileBreakpoint: false });

// The button hides on an empty result rather than synthesizing silence.
expect(
collectAssistantTurnSpeechForStreamRenderStrategy({
strategy: forward,
items: chronological,
startIndex: chronological.findIndex((item) => item.id === "t1"),
}),
).toBe("");
});

it("stops at the last tool call in both traversal directions", () => {
const chronological: StreamItem[] = [
userMessage("u1", "user-1", 1),
assistantMessage("a1", "before the tool", 2),
toolCall("t1", 3),
assistantMessage("a2", "after the tool", 4),
];

const inverted = resolveStreamRenderStrategy({
platform: "android",
isMobileBreakpoint: false,
});
const invertedItems = orderTailForStreamRenderStrategy({
strategy: inverted,
streamItems: chronological,
});

// Native renders an inverted list, so the walk runs the other way. Same text.
expect(
collectAssistantTurnSpeechForStreamRenderStrategy({
strategy: inverted,
items: invertedItems,
startIndex: invertedItems.findIndex((item) => item.id === "a2"),
}),
).toBe("after the tool");
});

it("returns undefined neighbor when index would be out of bounds", () => {
const forward = resolveStreamRenderStrategy({
platform: "web",
Expand Down
37 changes: 37 additions & 0 deletions packages/app/src/agent-stream/strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export interface StreamStrategy {
relation: NeighborRelation,
) => StreamItem | undefined;
collectAssistantTurnContent: (items: StreamItem[], startIndex: number) => string;
collectAssistantTurnSpeech: (items: StreamItem[], startIndex: number) => string;
isNearBottom: (input: StreamNearBottomInput) => boolean;
getBottomOffset: (metrics: StreamViewportMetrics) => number;
getEdgeSlotProps: (
Expand Down Expand Up @@ -180,6 +181,34 @@ export function createStreamStrategy(config: StreamStrategyConfig): StreamStrate
}
return messages.toReversed().join("\n\n");
},
/**
* The turn's closing prose — what the agent said after its last tool call.
*
* Unlike `collectAssistantTurnContent`, which copies the whole turn, this
* stops at the first `tool_call` walking backward. Reading a long turn aloud
* from the top would replay narration the user watched scroll by; the part
* worth hearing is the summary at the end.
*
* Empty when the turn ends on a tool call with nothing after it — the caller
* hides the button rather than synthesizing silence.
*/
collectAssistantTurnSpeech: (items, startIndex) => {
const messages: string[] = [];
for (
let index = startIndex;
index >= 0 && index < items.length;
index += config.assistantTurnTraversalStep
) {
const currentItem = items[index];
if (currentItem.kind === "user_message" || currentItem.kind === "tool_call") {
break;
}
if (currentItem.kind === "assistant_message") {
messages.push(currentItem.text);
}
}
return messages.toReversed().join("\n\n");
},
isNearBottom: (input) => config.isNearBottom(input),
getBottomOffset: (metrics) => config.getBottomOffset(metrics),
getEdgeSlotProps: (component, gapSize) => {
Expand Down Expand Up @@ -281,6 +310,14 @@ export function collectAssistantTurnContentForStreamRenderStrategy(params: {
return params.strategy.collectAssistantTurnContent(params.items, params.startIndex);
}

export function collectAssistantTurnSpeechForStreamRenderStrategy(params: {
strategy: StreamStrategy;
items: StreamItem[];
startIndex: number;
}): string {
return params.strategy.collectAssistantTurnSpeech(params.items, params.startIndex);
}

export function isNearBottomForStreamRenderStrategy(
params: StreamNearBottomInput & { strategy: StreamStrategy },
): boolean {
Expand Down
13 changes: 13 additions & 0 deletions packages/app/src/agent-stream/turn-footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { TurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import {
collectAssistantTurnContentForStreamRenderStrategy,
collectAssistantTurnSpeechForStreamRenderStrategy,
type StreamStrategy,
} from "./strategy";
import { resolveAssistantTurnForkBoundary, type AssistantTurnForkBoundary } from "./turn-boundary";
Expand Down Expand Up @@ -185,6 +186,16 @@ function CompletedTurnFooter({
}),
[strategy, items, startIndex],
);
const getSpeech = useCallback(
() =>
collectAssistantTurnSpeechForStreamRenderStrategy({
strategy,
items,
startIndex,
}),
[strategy, items, startIndex],
);
const turnId = items[startIndex]?.id ?? null;
const boundary = resolveAssistantTurnForkBoundary({
items,
startIndex,
Expand All @@ -203,6 +214,8 @@ function CompletedTurnFooter({
<View style={stylesheet.turnFooterSlot}>
<AssistantTurnFooter
getContent={getContent}
getSpeech={getSpeech}
turnId={turnId}
completedAt={timing?.completedAt}
durationMs={timing?.durationMs}
onFork={boundary && onForkAssistantTurn ? handleFork : undefined}
Expand Down
7 changes: 7 additions & 0 deletions packages/app/src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ import {
} from "@/utils/host-routes";
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { useReadAloudRouteGuard } from "@/read-aloud/use-read-aloud-route-guard";
import {
ensureOsNotificationPermission,
WEB_NOTIFICATION_CLICK_EVENT,
Expand Down Expand Up @@ -668,6 +669,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
<HostSessionManager />
<FaviconStatusSync />
<ReadAloudRouteGuard />
{children}
</VoiceProvider>
);
Expand Down Expand Up @@ -881,6 +883,11 @@ function FaviconStatusSync() {
return null;
}

function ReadAloudRouteGuard() {
useReadAloudRouteGuard();
return null;
}

const ROOT_STACK_SCREEN_OPTIONS = {
headerShown: false,
animation: "none" as const,
Expand Down
8 changes: 8 additions & 0 deletions packages/app/src/components/message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ import { RewindMenu, type RewindMode } from "@/components/rewind/rewind-menu";
import { useRewindAgentMutation } from "@/components/rewind/use-rewind-agent-mutation";
import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assistant-fork-menu";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { TurnReadAloudButton } from "@/read-aloud/turn-read-aloud-button";
import {
markdownCopyDataSet,
markdownCopyOrderedListDataSet,
Expand Down Expand Up @@ -566,6 +567,10 @@ export const UserMessage = memo(function UserMessage({

interface AssistantTurnFooterProps {
getContent: () => string;
/** The turn's closing prose, for read aloud. Omitted where speech isn't offered. */
getSpeech?: () => string;
/** Assistant message id, identifying this turn as the read-aloud owner. */
turnId?: string | null;
completedAt?: Date;
durationMs?: number;
onFork?: (target: AssistantForkTarget) => Promise<void> | void;
Expand Down Expand Up @@ -611,6 +616,8 @@ const TIMESTAMP_REVEAL_MS = 3000;
*/
export const AssistantTurnFooter = memo(function AssistantTurnFooter({
getContent,
getSpeech,
turnId,
completedAt,
durationMs,
onFork,
Expand Down Expand Up @@ -667,6 +674,7 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
getContent={getContent}
containerStyle={assistantTurnFooterStylesheet.copyButton}
/>
{getSpeech && turnId ? <TurnReadAloudButton turnId={turnId} getSpeech={getSpeech} /> : null}
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
{durationLabel ? (
<Pressable
Expand Down
11 changes: 11 additions & 0 deletions packages/app/src/i18n/resources/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1482,6 +1482,17 @@ export const ar: TranslationResources = {
copied: "منقول",
},
},
readAloud: {
action: "القراءة بصوت عالٍ",
stop: "إيقاف",
errors: {
ttsUnavailable: "لم يتم إعداد تحويل النص إلى كلام على هذا المضيف",
tooLong: "الرسالة أطول من أن تُقرأ بصوت عالٍ",
empty: "لا يوجد شيء لقراءته بصوت عالٍ",
unsupported: "القراءة بصوت عالٍ غير متوفرة هنا",
failed: "تعذّرت قراءة ذلك بصوت عالٍ",
},
},
realtimeVoice: {
actions: {
mute: "كتم صوت الوقت الحقيقي",
Expand Down
11 changes: 11 additions & 0 deletions packages/app/src/i18n/resources/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1493,6 +1493,17 @@ export const en = {
copied: "Copied",
},
},
readAloud: {
action: "Read aloud",
stop: "Stop",
errors: {
ttsUnavailable: "Text-to-speech isn't set up on this host",
tooLong: "Message is too long to read aloud",
empty: "Nothing to read aloud",
unsupported: "Read aloud isn't available here",
failed: "Couldn't read that aloud",
},
},
realtimeVoice: {
actions: {
mute: "Mute realtime voice",
Expand Down
11 changes: 11 additions & 0 deletions packages/app/src/i18n/resources/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1525,6 +1525,17 @@ export const es: TranslationResources = {
copied: "Copiado",
},
},
readAloud: {
action: "Leer en voz alta",
stop: "Detener",
errors: {
ttsUnavailable: "La conversión de texto a voz no está configurada en este host",
tooLong: "El mensaje es demasiado largo para leerlo en voz alta",
empty: "No hay nada que leer en voz alta",
unsupported: "Leer en voz alta no está disponible aquí",
failed: "No se pudo leer eso en voz alta",
},
},
realtimeVoice: {
actions: {
mute: "Silenciar voz en tiempo real",
Expand Down
11 changes: 11 additions & 0 deletions packages/app/src/i18n/resources/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1529,6 +1529,17 @@ export const fr: TranslationResources = {
copied: "Copié",
},
},
readAloud: {
action: "Lire à voix haute",
stop: "Arrêter",
errors: {
ttsUnavailable: "La synthèse vocale n'est pas configurée sur cet hôte",
tooLong: "Le message est trop long pour être lu à voix haute",
empty: "Rien à lire à voix haute",
unsupported: "La lecture à voix haute n'est pas disponible ici",
failed: "Impossible de lire ce texte à voix haute",
},
},
realtimeVoice: {
actions: {
mute: "Couper la voix en temps réel",
Expand Down
11 changes: 11 additions & 0 deletions packages/app/src/i18n/resources/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1498,6 +1498,17 @@ export const ja: TranslationResources = {
copied: "コピーしました",
},
},
readAloud: {
action: "読み上げ",
stop: "停止",
errors: {
ttsUnavailable: "このホストでは音声合成が設定されていません",
tooLong: "メッセージが長すぎて読み上げできません",
empty: "読み上げる内容がありません",
unsupported: "ここでは読み上げを利用できません",
failed: "読み上げできませんでした",
},
},
realtimeVoice: {
actions: {
mute: "リアルタイム音声をミュート",
Expand Down
11 changes: 11 additions & 0 deletions packages/app/src/i18n/resources/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1511,6 +1511,17 @@ export const ptBR: TranslationResources = {
copied: "Copiado",
},
},
readAloud: {
action: "Ler em voz alta",
stop: "Parar",
errors: {
ttsUnavailable: "A conversão de texto em fala não está configurada neste host",
tooLong: "A mensagem é longa demais para ser lida em voz alta",
empty: "Nada para ler em voz alta",
unsupported: "Ler em voz alta não está disponível aqui",
failed: "Não foi possível ler isso em voz alta",
},
},
realtimeVoice: {
actions: {
mute: "Silenciar voz em tempo real",
Expand Down
Loading
Loading