diff --git a/changelog.d/1508-title-code-points.fixed.md b/changelog.d/1508-title-code-points.fixed.md new file mode 100644 index 000000000..c25c13d50 --- /dev/null +++ b/changelog.d/1508-title-code-points.fixed.md @@ -0,0 +1 @@ +Workflow Runs and timeline rows now show the whole task title (`#id · title`) with a single-line CSS ellipsis and the full text in the tooltip, so a title containing an emoji or a CJK Ext-B character can no longer be cut mid-character and render as a replacement character ([#1508](https://github.com/ThinkFlowLab/FlyBot/issues/1508)). diff --git a/plugins/workflow/client.js b/plugins/workflow/client.js index 21c1a8f61..f19a7cf3f 100644 --- a/plugins/workflow/client.js +++ b/plugins/workflow/client.js @@ -324,20 +324,21 @@ window.__ModuleLoader__.load({ if (text.startsWith('task-')) return `#${text.slice(-6)}` return text } + // The row clips itself with CSS (single line + ellipsis) and the full title + // rides in a tooltip, so never cut the string: a UTF-16 offset can land + // inside a surrogate pair and render an emoji/CJK Ext-B title as a replacement character. function taskSummary(task, run) { if (run?.taskSummary) return run.taskSummary const id = shortId(task?.id ?? '') const title = String(task?.title ?? '').replace(/\s+/g, ' ').trim() if (!id && !title) return '' if (!title) return id - if (!id) return title.length > 72 ? `${title.slice(0, 69)}…` : title + if (!id) return title // Remove issue number prefix from title if it duplicates the id let cleanTitle = title const issueMatch = title.match(/^issue\s+#?\d+\s*[:\-–—]\s*/i) if (issueMatch && id) cleanTitle = title.slice(issueMatch[0].length) - const room = Math.max(24, 72 - id.length - 3) - const short = cleanTitle.length > room ? `${cleanTitle.slice(0, room - 1)}…` : cleanTitle - return `${id} · ${short}` + return `${id} · ${cleanTitle}` } function sidebarPreview(item) { const busy = (item.activeCount ?? 0) > 0 && item.run?.live === true diff --git a/plugins/workflow/src/graph.js b/plugins/workflow/src/graph.js index 30d07b397..215508ebd 100644 --- a/plugins/workflow/src/graph.js +++ b/plugins/workflow/src/graph.js @@ -18,8 +18,12 @@ function stripControls(value) { return out } +// Cut on code points, never on UTF-16 units: a unit offset can split a surrogate +// pair and leave a lone surrogate, which renders as a replacement character. export function cleanLine(value, max = 300) { - return stripControls(value).replace(/\s+/g, ' ').trim().slice(0, max) + const text = stripControls(value).replace(/\s+/g, ' ').trim() + if (!Number.isFinite(max) || text.length <= max) return text + return Array.from(text).slice(0, max).join('') } // A node prompt keeps line breaks. Other controls become spaces. @@ -817,9 +821,11 @@ export function chainLabel(graph) { return graph.layers.map(layer => layer.map(id => nodeById(graph, id)?.name ?? id).join(' ‖ ')).join(' → ') } -export function summarizeTask(task, maxTitle = 72) { +// The label is rendered whole: the row clips it with CSS ellipsis and the full +// title stays in the tooltip, so a length cap here only risks a split character. +export function summarizeTask(task) { const id = cleanLine(task?.id, 80) - const title = cleanLine(task?.title, maxTitle) + const title = cleanLine(task?.title, Infinity) if (!id && !title) return '' if (!title) return id if (!id) return title diff --git a/plugins/workflow/test/client.test.js b/plugins/workflow/test/client.test.js index 3d209dd62..ae2e7d5ff 100644 --- a/plugins/workflow/test/client.test.js +++ b/plugins/workflow/test/client.test.js @@ -754,6 +754,21 @@ function longLine(kind) { return line } +// A lone surrogate is what a UTF-16 cut leaves behind; it renders as \u{FFFD}. +function hasLoneSurrogate(text) { + for (let i = 0; i < text.length; i += 1) { + const code = text.charCodeAt(i) + if (code >= 0xd800 && code <= 0xdbff) { + const next = text.charCodeAt(i + 1) + if (!(next >= 0xdc00 && next <= 0xdfff)) return true + i += 1 + continue + } + if (code >= 0xdc00 && code <= 0xdfff) return true + } + return false +} + function assertFull(text, line) { assert.equal(text.includes(line), true, `the stage chat dropped part of a ${line.length}-character line`) assert.equal(text.includes(`${line.slice(0, 239)}…`), false, 'the stage chat still clipped a line at 240 characters') @@ -778,9 +793,11 @@ test('a long roster Bot line in the stage chat renders in full', async () => { assertFull(text, long) }) -test('the stage strip still clips a long task title as a summary', async () => { +test('a long emoji/CJK Ext-B task title renders whole, never cut mid-character', async () => { const graph = delivery() - const longTitle = '让发布说明覆盖四个小节并保留每条验收命令的原文'.repeat(12) + // 60 UTF-16 units then an astral pair: the old 61st-unit cut landed inside the + // surrogate pair and left a lone surrogate (rendered as \u{FFFD}). + const longTitle = `${'发布说明'.repeat(15)}🚀𠀋${'验收命令'.repeat(12)}` graph.run.task = { id: '#1460', title: longTitle } delete graph.run.taskSummary const call = async (name) => { @@ -791,11 +808,24 @@ test('the stage strip still clips a long task title as a summary', async () => { await settle() findAll(view.tree, node => node.type === 'button' && textOf(node).includes('发布交付流水线'))[0].props.onClick() await settle() - const text = textOf(view.tree) const summary = client.taskSummary({ id: '#1460', title: longTitle }) - assert.equal(text.includes(summary), true, 'the stage strip lost its task summary') - assert.equal(text.includes(longTitle), false, 'the stage strip rendered a full task title instead of a summary') - assert.equal(summary.endsWith('…'), true) + assert.equal(summary, `#1460 · ${longTitle}`, 'the task summary is not the whole id and title') + assert.equal(hasLoneSurrogate(summary), false, 'the task summary ends in a lone surrogate') + assert.equal(textOf(view.tree).includes(longTitle), true, 'the stage strip dropped part of the task title') + + // The tooltip keeps the whole title, and the row clips itself in CSS instead. + const label = findAll(view.tree, node => node.props?.className === 'wf-tk')[0] + assert.equal(label.props.title, longTitle, 'the row tooltip lost the whole title') + const css = readFileSync(new URL('../client.js', import.meta.url), 'utf8') + const rule = css.split('\n').find(line => line.startsWith('.wf-tk{')) + assert.match(rule, /white-space:nowrap/, 'the task row is no longer single-line') + assert.match(rule, /text-overflow:ellipsis/, 'the task row no longer ellipsizes') + + // An id-less title takes the other branch, which used to cut at 69 units. + const bareTitle = `${'验收命令'.repeat(17)}🚀` + const bare = client.taskSummary({ title: bareTitle }) + assert.equal(bare, bareTitle, 'the id-less task summary is not the whole title') + assert.equal(hasLoneSurrogate(bare), false, 'the id-less task summary ends in a lone surrogate') view.unmount() }) diff --git a/plugins/workflow/test/graph.test.js b/plugins/workflow/test/graph.test.js index 7d8b0f9f1..f7484b33a 100644 --- a/plugins/workflow/test/graph.test.js +++ b/plugins/workflow/test/graph.test.js @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import { test } from 'node:test' import { - addNode, chainLabel, completeNode, connectNodes, doneCount, failNode, freshStatus, layerMarks, + addNode, chainLabel, cleanLine, completeNode, connectNodes, doneCount, failNode, freshStatus, layerMarks, moveNode, nextDemoOp, normalizeGraph, reconnectEdge, removeEdge, removeNode, retryNode, rowPreview, startFrontier, summarizeTask, validateGraph, } from '../src/graph.js' @@ -9,6 +9,20 @@ import { deliveryWorkflow, weeklyWorkflow } from '../src/template.js' const delivery = deliveryWorkflow() +function hasLoneSurrogate(text) { + for (let i = 0; i < text.length; i += 1) { + const code = text.charCodeAt(i) + if (code >= 0xd800 && code <= 0xdbff) { + const next = text.charCodeAt(i + 1) + if (!(next >= 0xdc00 && next <= 0xdfff)) return true + i += 1 + continue + } + if (code >= 0xdc00 && code <= 0xdfff) return true + } + return false +} + function runToCoder() { let status = freshStatus(delivery) status = startFrontier(delivery, status).status @@ -156,5 +170,21 @@ test('the sidebar line names a live running Bot, never a demo script', () => { assert.equal(rowPreview(delivery, paused), '已暂停 · Coder · #1280') assert.equal(rowPreview(weeklyWorkflow(), { status: 'done', live: true, nodeStatus: {}, task: { id: 'W42' } }), '下次运行:周五 17:00') assert.equal(rowPreview(delivery, null), '等待任务') - assert.equal(summarizeTask({ id: '#9', title: 'a'.repeat(120) }).length <= 80, true) + assert.equal(summarizeTask({ id: '#9', title: 'a'.repeat(120) }).length <= 80, false) +}) + +test('a long task label keeps its whole title and never ends in a lone surrogate', () => { + const plain = 'a'.repeat(120) + assert.equal(summarizeTask({ id: '#9', title: plain }), `#9 · ${plain}`) + + // 71 units then an astral pair: the old 72-unit cap split the pair in half. + const astralTitle = `${'发布说明'.repeat(17)}验收命🚀` + const astral = summarizeTask({ id: '#9', title: astralTitle }) + assert.equal(astral, `#9 · ${astralTitle}`) + assert.equal(astral.includes('\uFFFD'), false) + assert.equal(hasLoneSurrogate(astral), false, 'summarizeTask left a lone surrogate in the label') + + // cleanLine still cuts when it must, but on code points. + assert.equal(cleanLine('🚀'.repeat(10), 7), '🚀'.repeat(7)) + assert.equal(hasLoneSurrogate(cleanLine('🚀'.repeat(10), 7)), false) })