From 446655651e5dcb649131642c91d9b4fa1f6ec716 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 12:26:37 +0000 Subject: [PATCH 1/3] Stop rescanning the whole reply on each streamed chunk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit text-playback rebuilt its grapheme end list with filter, spread and findIndex on every update, which is O(n²) over a long reply. Truncate in place and binary-search the cursor instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Lpx6qkP973FGPmahjst8nu --- apps/mobile/src/host/text-playback.ts | 24 +++++++++++++++--------- changes/ios-streaming-playback-scan.md | 11 +++++++++++ 2 files changed, 26 insertions(+), 9 deletions(-) create mode 100644 changes/ios-streaming-playback-scan.md diff --git a/apps/mobile/src/host/text-playback.ts b/apps/mobile/src/host/text-playback.ts index 62b3ce195f..400c1ba2b5 100644 --- a/apps/mobile/src/host/text-playback.ts +++ b/apps/mobile/src/host/text-playback.ts @@ -71,15 +71,10 @@ export function createTextPlayback(initial = '', now = Date.now()) { const oldLength = ends.length; const shown = body.length; const from = next.startsWith(target) ? (ends.at(-2) ?? 0) : 0; - const kept = from === 0 ? [] : ends.filter((end) => end <= from); - ends = [ - ...kept, - ...[...graphemeSegments(next.slice(from))].map( - (part) => from + part.index + part.segment.length, - ), - ]; - cursor = ends.findIndex((end) => end > shown); - if (cursor < 0) cursor = ends.length; + ends.length = from === 0 ? 0 : ends.length - 1; + for (const part of graphemeSegments(next.slice(from))) + ends.push(from + part.index + part.segment.length); + cursor = firstEndAfter(ends, shown); target = next; const added = Math.max(0, ends.length - oldLength); if (added > 0) { @@ -159,4 +154,15 @@ export function createTextPlayback(initial = '', now = Date.now()) { }; } +function firstEndAfter(ends: number[], offset: number) { + let low = 0; + let high = ends.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (ends[middle]! > offset) high = middle; + else low = middle + 1; + } + return low; +} + export type TextPlayback = ReturnType; diff --git a/changes/ios-streaming-playback-scan.md b/changes/ios-streaming-playback-scan.md new file mode 100644 index 0000000000..b374b10863 --- /dev/null +++ b/changes/ios-streaming-playback-scan.md @@ -0,0 +1,11 @@ +--- +section: Fixed +--- + +## en + +- Streaming a long reply no longer slows down as the reply grows: the text playback no longer rescans every earlier character on each update ([#3974](https://github.com/BotHarness/DeepSeekBot/issues/3974)). + +## zh + +- 流式输出长回复时不再越来越慢:文字播放不再在每次更新时重新扫描之前所有字符([#3974](https://github.com/BotHarness/DeepSeekBot/issues/3974))。 From a8029a6012506a1df810f8c3a41f443c04f507c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 12:29:00 +0000 Subject: [PATCH 2/3] Keep the bubble's long-press context stable across renders PressableBubble passed a fresh closure to MediaLongPress on every render, so each streamed tick changed the context value and React propagated it through the whole bubble subtree. Provide a stable function that calls the latest handler. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Lpx6qkP973FGPmahjst8nu --- apps/mobile/src/ui/chat/message-bubble.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/ui/chat/message-bubble.tsx b/apps/mobile/src/ui/chat/message-bubble.tsx index c9b24113f9..f6e67bdfb2 100644 --- a/apps/mobile/src/ui/chat/message-bubble.tsx +++ b/apps/mobile/src/ui/chat/message-bubble.tsx @@ -452,14 +452,14 @@ function PressableBubble({ if (anchor.current !== undefined) onLongPress(anchor.current); else measure(onLongPress); }; - const hold = - onLongPress === undefined - ? undefined - : () => { - haptic('menu'); - if (shrinks) scale.value = withSpring(1, spring.spatial.fast); - open(); - }; + const holdRef = useRef<() => void>(undefined); + holdRef.current = () => { + haptic('menu'); + if (shrinks) scale.value = withSpring(1, spring.spatial.fast); + open(); + }; + const holds = onLongPress !== undefined; + const hold = useMemo(() => (holds ? () => holdRef.current?.() : undefined), [holds]); return ( Date: Wed, 11 Nov 2026 12:29:49 +0000 Subject: [PATCH 3/3] Decode encrypted channel frames with a lookup table Each streamed update went through a regex test plus noble's hexToBytes. Validate and decode the hex in one pass instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Lpx6qkP973FGPmahjst8nu --- packages/core/src/devices/noise/channel.ts | 24 +++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/core/src/devices/noise/channel.ts b/packages/core/src/devices/noise/channel.ts index 795e241dea..960734e566 100644 --- a/packages/core/src/devices/noise/channel.ts +++ b/packages/core/src/devices/noise/channel.ts @@ -1,4 +1,4 @@ -import { bytesToHex, hexToBytes, concatBytes, utf8ToBytes } from '@noble/hashes/utils.js'; +import { bytesToHex, concatBytes, utf8ToBytes } from '@noble/hashes/utils.js'; import { NoiseXk, NOISE_PROTOCOL, type NoiseTransport, type NoiseKeyAgreement } from './xk.js'; export const CONNECT_PROTOCOL = 'connect-noise-xk-v1'; @@ -15,6 +15,20 @@ export interface ChannelResult { ready?: true; } +const NIBBLE = new Int8Array(128).fill(-1); +for (let index = 0; index < 16; index++) NIBBLE['0123456789abcdef'.charCodeAt(index)] = index; + +function wireBytes(wire: string): Uint8Array { + const bytes = new Uint8Array((wire.length - 1) / 2); + for (let index = 0, at = 1; index < bytes.length; index++, at += 2) { + const high = NIBBLE[wire.charCodeAt(at)] ?? -1; + const low = NIBBLE[wire.charCodeAt(at + 1)] ?? -1; + if (high < 0 || low < 0) throw new Error('channel-frame-invalid'); + bytes[index] = (high << 4) | low; + } + return bytes; +} + export class ConnectChannel { private readonly noise: NoiseXk; private readonly role: 'initiator' | 'responder'; @@ -71,7 +85,8 @@ export class ConnectChannel { if ( this.closed || wire.length > 2 * (HEADER + CHUNK + 17) + 1 || - !/^[hd][0-9a-f]+$/u.test(wire) || + (wire[0] !== 'h' && wire[0] !== 'd') || + wire.length < 3 || wire.length % 2 !== 1 ) throw new Error('channel-frame-invalid'); @@ -83,8 +98,7 @@ export class ConnectChannel { (this.role === 'initiator' && this.stage !== 1) ) throw new Error('channel-handshake-invalid'); - if (this.noise.read(hexToBytes(wire.slice(1))).length !== 0) - throw new Error('channel-early-payload'); + if (this.noise.read(wireBytes(wire)).length !== 0) throw new Error('channel-early-payload'); if (this.role === 'responder' && this.stage === 0) { this.stage = 1; return { reply: `h${bytesToHex(this.noise.write())}` }; @@ -94,7 +108,7 @@ export class ConnectChannel { return { ...(reply === undefined ? {} : { reply }), ready: true }; } if (wire[0] !== 'd') throw new Error('channel-frame-invalid'); - const frame = hexToBytes(wire.slice(1)); + const frame = wireBytes(wire); if (frame.length < HEADER + 17) throw new Error('channel-length-invalid'); const header = frame.subarray(0, HEADER); const view = new DataView(header.buffer, header.byteOffset, header.byteLength);