From d074b6844059f96170bfc459328f3b43268d3ee2 Mon Sep 17 00:00:00 2001 From: "CodexSol-GPT-5.6-sol" <773678591@qq.com> Date: Sun, 4 Oct 2026 18:36:00 -0700 Subject: [PATCH 1/4] fix(companion): keep live transcript tail visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: The call bar retained the newest text in data but ellipsized its right edge, hiding the words that had just streamed in. Separate the stable speaker label from a tail-oriented overflow region, prove both roles in a narrow Chromium surface, and pin the exact alpha.20 artifact without changing call or media behavior. [小太阳·砚砚/gpt-5.6-sol🐾] Thread-Context: threadId=thread_muj2oyola5htyhrm catId=codex-sol --- catalog/catalog.json | 17 ++++++ packages/companion/package.json | 2 +- packages/companion/src/recent-bubble.mjs | 16 +++++- packages/companion/src/style.css | 7 ++- .../companion/test/panel-layout.browser.mjs | 56 +++++++++++++++++++ 5 files changed, 94 insertions(+), 4 deletions(-) diff --git a/catalog/catalog.json b/catalog/catalog.json index dcff6550..8978e3bc 100644 --- a/catalog/catalog.json +++ b/catalog/catalog.json @@ -145,6 +145,23 @@ "voice" ], "versions": [ + { + "version": "0.1.0-alpha.20", + "contractVersion": "0.1.0", + "manifestPath": "plugin.yaml", + "artifact": { + "kind": "npm", + "packageName": "@clowder-ai/companion", + "version": "0.1.0-alpha.20", + "tarballUrl": "https://registry.npmjs.org/@clowder-ai/companion/-/companion-0.1.0-alpha.20.tgz", + "integrity": "sha512-MhQyZgYcLwTB4yfU0WNFbp+q4zTU5th50CkrBNroD1cN1dp0SqyPIqeym2kuGbGIcRAFWPC9xgitWYP+SaTjjw==", + "shasum": "4a628389cd9e08eccbedfa4a979eba0f5ec0f5c9", + "provenance": { + "repository": "https://github.com/zts212653/clowder-ai-plugins", + "sourceDirectory": "packages/companion" + } + } + }, { "version": "0.1.0-alpha.19", "contractVersion": "0.1.0", diff --git a/packages/companion/package.json b/packages/companion/package.json index 12882f7c..142a8476 100644 --- a/packages/companion/package.json +++ b/packages/companion/package.json @@ -1,6 +1,6 @@ { "name": "@clowder-ai/companion", - "version": "0.1.0-alpha.19", + "version": "0.1.0-alpha.20", "description": "The desktop voice body for your Clowder companion", "license": "MIT", "type": "module", diff --git a/packages/companion/src/recent-bubble.mjs b/packages/companion/src/recent-bubble.mjs index 458e61e3..0c971f3b 100644 --- a/packages/companion/src/recent-bubble.mjs +++ b/packages/companion/src/recent-bubble.mjs @@ -44,7 +44,21 @@ export class RecentBubble { for (const [index, row] of this.rows.entries()) { const entry = entries[index]; row.hidden = !entry; - row.textContent = entry ? `${String(entry.name).slice(0, 28)}:${String(entry.text).slice(-120)}` : ''; + const speaker = entry ? `${String(entry.name).slice(0, 28)}:` : ''; + const message = entry ? String(entry.text).slice(-120) : ''; + row.textContent = `${speaker}${message}`; + if (entry && row.ownerDocument?.createElement && typeof row.replaceChildren === 'function') { + const speakerNode = row.ownerDocument.createElement('span'); + const messageNode = row.ownerDocument.createElement('span'); + const messageText = row.ownerDocument.createElement('bdi'); + speakerNode.className = 'recent-speaker'; + messageNode.className = 'recent-message'; + messageText.className = 'recent-message-text'; + speakerNode.textContent = speaker; + messageText.textContent = message; + messageNode.append(messageText); + row.replaceChildren(speakerNode, messageNode); + } row.dataset ??= {}; row.dataset.role = entry?.role ?? ''; } diff --git a/packages/companion/src/style.css b/packages/companion/src/style.css index f271bbe7..9f51f237 100644 --- a/packages/companion/src/style.css +++ b/packages/companion/src/style.css @@ -73,7 +73,7 @@ button:disabled { opacity: .45; cursor: default; } .floating { position: absolute; z-index: 3; border-radius: 12px; border: 1px solid var(--desktop-line-strong); background: var(--desktop-paper); box-shadow: 0 14px 28px #18171526, 0 2px 5px #1817150f; } .floating { max-height: 500px; overflow: auto; } #bubble { width: 240px; max-height: 80px; overflow: hidden; padding: 8px 11px; pointer-events: none; } -#bubble p { margin: 0; font-size: 11px; line-height: 1.45; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +#bubble p { margin: 0; display: flex; min-width: 0; font-size: 11px; line-height: 1.45; overflow: hidden; white-space: nowrap; } #bubble p + p { margin-top: 4px; } #bubble [data-role="user"] { color: var(--cafe-text-secondary); } .floating button { border: 0; background: transparent; border-radius: 7px; padding: 8px 11px; font-weight: 400; } @@ -101,7 +101,10 @@ button:disabled { opacity: .45; cursor: default; } .call-share-context small, .call-share-line small { color: var(--desktop-muted); font-size: 11px; } .call-recent { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--desktop-line); } .call-recent-label { color: var(--desktop-muted); font-size: 11px; letter-spacing: .05em; } -.call-recent p { margin: 4px 0 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 12px; line-height: 1.45; } +.call-recent p { margin: 4px 0 0; display: flex; min-width: 0; overflow: hidden; white-space: nowrap; font-size: 12px; line-height: 1.45; } +.recent-speaker { flex: none; } +.recent-message { min-width: 0; flex: 1 1 auto; overflow: hidden; direction: rtl; text-align: left; text-overflow: ellipsis; white-space: nowrap; } +.recent-message-text { direction: ltr; unicode-bidi: isolate; } .call-recent p[data-role="user"] { color: var(--desktop-muted); } .call-actions { justify-content: flex-end; } .call-actions #end { min-width: 88px; margin-left: auto; } diff --git a/packages/companion/test/panel-layout.browser.mjs b/packages/companion/test/panel-layout.browser.mjs index 63775337..a1aa8d15 100644 --- a/packages/companion/test/panel-layout.browser.mjs +++ b/packages/companion/test/panel-layout.browser.mjs @@ -77,6 +77,31 @@ async function visibleClipping(page, panelId) { }, panelId); } +async function textRangeVisibility(page, rowSelector, needle) { + return page.evaluate(({ rowSelector, needle }) => { + const row = document.querySelector(rowSelector); + const walker = document.createTreeWalker(row, NodeFilter.SHOW_TEXT); + let node; + while ((node = walker.nextNode())) { + const index = node.data.indexOf(needle); + if (index === -1) continue; + const range = document.createRange(); + range.setStart(node, index); + range.setEnd(node, index + needle.length); + const bounds = row.getBoundingClientRect(); + const text = range.getBoundingClientRect(); + return { + rowText: row.textContent, + visible: text.left >= bounds.left - 0.5 && text.right <= bounds.right + 0.5 + && text.top >= bounds.top - 0.5 && text.bottom <= bounds.bottom + 0.5, + bounds: { left: bounds.left, right: bounds.right, top: bounds.top, bottom: bounds.bottom }, + text: { left: text.left, right: text.right, top: text.top, bottom: text.bottom }, + }; + } + return { rowText: row.textContent, visible: false, missing: true }; + }, { rowSelector, needle }); +} + test('dynamic call content and expanded transcript keep every action visible', async () => { const page = await openPreview(); try { @@ -143,6 +168,37 @@ test('subtitle return is visible and restores the same shared call at compact an } }); +test('the narrow call bar keeps both speaker labels and the newest streamed tail visibly on screen', async () => { + const page = await openPreview({ width: 390, height: 620 }); + try { + await page.click('#pet'); + await page.click('#begin'); + await page.waitForFunction(() => document.getElementById('call-state')?.textContent === '通话中'); + await page.evaluate(() => { + const callId = '11111111-1111-4111-8111-111111111111'; + window.__emitAudio({ kind: 'audio', type: 'transcript', callId, role: 'user', itemId: 'long-user', + text: `${'这是一段很长的用户实时输入'.repeat(18)}【用户最新尾部】` }); + window.__emitAudio({ kind: 'audio', type: 'transcript', callId, role: 'assistant', itemId: 'long-assistant', + text: `${'这是猫猫正在持续回答的长句'.repeat(18)}【猫猫最新尾部】` }); + }); + + const userLabel = await textRangeVisibility(page, '.call-recent p[data-role="user"]', '语音:'); + const userTail = await textRangeVisibility(page, '.call-recent p[data-role="user"]', '【用户最新尾部】'); + const assistantLabel = await textRangeVisibility(page, '.call-recent p[data-role="assistant"]', '宪宪:'); + const assistantTail = await textRangeVisibility(page, '.call-recent p[data-role="assistant"]', '【猫猫最新尾部】'); + assert.equal(userLabel.visible, true, JSON.stringify(userLabel)); + assert.equal(userTail.visible, true, JSON.stringify(userTail)); + assert.equal(assistantLabel.visible, true, JSON.stringify(assistantLabel)); + assert.equal(assistantTail.visible, true, JSON.stringify(assistantTail)); + + const callbar = await visibleClipping(page, 'actions'); + assert(callbar.height <= 500); + assert.deepEqual(callbar.clippedButtons, []); + } finally { + await page.close(); + } +}); + test('recovering shared subtitles keep every header action inside compact and expanded panels', async () => { const page = await openPreview({ width: 460, height: 700 }); try { From ebcbe3bdc7dcf68a4e72de1e3da173031c38eeba Mon Sep 17 00:00:00 2001 From: "CodexSol-GPT-5.6-sol" <773678591@qq.com> Date: Mon, 5 Oct 2026 00:05:17 -0700 Subject: [PATCH 2/4] fix(companion): preserve RTL live tails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: Tail-oriented overflow fixed the reported Chinese and Latin call-bar clipping but reversed the logical tail for right-to-left text. Use the browser's native bidi direction, preserve the correct overflow edge for both directions, and strengthen the real-layout regression so punctuation, short alignment, ellipsis, and RTL tails cannot silently regress. [小太阳·砚砚/gpt-5.6-sol🐾] Thread-Context: threadId=thread_muj2oyola5htyhrm catId=codex-sol --- catalog/catalog.json | 4 +- packages/companion/src/recent-bubble.mjs | 3 + packages/companion/src/style.css | 3 +- .../companion/test/panel-layout.browser.mjs | 67 +++++++++++++++++-- 4 files changed, 69 insertions(+), 8 deletions(-) diff --git a/catalog/catalog.json b/catalog/catalog.json index 8978e3bc..3f473ab9 100644 --- a/catalog/catalog.json +++ b/catalog/catalog.json @@ -154,8 +154,8 @@ "packageName": "@clowder-ai/companion", "version": "0.1.0-alpha.20", "tarballUrl": "https://registry.npmjs.org/@clowder-ai/companion/-/companion-0.1.0-alpha.20.tgz", - "integrity": "sha512-MhQyZgYcLwTB4yfU0WNFbp+q4zTU5th50CkrBNroD1cN1dp0SqyPIqeym2kuGbGIcRAFWPC9xgitWYP+SaTjjw==", - "shasum": "4a628389cd9e08eccbedfa4a979eba0f5ec0f5c9", + "integrity": "sha512-UCjmAL4raNddg5y87my210kTQd6lngqRUDY9EWjB5br07qgI7I4w+kQOOu6SKGEyGwgABzOu2w0bj//U/MUjQQ==", + "shasum": "7f00ef1a34cf95206c27d9c189f0f939343112f5", "provenance": { "repository": "https://github.com/zts212653/clowder-ai-plugins", "sourceDirectory": "packages/companion" diff --git a/packages/companion/src/recent-bubble.mjs b/packages/companion/src/recent-bubble.mjs index 0c971f3b..6719fce3 100644 --- a/packages/companion/src/recent-bubble.mjs +++ b/packages/companion/src/recent-bubble.mjs @@ -54,10 +54,13 @@ export class RecentBubble { speakerNode.className = 'recent-speaker'; messageNode.className = 'recent-message'; messageText.className = 'recent-message-text'; + messageText.dir = 'auto'; speakerNode.textContent = speaker; messageText.textContent = message; messageNode.append(messageText); row.replaceChildren(speakerNode, messageNode); + const direction = row.ownerDocument.defaultView?.getComputedStyle(messageText).direction; + messageNode.dataset.direction = direction === 'rtl' ? 'rtl' : 'ltr'; } row.dataset ??= {}; row.dataset.role = entry?.role ?? ''; diff --git a/packages/companion/src/style.css b/packages/companion/src/style.css index 9f51f237..0fe54d66 100644 --- a/packages/companion/src/style.css +++ b/packages/companion/src/style.css @@ -104,7 +104,8 @@ button:disabled { opacity: .45; cursor: default; } .call-recent p { margin: 4px 0 0; display: flex; min-width: 0; overflow: hidden; white-space: nowrap; font-size: 12px; line-height: 1.45; } .recent-speaker { flex: none; } .recent-message { min-width: 0; flex: 1 1 auto; overflow: hidden; direction: rtl; text-align: left; text-overflow: ellipsis; white-space: nowrap; } -.recent-message-text { direction: ltr; unicode-bidi: isolate; } +.recent-message[data-direction="rtl"] { direction: ltr; } +.recent-message-text { unicode-bidi: isolate; } .call-recent p[data-role="user"] { color: var(--desktop-muted); } .call-actions { justify-content: flex-end; } .call-actions #end { min-width: 88px; margin-left: auto; } diff --git a/packages/companion/test/panel-layout.browser.mjs b/packages/companion/test/panel-layout.browser.mjs index a1aa8d15..9968ab85 100644 --- a/packages/companion/test/panel-layout.browser.mjs +++ b/packages/companion/test/panel-layout.browser.mjs @@ -90,10 +90,12 @@ async function textRangeVisibility(page, rowSelector, needle) { range.setEnd(node, index + needle.length); const bounds = row.getBoundingClientRect(); const text = range.getBoundingClientRect(); + const hit = document.elementFromPoint((text.left + text.right) / 2, (text.top + text.bottom) / 2); return { rowText: row.textContent, visible: text.left >= bounds.left - 0.5 && text.right <= bounds.right + 0.5 - && text.top >= bounds.top - 0.5 && text.bottom <= bounds.bottom + 0.5, + && text.top >= bounds.top - 0.5 && text.bottom <= bounds.bottom + 0.5 + && hit !== null && row.contains(hit), bounds: { left: bounds.left, right: bounds.right, top: bounds.top, bottom: bounds.bottom }, text: { left: text.left, right: text.right, top: text.top, bottom: text.bottom }, }; @@ -102,6 +104,20 @@ async function textRangeVisibility(page, rowSelector, needle) { }, { rowSelector, needle }); } +async function recentRowPresentation(page, rowSelector) { + return page.evaluate(rowSelector => { + const row = document.querySelector(rowSelector); + const speaker = row.querySelector('.recent-speaker').getBoundingClientRect(); + const message = row.querySelector('.recent-message'); + const text = row.querySelector('.recent-message-text').getBoundingClientRect(); + return { + gap: text.left - speaker.right, + textOverflow: getComputedStyle(message).textOverflow, + textDirection: message.dataset.direction, + }; + }, rowSelector); +} + test('dynamic call content and expanded transcript keep every action visible', async () => { const page = await openPreview(); try { @@ -177,20 +193,35 @@ test('the narrow call bar keeps both speaker labels and the newest streamed tail await page.evaluate(() => { const callId = '11111111-1111-4111-8111-111111111111'; window.__emitAudio({ kind: 'audio', type: 'transcript', callId, role: 'user', itemId: 'long-user', - text: `${'这是一段很长的用户实时输入'.repeat(18)}【用户最新尾部】` }); + text: `${'这是一段很长的用户实时输入'.repeat(18)}用户最新尾部。` }); window.__emitAudio({ kind: 'audio', type: 'transcript', callId, role: 'assistant', itemId: 'long-assistant', - text: `${'这是猫猫正在持续回答的长句'.repeat(18)}【猫猫最新尾部】` }); + text: `${'这是猫猫正在持续回答的长句'.repeat(18)}assistant tail!` }); }); const userLabel = await textRangeVisibility(page, '.call-recent p[data-role="user"]', '语音:'); - const userTail = await textRangeVisibility(page, '.call-recent p[data-role="user"]', '【用户最新尾部】'); + const userTail = await textRangeVisibility(page, '.call-recent p[data-role="user"]', '用户最新尾部。'); const assistantLabel = await textRangeVisibility(page, '.call-recent p[data-role="assistant"]', '宪宪:'); - const assistantTail = await textRangeVisibility(page, '.call-recent p[data-role="assistant"]', '【猫猫最新尾部】'); + const assistantTail = await textRangeVisibility(page, '.call-recent p[data-role="assistant"]', 'assistant tail!'); assert.equal(userLabel.visible, true, JSON.stringify(userLabel)); assert.equal(userTail.visible, true, JSON.stringify(userTail)); assert.equal(assistantLabel.visible, true, JSON.stringify(assistantLabel)); assert.equal(assistantTail.visible, true, JSON.stringify(assistantTail)); + await page.evaluate(async () => { + const { RecentBubble } = await import('/recent-bubble.mjs'); + const bubble = new RecentBubble([ + document.getElementById('call-bubble-first'), document.getElementById('call-bubble-second'), + ]); + bubble.append('user', 'hi', '语音'); + bubble.append('assistant', '好的。', '宪宪'); + }); + for (const role of ['user', 'assistant']) { + const presentation = await recentRowPresentation(page, `.call-recent p[data-role="${role}"]`); + assert(Math.abs(presentation.gap) <= 0.5, JSON.stringify(presentation)); + assert.equal(presentation.textOverflow, 'ellipsis'); + assert.equal(presentation.textDirection, 'ltr'); + } + const callbar = await visibleClipping(page, 'actions'); assert(callbar.height <= 500); assert.deepEqual(callbar.clippedButtons, []); @@ -199,6 +230,32 @@ test('the narrow call bar keeps both speaker labels and the newest streamed tail } }); +test('the narrow call bar keeps the logical tail visible for right-to-left messages', async () => { + const page = await openPreview({ width: 390, height: 620 }); + try { + await page.click('#pet'); + await page.click('#begin'); + await page.waitForFunction(() => document.getElementById('call-state')?.textContent === '通话中'); + await page.evaluate(async () => { + const { RecentBubble } = await import('/recent-bubble.mjs'); + const bubble = new RecentBubble([ + document.getElementById('call-bubble-first'), document.getElementById('call-bubble-second'), + ]); + bubble.append('user', `${'مرحبا '.repeat(40)}نهاية الجملة`, '语音'); + bubble.append('assistant', `${'שלום '.repeat(40)}סוף המשפט`, '宪宪'); + }); + + const arabic = await textRangeVisibility(page, '.call-recent p[data-role="user"]', 'نهاية الجملة'); + const hebrew = await textRangeVisibility(page, '.call-recent p[data-role="assistant"]', 'סוף המשפט'); + assert.equal(arabic.visible, true, JSON.stringify(arabic)); + assert.equal(hebrew.visible, true, JSON.stringify(hebrew)); + assert.equal((await recentRowPresentation(page, '.call-recent p[data-role="user"]')).textDirection, 'rtl'); + assert.equal((await recentRowPresentation(page, '.call-recent p[data-role="assistant"]')).textDirection, 'rtl'); + } finally { + await page.close(); + } +}); + test('recovering shared subtitles keep every header action inside compact and expanded panels', async () => { const page = await openPreview({ width: 460, height: 700 }); try { From aac65166aa7911d61573d31876dc43c0cc99e84b Mon Sep 17 00:00:00 2001 From: "CodexSol-GPT-5.6-sol" <773678591@qq.com> Date: Mon, 5 Oct 2026 00:31:58 -0700 Subject: [PATCH 3/4] fix(companion): follow mixed-script tail direction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: A compact live transcript can change writing direction as new chunks arrive, so first-strong direction can still hide the newest suffix. Resolve and display against the last strong character, then cover both direction changes and a 60-step mixed stream in Chromium. [小太阳·砚砚/gpt-5.6-sol🐾] Thread-Context: threadId=thread_muj2oyola5htyhrm catId=codex-sol --- catalog/catalog.json | 4 +- packages/companion/src/recent-bubble.mjs | 7 +++- .../companion/test/panel-layout.browser.mjs | 39 +++++++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/catalog/catalog.json b/catalog/catalog.json index 3f473ab9..a68adce4 100644 --- a/catalog/catalog.json +++ b/catalog/catalog.json @@ -154,8 +154,8 @@ "packageName": "@clowder-ai/companion", "version": "0.1.0-alpha.20", "tarballUrl": "https://registry.npmjs.org/@clowder-ai/companion/-/companion-0.1.0-alpha.20.tgz", - "integrity": "sha512-UCjmAL4raNddg5y87my210kTQd6lngqRUDY9EWjB5br07qgI7I4w+kQOOu6SKGEyGwgABzOu2w0bj//U/MUjQQ==", - "shasum": "7f00ef1a34cf95206c27d9c189f0f939343112f5", + "integrity": "sha512-R7KbX0EZAvE3Ll4S7d9lSrk5zIkBe8jnIHpdh82lE7v+mqoXtkDUifbXCYuEdMoDcEgkq/r49M9VBHnx9vtk6A==", + "shasum": "ac97cb246ab15f64ecb8404a73eeb3e58b59fc3d", "provenance": { "repository": "https://github.com/zts212653/clowder-ai-plugins", "sourceDirectory": "packages/companion" diff --git a/packages/companion/src/recent-bubble.mjs b/packages/companion/src/recent-bubble.mjs index 6719fce3..f826142d 100644 --- a/packages/companion/src/recent-bubble.mjs +++ b/packages/companion/src/recent-bubble.mjs @@ -56,10 +56,15 @@ export class RecentBubble { messageText.className = 'recent-message-text'; messageText.dir = 'auto'; speakerNode.textContent = speaker; - messageText.textContent = message; + // dir=auto resolves the first strong character. Reversing code points for + // this hidden-in-the-same-tick probe therefore resolves the latest strong + // character, which is the edge this compact tail projection must retain. + messageText.textContent = Array.from(message).reverse().join(''); messageNode.append(messageText); row.replaceChildren(speakerNode, messageNode); const direction = row.ownerDocument.defaultView?.getComputedStyle(messageText).direction; + messageText.dir = direction === 'rtl' ? 'rtl' : 'ltr'; + messageText.textContent = message; messageNode.dataset.direction = direction === 'rtl' ? 'rtl' : 'ltr'; } row.dataset ??= {}; diff --git a/packages/companion/test/panel-layout.browser.mjs b/packages/companion/test/panel-layout.browser.mjs index 9968ab85..eb38b415 100644 --- a/packages/companion/test/panel-layout.browser.mjs +++ b/packages/companion/test/panel-layout.browser.mjs @@ -251,6 +251,45 @@ test('the narrow call bar keeps the logical tail visible for right-to-left messa assert.equal(hebrew.visible, true, JSON.stringify(hebrew)); assert.equal((await recentRowPresentation(page, '.call-recent p[data-role="user"]')).textDirection, 'rtl'); assert.equal((await recentRowPresentation(page, '.call-recent p[data-role="assistant"]')).textDirection, 'rtl'); + + await page.evaluate(async () => { + const { RecentBubble } = await import('/recent-bubble.mjs'); + const bubble = new RecentBubble([ + document.getElementById('call-bubble-first'), document.getElementById('call-bubble-second'), + ]); + bubble.append('user', `${'ك'.repeat(50)}iPad ${'مرحبا '.repeat(17)}نهاية الجملة`, '语音'); + bubble.append('assistant', `${'word '.repeat(60)}مرحبا ${'word '.repeat(20)}final words!`, '宪宪'); + }); + const mixedRtl = await textRangeVisibility(page, '.call-recent p[data-role="user"]', 'نهاية الجملة'); + const mixedLtr = await textRangeVisibility(page, '.call-recent p[data-role="assistant"]', 'final words!'); + assert.equal(mixedRtl.visible, true, JSON.stringify(mixedRtl)); + assert.equal(mixedLtr.visible, true, JSON.stringify(mixedLtr)); + assert.equal((await recentRowPresentation(page, '.call-recent p[data-role="user"]')).textDirection, 'rtl'); + assert.equal((await recentRowPresentation(page, '.call-recent p[data-role="assistant"]')).textDirection, 'ltr'); + + const streaming = await page.evaluate(async () => { + const { RecentBubble } = await import('/recent-bubble.mjs'); + const row = document.getElementById('call-bubble-first'); + const bubble = new RecentBubble([row, document.getElementById('call-bubble-second')]); + bubble.append('assistant', 'hello world ', '宪宪'); + const bad = []; + for (let index = 0; index < 60; index += 1) { + const delta = `كلمة${index} `; + bubble.append('assistant', delta, '宪宪'); + const message = row.querySelector('.recent-message'); + const text = row.querySelector('.recent-message-text').firstChild; + const offset = text.data.lastIndexOf(delta); + const range = document.createRange(); + range.setStart(text, offset); + range.setEnd(text, offset + delta.length); + const tail = range.getBoundingClientRect(); + const bounds = message.getBoundingClientRect(); + const hit = document.elementFromPoint((tail.left + tail.right) / 2, (tail.top + tail.bottom) / 2); + if (tail.left < bounds.left - 0.5 || tail.right > bounds.right + 0.5 || !message.contains(hit)) bad.push(index); + } + return bad; + }); + assert.deepEqual(streaming, []); } finally { await page.close(); } From 6349fa51d908381b53d1ed8fc6b794d45b3ac30d Mon Sep 17 00:00:00 2001 From: "CodexSol-GPT-5.6-sol" <773678591@qq.com> Date: Mon, 5 Oct 2026 00:58:01 -0700 Subject: [PATCH 4/4] test(companion): guard supplementary-plane bidi tails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: The renderer intentionally reverses Unicode code points when resolving the latest strong direction. Guard an Adlam tail so a future code-unit reversal cannot silently corrupt supplementary-plane RTL text while ordinary Arabic and Hebrew tests remain green. [小太阳·砚砚/gpt-5.6-sol🐾] Thread-Context: threadId=thread_muj2oyola5htyhrm catId=codex-sol --- packages/companion/test/panel-layout.browser.mjs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/companion/test/panel-layout.browser.mjs b/packages/companion/test/panel-layout.browser.mjs index eb38b415..8fb434df 100644 --- a/packages/companion/test/panel-layout.browser.mjs +++ b/packages/companion/test/panel-layout.browser.mjs @@ -267,6 +267,20 @@ test('the narrow call bar keeps the logical tail visible for right-to-left messa assert.equal((await recentRowPresentation(page, '.call-recent p[data-role="user"]')).textDirection, 'rtl'); assert.equal((await recentRowPresentation(page, '.call-recent p[data-role="assistant"]')).textDirection, 'ltr'); + const adlamTail = String.fromCodePoint(0x1e922, 0x1e923, 0x1e924, 0x1e925, 0x1e926); + await page.evaluate(async ({ adlamPrefix, adlamTail }) => { + const { RecentBubble } = await import('/recent-bubble.mjs'); + const row = document.getElementById('call-bubble-first'); + const bubble = new RecentBubble([row, document.getElementById('call-bubble-second')]); + bubble.append('user', `${`${adlamPrefix} `.repeat(30)}${adlamTail}`, '语音'); + }, { + adlamPrefix: String.fromCodePoint(0x1e927, 0x1e928, 0x1e929, 0x1e92a, 0x1e92b), + adlamTail, + }); + const adlam = await textRangeVisibility(page, '.call-recent p[data-role="user"]', adlamTail); + assert.equal(adlam.visible, true, JSON.stringify(adlam)); + assert.equal((await recentRowPresentation(page, '.call-recent p[data-role="user"]')).textDirection, 'rtl'); + const streaming = await page.evaluate(async () => { const { RecentBubble } = await import('/recent-bubble.mjs'); const row = document.getElementById('call-bubble-first');