Skip to content

Commit bef1831

Browse files
committed
fix(agent): align voice notes with spec
1 parent 2c4c1e5 commit bef1831

20 files changed

Lines changed: 988 additions & 97 deletions

apps/server/src/provider/Drivers/HermesDriver.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,31 @@ it("does not invent a model inventory while the Hermes bridge is unavailable", (
122122
assert.equal(snapshot.status, "error");
123123
assert.deepEqual(snapshot.models, []);
124124
});
125+
126+
it("uses Hermes' STT file limit when an older voice-capable bridge omits its byte cap", () => {
127+
const snapshot = makeHermesProviderSnapshot({
128+
instanceId: ProviderInstanceId.make("hermes"),
129+
displayName: undefined,
130+
accentColor: undefined,
131+
enabled: true,
132+
checkedAt: "2026-07-22T00:00:00.000Z",
133+
capabilities: {
134+
protocolVersion: 1,
135+
requestId: HermesBridgeRequestId.make("capabilities"),
136+
capabilities: {
137+
asynchronousDelivery: true,
138+
imageAttachments: true,
139+
interrupts: true,
140+
approvals: true,
141+
clarifications: true,
142+
slashConfirmations: true,
143+
threadCreation: true,
144+
commandCatalog: true,
145+
voiceNotes: true,
146+
},
147+
commands: [],
148+
},
149+
});
150+
151+
assert.deepEqual(snapshot.voiceNotes, { maxBytes: 25 * 1024 * 1024 });
152+
});

