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
Binary file modified docs/design-system/evidence/1300/01-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/design-system/evidence/1300/03-dark-hc.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/design-system/evidence/1300/04-light-hc.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/design-system/evidence/1300/05-prefers-contrast.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/design-system/evidence/1300/06-forced-colors.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/design-system/evidence/1300/09-responsive.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 4 additions & 4 deletions docs/design-system/evidence/1300/a11y/a11y-proof.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
"issue": 1300,
"epic": 1290,
"tool": "axe-core 4.12.0",
"postCssSha256": "e9dfdde86df78e8137ac47448986b1b3ffcc4006567d4f335be3a25b3aa00a7d",
"postCssSha256": "4d67f6ccfe6012b0a2e02769f88ac647340bd4bcd5d0e280fb47d2fea1f0ddba",
"run": {
"generatedAt": "2026-06-27T16:18:23.566Z",
"repoHeadSha": "29362c523bcbddd665a307dbd7fb91c5fc1a4507"
"generatedAt": "2026-06-28T08:43:06.669Z",
"repoHeadSha": "9cdb3ba00d22171fe0dda03e93aee512cc68f4d2"
},
"gate": "zero serious/critical violations, zero undispositioned axe incomplete rules, and deterministic focus/name/keyboard/motion/forced-colors checks passing in every theme/contrast/motion mode",
"coverage": "contrast (color-contrast), name/role/value (aria-*, label, button-name, link-name), keyboard semantics (tablist/grid/listbox roles), reduced-motion, forced-colors, prefers-contrast",
Expand Down Expand Up @@ -6127,4 +6127,4 @@
}
},
"verdict": "PASS"
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"issue": 1300,
"epic": 1290,
"postCssSha256": "e9dfdde86df78e8137ac47448986b1b3ffcc4006567d4f335be3a25b3aa00a7d",
"postCssSha256": "4d67f6ccfe6012b0a2e02769f88ac647340bd4bcd5d0e280fb47d2fea1f0ddba",
"referenceFiles": [
"keiko-tokens.css",
"keiko-semantic-tokens.css",
Expand All @@ -21,9 +21,9 @@
"keiko-nav.css": "21102a96a32a9af5541a162ef2c55b9eb33ffc9172788c2bda76371b7b9d2c8e"
},
"run": {
"startedAt": "2026-06-27T16:18:14.039Z",
"generatedAt": "2026-06-27T16:18:16.514Z",
"repoHeadSha": "29362c523bcbddd665a307dbd7fb91c5fc1a4507"
"startedAt": "2026-06-28T08:42:32.615Z",
"generatedAt": "2026-06-28T08:42:35.363Z",
"repoHeadSha": "9cdb3ba00d22171fe0dda03e93aee512cc68f4d2"
},
"tolerance": "≤1 LSB per sRGB channel (canvas-normalised)",
"groupR": {
Expand Down Expand Up @@ -276,7 +276,7 @@
"boundedRowSmoke": {
"ok": true,
"rowCount": 250,
"durationMs": 0.3999999761581421,
"durationMs": 0.699999988079071,
"afterScrollTop": 8363,
"scrollHeight": 8783,
"clientHeight": 420,
Expand Down Expand Up @@ -315,4 +315,4 @@
}
},
"verdict": "PASS"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"issue": 1300,
"epic": 1290,
"surface": "editor",
"postCssSha256": "e9dfdde86df78e8137ac47448986b1b3ffcc4006567d4f335be3a25b3aa00a7d",
"postCssSha256": "4d67f6ccfe6012b0a2e02769f88ac647340bd4bcd5d0e280fb47d2fea1f0ddba",
"referenceFiles": [
"keiko-tokens.css",
"keiko-semantic-tokens.css",
Expand All @@ -14,8 +14,8 @@
"keiko-editor-tokens.css": "9d61f7df19dc02861354eb30206403942695608a8c9ee43cde0db13c0da81bac"
},
"run": {
"generatedAt": "2026-06-27T16:18:32.685Z",
"repoHeadSha": "29362c523bcbddd665a307dbd7fb91c5fc1a4507"
"generatedAt": "2026-06-28T08:42:49.307Z",
"repoHeadSha": "9cdb3ba00d22171fe0dda03e93aee512cc68f4d2"
},
"tolerance": "≤1 LSB per sRGB channel (canvas-normalised)",
"tokenFidelity": {
Expand Down Expand Up @@ -658,4 +658,4 @@
"missingReferenceTokenCount": 0,
"missing": [],
"verdict": "PASS"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,91 @@ describe("useRealtimeVoice — Realtime data-channel transcripts", () => {
expect(result.current.turnSnapshot.state).toBe("yielding");
});

it("deduplicates assistant transcripts mirrored by multiple provider event shapes", async () => {
const { session, fireDataChannelEvent } = makeFakeSession();
const transport = makeFakeTransport({ session });
const { client } = makeFakeControl({});
const onAssistantTranscriptCommitted = vi.fn();

const { result } = renderHook(() =>
useRealtimeVoice({
createTransport: () => transport,
createControl: () => client,
onAssistantTranscriptCommitted,
}),
);

act(() => result.current.start());
await waitFor(() => expect(result.current.phase).toBe("negotiating"));

act(() => {
fireDataChannelEvent({
type: "response.output_audio_transcript.done",
response_id: "r1",
item_id: "audio-item",
transcript: "Na klar, machen wir weiter auf Deutsch.",
});
fireDataChannelEvent({
type: "response.output_item.done",
response: { id: "r1" },
item: {
role: "assistant",
content: [{ transcript: "Na klar, machen wir weiter auf Deutsch." }],
},
});
});

expect(onAssistantTranscriptCommitted).toHaveBeenCalledTimes(1);
expect(onAssistantTranscriptCommitted).toHaveBeenCalledWith(
"Na klar, machen wir weiter auf Deutsch.",
);
});

it("allows the same assistant text again after a new user turn", async () => {
const { session, fireDataChannelEvent } = makeFakeSession();
const transport = makeFakeTransport({ session });
const { client } = makeFakeControl({});
const onAssistantTranscriptCommitted = vi.fn();

const { result } = renderHook(() =>
useRealtimeVoice({
createTransport: () => transport,
createControl: () => client,
onAssistantTranscriptCommitted,
}),
);

act(() => result.current.start());
await waitFor(() => expect(result.current.phase).toBe("negotiating"));

act(() => {
fireDataChannelEvent({
type: "conversation.item.input_audio_transcription.completed",
item_id: "u1",
transcript: "Hallo.",
});
fireDataChannelEvent({
type: "response.output_audio_transcript.done",
response_id: "r1",
transcript: "Gerne.",
});
fireDataChannelEvent({
type: "conversation.item.input_audio_transcription.completed",
item_id: "u2",
transcript: "Nochmal.",
});
fireDataChannelEvent({
type: "response.output_audio_transcript.done",
response_id: "r2",
transcript: "Gerne.",
});
});

expect(onAssistantTranscriptCommitted).toHaveBeenCalledTimes(2);
expect(onAssistantTranscriptCommitted).toHaveBeenNthCalledWith(1, "Gerne.");
expect(onAssistantTranscriptCommitted).toHaveBeenNthCalledWith(2, "Gerne.");
});

it("sends response.cancel when the user interrupts the assistant", async () => {
const { session, fireDataChannelEvent, sendDataChannelEvent } = makeFakeSession();
const transport = makeFakeTransport({ session });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,13 @@ function eventIdentity(event: {
return event.itemId ?? event.responseId;
}

function transcriptTextIdentity(event: {
readonly text: string;
readonly responseId?: string | undefined;
}): string {
return `${event.responseId ?? "unknown-response"}:${event.text.replace(/\s+/gu, " ").trim()}`;
}

export function useRealtimeVoice(options: UseRealtimeVoiceOptions): RealtimeVoiceController {
const [state, dispatch] = useReducer(realtimeVoiceReducer, INITIAL_STATE);
const turnManagerRef = useRef<VoiceTurnManagerEngine>(
Expand Down Expand Up @@ -212,6 +219,7 @@ export function useRealtimeVoice(options: UseRealtimeVoiceOptions): RealtimeVoic
const audioSinkRef = useRef<RealtimeAudioSink | undefined>(undefined);
const userTranscriptItemsRef = useRef<Set<string>>(new Set());
const assistantTranscriptItemsRef = useRef<Set<string>>(new Set());
const assistantTranscriptTextItemsRef = useRef<Set<string>>(new Set());
const assistantTranscriptBufferRef = useRef("");
const assistantTranscriptResponseRef = useRef<string | undefined>(undefined);
// Pending teardown timer for a transient `disconnected` state (see ICE_DISCONNECT_GRACE_MS).
Expand Down Expand Up @@ -240,6 +248,7 @@ export function useRealtimeVoice(options: UseRealtimeVoiceOptions): RealtimeVoic
setTurnSnapshot(turnManagerRef.current.snapshot());
userTranscriptItemsRef.current.clear();
assistantTranscriptItemsRef.current.clear();
assistantTranscriptTextItemsRef.current.clear();
assistantTranscriptBufferRef.current = "";
assistantTranscriptResponseRef.current = undefined;
}, []);
Expand All @@ -253,6 +262,7 @@ export function useRealtimeVoice(options: UseRealtimeVoiceOptions): RealtimeVoic
}
userTranscriptItemsRef.current.add(id);
}
assistantTranscriptTextItemsRef.current.clear();
void onUserTranscriptCommittedRef.current?.(event.text);
},
[],
Expand All @@ -269,6 +279,11 @@ export function useRealtimeVoice(options: UseRealtimeVoiceOptions): RealtimeVoic
}
assistantTranscriptItemsRef.current.add(id);
}
const textKey = transcriptTextIdentity(event);
if (assistantTranscriptTextItemsRef.current.has(textKey)) {
return;
}
assistantTranscriptTextItemsRef.current.add(textKey);
assistantTranscriptBufferRef.current = "";
assistantTranscriptResponseRef.current = undefined;
applyTurnSignal({ kind: "assistant-speech-start" });
Expand Down
4 changes: 4 additions & 0 deletions packages/keiko-ui/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -7762,6 +7762,9 @@ select.dlg-input {
.chat-msg[data-layout="turn"] {
margin-bottom: 0;
}
.chat-msg[data-role="user"][data-layout="turn"] {
width: 100%;
}
.chat-msg[data-role="assistant"][data-layout="turn"] {
justify-content: stretch;
}
Expand Down Expand Up @@ -7815,6 +7818,7 @@ select.dlg-input {
background: var(--accent-dim);
}
.chat-msg[data-role="user"][data-layout="turn"] .chat-msg-bubble {
width: max-content;
max-width: min(720px, 72%);
border-color: var(--border-subtle);
border-radius: 18px;
Expand Down
Loading