diff --git a/docs/design-system/evidence/1300/01-dark.png b/docs/design-system/evidence/1300/01-dark.png index b22d0e30..67c634a4 100644 Binary files a/docs/design-system/evidence/1300/01-dark.png and b/docs/design-system/evidence/1300/01-dark.png differ diff --git a/docs/design-system/evidence/1300/03-dark-hc.png b/docs/design-system/evidence/1300/03-dark-hc.png index 5379ab34..dec1d6e0 100644 Binary files a/docs/design-system/evidence/1300/03-dark-hc.png and b/docs/design-system/evidence/1300/03-dark-hc.png differ diff --git a/docs/design-system/evidence/1300/04-light-hc.png b/docs/design-system/evidence/1300/04-light-hc.png index 90d2c312..415898a1 100644 Binary files a/docs/design-system/evidence/1300/04-light-hc.png and b/docs/design-system/evidence/1300/04-light-hc.png differ diff --git a/docs/design-system/evidence/1300/05-prefers-contrast.png b/docs/design-system/evidence/1300/05-prefers-contrast.png index 0bfe8f98..cc2e4ba8 100644 Binary files a/docs/design-system/evidence/1300/05-prefers-contrast.png and b/docs/design-system/evidence/1300/05-prefers-contrast.png differ diff --git a/docs/design-system/evidence/1300/06-forced-colors.png b/docs/design-system/evidence/1300/06-forced-colors.png index c78f6968..c97102cc 100644 Binary files a/docs/design-system/evidence/1300/06-forced-colors.png and b/docs/design-system/evidence/1300/06-forced-colors.png differ diff --git a/docs/design-system/evidence/1300/09-responsive.png b/docs/design-system/evidence/1300/09-responsive.png index cfbbbfbf..570b0ce1 100644 Binary files a/docs/design-system/evidence/1300/09-responsive.png and b/docs/design-system/evidence/1300/09-responsive.png differ diff --git a/docs/design-system/evidence/1300/a11y/a11y-proof.json b/docs/design-system/evidence/1300/a11y/a11y-proof.json index bf4beeb6..6276944c 100644 --- a/docs/design-system/evidence/1300/a11y/a11y-proof.json +++ b/docs/design-system/evidence/1300/a11y/a11y-proof.json @@ -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", @@ -6127,4 +6127,4 @@ } }, "verdict": "PASS" -} +} \ No newline at end of file diff --git a/docs/design-system/evidence/1300/consolidated-fidelity-proof.json b/docs/design-system/evidence/1300/consolidated-fidelity-proof.json index 2d17416e..27b87f27 100644 --- a/docs/design-system/evidence/1300/consolidated-fidelity-proof.json +++ b/docs/design-system/evidence/1300/consolidated-fidelity-proof.json @@ -1,7 +1,7 @@ { "issue": 1300, "epic": 1290, - "postCssSha256": "e9dfdde86df78e8137ac47448986b1b3ffcc4006567d4f335be3a25b3aa00a7d", + "postCssSha256": "4d67f6ccfe6012b0a2e02769f88ac647340bd4bcd5d0e280fb47d2fea1f0ddba", "referenceFiles": [ "keiko-tokens.css", "keiko-semantic-tokens.css", @@ -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": { @@ -276,7 +276,7 @@ "boundedRowSmoke": { "ok": true, "rowCount": 250, - "durationMs": 0.3999999761581421, + "durationMs": 0.699999988079071, "afterScrollTop": 8363, "scrollHeight": 8783, "clientHeight": 420, @@ -315,4 +315,4 @@ } }, "verdict": "PASS" -} +} \ No newline at end of file diff --git a/docs/design-system/evidence/1300/editor/editor-fidelity-proof.json b/docs/design-system/evidence/1300/editor/editor-fidelity-proof.json index 46625761..837d7d8e 100644 --- a/docs/design-system/evidence/1300/editor/editor-fidelity-proof.json +++ b/docs/design-system/evidence/1300/editor/editor-fidelity-proof.json @@ -2,7 +2,7 @@ "issue": 1300, "epic": 1290, "surface": "editor", - "postCssSha256": "e9dfdde86df78e8137ac47448986b1b3ffcc4006567d4f335be3a25b3aa00a7d", + "postCssSha256": "4d67f6ccfe6012b0a2e02769f88ac647340bd4bcd5d0e280fb47d2fea1f0ddba", "referenceFiles": [ "keiko-tokens.css", "keiko-semantic-tokens.css", @@ -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": { @@ -658,4 +658,4 @@ "missingReferenceTokenCount": 0, "missing": [], "verdict": "PASS" -} +} \ No newline at end of file diff --git a/packages/keiko-ui/src/app/components/desktop/hooks/useRealtimeVoice.test.ts b/packages/keiko-ui/src/app/components/desktop/hooks/useRealtimeVoice.test.ts index 62035257..129b4648 100644 --- a/packages/keiko-ui/src/app/components/desktop/hooks/useRealtimeVoice.test.ts +++ b/packages/keiko-ui/src/app/components/desktop/hooks/useRealtimeVoice.test.ts @@ -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 }); diff --git a/packages/keiko-ui/src/app/components/desktop/hooks/useRealtimeVoice.ts b/packages/keiko-ui/src/app/components/desktop/hooks/useRealtimeVoice.ts index a6f11a4e..a6abf304 100644 --- a/packages/keiko-ui/src/app/components/desktop/hooks/useRealtimeVoice.ts +++ b/packages/keiko-ui/src/app/components/desktop/hooks/useRealtimeVoice.ts @@ -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( @@ -212,6 +219,7 @@ export function useRealtimeVoice(options: UseRealtimeVoiceOptions): RealtimeVoic const audioSinkRef = useRef(undefined); const userTranscriptItemsRef = useRef>(new Set()); const assistantTranscriptItemsRef = useRef>(new Set()); + const assistantTranscriptTextItemsRef = useRef>(new Set()); const assistantTranscriptBufferRef = useRef(""); const assistantTranscriptResponseRef = useRef(undefined); // Pending teardown timer for a transient `disconnected` state (see ICE_DISCONNECT_GRACE_MS). @@ -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; }, []); @@ -253,6 +262,7 @@ export function useRealtimeVoice(options: UseRealtimeVoiceOptions): RealtimeVoic } userTranscriptItemsRef.current.add(id); } + assistantTranscriptTextItemsRef.current.clear(); void onUserTranscriptCommittedRef.current?.(event.text); }, [], @@ -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" }); diff --git a/packages/keiko-ui/src/app/globals.css b/packages/keiko-ui/src/app/globals.css index fb64f484..f3264eb7 100644 --- a/packages/keiko-ui/src/app/globals.css +++ b/packages/keiko-ui/src/app/globals.css @@ -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; } @@ -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;