apps/server/src/provider/Drivers/HermesDriver.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ export function makeHermesProviderSnapshot(input: {
147147
...(input.capabilities?.capabilities.voiceNotes
148148
? {
149149
voiceNotes: {
150-
maxBytes: input.capabilities.capabilities.voiceNoteMaxBytes ?? 128 * 1024 * 1024,
150+
maxBytes: input.capabilities.capabilities.voiceNoteMaxBytes ?? 25 * 1024 * 1024,
151151
},
152152
}
153153
: {}),

apps/server/src/provider/Layers/HermesAdapter.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,48 @@ it.layer(testLayer)("HermesAdapter", (it) => {
168168
}),
169169
);
170170

171+
it.effect("preserves voice attachment identity in transcription events", () =>
172+
Effect.gen(function* () {
173+
const { adapter } = yield* HermesAdapterTestHarness;
174+
const threadId = ThreadId.make("hermes-voice-thread");
175+
yield* adapter.startSession({
176+
provider: ProviderDriverKind.make("hermes"),
177+
threadId,
178+
runtimeMode: "full-access",
179+
});
180+
181+
const eventsFiber = yield* adapter.streamEvents.pipe(
182+
Stream.take(1),
183+
Stream.runCollect,
184+
Effect.forkChild,
185+
);
186+
yield* Effect.yieldNow;
187+
yield* adapter.receiveCallback({
188+
protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION,
189+
requestId: "voice-transcription-request",
190+
deliveryId: "voice-transcription-delivery",
191+
type: "voice.transcription",
192+
threadId,
193+
sourceMessageId: "hermes-user:turn-voice",
194+
messageId: "hermes-user:turn-voice",
195+
attachmentId: "voice-attachment-1",
196+
status: "ready",
197+
transcript: "Transcript text",
198+
});
199+
200+
const events = Array.from(yield* Fiber.join(eventsFiber));
201+
const event = events[0];
202+
NodeAssert.equal(event?.type, "item.updated");
203+
if (event?.type !== "item.updated") return;
204+
NodeAssert.deepEqual(event.payload.data, {
205+
messageId: "hermes-user:turn-voice",
206+
attachmentId: "voice-attachment-1",
207+
status: "ready",
208+
transcript: "Transcript text",
209+
});
210+
}),
211+
);
212+
171213
it.effect("emits only cumulative text deltas and completes the active turn", () =>
172214
Effect.gen(function* () {
173215
const { adapter, sent } = yield* HermesAdapterTestHarness;

apps/server/src/provider/Layers/HermesAdapter.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -772,6 +772,9 @@ export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (
772772
: "Voice note transcribed",
773773
data: {
774774
messageId: callback.messageId,
775+
...(callback.attachmentId !== undefined
776+
? { attachmentId: callback.attachmentId }
777+
: {}),
775778
status: callback.status,
776779
...(callback.transcript !== undefined
777780
? { transcript: callback.transcript }

apps/web/src/components/ChatView.tsx

Lines changed: 6 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ import {
113113
} from "../types";
114114
import { useTheme } from "../hooks/useTheme";
115115
import { useVoiceRecorderStore } from "../voiceRecorderStore";
116+
import { deriveVoiceTranscriptionsByAttachmentId } from "../voiceTranscription";
116117
import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
117118
import { isCommandPaletteOpen } from "../commandPaletteBus";
118119
import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
@@ -2118,26 +2119,10 @@ function ChatViewContent(props: ChatViewProps) {
21182119
);
21192120
const displayServerMessages = useMemo<ReadonlyArray<ChatMessage>>(() => {
21202121
if (!serverMessages) return [];
2121-
const voiceTranscriptionByTurnId = new Map<
2122-
string,
2123-
{ readonly status: "transcribing" | "ready" | "failed"; readonly transcript?: string }
2124-
>();
2125-
for (const activity of activeThread?.activities ?? []) {
2126-
if (activity.kind !== "voice-transcription.updated" || !activity.turnId) continue;
2127-
if (typeof activity.payload !== "object" || activity.payload === null) continue;
2128-
const payload = activity.payload as Record<string, unknown>;
2129-
if (
2130-
payload.status !== "transcribing" &&
2131-
payload.status !== "ready" &&
2132-
payload.status !== "failed"
2133-
) {
2134-
continue;
2135-
}
2136-
voiceTranscriptionByTurnId.set(activity.turnId, {
2137-
status: payload.status,
2138-
...(typeof payload.transcript === "string" ? { transcript: payload.transcript } : {}),
2139-
});
2140-
}
2122+
const voiceTranscriptionByAttachmentId = deriveVoiceTranscriptionsByAttachmentId(
2123+
serverMessages,
2124+
activeThread?.activities ?? [],
2125+
);
21412126
return serverMessages.map((message) => {
21422127
if (!message.attachments || message.attachments.length === 0) {
21432128
return message;
@@ -2147,9 +2132,7 @@ function ChatViewContent(props: ChatViewProps) {
21472132
attachments: message.attachments.map((attachment) => {
21482133
const previewUrl = serverAttachmentUrlById.get(attachment.id);
21492134
if (attachment.type === "audio") {
2150-
const transcription = message.turnId
2151-
? voiceTranscriptionByTurnId.get(message.turnId)
2152-
: undefined;
2135+
const transcription = voiceTranscriptionByAttachmentId.get(attachment.id);
21532136
return {
21542137
...attachment,
21552138
...(previewUrl ? { previewUrl } : {}),

apps/web/src/components/chat/ChatComposer.tsx

Lines changed: 88 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
} from "@t3tools/client-runtime/connection";
2525
import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
2626
import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
27+
import { useNavigate } from "@tanstack/react-router";
2728
import {
2829
memo,
2930
type ReactNode,
@@ -212,7 +213,7 @@ import {
212213
type VoiceDraft,
213214
useVoiceRecorderStore,
214215
} from "../../voiceRecorderStore";
215-
import { VoiceWaveform } from "./VoiceWaveform";
216+
import { VoiceWaveform, voiceSeekTargetSeconds } from "./VoiceWaveform";
216217

217218
const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
218219

@@ -226,7 +227,8 @@ function VoiceDraftPreview({
226227
const audioRef = useRef<HTMLAudioElement | null>(null);
227228
const [playing, setPlaying] = useState(false);
228229
const [elapsedMs, setElapsedMs] = useState(0);
229-
const progress = draft.durationMs > 0 ? elapsedMs / draft.durationMs : 0;
230+
const [playbackDurationMs, setPlaybackDurationMs] = useState(draft.durationMs);
231+
const progress = playbackDurationMs > 0 ? elapsedMs / playbackDurationMs : 0;
230232

231233
return (
232234
<div className="mx-3 mb-2 flex items-center gap-2 rounded-xl bg-muted/45 px-2.5 py-2">
@@ -250,12 +252,17 @@ function VoiceDraftPreview({
250252
onSeek={(nextProgress) => {
251253
const audio = audioRef.current;
252254
if (!audio) return;
253-
audio.currentTime = nextProgress * (audio.duration || draft.durationMs / 1_000);
254-
setElapsedMs(audio.currentTime * 1_000);
255+
const targetTime = voiceSeekTargetSeconds(
256+
nextProgress,
257+
audio.duration,
258+
playbackDurationMs,
259+
);
260+
audio.currentTime = targetTime;
261+
setElapsedMs(targetTime * 1_000);
255262
}}
256263
/>
257-
<span className="text-muted-foreground text-xs tabular-nums">
258-
{formatVoiceDuration(elapsedMs)} / {formatVoiceDuration(draft.durationMs)}
264+
<span className="w-24 shrink-0 text-right text-muted-foreground text-xs tabular-nums">
265+
{formatVoiceDuration(elapsedMs)} / {formatVoiceDuration(playbackDurationMs)}
259266
</span>
260267
<Button
261268
type="button"
@@ -270,13 +277,25 @@ function VoiceDraftPreview({
270277
ref={audioRef}
271278
src={draft.previewUrl}
272279
className="hidden"
273-
onPlay={() => setPlaying(true)}
280+
onPlay={(event) => {
281+
document.querySelectorAll<HTMLAudioElement>("audio[data-voice-note]").forEach((other) => {
282+
if (other !== event.currentTarget) other.pause();
283+
});
284+
setPlaying(true);
285+
}}
274286
onPause={() => setPlaying(false)}
275287
onEnded={() => {
276288
setPlaying(false);
277289
setElapsedMs(0);
278290
}}
279291
onTimeUpdate={(event) => setElapsedMs(event.currentTarget.currentTime * 1_000)}
292+
onLoadedMetadata={(event) => {
293+
const durationMs = event.currentTarget.duration * 1_000;
294+
if (Number.isFinite(durationMs) && durationMs > 0) {
295+
setPlaybackDurationMs(durationMs);
296+
}
297+
}}
298+
data-voice-note
280299
/>
281300
</div>
282301
);
@@ -706,6 +725,7 @@ export interface ChatComposerProps {
706725
// --------------------------------------------------------------------------
707726

708727
export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {
728+
const navigate = useNavigate();
709729
const {
710730
composerDraftTarget,
711731
environmentId,
@@ -955,6 +975,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
955975
const voiceRecorder = useVoiceRecorderStore((state) => state.recorder);
956976
const voiceRecorderError = useVoiceRecorderStore((state) => state.error);
957977
const startVoiceRecording = useVoiceRecorderStore((state) => state.start);
978+
const pauseVoiceRecording = useVoiceRecorderStore((state) => state.pause);
979+
const resumeVoiceRecording = useVoiceRecorderStore((state) => state.resume);
958980
const stopVoiceRecording = useVoiceRecorderStore((state) => state.stop);
959981
const discardVoiceRecording = useVoiceRecorderStore((state) => state.discard);
960982
const restoreVoiceRecording = useVoiceRecorderStore((state) => state.restore);
@@ -964,6 +986,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
964986
voiceRecorder.status !== "idle" && voiceRecorder.threadId === voiceTargetThreadId;
965987
const activeVoiceDraft =
966988
voiceRecorder.status === "draft" && voiceBelongsToActiveThread ? voiceRecorder.draft : null;
989+
const voiceInputLocked = voiceRecorder.status !== "idle" && voiceBelongsToActiveThread;
967990
useEffect(() => {
968991
void restoreVoiceRecording();
969992
}, [restoreVoiceRecording]);
@@ -2715,7 +2738,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
27152738
: "Ask anything, @tag files/folders, $use skills, or / for commands"
27162739
}
27172740
ghostHint={commandGhostHint}
2718-
disabled={isConnecting || isComposerApprovalState || projectSelectionRequired}
2741+
disabled={
2742+
isConnecting ||
2743+
isComposerApprovalState ||
2744+
projectSelectionRequired ||
2745+
voiceInputLocked
2746+
}
27192747
/>
27202748
{showMobilePendingAnswerActions ? (
27212749
<div
@@ -2749,11 +2777,40 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
27492777

27502778
{voiceRecorder.status === "recording" && voiceBelongsToActiveThread ? (
27512779
<div className="mx-3 mb-2 flex items-center gap-2 rounded-xl bg-red-500/8 px-2.5 py-2">
2752-
<span className="size-2 shrink-0 animate-pulse rounded-full bg-red-500" />
2780+
<span
2781+
className={cn(
2782+
"size-2 shrink-0 rounded-full bg-red-500",
2783+
!voiceRecorder.paused && "animate-pulse",
2784+
)}
2785+
/>
27532786
<VoiceWaveform levels={voiceRecorder.waveform} live />
2754-
<span className="text-red-500 text-xs tabular-nums">
2787+
<span className="w-12 shrink-0 text-right text-red-500 text-xs tabular-nums">
27552788
{formatVoiceDuration(voiceRecorder.elapsedMs)}
27562789
</span>
2790+
<Button
2791+
type="button"
2792+
size="icon-sm"
2793+
variant="ghost"
2794+
className="rounded-full text-muted-foreground"
2795+
aria-label={voiceRecorder.paused ? "Resume recording" : "Pause recording"}
2796+
onClick={voiceRecorder.paused ? resumeVoiceRecording : pauseVoiceRecording}
2797+
>
2798+
{voiceRecorder.paused ? (
2799+
<PlayIcon className="size-3.5 fill-current" />
2800+
) : (
2801+
<PauseIcon className="size-3.5 fill-current" />
2802+
)}
2803+
</Button>
2804+
<Button
2805+
type="button"
2806+
size="icon-sm"
2807+
variant="ghost"
2808+
className="rounded-full text-muted-foreground"
2809+
aria-label="Cancel recording"
2810+
onClick={discardVoiceRecording}
2811+
>
2812+
<XIcon className="size-4" />
2813+
</Button>
27572814
<Button
27582815
type="button"
27592816
size="icon-sm"
@@ -2890,6 +2947,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
28902947
size="icon"
28912948
variant="ghost"
28922949
aria-label="Record voice note"
2950+
disabled={composerSendState.hasSendableContent}
28932951
onClick={() =>
28942952
void startVoiceRecording(
28952953
environmentId,
@@ -2903,7 +2961,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
29032961
>
29042962
<MicIcon />
29052963
</TooltipTrigger>
2906-
<TooltipPopup side="top">Record voice note</TooltipPopup>
2964+
<TooltipPopup side="top">
2965+
{composerSendState.hasSendableContent
2966+
? "Clear the composer to record a voice note"
2967+
: "Record voice note"}
2968+
</TooltipPopup>
29072969
</Tooltip>
29082970
) : null
29092971
) : null}
@@ -2941,20 +3003,29 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
29413003
) : null}
29423004
{voiceRecorder.status !== "idle" && !voiceBelongsToActiveThread ? (
29433005
<div className="fixed right-4 bottom-4 z-50 flex items-center gap-3 rounded-full border border-border/70 bg-background/95 px-3 py-2 shadow-lg backdrop-blur">
2944-
<span className="size-2 animate-pulse rounded-full bg-red-500" />
2945-
<span className="text-sm">
3006+
<span
3007+
className={cn(
3008+
"size-2 rounded-full bg-red-500",
3009+
voiceRecorder.status === "recording" && !voiceRecorder.paused && "animate-pulse",
3010+
)}
3011+
/>
3012+
<span className="min-w-28 text-sm tabular-nums">
29463013
{voiceRecorder.status === "recording"
2947-
? `Recording · ${formatVoiceDuration(voiceRecorder.elapsedMs)}`
3014+
? `${voiceRecorder.paused ? "Paused" : "Recording"} · ${formatVoiceDuration(voiceRecorder.elapsedMs)}`
29483015
: `Voice draft · ${formatVoiceDuration(voiceRecorder.draft.durationMs)}`}
29493016
</span>
29503017
<Button
29513018
type="button"
29523019
size="sm"
29533020
variant="ghost"
29543021
onClick={() => {
2955-
window.location.assign(
2956-
`/${voiceRecorder.environmentId}/${voiceRecorder.threadId}`,
2957-
);
3022+
void navigate({
3023+
to: "/$environmentId/$threadId",
3024+
params: {
3025+
environmentId: voiceRecorder.environmentId,
3026+
threadId: voiceRecorder.threadId,
3027+
},
3028+
});
29583029
}}
29593030
>
29603031
Return

0 commit comments

Comments
 (0)