diff --git a/changelog.d/1642-folded-thinking-preview.fixed.md b/changelog.d/1642-folded-thinking-preview.fixed.md new file mode 100644 index 000000000..af5609109 --- /dev/null +++ b/changelog.d/1642-folded-thinking-preview.fixed.md @@ -0,0 +1 @@ +The folded thinking row's one-line preview now skips fences and horizontal rules, cuts on a character boundary instead of halving an emoji, and ends a clipped line with an ellipsis; reasoning that is nothing but markup says how many lines it has instead of showing a fence fragment ([#1642](https://github.com/ThinkFlowLab/FlyBot/issues/1642)). diff --git a/core/client.js b/core/client.js index 3414e1f00..e3295b9ac 100644 --- a/core/client.js +++ b/core/client.js @@ -838,6 +838,8 @@ var ZH = { "Message actions": "消息操作", Download: "下载", "Thought it through": "思考过程", + "{n} line of reasoning": "{n} 行思考内容", + "{n} lines of reasoning": "{n} 行思考内容", "Seen — I’ll get to this right after the current step.": "看到了,这步做完就接着处理你这条。", "Earlier messages": "更早的消息", "Loading earlier messages…": "正在加载更早的消息…", @@ -6171,9 +6173,24 @@ function MessageTools({ text, name, sessionId, react, actions }) { ); } var PEEK_MAX = 160; +var CUT = "…"; +var MARKUP = [/^\s{0,3}(?:`{3,}|~{3,})/, /^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/]; +var isMarkup = (line2) => MARKUP.some((pattern) => pattern.test(line2)); +var graphemes = (line2) => typeof Intl !== "undefined" && typeof Intl.Segmenter === "function" ? [...new Intl.Segmenter().segment(line2)].map((part) => part.segment) : [...line2]; +var clip = (line2) => { + if (line2.length <= PEEK_MAX) return line2; + let kept = ""; + for (const part of graphemes(line2)) { + if (kept.length + part.length > PEEK_MAX - CUT.length) break; + kept += part; + } + return kept + CUT; +}; +var onlyMarkup = (lines) => lines.length === 1 ? t("{n} line of reasoning", { n: lines.length }) : t("{n} lines of reasoning", { n: lines.length }); var peekLine = (text) => { - const line2 = text.split("\n").map((part) => part.replace(/\s+/g, " ").trim()).find((part) => part !== ""); - return line2 === void 0 ? "" : line2.slice(0, PEEK_MAX); + const lines = text.split("\n").map((part) => part.replace(/\s+/g, " ").trim()).filter((part) => part !== ""); + const prose = lines.find((line2) => !isMarkup(line2)); + return prose === void 0 ? onlyMarkup(lines) : clip(prose); }; function ThinkingRow({ text }) { if (typeof text !== "string" || text.trim() === "") return null; diff --git a/core/src/client/cells.js b/core/src/client/cells.js index d8b5c316c..cdf9a73bc 100644 --- a/core/src/client/cells.js +++ b/core/src/client/cells.js @@ -130,9 +130,33 @@ function MessageTools({ text, name, sessionId, react, actions }) { // The first line with something on it, inner runs of whitespace squeezed: a folded row shows it, so // the row never reads as an empty frame while the text it names sits behind the caret (#1624). const PEEK_MAX = 160 +const CUT = '…' +// A fence (```js, ~~~, a closing fence) and a horizontal rule are markup, not something thought, so +// the preview skips them and takes the first line that has prose (#1642). +const MARKUP = [/^\s{0,3}(?:`{3,}|~{3,})/, /^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/] +const isMarkup = (line) => MARKUP.some(pattern => pattern.test(line)) +// Graphemes where the platform segments them, code points otherwise: either way the cut never lands +// inside a surrogate pair, so a clipped emoji cannot show up as U+FFFD (#1642). +const graphemes = (line) => typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function' + ? [...new Intl.Segmenter().segment(line)].map(part => part.segment) + : [...line] +const clip = (line) => { + if (line.length <= PEEK_MAX) return line + let kept = '' + for (const part of graphemes(line)) { + if (kept.length + part.length > PEEK_MAX - CUT.length) break + kept += part + } + return kept + CUT +} +// Nothing but markup: a line count still says something, where a fence fragment says nothing (#1642). +const onlyMarkup = (lines) => (lines.length === 1 + ? t('{n} line of reasoning', { n: lines.length }) + : t('{n} lines of reasoning', { n: lines.length })) const peekLine = (text) => { - const line = text.split('\n').map((part) => part.replace(/\s+/g, ' ').trim()).find((part) => part !== '') - return line === undefined ? '' : line.slice(0, PEEK_MAX) + const lines = text.split('\n').map((part) => part.replace(/\s+/g, ' ').trim()).filter((part) => part !== '') + const prose = lines.find((line) => !isMarkup(line)) + return prose === undefined ? onlyMarkup(lines) : clip(prose) } function ThinkingRow({ text }) { diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 72d01720e..91e0a1644 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -785,6 +785,8 @@ const ZH = { 'Message actions': '消息操作', Download: '下载', 'Thought it through': '思考过程', + '{n} line of reasoning': '{n} 行思考内容', + '{n} lines of reasoning': '{n} 行思考内容', 'Seen — I’ll get to this right after the current step.': '看到了,这步做完就接着处理你这条。', 'Earlier messages': '更早的消息', 'Loading earlier messages…': '正在加载更早的消息…', diff --git a/core/test/thinking-row.test.js b/core/test/thinking-row.test.js index ef3971cc4..c8bdec65d 100644 --- a/core/test/thinking-row.test.js +++ b/core/test/thinking-row.test.js @@ -136,3 +136,43 @@ test('#1636: an opened row hides its preview, so its first line is not printed t /\.bt-think\[open\] \.bt-think-peek\{display:none\}/, ) }) + +test('#1642: a fence or a rule is not the peek line; the first prose line is', () => { + const fenced = [think('```js\nconst x = 1\n```')] + const peek = classed(thinkingRow(render(step(1, fenced), [step(1, fenced)])), 'bt-think-peek') + assert.ok(peek, 'the closed row carries a line of the reasoning') + assert.equal(text(peek), 'const x = 1', 'markup is not what the Bot was thinking') + assert.doesNotMatch(text(peek), /```/) + + const ruled = [think('---\n***\nLet me read it\n___')] + const second = classed(thinkingRow(render(step(1, ruled), [step(1, ruled)])), 'bt-think-peek') + assert.ok(second) + assert.equal(text(second), 'Let me read it', 'a horizontal rule is markup too') +}) + +test('#1642: a cut preview keeps whole characters and says it was cut', () => { + const long = [think(`${'a'.repeat(150)}😀${'b'.repeat(100)}`)] + const peek = classed(thinkingRow(render(step(1, long), [step(1, long)])), 'bt-think-peek') + assert.ok(peek) + const shown = text(peek) + assert.equal(shown.length, 160, 'the cap still holds') + assert.ok(shown.includes('😀'), 'an emoji inside the cap survives whole') + assert.ok(shown.endsWith('…'), 'a clipped line says it was clipped') + assert.doesNotMatch(shown, /[\uD800-\uDFFF]/u, 'no half a surrogate pair') + + // The reported input: a pair cut exactly at the cap used to render as U+FFFD. + const edge = [think(`${'a'.repeat(159)}😀 tail`)] + const cut = text(classed(thinkingRow(render(step(1, edge), [step(1, edge)])), 'bt-think-peek')) + assert.equal(cut.length, 160) + assert.ok(cut.endsWith('…')) + assert.doesNotMatch(cut, /[\uD800-\uDFFF]/u, 'no half a surrogate pair at the cap either') +}) + +test('#1642: reasoning that is only markup gets a neutral summary, never a bare fence or an empty row', () => { + const onlyMarkup = [think('```js\n```')] + const details = thinkingRow(render(step(1, onlyMarkup), [step(1, onlyMarkup)])) + assert.ok(details, 'the row stays') + const peek = classed(details, 'bt-think-peek') + assert.ok(peek, 'the folded row never reads as an empty frame') + assert.equal(text(peek), '2 lines of reasoning') +}) diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 3bf37caac..8f10c751e 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -1152,7 +1152,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js // Command palette Messages: '消息', Commands: '命令', -@@ -390,10 +390,12 @@ const ZH = { +@@ -390,10 +390,14 @@ const ZH = { Reply: '回复', 'More message actions': '更多操作', More: '更多', @@ -1161,6 +1161,8 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Message actions': '消息操作', Download: '下载', 'Thought it through': '思考过程', ++ '{n} line of reasoning': '{n} 行思考内容', ++ '{n} lines of reasoning': '{n} 行思考内容', + 'Seen — I’ll get to this right after the current step.': '看到了,这步做完就接着处理你这条。', 'Earlier messages': '更早的消息', 'Loading earlier messages…': '正在加载更早的消息…', diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 3f1a67494..a83f748bf 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -529,16 +529,40 @@ diff --git a/src/client/cells.js b/src/client/cells.js // Closes a popover on a press outside the given elements or on Escape. function useDismiss(open, close, refs) { useEffect(() => { -@@ -100,9 +100,21 @@ function MessageTools({ text, name, sessionId, react, actions }) { +@@ -100,9 +100,45 @@ function MessageTools({ text, name, sessionId, react, actions }) { item(t('Copy'), CopyIcon, copy), item(t('Download'), DownloadIcon, download)), document.body) : null) } +// The first line with something on it, inner runs of whitespace squeezed: a folded row shows it, so +// the row never reads as an empty frame while the text it names sits behind the caret (#1624). +const PEEK_MAX = 160 ++const CUT = '…' ++// A fence (```js, ~~~, a closing fence) and a horizontal rule are markup, not something thought, so ++// the preview skips them and takes the first line that has prose (#1642). ++const MARKUP = [/^\s{0,3}(?:`{3,}|~{3,})/, /^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/] ++const isMarkup = (line) => MARKUP.some(pattern => pattern.test(line)) ++// Graphemes where the platform segments them, code points otherwise: either way the cut never lands ++// inside a surrogate pair, so a clipped emoji cannot show up as U+FFFD (#1642). ++const graphemes = (line) => typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function' ++ ? [...new Intl.Segmenter().segment(line)].map(part => part.segment) ++ : [...line] ++const clip = (line) => { ++ if (line.length <= PEEK_MAX) return line ++ let kept = '' ++ for (const part of graphemes(line)) { ++ if (kept.length + part.length > PEEK_MAX - CUT.length) break ++ kept += part ++ } ++ return kept + CUT ++} ++// Nothing but markup: a line count still says something, where a fence fragment says nothing (#1642). ++const onlyMarkup = (lines) => (lines.length === 1 ++ ? t('{n} line of reasoning', { n: lines.length }) ++ : t('{n} lines of reasoning', { n: lines.length })) +const peekLine = (text) => { -+ const line = text.split('\n').map((part) => part.replace(/\s+/g, ' ').trim()).find((part) => part !== '') -+ return line === undefined ? '' : line.slice(0, PEEK_MAX) ++ const lines = text.split('\n').map((part) => part.replace(/\s+/g, ' ').trim()).filter((part) => part !== '') ++ const prose = lines.find((line) => !isMarkup(line)) ++ return prose === undefined ? onlyMarkup(lines) : clip(prose) +} + function ThinkingRow({ text }) { diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index ba448a312..6ef71528a 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -572,7 +572,7 @@ diff --git a/client.js b/client.js // Command palette Messages: "消息", Commands: "命令", -@@ -446,10 +446,12 @@ var ZH = { +@@ -446,10 +446,14 @@ var ZH = { Reply: "回复", "More message actions": "更多操作", More: "更多", @@ -581,6 +581,8 @@ diff --git a/client.js b/client.js "Message actions": "消息操作", Download: "下载", "Thought it through": "思考过程", ++ "{n} line of reasoning": "{n} 行思考内容", ++ "{n} lines of reasoning": "{n} 行思考内容", + "Seen — I’ll get to this right after the current step.": "看到了,这步做完就接着处理你这条。", "Earlier messages": "更早的消息", "Loading earlier messages…": "正在加载更早的消息…", @@ -3668,7 +3670,7 @@ diff --git a/client.js b/client.js .bt-ububble{max-width:min(560px,78%);background:var(--bt-user);color:var(--bt-user-ink);border-radius:18px;padding:7px 12px;font-size:14px;line-height:20px;box-sizing:border-box;overflow-wrap:anywhere} .bt-ububble [class*="_markdown"],.bt-ububble a{color:inherit!important} .bt-ububble p{margin:0;line-height:22px} -@@ -2805,1975 +2805,4080 @@ function sheetClassNames(sheet) { +@@ -2805,1975 +2805,4095 @@ function sheetClassNames(sheet) { visit(sheet.cssRules); } catch { } @@ -5172,19 +5174,12 @@ diff --git a/client.js b/client.js - ) : null, - foot + error ? (0, import_react8.createElement)("div", { className: "bt-flybot-approval-error", role: "alert" }, error) : null - ); ++ ); + return (0, import_react8.createElement)( + "div", + { className: "bt-held-actions", "data-flybot": "held-reply-actions" }, + custom ? (0, import_react8.createElement)(boundary(), { id: custom.id, fallback: plain }, (0, import_react8.createElement)(custom.component, { held, decide, call: call3, actions })) : plain -+ ); -+} -+ -+// src/client/turn-reasoning.js -+function turnReasoning(steps, current) { -+ const members = steps.length ? steps : [current]; -+ if (members.find((step) => stepReasoning(step) !== "")?.step !== current.step) return ""; -+ return members.filter((step) => !thinkingLive(step)).map(stepReasoning).filter(Boolean).join("\n\n"); + ); } -function SettledQuestion({ question, answer }) { - const options = optionsOf(question); @@ -5203,6 +5198,13 @@ diff --git a/client.js b/client.js - ); - } - return (0, import_react4.createElement)( ++ ++// src/client/turn-reasoning.js ++function turnReasoning(steps, current) { ++ const members = steps.length ? steps : [current]; ++ if (members.find((step) => stepReasoning(step) !== "")?.step !== current.step) return ""; ++ return members.filter((step) => !thinkingLive(step)).map(stepReasoning).filter(Boolean).join("\n\n"); ++} +var texts = (step) => step.blocks.filter((block) => block.kind === "text" && block.text.trim() !== ""); +var thinkingLive = (step) => step.status === "running" && texts(step).length === 0 && step.blocks.some((block) => block.kind === "reasoning"); +var stepReasoning = (step) => step.blocks.filter((block) => block.kind === "reasoning").map((block) => block.text).join("\n").trim(); @@ -5388,49 +5390,31 @@ diff --git a/client.js b/client.js - const loading = useSession?.(historyLoading) === true; - if (question === void 0 || question.callId && (drawn || loading)) return null; - return (0, import_react4.createElement)(PendingQuestion, { sessionId, question, actions, inline: false }); -+var PEEK_MAX = 160; -+var peekLine = (text) => { -+ const line2 = text.split("\n").map((part) => part.replace(/\s+/g, " ").trim()).find((part) => part !== ""); -+ return line2 === void 0 ? "" : line2.slice(0, PEEK_MAX); -+}; -+function ThinkingRow({ text }) { -+ if (typeof text !== "string" || text.trim() === "") return null; -+ const peek = peekLine(text); -+ return (0, import_react9.createElement)( -+ "details", -+ { className: "bt-think" }, -+ (0, import_react9.createElement)( -+ "summary", -+ null, -+ (0, import_react9.createElement)("span", { className: "bt-think-caret", "aria-hidden": true }, "›"), -+ t("Thought it through"), -+ peek ? (0, import_react9.createElement)("span", { className: "bt-think-peek" }, peek) : null -+ ), -+ (0, import_react9.createElement)("div", { className: "bt-think-text" }, text) -+ ); - } +-} -function AnsweredCell({ node }) { - const data = node.data ?? {}; - const questions = Array.isArray(data.questions) ? data.questions : []; - if (questions.length === 0) { - return data.text ? (0, import_react4.createElement)("div", { className: "bt-urow" }, (0, import_react4.createElement)("div", { className: "bt-ububble" }, data.text)) : null; -- } ++var PEEK_MAX = 160; ++var CUT = "…"; ++var MARKUP = [/^\s{0,3}(?:`{3,}|~{3,})/, /^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/]; ++var isMarkup = (line2) => MARKUP.some((pattern) => pattern.test(line2)); ++var graphemes = (line2) => typeof Intl !== "undefined" && typeof Intl.Segmenter === "function" ? [...new Intl.Segmenter().segment(line2)].map((part) => part.segment) : [...line2]; ++var clip = (line2) => { ++ if (line2.length <= PEEK_MAX) return line2; ++ let kept = ""; ++ for (const part of graphemes(line2)) { ++ if (kept.length + part.length > PEEK_MAX - CUT.length) break; ++ kept += part; + } - const answers = Array.isArray(data.answers) ? data.answers : []; - return (0, import_react4.createElement)("div", { className: "bt-q-inline" }, questions.map((question) => (0, import_react4.createElement)(SettledQuestion, { - key: question.id, - question, - answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } - }))); -+function useFileMentions({ node, openFile, fileMentions, useTurnData }) { -+ const tail = useTurnData?.("turn-tail"); -+ const location2 = node.location; -+ const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0; -+ const seq = node.data.finalNode?.seq; -+ return (0, import_react9.useMemo)(() => { -+ if (typeof fileMentions !== "function" || turn?.status !== "closed" || seq === void 0) return void 0; -+ return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0; -+ }, [fileMentions, openFile, seq, tail, turn]); - } +-} - -// src/client/secret-card.js -var import_react5 = require("react"); @@ -5460,7 +5444,8 @@ diff --git a/client.js b/client.js - if (scope === "all") return t("All Bots"); - const names = (scope ?? []).map((id) => roster.byId[id]?.name).filter(Boolean); - return names.length === 0 ? t("No Bot") : names.join(t(" and ")); --}; ++ return kept + CUT; + }; -var when = (time) => Number.isFinite(time) ? new Date(time).toLocaleString(dateLocale(), { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""; -function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { - return (0, import_react5.createElement)("input", { @@ -5485,6 +5470,38 @@ diff --git a/client.js b/client.js - event.preventDefault(); - onEnter?.(); - } ++var onlyMarkup = (lines) => lines.length === 1 ? t("{n} line of reasoning", { n: lines.length }) : t("{n} lines of reasoning", { n: lines.length }); ++var peekLine = (text) => { ++ const lines = text.split("\n").map((part) => part.replace(/\s+/g, " ").trim()).filter((part) => part !== ""); ++ const prose = lines.find((line2) => !isMarkup(line2)); ++ return prose === void 0 ? onlyMarkup(lines) : clip(prose); ++}; ++function ThinkingRow({ text }) { ++ if (typeof text !== "string" || text.trim() === "") return null; ++ const peek = peekLine(text); ++ return (0, import_react9.createElement)( ++ "details", ++ { className: "bt-think" }, ++ (0, import_react9.createElement)( ++ "summary", ++ null, ++ (0, import_react9.createElement)("span", { className: "bt-think-caret", "aria-hidden": true }, "›"), ++ t("Thought it through"), ++ peek ? (0, import_react9.createElement)("span", { className: "bt-think-peek" }, peek) : null ++ ), ++ (0, import_react9.createElement)("div", { className: "bt-think-text" }, text) ++ ); ++} ++function useFileMentions({ node, openFile, fileMentions, useTurnData }) { ++ const tail = useTurnData?.("turn-tail"); ++ const location2 = node.location; ++ const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0; ++ const seq = node.data.finalNode?.seq; ++ return (0, import_react9.useMemo)(() => { ++ if (typeof fileMentions !== "function" || turn?.status !== "closed" || seq === void 0) return void 0; ++ return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0; ++ }, [fileMentions, openFile, seq, tail, turn]); ++} +function useClosingText(node, useTurnData) { + const tail = useTurnData?.("turn-tail"); + const seq = node.data.finalNode?.seq; diff --git a/patches/ds-bot/files/test/thinking-row.test.js b/patches/ds-bot/files/test/thinking-row.test.js index ef3971cc4..c8bdec65d 100644 --- a/patches/ds-bot/files/test/thinking-row.test.js +++ b/patches/ds-bot/files/test/thinking-row.test.js @@ -136,3 +136,43 @@ test('#1636: an opened row hides its preview, so its first line is not printed t /\.bt-think\[open\] \.bt-think-peek\{display:none\}/, ) }) + +test('#1642: a fence or a rule is not the peek line; the first prose line is', () => { + const fenced = [think('```js\nconst x = 1\n```')] + const peek = classed(thinkingRow(render(step(1, fenced), [step(1, fenced)])), 'bt-think-peek') + assert.ok(peek, 'the closed row carries a line of the reasoning') + assert.equal(text(peek), 'const x = 1', 'markup is not what the Bot was thinking') + assert.doesNotMatch(text(peek), /```/) + + const ruled = [think('---\n***\nLet me read it\n___')] + const second = classed(thinkingRow(render(step(1, ruled), [step(1, ruled)])), 'bt-think-peek') + assert.ok(second) + assert.equal(text(second), 'Let me read it', 'a horizontal rule is markup too') +}) + +test('#1642: a cut preview keeps whole characters and says it was cut', () => { + const long = [think(`${'a'.repeat(150)}😀${'b'.repeat(100)}`)] + const peek = classed(thinkingRow(render(step(1, long), [step(1, long)])), 'bt-think-peek') + assert.ok(peek) + const shown = text(peek) + assert.equal(shown.length, 160, 'the cap still holds') + assert.ok(shown.includes('😀'), 'an emoji inside the cap survives whole') + assert.ok(shown.endsWith('…'), 'a clipped line says it was clipped') + assert.doesNotMatch(shown, /[\uD800-\uDFFF]/u, 'no half a surrogate pair') + + // The reported input: a pair cut exactly at the cap used to render as U+FFFD. + const edge = [think(`${'a'.repeat(159)}😀 tail`)] + const cut = text(classed(thinkingRow(render(step(1, edge), [step(1, edge)])), 'bt-think-peek')) + assert.equal(cut.length, 160) + assert.ok(cut.endsWith('…')) + assert.doesNotMatch(cut, /[\uD800-\uDFFF]/u, 'no half a surrogate pair at the cap either') +}) + +test('#1642: reasoning that is only markup gets a neutral summary, never a bare fence or an empty row', () => { + const onlyMarkup = [think('```js\n```')] + const details = thinkingRow(render(step(1, onlyMarkup), [step(1, onlyMarkup)])) + assert.ok(details, 'the row stays') + const peek = classed(details, 'bt-think-peek') + assert.ok(peek, 'the folded row never reads as an empty frame') + assert.equal(text(peek), '2 lines of reasoning') +})