From 7c8853317281278508f9dbd0d16c1c06b56669aa Mon Sep 17 00:00:00 2001 From: 4Dim Date: Tue, 20 Oct 2026 21:15:15 +0800 Subject: [PATCH 1/8] fix(minimax-bot): move reply actions to the bottom-left of the message The reaction, copy, quote and open-session buttons used to sit inside the last bubble, pinned to its top-right corner. A long reply therefore pushed them out of reach while the reader was still at the end of the text, which made quoting a long answer awkward. - Render the action row as a sibling below the bubble, aligned to its left edge, so it always stays under the last line of the reply. - Keep the reaction indicator inside the bubble at its bottom-left, which puts it directly above the action row, and paint it with the app's own tag colours instead of a fixed brand-colour pill so it follows the light and dark themes. - Give the transcript enough bottom padding that scrolling to the end never clips the action row of the last reply. Bumps the package to 0.7.5 and aligns the MCP serverInfo version with the manifest. Assisted-by: mavis reason:minimax-bot-reply-actions --- .../minimax-bot/.minimax-plugin/plugin.json | 2 +- plugins/minimax-ai/minimax-bot/README.md | 1 + .../minimax-bot/miniapp/client/app.css | 49 +++++++++---------- .../minimax-bot/miniapp/client/app.js | 19 ++----- .../minimax-bot/miniapp/client/reactions.js | 10 +++- .../minimax-bot/miniapp/node/mcp.mjs | 2 +- plugins/minimax-ai/minimax-bot/package.json | 2 +- .../tests/client-reliability.test.mjs | 40 +++++++++++++++ 8 files changed, 79 insertions(+), 46 deletions(-) diff --git a/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json b/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json index 4ae17ba..ae87257 100644 --- a/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json +++ b/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "name": "minimax-bot", "displayName": "MiniMax Bot", - "version": "0.7.4", + "version": "0.7.5", "description": "Turn MiniMax Code into an always-on team of Bots. A Chief Bot helps you build the team; in group chats the admin answers and @mentions hand work to other members. Every Bot has its own octopus avatar and memory, with scheduled tasks and usage stats.", "author": "MiniMax Code", "icon": "icon.png", diff --git a/plugins/minimax-ai/minimax-bot/README.md b/plugins/minimax-ai/minimax-bot/README.md index 1b7a956..0855711 100644 --- a/plugins/minimax-ai/minimax-bot/README.md +++ b/plugins/minimax-ai/minimax-bot/README.md @@ -27,6 +27,7 @@ Copy this directory, including the hidden `.minimax-plugin/` directory, into the - **Chat filters**: the icon bar switches between all chats, unread, groups and direct chats, with unread counts; your choice is remembered. - **Language**: English and Chinese. By default it follows the operating system's preferred language (the first English or Chinese entry in `navigator.languages`; other languages fall back to English), or you can fix it under Settings → General → Language. The interface, errors, system notices, Chief's default setup, the context Bots receive and the MCP tool descriptions all switch; replies the Bots wrote and text you entered stay as they are. - **Reactions**: react to finished Bot replies in private and group chats. The quick bar offers 👍 ❤️ 🙌 😂 😭 😮, and more emoji can be browsed by category or searched in English or Chinese. Each message holds one reaction: picking a new one replaces it, and picking the same one again removes it. Reactions are saved locally, survive refreshes and restarts, and sync to other open windows. They do not trigger a Bot reply and are not sent to the model. +- **Reply actions**: the reaction, copy, quote-reply and open-session buttons sit at the bottom-left of a reply, under its last line. A long reply therefore keeps its actions within reach at the end of the text instead of pushing them off the top of the message. ## 0.7.0 Reliable delegation and result delivery diff --git a/plugins/minimax-ai/minimax-bot/miniapp/client/app.css b/plugins/minimax-ai/minimax-bot/miniapp/client/app.css index 751352e..960a69c 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/client/app.css +++ b/plugins/minimax-ai/minimax-bot/miniapp/client/app.css @@ -23,7 +23,6 @@ --shadow-pill: 0 2px 8px -1px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04); --scrim: rgba(10, 10, 10, 0.28); --ring: var(--sidebar); - --reaction-ink: #fff; color-scheme: light; } @@ -757,10 +756,12 @@ button { .feed { flex: 1; - /* Padding alone is 84px. Without a real floor, task rows (min-height: auto) shrink the transcript to that sliver. */ + /* Padding alone is 112px. Without a real floor, task rows (min-height: auto) shrink the transcript to that sliver. */ min-height: min(220px, 48%); overflow-y: auto; - padding: 72px 24px 12px; + /* The bottom room holds the action row of the last reply: 12px of padding plus the + 28px the row needs, so scrolling to the end never clips the buttons. */ + padding: 72px 24px 40px; scroll-behavior: auto; } @@ -827,6 +828,9 @@ button { max-width: min(88%, 680px); } +/* The action row of a reply: reaction, copy, quote and open-session. It hangs below the + bubble at the left, the way a comment toolbar does, so a long reply cannot push the + buttons out of reach while the reader is at the end of it. */ .msg-tools { display: flex; gap: 2px; @@ -834,6 +838,7 @@ button { margin-top: 2px; opacity: 0; transition: opacity 120ms ease; + white-space: nowrap; } .msg:hover .msg-tools, @@ -2951,34 +2956,30 @@ fieldset.field { .avatar-errors { max-height: 100px; overflow-y: auto; padding-left: 20px; } .oc-img { object-fit: cover; border-radius: 22%; } -/* Reactions sit on the last bubble; the picker uses the same compact chat scale. */ +/* Reactions sit on the last bubble; the picker uses the same compact chat scale. The + indicator wears the app's own tag language — a soft surface and a hairline border — so + it reads as part of the bubble in either theme. */ .reaction-bar { display: inline-flex; align-items: center; max-width: 100%; - padding: 2px; - border: 3px solid var(--bg); + padding: 1px; + border: 1px solid var(--line-strong); border-radius: 999px; - background: var(--accent); - color: var(--reaction-ink); + background: var(--tag); + color: var(--muted); } .reaction-bar:empty { display: none; } -.bubble:has(> .msg-tools) { - max-width: min(calc(100% - var(--message-tools-width, 92px)), 680px); -} -.bubble > .msg-tools { - position: absolute; - left: calc(100% + 8px); - top: 7px; - margin: 0; - white-space: nowrap; -} +/* The indicator keeps the place a comment's reaction row takes: inside the message box, at + its bottom-left, and therefore above the reply's action row. The bubble reserves the + room for it so the last line of the reply stays readable. */ .bubble > .reaction-bar { position: absolute; - right: -6px; - top: -22px; + left: 14px; + bottom: 6px; } -.bubble:has(> .reaction-bar:not(:empty)) { margin-top: 14px; } +/* 6px below the bubble edge + a 32px indicator (28px chip + 2px padding + 2px border). */ +.bubble:has(> .reaction-bar:not(:empty)) { padding-bottom: 40px; } .reaction-chip { display: inline-flex; align-items: center; @@ -2994,7 +2995,7 @@ fieldset.field { line-height: 1; } .reaction-chip { font-size: 21px; } -.reaction-chip:hover { background: color-mix(in srgb, var(--reaction-ink) 20%, transparent); } +.reaction-chip:hover { background: var(--hover); color: var(--ink); } .reaction-bar button:focus-visible { outline-color: var(--ink); } .reaction-picker { position: fixed; @@ -3119,10 +3120,6 @@ fieldset.field { .gmsg:hover .msg-tools, .gmsg:focus-within .msg-tools, .msg-tools:has([aria-expanded='true']) { opacity: 1; } -@media (max-width: 720px) { - .bubble:has(> .msg-tools) { max-width: min(88%, 680px); margin-bottom: 28px; } - .bubble > .msg-tools { top: calc(100% + 2px); left: 0; } -} @media (hover: none) { .msg-tools { opacity: 1; } .emoji-quick .emoji-option { width: 40px; height: 40px; } diff --git a/plugins/minimax-ai/minimax-bot/miniapp/client/app.js b/plugins/minimax-ai/minimax-bot/miniapp/client/app.js index 54ac3ca..21b7ff6 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/client/app.js +++ b/plugins/minimax-ai/minimax-bot/miniapp/client/app.js @@ -971,22 +971,9 @@ function partViews(m, inGroup) { return views; } +/** The bubble carries the reply text and the reaction badge; the action row lives below it. */ function updateBubbleControls(bubble, m, isLast) { updateBubbleReactions(bubble, m, isLast); - const old = bubble.querySelector(':scope > .msg-tools'); - if (!isLast || m.status === 'streaming') { - old?.remove(); - bubble.style.removeProperty('--message-tools-width'); - } else { - const signature = JSON.stringify([displayText(m), m.sessionId, botById(m.botId)?.name, locale()]); - if (old?.dataset.signature !== signature) { - const tools = msgTools(displayText(m), m); - tools.dataset.signature = signature; - if (old) old.replaceWith(tools); - else bubble.append(tools); - bubble.style.setProperty('--message-tools-width', `${tools.children.length * 28 + 8}px`); - } - } return bubble; } @@ -1103,7 +1090,9 @@ function botMessage(m, existing) { notes.forEach((node, index) => views.push({ key: `note:${index}`, signature: node.outerHTML, create: () => node })); if (m.question) views.push({ key: 'question', signature: JSON.stringify([m.question, m.answering, locale()]), create: () => questionCard(m) }); if (m.answerSummary?.length) views.push({ key: 'answers', signature: JSON.stringify(m.answerSummary), create: () => h('dl', { class: 'answered' }, m.answerSummary.map((a) => [h('dt', null, a.question), h('dd', null, a.answer)])) }); - if (m.status !== 'streaming' && views.some((view) => view.key === 'reactions')) views.push({ key: 'tools', signature: JSON.stringify([displayText(m), m.sessionId, name, locale()]), create: () => msgTools(displayText(m), m) }); + // The action row hangs below the reply, at its bottom-left. Anchoring it to the bubble's + // top pushed the buttons off-screen as soon as the reply grew past the viewport. + if (m.status !== 'streaming') views.push({ key: 'tools', signature: JSON.stringify([displayText(m), m.sessionId, name, locale()]), create: () => msgTools(displayText(m), m) }); const compatible = existing?.matches(inGroup ? 'article.gmsg' : 'article.msg'); const article = compatible ? existing : h('article', { class: inGroup ? 'gmsg' : 'msg' }); article.setAttribute('aria-label', t('a11y.reply', { name })); diff --git a/plugins/minimax-ai/minimax-bot/miniapp/client/reactions.js b/plugins/minimax-ai/minimax-bot/miniapp/client/reactions.js index abd7667..5d082b8 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/client/reactions.js +++ b/plugins/minimax-ai/minimax-bot/miniapp/client/reactions.js @@ -53,8 +53,14 @@ function replaceBar(bar, m) { /** Keep the badge on the final text bubble without replacing its text or media. */ export function updateBubbleReactions(bubble, m, isLast) { const bar = bubble.querySelector(':scope > .reaction-bar'); - if (!isLast) bar?.remove(); - else if (bar) replaceBar(bar, m); + if (!isLast) { + // The reply gained a new final part, so the badge moves to it. The picker opened from + // this bubble's action row would otherwise stay open over a reply that was replaced. + if (bar) { + bar.remove(); + closeReactionPicker(); + } + } else if (bar) replaceBar(bar, m); else bubble.append(reactionBar(m)); } diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs index ac948fe..25536f6 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs @@ -586,7 +586,7 @@ export function createMcpHandler({ team, engine }) { result = { protocolVersion: PROTOCOL_VERSIONS.includes(requested) ? requested : PROTOCOL_VERSIONS[0], capabilities: { tools: { listChanged: false } }, - serverInfo: { name: 'minimax-bot', version: '0.7.0' }, + serverInfo: { name: 'minimax-bot', version: '0.7.5' }, instructions: t('mcp.instructions'), }; break; diff --git a/plugins/minimax-ai/minimax-bot/package.json b/plugins/minimax-ai/minimax-bot/package.json index aee5aa8..9584fc9 100644 --- a/plugins/minimax-ai/minimax-bot/package.json +++ b/plugins/minimax-ai/minimax-bot/package.json @@ -1,6 +1,6 @@ { "name": "minimax-bot", - "version": "0.7.4", + "version": "0.7.5", "private": true, "type": "module", "mcode": { diff --git a/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs b/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs index 184f2e3..2d4cf8a 100644 --- a/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs +++ b/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs @@ -229,6 +229,46 @@ test('reactions stay on the final bubble while message parts change, preserving assert.equal(document.activeElement, document.querySelector('.reaction-trigger')); }); +test('a multi-line reply keeps its actions below the text and its reaction indicator above them', async () => { + const text = 'First line\nSecond line\n\nThird paragraph\nFourth line'; + const item = message('m-multiline', text, { + parts: [{ type: 'text', text }], + userReactions: ['👍'], reactionsRevision: 1, + }); + await reset([item]); + const wrap = document.querySelector('article.msg .bubble-wrap'); + const bubble = wrap.querySelector('.bubble'); + const tools = wrap.querySelector('.msg-tools'); + // The action row is a sibling below the bubble, never a child of it: a reply that runs + // over several lines must not push the buttons back to the top of the message. + assert.equal(bubble.querySelector('.msg-tools'), null); + assert.equal(tools.parentElement, wrap); + assert.equal(wrap.lastElementChild, tools); + assert.deepEqual([...wrap.children].map((child) => child.className), ['bubble', 'msg-tools']); + // Every line of the reply belongs to that one bubble. + assert.match(bubble.textContent, /First line/); + assert.match(bubble.textContent, /Fourth line/); + // The reaction indicator stays with the bubble it belongs to, so it renders above the row. + assert.equal(bubble.querySelector('.reaction-chip').dataset.emoji, '👍'); + // The quote action still carries the whole reply, however many lines it has. + tools.querySelector('[aria-label="Quote"]').click(); + await settle(); + assert.match(document.querySelector('.quoting').textContent, /First line/); +}); + +test('the reaction indicator is painted from the theme instead of a fixed brand colour', async () => { + const css = await readFile(new URL('../miniapp/client/app.css', import.meta.url), 'utf8'); + const rule = css.match(/\n\.reaction-bar \{[^}]*\}/)?.[0] ?? ''; + assert.ok(rule, 'the reaction indicator needs a rule of its own'); + // The indicator sits on the bubble, so it follows the app's tag language: a themed + // surface, a hairline border and muted ink. Both themes then read the same way. + assert.match(rule, /background: var\(--tag\)/); + assert.match(rule, /border: 1px solid var\(--line-strong\)/); + assert.match(rule, /color: var\(--muted\)/); + assert.doesNotMatch(rule, /var\(--accent\)/); + assert.doesNotMatch(css, /--reaction-ink/); +}); + test('history paging prepends in order, retains scroll position and exports every page separately', async () => { await reset(); // Several messages can share the same millisecond; page order remains authoritative. From 9b16e8d58fb5831414023d721ca7c010928c5d96 Mon Sep 17 00:00:00 2001 From: 4Dim Date: Wed, 21 Oct 2026 07:47:03 +0800 Subject: [PATCH 2/8] refactor(minimax-bot): make the background task strip readable at a glance The strip put the owner and the state in the same sentence as the task, then truncated the task to one line, so a busy queue read as a wall of near identical rows. - Lead each row with the task (up to two lines) and put the owner, the state and the elapsed time on a second line. - Tell the states apart by their dot: quiet while queued, amber while it waits, accent while it runs. - Keep the stop action reachable, but out of the way until the row is looked at. - Fold a queue of more than three tasks into one line that can be expanded. Assisted-by: mavis reason:minimax-bot-task-strip --- .../minimax-bot/miniapp/client/app.css | 67 ++++++++++++++++--- .../minimax-bot/miniapp/client/app.js | 58 +++++++++++++--- .../minimax-bot/miniapp/client/strings-app.js | 16 +++-- .../tests/client-reliability.test.mjs | 37 ++++++++++ 4 files changed, 155 insertions(+), 23 deletions(-) diff --git a/plugins/minimax-ai/minimax-bot/miniapp/client/app.css b/plugins/minimax-ai/minimax-bot/miniapp/client/app.css index 960a69c..026ad8c 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/client/app.css +++ b/plugins/minimax-ai/minimax-bot/miniapp/client/app.css @@ -2730,23 +2730,27 @@ fieldset.field { .helper-row { display: flex; - align-items: center; - gap: 8px; - padding: 6px 8px 6px 12px; + align-items: flex-start; + gap: 10px; + padding: 8px 10px 8px 12px; border-radius: 12px; border: 0.5px solid var(--line); background: var(--surface); font-size: 13px; } +/* The dot carries the state: quiet while queued, amber while it waits, accent while it runs. */ .helper-dot { width: 7px; height: 7px; + margin-top: 6px; border-radius: 50%; background: var(--accent); flex: none; - animation: helper-pulse 1.6s ease-in-out infinite; } +.helper-row.is-queued .helper-dot { background: var(--subtle); } +.helper-row.is-waiting .helper-dot { background: var(--warning); } +.helper-row.is-running .helper-dot { animation: helper-pulse 1.6s ease-in-out infinite; } @keyframes helper-pulse { 50% { @@ -2754,22 +2758,67 @@ fieldset.field { } } -[data-motion='quiet'] .helper-dot { +[data-motion='quiet'] .helper-row.is-running .helper-dot { animation: none; } -.helper-text { +.helper-main { flex: 1; min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +/* The task is what the row is about, so it gets the space and up to two lines. */ +.helper-title { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + overflow-wrap: anywhere; } -.helper-time { +.helper-meta { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; color: var(--muted); font-size: 12px; +} +.helper-who { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.helper-state, .helper-time { flex: none; + white-space: nowrap; +} + +/* The stop action stays reachable, but out of the way until the row is looked at. */ +.helper-stop { + flex: none; + align-self: center; + border: 0; + border-radius: 6px; + background: none; + color: var(--subtle); + font-size: 12px; + padding: 2px 6px; + cursor: pointer; +} +.helper-stop:hover { + color: var(--ink); + background: var(--hover); +} + +.helper-more { + align-self: flex-start; + font-size: 12px; + color: var(--muted); } /* attachments waiting to be sent, above the input */ diff --git a/plugins/minimax-ai/minimax-bot/miniapp/client/app.js b/plugins/minimax-ai/minimax-bot/miniapp/client/app.js index 21b7ff6..f8924ca 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/client/app.js +++ b/plugins/minimax-ai/minimax-bot/miniapp/client/app.js @@ -447,6 +447,7 @@ async function mountChat(chatId) { const revision = ++mountRevision; closeReactionPicker(); mounted = chatId; + helpersExpanded = false; const target = targetOf(chatId); ui.pillBar = h('div', { class: 'pill-bar' }); ui.feedInner = h('div', { class: 'feed-inner' }); @@ -1229,28 +1230,54 @@ function helperElapsed(helper) { return minutes < 1 ? t('helper.justStarted') : t('helper.minutes', { n: minutes }); } -/** Background helpers working for this chat: one line each, with a stop button. */ +/** The phase a helper is in. The rows are ordered by it: what runs, then what waits, then the queue. */ +function helperPhase(helper) { + if (helper.status === 'waiting_for_tasks') return 'waiting'; + if (helper.status === 'queued') return 'queued'; + return 'running'; +} + +const HELPER_PHASE_ORDER = { running: 0, waiting: 1, queued: 2 }; +/** How many queued tasks stay visible before the queue folds into one line. */ +const HELPER_QUEUE_PREVIEW = 3; +let helpersExpanded = false; + +/** Background helpers working for this chat: the task on top, its owner and state below. */ function renderHelpers() { if (!ui.helpers) return; - const items = [...state.helpers, ...state.tasks].filter((x) => [x.chatId, x.originChatId, x.resultChatId].includes(mounted) && ['queued', 'running', 'waiting_for_tasks'].includes(x.status)); + const items = [...state.helpers, ...state.tasks] + .filter((x) => [x.chatId, x.originChatId, x.resultChatId].includes(mounted) && ['queued', 'running', 'waiting_for_tasks'].includes(x.status)) + .sort((a, b) => HELPER_PHASE_ORDER[helperPhase(a)] - HELPER_PHASE_ORDER[helperPhase(b)]); + const queued = items.filter((x) => x.status === 'queued'); + const shown = helpersExpanded ? queued : queued.slice(0, HELPER_QUEUE_PREVIEW); + const hidden = queued.length - shown.length; ui.helpers.replaceChildren( - ...items.map((helper) => { + ...[...items.filter((x) => x.status !== 'queued'), ...shown].map((helper) => { const bot = botById(helper.botId); + const phase = helperPhase(helper); return h( 'div', - { class: 'helper-row', title: helper.task }, + { class: `helper-row is-${phase}`, title: helper.task }, h('span', { class: 'helper-dot', 'aria-hidden': 'true' }), h( 'span', - { class: 'helper-text' }, - t(helper.status === 'waiting_for_tasks' ? 'helper.waitingForTasks' : helper.status === 'queued' ? 'helper.queued' : 'helper.running', { name: bot?.name ?? 'Bot', title: helper.title }), + { class: 'helper-main' }, + h('span', { class: 'helper-title' }, helper.title || helper.task || ''), + h( + 'span', + { class: 'helper-meta' }, + h('span', { class: 'helper-who' }, bot?.name ?? 'Bot'), + h('span', { class: 'helper-state' }, t(`helper.state.${phase}`)), + helper.status === 'queued' ? null : h('span', { class: 'helper-time' }, helperElapsed(helper)), + ), ), - h('span', { class: 'helper-time' }, helper.status === 'queued' ? '' : helperElapsed(helper)), h( 'button', { type: 'button', - class: 'btn sm', + class: 'helper-stop', + title: t('helper.stop'), + 'aria-label': t('helper.stop'), onclick: guard(async () => { await api('POST', `/api/${helper.batchId ? 'tasks' : 'helpers'}/${helper.id}/stop`); }), @@ -1259,6 +1286,21 @@ function renderHelpers() { ), ); }), + hidden > 0 || (helpersExpanded && queued.length > HELPER_QUEUE_PREVIEW) + ? h( + 'button', + { + type: 'button', + class: 'helper-more soft', + 'aria-expanded': String(helpersExpanded), + onclick: () => { + helpersExpanded = !helpersExpanded; + renderHelpers(); + }, + }, + hidden > 0 ? t('helper.more', { n: hidden }) : t('helper.collapse'), + ) + : null, ); } setInterval(() => { diff --git a/plugins/minimax-ai/minimax-bot/miniapp/client/strings-app.js b/plugins/minimax-ai/minimax-bot/miniapp/client/strings-app.js index 0e84843..c6ddbde 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/client/strings-app.js +++ b/plugins/minimax-ai/minimax-bot/miniapp/client/strings-app.js @@ -189,13 +189,15 @@ export default { 'live.startHelper': '正在放到后台:{name}', 'live.listHelpers': '正在查看后台任务', 'live.stopHelper': '正在停止后台任务', - 'helper.waitingForTasks': '{name} · 等待子任务:{title}', 'helper.strip': '后台任务', - 'helper.running': '{name} 正在做「{title}」,你可以继续聊', - 'helper.queued': '{name} 的任务在排队「{title}」', + 'helper.state.running': '执行中', + 'helper.state.waiting': '等待子任务', + 'helper.state.queued': '排队中', 'helper.justStarted': '刚开始', 'helper.minutes': '{n} 分钟', 'helper.stop': '停止', + 'helper.more': '还有 {n} 个任务在排队', + 'helper.collapse': '收起队列', 'event.helperStarted': '开始了:{title}', 'event.helper.done': '做完了:{title}', 'event.helper.blocked': '卡住了,需要你定一下:{title}', @@ -458,13 +460,15 @@ export default { 'live.startHelper': 'Moving to the background: {name}', 'live.listHelpers': 'Checking background tasks', 'live.stopHelper': 'Stopping a background task', - 'helper.waitingForTasks': '{name} · Awaiting dependencies: {title}', 'helper.strip': 'Background tasks', - 'helper.running': '{name} is working on "{title}" — you can keep chatting', - 'helper.queued': '{name} has this queued: "{title}"', + 'helper.state.running': 'Running', + 'helper.state.waiting': 'Waiting for subtasks', + 'helper.state.queued': 'Queued', 'helper.justStarted': 'just started', 'helper.minutes': '{n} min', 'helper.stop': 'Stop', + 'helper.more': '{n} more tasks queued', + 'helper.collapse': 'Collapse queue', 'event.helperStarted': 'Started: {title}', 'event.helper.done': 'Finished: {title}', 'event.helper.blocked': 'Stuck and needs your decision: {title}', diff --git a/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs b/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs index 2d4cf8a..c68f562 100644 --- a/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs +++ b/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs @@ -677,3 +677,40 @@ test('group execution records keep each reply linked to its own session and part await reset(); } }); + +test('the background task strip leads with the task, separates its state and folds a long queue', async () => { + await reset(); + const chat = 'bot:bot_a'; + const queued = Array.from({ length: 5 }, (_, index) => ({ + id: `hlp_q${index}`, botId: 'bot_a', chatId: chat, title: `Queued task ${index + 1}`, task: `Queued task ${index + 1}`, + status: 'queued', createdAt: Date.now(), + })); + emit({ + type: 'helpers.changed', + helpers: [ + { id: 'hlp_run', botId: 'bot_a', chatId: chat, title: 'Rebuild the truth table', task: 'Rebuild the truth table in the verifier', status: 'running', startedAt: Date.now() - 60000 }, + { id: 'hlp_wait', botId: 'bot_b', chatId: chat, title: 'Collect the samples', task: 'Collect the samples', status: 'waiting_for_tasks', createdAt: Date.now() - 120000 }, + ...queued, + ], + }); + await settle(); + const strip = document.querySelector('.helpers'); + const rows = strip.querySelectorAll('.helper-row'); + // What runs comes first, then what waits, then the queue. + assert.deepEqual([...rows].map((row) => row.className), [ + 'helper-row is-running', 'helper-row is-waiting', 'helper-row is-queued', 'helper-row is-queued', 'helper-row is-queued', + ]); + // The task leads the row; its owner, state and elapsed time sit below it. + const running = rows[0]; + assert.equal(running.querySelector('.helper-title').textContent, 'Rebuild the truth table'); + assert.equal(running.querySelector('.helper-who').textContent, 'Rob'); + assert.equal(running.querySelector('.helper-state').textContent, 'Running'); + assert.ok(running.querySelector('.helper-time').textContent); + // A long queue folds into one line instead of filling the strip. + const more = strip.querySelector('.helper-more'); + assert.match(more.textContent, /2 more tasks queued/); + more.click(); + await settle(); + assert.equal(strip.querySelectorAll('.helper-row').length, 7); + assert.equal(strip.querySelector('.helper-more').textContent, 'Collapse queue'); +}); \ No newline at end of file From c8e53f47ddcf2b5076c5fe756bbfab53de97fbf0 Mon Sep 17 00:00:00 2001 From: 4Dim Date: Wed, 21 Oct 2026 08:26:39 +0800 Subject: [PATCH 3/8] fix(minimax-bot): render a reply as one bubble instead of one per recorded part The Host opens a text part for every assistant turn between two tool calls, so a reply that ran a dozen tools painted a bubble per part. A real group message in the CodeMap chat has 118 text parts, and the bubbles filled the screen so the conversation above was out of reach. Join the text parts of a message into one bubble, the way the server joins them when it stores the message text. The reaction badge follows that single bubble. --- plugins/minimax-ai/minimax-bot/README.md | 1 + .../minimax-bot/miniapp/client/app.js | 32 +++++++++------ .../minimax-bot/miniapp/client/reactions.js | 13 ++---- .../tests/client-reliability.test.mjs | 41 ++++++++++++++----- 4 files changed, 55 insertions(+), 32 deletions(-) diff --git a/plugins/minimax-ai/minimax-bot/README.md b/plugins/minimax-ai/minimax-bot/README.md index 0855711..d3fc3fb 100644 --- a/plugins/minimax-ai/minimax-bot/README.md +++ b/plugins/minimax-ai/minimax-bot/README.md @@ -28,6 +28,7 @@ Copy this directory, including the hidden `.minimax-plugin/` directory, into the - **Language**: English and Chinese. By default it follows the operating system's preferred language (the first English or Chinese entry in `navigator.languages`; other languages fall back to English), or you can fix it under Settings → General → Language. The interface, errors, system notices, Chief's default setup, the context Bots receive and the MCP tool descriptions all switch; replies the Bots wrote and text you entered stay as they are. - **Reactions**: react to finished Bot replies in private and group chats. The quick bar offers 👍 ❤️ 🙌 😂 😭 😮, and more emoji can be browsed by category or searched in English or Chinese. Each message holds one reaction: picking a new one replaces it, and picking the same one again removes it. Reactions are saved locally, survive refreshes and restarts, and sync to other open windows. They do not trigger a Bot reply and are not sent to the model. - **Reply actions**: the reaction, copy, quote-reply and open-session buttons sit at the bottom-left of a reply, under its last line. A long reply therefore keeps its actions within reach at the end of the text instead of pushing them off the top of the message. +- **One bubble per reply**: a reply stays a single message bubble, however many tools it used. The Host records a text part for every turn the Bot took between two tool calls; those parts are joined into that bubble, one paragraph each, so a long working reply no longer fills the screen with dozens of small bubbles. ## 0.7.0 Reliable delegation and result delivery diff --git a/plugins/minimax-ai/minimax-bot/miniapp/client/app.js b/plugins/minimax-ai/minimax-bot/miniapp/client/app.js index f8924ca..d1d1309 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/client/app.js +++ b/plugins/minimax-ai/minimax-bot/miniapp/client/app.js @@ -939,30 +939,38 @@ function legacyParts(m) { return parts; } -/** Keep part order exactly as recorded; matching prefixes are not evidence of duplication. */ +/** + * One reply is one bubble. The Host opens a new text part for every assistant turn between two + * tool calls, so a reply that ran a dozen tools used to paint a bubble per part and pushed the + * conversation above it off the screen. The parts are joined the way the server joins them when + * it stores the text of a message; the event lines keep their place in the order. + */ function partViews(m, inGroup) { const parts = m.key ? [{ type: 'text', text: displayText(m) }] : m.parts ?? legacyParts(m); - const lastText = parts.findLastIndex((part) => part.type === 'text' && part.text?.trim()); + const texts = parts.filter((part) => part.type === 'text' && part.text?.trim()); + const joined = texts.map((part) => part.text).join('\n\n'); const views = []; const presentation = presentationKey(); + let bubblePlaced = false; parts.forEach((part, index) => { - const key = `part:${index}`; if (part.type === 'text' && part.text?.trim()) { + if (bubblePlaced) return; + bubblePlaced = true; views.push({ - key, - signature: JSON.stringify([part.text, presentation, index === lastText ? [m.status, m.userReactions, m.reactionsRevision, displayText(m), m.sessionId, botById(m.botId)?.name] : null]), - create: () => updateBubbleControls(h('div', { class: 'bubble' }, markdownNode(part.text, m.chatId)), m, index === lastText), + key: 'bubble', + signature: JSON.stringify([joined, presentation, [m.status, m.userReactions, m.reactionsRevision, displayText(m), m.sessionId, botById(m.botId)?.name]]), + create: () => updateBubbleControls(h('div', { class: 'bubble' }, markdownNode(joined, m.chatId)), m), update: (bubble) => { - markdownNode(part.text, m.chatId, bubble.firstElementChild); - return updateBubbleControls(bubble, m, index === lastText); + markdownNode(joined, m.chatId, bubble.firstElementChild); + return updateBubbleControls(bubble, m); }, }); } else if (part.type === 'event') { const node = eventLine(part, m); - if (node) views.push({ key, signature: JSON.stringify([part, presentation]), create: () => node }); + if (node) views.push({ key: `event:${index}`, signature: JSON.stringify([part, presentation]), create: () => node }); } }); - if (lastText < 0) views.push({ key: 'reactions', signature: JSON.stringify([m.status, m.userReactions, m.reactionsRevision, locale()]), create: () => reactionBar(m) }); + if (!texts.length) views.push({ key: 'reactions', signature: JSON.stringify([m.status, m.userReactions, m.reactionsRevision, locale()]), create: () => reactionBar(m) }); if (m.status === 'streaming') { const running = [...parts].reverse().find((p) => p.type === 'tool' && p.state === 'running'); const lastText = parts.at(-1)?.type === 'text' && parts.at(-1)?.live; @@ -973,8 +981,8 @@ function partViews(m, inGroup) { } /** The bubble carries the reply text and the reaction badge; the action row lives below it. */ -function updateBubbleControls(bubble, m, isLast) { - updateBubbleReactions(bubble, m, isLast); +function updateBubbleControls(bubble, m) { + updateBubbleReactions(bubble, m); return bubble; } diff --git a/plugins/minimax-ai/minimax-bot/miniapp/client/reactions.js b/plugins/minimax-ai/minimax-bot/miniapp/client/reactions.js index 5d082b8..ed69229 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/client/reactions.js +++ b/plugins/minimax-ai/minimax-bot/miniapp/client/reactions.js @@ -50,17 +50,10 @@ function replaceBar(bar, m) { } } -/** Keep the badge on the final text bubble without replacing its text or media. */ -export function updateBubbleReactions(bubble, m, isLast) { +/** Keep the badge on the bubble of the reply without replacing its text or media. */ +export function updateBubbleReactions(bubble, m) { const bar = bubble.querySelector(':scope > .reaction-bar'); - if (!isLast) { - // The reply gained a new final part, so the badge moves to it. The picker opened from - // this bubble's action row would otherwise stay open over a reply that was replaced. - if (bar) { - bar.remove(); - closeReactionPicker(); - } - } else if (bar) replaceBar(bar, m); + if (bar) replaceBar(bar, m); else bubble.append(reactionBar(m)); } diff --git a/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs b/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs index c68f562..c155ee1 100644 --- a/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs +++ b/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs @@ -205,30 +205,50 @@ test('reconnect and unloaded older-page reactions are retained, while a chat res assert.equal(document.querySelector('.reaction-chip'), null); }); -test('reactions stay on the final bubble while message parts change, preserving media and removal focus', async () => { +test('the badge stays on the bubble of the reply while its parts change, preserving media and removal focus', async () => { let item = message('reaction-bubbles', 'First\nSecond', { parts: [{ type: 'text', text: '\nFirst' }, { type: 'text', text: 'Second' }], userReactions: ['👍'], reactionsRevision: 1, }); await reset([item]); const video = document.querySelector('video'); + // One reply is one bubble, however many parts the Host recorded for it. const bubbles = document.querySelectorAll('.bubble'); - assert.equal(bubbles[0].querySelector('.reaction-bar'), null); - assert.equal(bubbles[1].querySelector('.reaction-chip').dataset.emoji, '👍'); + assert.equal(bubbles.length, 1); + assert.equal(bubbles[0].querySelector('.reaction-chip').dataset.emoji, '👍'); + assert.match(bubbles[0].textContent, /First/); + assert.match(bubbles[0].textContent, /Second/); document.querySelector('.reaction-trigger').click(); document.querySelector('[aria-label="More emoji"]').click(); item = { ...item, parts: [...item.parts, { type: 'text', text: 'Final' }] }; emit({ type: 'message.upsert', chatId: item.chatId, message: item }); + // The reply grows inside the same bubble: one badge, and the player keeps running. + assert.equal(document.querySelectorAll('.bubble').length, 1); assert.equal(document.querySelectorAll('.reaction-bar').length, 1); assert.match(document.querySelector('.reaction-bar').parentElement.textContent, /Final/); assert.equal(document.querySelector('video'), video); - assert.equal(document.querySelector('.reaction-picker'), null); - assert.equal(document.activeElement, document.querySelector('.reaction-trigger')); document.querySelector('.reaction-chip').focus(); emit({ type: 'message.reactions', chatId: item.chatId, messageId: item.id, userReactions: [], reactionsRevision: 2 }); assert.equal(document.activeElement, document.querySelector('.reaction-trigger')); }); +test('a reply that ran many tools is one bubble, not a bubble per recorded part', async () => { + const parts = []; + for (let index = 0; index < 40; index += 1) { + parts.push({ type: 'text', text: `Step ${index}` }, { type: 'tool', name: 'read', state: 'done' }); + } + const item = message('m-many-parts', 'Step 39', { parts, userReactions: ['👍'], reactionsRevision: 1 }); + await reset([item]); + // The Host opens a text part per assistant turn; the reply still reads as one message. + assert.equal(document.querySelectorAll('.bubble').length, 1); + const bubble = document.querySelector('.bubble'); + assert.match(bubble.textContent, /Step 0/); + assert.match(bubble.textContent, /Step 39/); + // Every part keeps its own paragraph, so the reply stays readable. + assert.equal(bubble.querySelectorAll('.md p').length, 40); + assert.equal(bubble.querySelector('.reaction-chip').dataset.emoji, '👍'); +}); + test('a multi-line reply keeps its actions below the text and its reaction indicator above them', async () => { const text = 'First line\nSecond line\n\nThird paragraph\nFourth line'; const item = message('m-multiline', text, { @@ -320,9 +340,10 @@ test('successive stream frames update existing paragraphs and finish rendering t item = { ...item, text, parts: [{ type: 'text', text, live: true }, { type: 'text', text: `After: ${text.split('\n')[0]}` }] }; emit({ type: 'message.upsert', chatId: item.chatId, message: item }); const bubbles = [...document.querySelectorAll('.bubble')]; - assert.equal(bubbles.length, 2); - assert.equal(bubbles[0].querySelector('p').textContent, text.split('\n')[0]); - assert.equal(bubbles[1].textContent.trim(), `After: ${text.split('\n')[0]}`); + assert.equal(bubbles.length, 1); + const paragraphs = [...bubbles[0].querySelectorAll('p')]; + assert.equal(paragraphs[0].textContent, text.split('\n')[0]); + assert.equal(paragraphs.at(-1).textContent, `After: ${text.split('\n')[0]}`); } item = { ...item, status: 'done' }; emit({ type: 'message.upsert', chatId: item.chatId, message: item }); @@ -359,9 +380,9 @@ test('stream updates preserve media, questionnaire input and expanded failure de emit({ type: 'message.upsert', chatId: item.chatId, message: item }); assert.equal(document.querySelector('video'), video); const bubbles = [...document.querySelectorAll('.bubble')].map((node) => node.textContent); - assert.equal(bubbles.length, 2); + assert.equal(bubbles.length, 1); assert.match(bubbles[0], /The shared prefix starts here/); - assert.match(bubbles[1], /later message with more tokens/); + assert.match(bubbles[0], /later message with more tokens/); details.querySelector('summary').focus(); item = { ...item, parts: [...item.parts, { type: 'tool', name: 'remember', state: 'ok' }] }; emit({ type: 'message.upsert', chatId: item.chatId, message: item }); From c41351168e57d40a5306e1db71b2ea8ca05ae71a Mon Sep 17 00:00:00 2001 From: 4Dim Date: Wed, 21 Oct 2026 08:46:42 +0800 Subject: [PATCH 4/8] fix(minimax-bot): keep the task strip empty instead of printing null replaceChildren renders a null argument as the text "null", so every chat without a background job showed that word above the composer. The fold button is appended on its own now, and only when there is something to fold. --- .../minimax-bot/miniapp/client/app.js | 34 +++++++++++-------- .../tests/client-reliability.test.mjs | 2 ++ 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/plugins/minimax-ai/minimax-bot/miniapp/client/app.js b/plugins/minimax-ai/minimax-bot/miniapp/client/app.js index d1d1309..aba094b 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/client/app.js +++ b/plugins/minimax-ai/minimax-bot/miniapp/client/app.js @@ -1294,22 +1294,26 @@ function renderHelpers() { ), ); }), - hidden > 0 || (helpersExpanded && queued.length > HELPER_QUEUE_PREVIEW) - ? h( - 'button', - { - type: 'button', - class: 'helper-more soft', - 'aria-expanded': String(helpersExpanded), - onclick: () => { - helpersExpanded = !helpersExpanded; - renderHelpers(); - }, - }, - hidden > 0 ? t('helper.more', { n: hidden }) : t('helper.collapse'), - ) - : null, ); + // `replaceChildren` turns a null argument into the text "null", so the fold button is + // appended on its own and only when there is something to fold. + if (hidden > 0 || (helpersExpanded && queued.length > HELPER_QUEUE_PREVIEW)) { + ui.helpers.append( + h( + 'button', + { + type: 'button', + class: 'helper-more soft', + 'aria-expanded': String(helpersExpanded), + onclick: () => { + helpersExpanded = !helpersExpanded; + renderHelpers(); + }, + }, + hidden > 0 ? t('helper.more', { n: hidden }) : t('helper.collapse'), + ), + ); + } } setInterval(() => { if (mounted && state.helpers.some((x) => x.chatId === mounted && x.status === 'running')) renderHelpers(); diff --git a/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs b/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs index c155ee1..26d4a85 100644 --- a/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs +++ b/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs @@ -701,6 +701,8 @@ test('group execution records keep each reply linked to its own session and part test('the background task strip leads with the task, separates its state and folds a long queue', async () => { await reset(); + // Nothing is running: the strip stays empty instead of printing a stray "null". + assert.equal(document.querySelector('.helpers').textContent, ''); const chat = 'bot:bot_a'; const queued = Array.from({ length: 5 }, (_, index) => ({ id: `hlp_q${index}`, botId: 'bot_a', chatId: chat, title: `Queued task ${index + 1}`, task: `Queued task ${index + 1}`, From 4d3b25836838851a2023ee4f86e17c8d3e44adc6 Mon Sep 17 00:00:00 2001 From: 4Dim Date: Wed, 21 Oct 2026 10:05:49 +0800 Subject: [PATCH 5/8] fix(minimax-bot): fold the earlier bubbles of a long reply behind a scrollable summary The Host records a text part for every assistant turn between two tool calls, so a reply that worked for a while arrives with dozens of parts, and one bubble per part filled the screen: a real message in our own Bot history carried 479 parts and 118 text segments. The earlier bubbles keep their own shape, but they move into a folded area under the reply: one line says how many of them there are, and opening it shows them in a fixed height area with its own scrollbar, so the conversation above stays in reach. The answer itself is never folded, and a reply with a few parts is unchanged. This replaces the earlier attempt to join the parts into a single bubble: that changed how a reply reads compared to the rest of the app, and the fold keeps the original bubbles. --- plugins/minimax-ai/minimax-bot/README.md | 2 +- .../minimax-bot/miniapp/client/app.css | 49 +++++++++++ .../minimax-bot/miniapp/client/app.js | 88 +++++++++++++++---- .../minimax-bot/miniapp/client/reactions.js | 13 ++- .../minimax-bot/miniapp/client/strings-app.js | 4 + .../tests/client-reliability.test.mjs | 73 +++++++++------ 6 files changed, 181 insertions(+), 48 deletions(-) diff --git a/plugins/minimax-ai/minimax-bot/README.md b/plugins/minimax-ai/minimax-bot/README.md index d3fc3fb..f0fd24b 100644 --- a/plugins/minimax-ai/minimax-bot/README.md +++ b/plugins/minimax-ai/minimax-bot/README.md @@ -28,7 +28,7 @@ Copy this directory, including the hidden `.minimax-plugin/` directory, into the - **Language**: English and Chinese. By default it follows the operating system's preferred language (the first English or Chinese entry in `navigator.languages`; other languages fall back to English), or you can fix it under Settings → General → Language. The interface, errors, system notices, Chief's default setup, the context Bots receive and the MCP tool descriptions all switch; replies the Bots wrote and text you entered stay as they are. - **Reactions**: react to finished Bot replies in private and group chats. The quick bar offers 👍 ❤️ 🙌 😂 😭 😮, and more emoji can be browsed by category or searched in English or Chinese. Each message holds one reaction: picking a new one replaces it, and picking the same one again removes it. Reactions are saved locally, survive refreshes and restarts, and sync to other open windows. They do not trigger a Bot reply and are not sent to the model. - **Reply actions**: the reaction, copy, quote-reply and open-session buttons sit at the bottom-left of a reply, under its last line. A long reply therefore keeps its actions within reach at the end of the text instead of pushing them off the top of the message. -- **One bubble per reply**: a reply stays a single message bubble, however many tools it used. The Host records a text part for every turn the Bot took between two tool calls; those parts are joined into that bubble, one paragraph each, so a long working reply no longer fills the screen with dozens of small bubbles. +- **Folded working messages**: the Host records a text part for every turn a Bot takes between two tool calls, so a reply that worked for a while arrives with many parts. The earlier bubbles keep their own shape, but they move into a folded area under the reply: one line says how many of them there are, and opening it shows them in a fixed height area with its own scrollbar, so the conversation above stays in reach. The answer itself is never folded. ## 0.7.0 Reliable delegation and result delivery diff --git a/plugins/minimax-ai/minimax-bot/miniapp/client/app.css b/plugins/minimax-ai/minimax-bot/miniapp/client/app.css index 026ad8c..deee898 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/client/app.css +++ b/plugins/minimax-ai/minimax-bot/miniapp/client/app.css @@ -819,6 +819,55 @@ button { max-width: 100%; } +/* The earlier bubbles of a long reply. The Host records a text part per assistant turn, so a + reply that ran a dozen tools arrives with dozens of bubbles; they live here instead, behind a + one line summary. The area that opens has a fixed height and its own scrollbar, so opening + it never makes the feed grow. */ +.bubble-fold { + max-width: min(88%, 680px); + margin: 2px 0; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--tag); + overflow: hidden; +} + +.bubble-fold > summary { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + font-size: 12.5px; + color: var(--muted); + cursor: pointer; + list-style: none; + user-select: none; +} + +.bubble-fold > summary::-webkit-details-marker { display: none; } +.bubble-fold > summary:hover { color: var(--ink); background: var(--hover); } +.bubble-fold > summary svg { flex: none; transition: transform 120ms ease; } +.bubble-fold[open] > summary svg { transform: rotate(180deg); } + +.bubble-fold-closed { display: inline; } +.bubble-fold-open { display: none; } +.bubble-fold[open] > summary .bubble-fold-closed { display: none; } +.bubble-fold[open] > summary .bubble-fold-open { display: inline; } + +/* The fixed height with a scrollbar is the point of the fold: the bubbles inside it scroll, + the feed around it does not. */ +.bubble-fold-body { + max-height: min(320px, 40vh); + overflow-y: auto; + overscroll-behavior: contain; + padding: 2px 10px 10px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.bubble-fold-body .bubble { max-width: 100%; } + .msg.user .bubble-wrap { align-items: flex-end; /* The width bound belongs here, not on .bubble: this wrapper is shrink-to-fit, so a diff --git a/plugins/minimax-ai/minimax-bot/miniapp/client/app.js b/plugins/minimax-ai/minimax-bot/miniapp/client/app.js index aba094b..45ad9c9 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/client/app.js +++ b/plugins/minimax-ai/minimax-bot/miniapp/client/app.js @@ -940,37 +940,87 @@ function legacyParts(m) { } /** - * One reply is one bubble. The Host opens a new text part for every assistant turn between two - * tool calls, so a reply that ran a dozen tools used to paint a bubble per part and pushed the - * conversation above it off the screen. The parts are joined the way the server joins them when - * it stores the text of a message; the event lines keep their place in the order. + * More text parts than this and the earlier bubbles are folded away. The Host opens a text part + * for every assistant turn between two tool calls, so a reply that worked for a while arrives + * with dozens of them, and one bubble per part filled the screen. */ +const FOLD_AFTER_PARTS = 3; + +/** + * The earlier bubbles of a long reply: folded behind a one line summary, and the area that + * opens has a fixed height with its own scrollbar, so the feed keeps its length. + */ +function foldView(m, parts, presentation) { + const inner = () => + parts.map((part, index) => ({ + key: `text:${index}`, + signature: JSON.stringify([part.text, presentation]), + create: () => h('div', { class: 'bubble' }, markdownNode(part.text, m.chatId)), + update: (bubble) => { + markdownNode(part.text, m.chatId, bubble.firstElementChild); + return bubble; + }, + })); + const body = h('div', { class: 'bubble-fold-body' }); + reconcileChildren(body, inner()); + return { + key: 'fold', + signature: JSON.stringify([parts.map((part) => part.text), presentation, locale()]), + create: () => + h( + 'details', + { class: 'bubble-fold' }, + h( + 'summary', + { class: 'bubble-fold-summary' }, + icon('down', 12), + h('span', { class: 'bubble-fold-closed' }, t('fold.show', { n: parts.length })), + h('span', { class: 'bubble-fold-open' }, t('fold.hide')), + ), + body, + ), + update: (node) => { + reconcileChildren(node.querySelector('.bubble-fold-body'), inner()); + const closed = node.querySelector('.bubble-fold-closed'); + if (closed) closed.textContent = t('fold.show', { n: parts.length }); + return node; + }, + }; +} + +/** Keep part order exactly as recorded; matching prefixes are not evidence of duplication. */ function partViews(m, inGroup) { const parts = m.key ? [{ type: 'text', text: displayText(m) }] : m.parts ?? legacyParts(m); - const texts = parts.filter((part) => part.type === 'text' && part.text?.trim()); - const joined = texts.map((part) => part.text).join('\n\n'); + const lastText = parts.findLastIndex((part) => part.type === 'text' && part.text?.trim()); const views = []; const presentation = presentationKey(); - let bubblePlaced = false; + const textParts = []; + parts.forEach((part, index) => { + if (part.type === 'text' && part.text?.trim()) textParts.push(index); + }); + // The earlier bubbles go into the folded area; the last one, the answer, stays in the open. + const folded = textParts.length > FOLD_AFTER_PARTS ? textParts.slice(0, -1) : []; + const foldedSet = new Set(folded); + if (folded.length) views.push(foldView(m, folded.map((index) => parts[index]), presentation)); parts.forEach((part, index) => { + const key = `part:${index}`; if (part.type === 'text' && part.text?.trim()) { - if (bubblePlaced) return; - bubblePlaced = true; + if (foldedSet.has(index)) return; views.push({ - key: 'bubble', - signature: JSON.stringify([joined, presentation, [m.status, m.userReactions, m.reactionsRevision, displayText(m), m.sessionId, botById(m.botId)?.name]]), - create: () => updateBubbleControls(h('div', { class: 'bubble' }, markdownNode(joined, m.chatId)), m), + key, + signature: JSON.stringify([part.text, presentation, index === lastText ? [m.status, m.userReactions, m.reactionsRevision, displayText(m), m.sessionId, botById(m.botId)?.name] : null]), + create: () => updateBubbleControls(h('div', { class: 'bubble' }, markdownNode(part.text, m.chatId)), m, index === lastText), update: (bubble) => { - markdownNode(joined, m.chatId, bubble.firstElementChild); - return updateBubbleControls(bubble, m); + markdownNode(part.text, m.chatId, bubble.firstElementChild); + return updateBubbleControls(bubble, m, index === lastText); }, }); } else if (part.type === 'event') { const node = eventLine(part, m); - if (node) views.push({ key: `event:${index}`, signature: JSON.stringify([part, presentation]), create: () => node }); + if (node) views.push({ key, signature: JSON.stringify([part, presentation]), create: () => node }); } }); - if (!texts.length) views.push({ key: 'reactions', signature: JSON.stringify([m.status, m.userReactions, m.reactionsRevision, locale()]), create: () => reactionBar(m) }); + if (lastText < 0) views.push({ key: 'reactions', signature: JSON.stringify([m.status, m.userReactions, m.reactionsRevision, locale()]), create: () => reactionBar(m) }); if (m.status === 'streaming') { const running = [...parts].reverse().find((p) => p.type === 'tool' && p.state === 'running'); const lastText = parts.at(-1)?.type === 'text' && parts.at(-1)?.live; @@ -981,8 +1031,8 @@ function partViews(m, inGroup) { } /** The bubble carries the reply text and the reaction badge; the action row lives below it. */ -function updateBubbleControls(bubble, m) { - updateBubbleReactions(bubble, m); +function updateBubbleControls(bubble, m, isLast) { + updateBubbleReactions(bubble, m, isLast); return bubble; } @@ -1295,7 +1345,7 @@ function renderHelpers() { ); }), ); - // `replaceChildren` turns a null argument into the text "null", so the fold button is + // `replaceChildren` turns a null argument into the text "null", so the queue button is // appended on its own and only when there is something to fold. if (hidden > 0 || (helpersExpanded && queued.length > HELPER_QUEUE_PREVIEW)) { ui.helpers.append( diff --git a/plugins/minimax-ai/minimax-bot/miniapp/client/reactions.js b/plugins/minimax-ai/minimax-bot/miniapp/client/reactions.js index ed69229..5d082b8 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/client/reactions.js +++ b/plugins/minimax-ai/minimax-bot/miniapp/client/reactions.js @@ -50,10 +50,17 @@ function replaceBar(bar, m) { } } -/** Keep the badge on the bubble of the reply without replacing its text or media. */ -export function updateBubbleReactions(bubble, m) { +/** Keep the badge on the final text bubble without replacing its text or media. */ +export function updateBubbleReactions(bubble, m, isLast) { const bar = bubble.querySelector(':scope > .reaction-bar'); - if (bar) replaceBar(bar, m); + if (!isLast) { + // The reply gained a new final part, so the badge moves to it. The picker opened from + // this bubble's action row would otherwise stay open over a reply that was replaced. + if (bar) { + bar.remove(); + closeReactionPicker(); + } + } else if (bar) replaceBar(bar, m); else bubble.append(reactionBar(m)); } diff --git a/plugins/minimax-ai/minimax-bot/miniapp/client/strings-app.js b/plugins/minimax-ai/minimax-bot/miniapp/client/strings-app.js index c6ddbde..3ecb8f1 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/client/strings-app.js +++ b/plugins/minimax-ai/minimax-bot/miniapp/client/strings-app.js @@ -198,6 +198,8 @@ export default { 'helper.stop': '停止', 'helper.more': '还有 {n} 个任务在排队', 'helper.collapse': '收起队列', + 'fold.show': '展开 {n} 条过程消息', + 'fold.hide': '收起过程消息', 'event.helperStarted': '开始了:{title}', 'event.helper.done': '做完了:{title}', 'event.helper.blocked': '卡住了,需要你定一下:{title}', @@ -469,6 +471,8 @@ export default { 'helper.stop': 'Stop', 'helper.more': '{n} more tasks queued', 'helper.collapse': 'Collapse queue', + 'fold.show': 'Show {n} earlier messages', + 'fold.hide': 'Hide earlier messages', 'event.helperStarted': 'Started: {title}', 'event.helper.done': 'Finished: {title}', 'event.helper.blocked': 'Stuck and needs your decision: {title}', diff --git a/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs b/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs index 26d4a85..d72e8f8 100644 --- a/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs +++ b/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs @@ -205,48 +205,72 @@ test('reconnect and unloaded older-page reactions are retained, while a chat res assert.equal(document.querySelector('.reaction-chip'), null); }); -test('the badge stays on the bubble of the reply while its parts change, preserving media and removal focus', async () => { +test('reactions stay on the final bubble while message parts change, preserving media and removal focus', async () => { let item = message('reaction-bubbles', 'First\nSecond', { parts: [{ type: 'text', text: '\nFirst' }, { type: 'text', text: 'Second' }], userReactions: ['👍'], reactionsRevision: 1, }); await reset([item]); const video = document.querySelector('video'); - // One reply is one bubble, however many parts the Host recorded for it. const bubbles = document.querySelectorAll('.bubble'); - assert.equal(bubbles.length, 1); - assert.equal(bubbles[0].querySelector('.reaction-chip').dataset.emoji, '👍'); - assert.match(bubbles[0].textContent, /First/); - assert.match(bubbles[0].textContent, /Second/); + assert.equal(bubbles[0].querySelector('.reaction-bar'), null); + assert.equal(bubbles[1].querySelector('.reaction-chip').dataset.emoji, '👍'); document.querySelector('.reaction-trigger').click(); document.querySelector('[aria-label="More emoji"]').click(); item = { ...item, parts: [...item.parts, { type: 'text', text: 'Final' }] }; emit({ type: 'message.upsert', chatId: item.chatId, message: item }); - // The reply grows inside the same bubble: one badge, and the player keeps running. - assert.equal(document.querySelectorAll('.bubble').length, 1); assert.equal(document.querySelectorAll('.reaction-bar').length, 1); assert.match(document.querySelector('.reaction-bar').parentElement.textContent, /Final/); assert.equal(document.querySelector('video'), video); + assert.equal(document.querySelector('.reaction-picker'), null); + assert.equal(document.activeElement, document.querySelector('.reaction-trigger')); document.querySelector('.reaction-chip').focus(); emit({ type: 'message.reactions', chatId: item.chatId, messageId: item.id, userReactions: [], reactionsRevision: 2 }); assert.equal(document.activeElement, document.querySelector('.reaction-trigger')); }); -test('a reply that ran many tools is one bubble, not a bubble per recorded part', async () => { +test('a long reply folds its earlier bubbles away and keeps the answer in the open', async () => { const parts = []; - for (let index = 0; index < 40; index += 1) { + for (let index = 0; index < 12; index += 1) { parts.push({ type: 'text', text: `Step ${index}` }, { type: 'tool', name: 'read', state: 'done' }); } - const item = message('m-many-parts', 'Step 39', { parts, userReactions: ['👍'], reactionsRevision: 1 }); + parts.push({ type: 'text', text: 'The answer' }); + const item = message('m-long-reply', 'The answer', { parts, userReactions: ['👍'], reactionsRevision: 1 }); await reset([item]); - // The Host opens a text part per assistant turn; the reply still reads as one message. - assert.equal(document.querySelectorAll('.bubble').length, 1); - const bubble = document.querySelector('.bubble'); - assert.match(bubble.textContent, /Step 0/); - assert.match(bubble.textContent, /Step 39/); - // Every part keeps its own paragraph, so the reply stays readable. - assert.equal(bubble.querySelectorAll('.md p').length, 40); - assert.equal(bubble.querySelector('.reaction-chip').dataset.emoji, '👍'); + // The earlier bubbles keep their own shape; they move into the folded area. + const fold = document.querySelector('.bubble-fold'); + assert.ok(fold, 'a reply with many parts must fold its earlier bubbles'); + assert.equal(fold.open, false, 'the fold starts closed'); + assert.match(fold.querySelector('.bubble-fold-closed').textContent, /12/); + assert.equal(fold.querySelectorAll('.bubble').length, 12); + // The answer stays in the open, with the reaction indicator on it. + const wrap = document.querySelector('article.msg .bubble-wrap'); + const open = [...wrap.children].filter((child) => child.classList.contains('bubble')); + assert.equal(open.length, 1); + assert.match(open[0].textContent, /The answer/); + assert.equal(open[0].querySelector('.reaction-chip').dataset.emoji, '👍'); + // Opening the fold shows the same bubbles again; the reply around it does not move. + fold.open = true; + assert.equal(fold.querySelectorAll('.bubble').length, 12); + assert.equal(wrap.querySelectorAll('.bubble').length, 13); +}); + +test('a short reply keeps its bubbles in the open, without a fold', async () => { + const item = message('m-short-reply', 'Second', { + parts: [{ type: 'text', text: 'First' }, { type: 'text', text: 'Second' }], + }); + await reset([item]); + assert.equal(document.querySelector('.bubble-fold'), null); + assert.equal(document.querySelectorAll('article.msg .bubble').length, 2); +}); + +test('the folded area keeps a fixed height and scrolls inside itself', async () => { + const css = await readFile(new URL('../miniapp/client/app.css', import.meta.url), 'utf8'); + const rule = css.match(/\n\.bubble-fold-body \{[^}]*\}/)?.[0] ?? ''; + assert.ok(rule, 'the folded area needs a rule of its own'); + // The point of the fold: the bubbles inside it scroll, the feed around it does not grow. + assert.match(rule, /max-height: min\(320px, 40vh\)/); + assert.match(rule, /overflow-y: auto/); }); test('a multi-line reply keeps its actions below the text and its reaction indicator above them', async () => { @@ -340,10 +364,9 @@ test('successive stream frames update existing paragraphs and finish rendering t item = { ...item, text, parts: [{ type: 'text', text, live: true }, { type: 'text', text: `After: ${text.split('\n')[0]}` }] }; emit({ type: 'message.upsert', chatId: item.chatId, message: item }); const bubbles = [...document.querySelectorAll('.bubble')]; - assert.equal(bubbles.length, 1); - const paragraphs = [...bubbles[0].querySelectorAll('p')]; - assert.equal(paragraphs[0].textContent, text.split('\n')[0]); - assert.equal(paragraphs.at(-1).textContent, `After: ${text.split('\n')[0]}`); + assert.equal(bubbles.length, 2); + assert.equal(bubbles[0].querySelector('p').textContent, text.split('\n')[0]); + assert.equal(bubbles[1].textContent.trim(), `After: ${text.split('\n')[0]}`); } item = { ...item, status: 'done' }; emit({ type: 'message.upsert', chatId: item.chatId, message: item }); @@ -380,9 +403,9 @@ test('stream updates preserve media, questionnaire input and expanded failure de emit({ type: 'message.upsert', chatId: item.chatId, message: item }); assert.equal(document.querySelector('video'), video); const bubbles = [...document.querySelectorAll('.bubble')].map((node) => node.textContent); - assert.equal(bubbles.length, 1); + assert.equal(bubbles.length, 2); assert.match(bubbles[0], /The shared prefix starts here/); - assert.match(bubbles[0], /later message with more tokens/); + assert.match(bubbles[1], /later message with more tokens/); details.querySelector('summary').focus(); item = { ...item, parts: [...item.parts, { type: 'tool', name: 'remember', state: 'ok' }] }; emit({ type: 'message.upsert', chatId: item.chatId, message: item }); From 3f74c9d09a5edad9496eb0041df32c9cc400605b Mon Sep 17 00:00:00 2001 From: 4Dim Date: Wed, 21 Oct 2026 10:32:25 +0800 Subject: [PATCH 6/8] fix(minimax-bot): keep the newest three bubbles of a long reply in the open The fold took every bubble but the answer out of sight, so a collapsed reply showed nothing of the work that produced it. The newest three bubbles now stay where the flat layout put them, and only the earlier ones fold behind the summary. A bubble of such a reply also tells on hover what it said, the way the task rows and the attachments do. A short reply keeps the official markup untouched. --- plugins/minimax-ai/minimax-bot/README.md | 2 +- .../minimax-bot/miniapp/client/app.js | 27 +++++++++---- .../minimax-bot/miniapp/client/strings-app.js | 2 + .../tests/client-reliability.test.mjs | 38 +++++++++++++++---- 4 files changed, 52 insertions(+), 17 deletions(-) diff --git a/plugins/minimax-ai/minimax-bot/README.md b/plugins/minimax-ai/minimax-bot/README.md index f0fd24b..07010b9 100644 --- a/plugins/minimax-ai/minimax-bot/README.md +++ b/plugins/minimax-ai/minimax-bot/README.md @@ -28,7 +28,7 @@ Copy this directory, including the hidden `.minimax-plugin/` directory, into the - **Language**: English and Chinese. By default it follows the operating system's preferred language (the first English or Chinese entry in `navigator.languages`; other languages fall back to English), or you can fix it under Settings → General → Language. The interface, errors, system notices, Chief's default setup, the context Bots receive and the MCP tool descriptions all switch; replies the Bots wrote and text you entered stay as they are. - **Reactions**: react to finished Bot replies in private and group chats. The quick bar offers 👍 ❤️ 🙌 😂 😭 😮, and more emoji can be browsed by category or searched in English or Chinese. Each message holds one reaction: picking a new one replaces it, and picking the same one again removes it. Reactions are saved locally, survive refreshes and restarts, and sync to other open windows. They do not trigger a Bot reply and are not sent to the model. - **Reply actions**: the reaction, copy, quote-reply and open-session buttons sit at the bottom-left of a reply, under its last line. A long reply therefore keeps its actions within reach at the end of the text instead of pushing them off the top of the message. -- **Folded working messages**: the Host records a text part for every turn a Bot takes between two tool calls, so a reply that worked for a while arrives with many parts. The earlier bubbles keep their own shape, but they move into a folded area under the reply: one line says how many of them there are, and opening it shows them in a fixed height area with its own scrollbar, so the conversation above stays in reach. The answer itself is never folded. +- **Folded working messages**: the Host records a text part for every turn a Bot takes between two tool calls, so a reply that worked for a while arrives with many parts. The newest three bubbles stay in the open, exactly where the flat layout put them, the answer among them. The earlier ones keep their own shape but move into a folded area above them: one line says how many of them there are, and opening it shows them in a fixed height area with its own scrollbar, so the conversation above stays in reach. Hovering a bubble of such a reply tells what that bubble said, the way the task rows and the attachments do. ## 0.7.0 Reliable delegation and result delivery diff --git a/plugins/minimax-ai/minimax-bot/miniapp/client/app.js b/plugins/minimax-ai/minimax-bot/miniapp/client/app.js index 45ad9c9..f6397d6 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/client/app.js +++ b/plugins/minimax-ai/minimax-bot/miniapp/client/app.js @@ -940,12 +940,19 @@ function legacyParts(m) { } /** - * More text parts than this and the earlier bubbles are folded away. The Host opens a text part - * for every assistant turn between two tool calls, so a reply that worked for a while arrives - * with dozens of them, and one bubble per part filled the screen. + * The Host opens a text part for every assistant turn between two tool calls, so a reply that + * worked for a while arrives with dozens of them, and one bubble per part filled the screen. + * The newest bubbles stay in the open, exactly where the flat layout put them; the earlier ones + * fold away behind a one line summary. */ const FOLD_AFTER_PARTS = 3; +/** The bubble's own text, short enough to be a hover hint. */ +function hintOf(text) { + const flat = String(text ?? '').replace(/\s+/g, ' ').trim(); + return flat.length > 160 ? `${flat.slice(0, 160)}…` : flat; +} + /** * The earlier bubbles of a long reply: folded behind a one line summary, and the area that * opens has a fixed height with its own scrollbar, so the feed keeps its length. @@ -955,9 +962,10 @@ function foldView(m, parts, presentation) { parts.map((part, index) => ({ key: `text:${index}`, signature: JSON.stringify([part.text, presentation]), - create: () => h('div', { class: 'bubble' }, markdownNode(part.text, m.chatId)), + create: () => h('div', { class: 'bubble', title: hintOf(part.text) }, markdownNode(part.text, m.chatId)), update: (bubble) => { markdownNode(part.text, m.chatId, bubble.firstElementChild); + bubble.title = hintOf(part.text); return bubble; }, })); @@ -972,7 +980,7 @@ function foldView(m, parts, presentation) { { class: 'bubble-fold' }, h( 'summary', - { class: 'bubble-fold-summary' }, + { class: 'bubble-fold-summary', title: t('fold.hint', { n: parts.length }) }, icon('down', 12), h('span', { class: 'bubble-fold-closed' }, t('fold.show', { n: parts.length })), h('span', { class: 'bubble-fold-open' }, t('fold.hide')), @@ -981,6 +989,8 @@ function foldView(m, parts, presentation) { ), update: (node) => { reconcileChildren(node.querySelector('.bubble-fold-body'), inner()); + const summary = node.querySelector('.bubble-fold-summary'); + if (summary) summary.title = t('fold.hint', { n: parts.length }); const closed = node.querySelector('.bubble-fold-closed'); if (closed) closed.textContent = t('fold.show', { n: parts.length }); return node; @@ -998,8 +1008,8 @@ function partViews(m, inGroup) { parts.forEach((part, index) => { if (part.type === 'text' && part.text?.trim()) textParts.push(index); }); - // The earlier bubbles go into the folded area; the last one, the answer, stays in the open. - const folded = textParts.length > FOLD_AFTER_PARTS ? textParts.slice(0, -1) : []; + // The newest bubbles stay in the open, where the flat layout had them; the earlier ones fold. + const folded = textParts.length > FOLD_AFTER_PARTS ? textParts.slice(0, -FOLD_AFTER_PARTS) : []; const foldedSet = new Set(folded); if (folded.length) views.push(foldView(m, folded.map((index) => parts[index]), presentation)); parts.forEach((part, index) => { @@ -1009,9 +1019,10 @@ function partViews(m, inGroup) { views.push({ key, signature: JSON.stringify([part.text, presentation, index === lastText ? [m.status, m.userReactions, m.reactionsRevision, displayText(m), m.sessionId, botById(m.botId)?.name] : null]), - create: () => updateBubbleControls(h('div', { class: 'bubble' }, markdownNode(part.text, m.chatId)), m, index === lastText), + create: () => updateBubbleControls(h('div', { class: 'bubble', title: folded.length ? hintOf(part.text) : undefined }, markdownNode(part.text, m.chatId)), m, index === lastText), update: (bubble) => { markdownNode(part.text, m.chatId, bubble.firstElementChild); + if (folded.length) bubble.title = hintOf(part.text); return updateBubbleControls(bubble, m, index === lastText); }, }); diff --git a/plugins/minimax-ai/minimax-bot/miniapp/client/strings-app.js b/plugins/minimax-ai/minimax-bot/miniapp/client/strings-app.js index 3ecb8f1..4b0f674 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/client/strings-app.js +++ b/plugins/minimax-ai/minimax-bot/miniapp/client/strings-app.js @@ -200,6 +200,7 @@ export default { 'helper.collapse': '收起队列', 'fold.show': '展开 {n} 条过程消息', 'fold.hide': '收起过程消息', + 'fold.hint': '共 {n} 条过程消息,展开后在固定高度内滚动查看', 'event.helperStarted': '开始了:{title}', 'event.helper.done': '做完了:{title}', 'event.helper.blocked': '卡住了,需要你定一下:{title}', @@ -473,6 +474,7 @@ export default { 'helper.collapse': 'Collapse queue', 'fold.show': 'Show {n} earlier messages', 'fold.hide': 'Hide earlier messages', + 'fold.hint': '{n} earlier messages, they scroll inside the opened area', 'event.helperStarted': 'Started: {title}', 'event.helper.done': 'Finished: {title}', 'event.helper.blocked': 'Stuck and needs your decision: {title}', diff --git a/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs b/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs index d72e8f8..3dfcc6f 100644 --- a/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs +++ b/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs @@ -229,7 +229,7 @@ test('reactions stay on the final bubble while message parts change, preserving assert.equal(document.activeElement, document.querySelector('.reaction-trigger')); }); -test('a long reply folds its earlier bubbles away and keeps the answer in the open', async () => { +test('a long reply folds its earlier bubbles away and keeps the newest three in the open', async () => { const parts = []; for (let index = 0; index < 12; index += 1) { parts.push({ type: 'text', text: `Step ${index}` }, { type: 'tool', name: 'read', state: 'done' }); @@ -241,20 +241,42 @@ test('a long reply folds its earlier bubbles away and keeps the answer in the op const fold = document.querySelector('.bubble-fold'); assert.ok(fold, 'a reply with many parts must fold its earlier bubbles'); assert.equal(fold.open, false, 'the fold starts closed'); - assert.match(fold.querySelector('.bubble-fold-closed').textContent, /12/); - assert.equal(fold.querySelectorAll('.bubble').length, 12); - // The answer stays in the open, with the reaction indicator on it. + assert.match(fold.querySelector('.bubble-fold-closed').textContent, /10/); + assert.equal(fold.querySelectorAll('.bubble').length, 10); + // The newest three stay in the open, the answer last, with the reaction indicator on it. const wrap = document.querySelector('article.msg .bubble-wrap'); const open = [...wrap.children].filter((child) => child.classList.contains('bubble')); - assert.equal(open.length, 1); - assert.match(open[0].textContent, /The answer/); - assert.equal(open[0].querySelector('.reaction-chip').dataset.emoji, '👍'); + assert.equal(open.length, 3); + assert.match(open[0].textContent, /Step 10/); + assert.match(open[1].textContent, /Step 11/); + assert.match(open[2].textContent, /The answer/); + assert.equal(open[2].querySelector('.reaction-chip').dataset.emoji, '👍'); // Opening the fold shows the same bubbles again; the reply around it does not move. fold.open = true; - assert.equal(fold.querySelectorAll('.bubble').length, 12); + assert.equal(fold.querySelectorAll('.bubble').length, 10); assert.equal(wrap.querySelectorAll('.bubble').length, 13); }); +test('a folded reply tells on hover what every bubble said, a short one stays as it was', async () => { + const parts = []; + for (let index = 0; index < 6; index += 1) parts.push({ type: 'text', text: `Step ${index}` }); + await reset([message('m-hint-reply', 'Step 5', { parts })]); + // A title is the official way of telling more on hover: the task rows and the + // attachments use it too. + const fold = document.querySelector('.bubble-fold'); + assert.ok(fold, 'six text parts must fold'); + assert.equal(fold.querySelectorAll('.bubble').length, 3); + assert.equal(fold.querySelector('.bubble').title, 'Step 0'); + assert.match(fold.querySelector('.bubble-fold-summary').title, /3/); + const open = [...document.querySelectorAll('article.msg .bubble-wrap > .bubble')]; + assert.equal(open.length, 3); + assert.equal(open[0].title, 'Step 3'); + // A short reply keeps the official markup: no title added to its bubbles. + await reset([message('m-no-hint', 'Second', { parts: [{ type: 'text', text: 'First' }, { type: 'text', text: 'Second' }] })]); + assert.equal(document.querySelector('.bubble-fold'), null); + assert.equal(document.querySelector('article.msg .bubble-wrap > .bubble').title, ''); +}); + test('a short reply keeps its bubbles in the open, without a fold', async () => { const item = message('m-short-reply', 'Second', { parts: [{ type: 'text', text: 'First' }, { type: 'text', text: 'Second' }], From f750ea2bd4f1c5ccc811bd73eeebb15f8f1a7649 Mon Sep 17 00:00:00 2001 From: 4Dim Date: Wed, 21 Oct 2026 11:17:16 +0800 Subject: [PATCH 7/8] fix(minimax-bot): put the background task strip back to the official rows The strip was the one part of this branch that changed the official look instead of adding a layer on top of it: two lines per job, different wording, a borderless stop button and a folded queue. The rows render the way the official build does again, one line each, with the stop button at the end of the row. Measured against the official build on the same data: a row is 42px high in both, the strip is 88px high in both, and the stop button sits 9px from the right edge in both. The fold of the long reply and the reply actions stay as they are. --- .../minimax-bot/miniapp/client/app.css | 67 +++---------------- .../minimax-bot/miniapp/client/app.js | 62 +++-------------- .../minimax-bot/miniapp/client/strings-app.js | 18 +++-- .../tests/client-reliability.test.mjs | 30 ++++----- 4 files changed, 39 insertions(+), 138 deletions(-) diff --git a/plugins/minimax-ai/minimax-bot/miniapp/client/app.css b/plugins/minimax-ai/minimax-bot/miniapp/client/app.css index deee898..7c94d5c 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/client/app.css +++ b/plugins/minimax-ai/minimax-bot/miniapp/client/app.css @@ -2779,27 +2779,23 @@ fieldset.field { .helper-row { display: flex; - align-items: flex-start; - gap: 10px; - padding: 8px 10px 8px 12px; + align-items: center; + gap: 8px; + padding: 6px 8px 6px 12px; border-radius: 12px; border: 0.5px solid var(--line); background: var(--surface); font-size: 13px; } -/* The dot carries the state: quiet while queued, amber while it waits, accent while it runs. */ .helper-dot { width: 7px; height: 7px; - margin-top: 6px; border-radius: 50%; background: var(--accent); flex: none; + animation: helper-pulse 1.6s ease-in-out infinite; } -.helper-row.is-queued .helper-dot { background: var(--subtle); } -.helper-row.is-waiting .helper-dot { background: var(--warning); } -.helper-row.is-running .helper-dot { animation: helper-pulse 1.6s ease-in-out infinite; } @keyframes helper-pulse { 50% { @@ -2807,67 +2803,22 @@ fieldset.field { } } -[data-motion='quiet'] .helper-row.is-running .helper-dot { +[data-motion='quiet'] .helper-dot { animation: none; } -.helper-main { +.helper-text { flex: 1; min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; -} - -/* The task is what the row is about, so it gets the space and up to two lines. */ -.helper-title { - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - line-clamp: 2; - overflow: hidden; - overflow-wrap: anywhere; -} - -.helper-meta { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; - color: var(--muted); - font-size: 12px; -} -.helper-who { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.helper-state, .helper-time { - flex: none; - white-space: nowrap; -} -/* The stop action stays reachable, but out of the way until the row is looked at. */ -.helper-stop { - flex: none; - align-self: center; - border: 0; - border-radius: 6px; - background: none; - color: var(--subtle); - font-size: 12px; - padding: 2px 6px; - cursor: pointer; -} -.helper-stop:hover { - color: var(--ink); - background: var(--hover); -} - -.helper-more { - align-self: flex-start; - font-size: 12px; +.helper-time { color: var(--muted); + font-size: 12px; + flex: none; } /* attachments waiting to be sent, above the input */ diff --git a/plugins/minimax-ai/minimax-bot/miniapp/client/app.js b/plugins/minimax-ai/minimax-bot/miniapp/client/app.js index f6397d6..52ffcf6 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/client/app.js +++ b/plugins/minimax-ai/minimax-bot/miniapp/client/app.js @@ -447,7 +447,6 @@ async function mountChat(chatId) { const revision = ++mountRevision; closeReactionPicker(); mounted = chatId; - helpersExpanded = false; const target = targetOf(chatId); ui.pillBar = h('div', { class: 'pill-bar' }); ui.feedInner = h('div', { class: 'feed-inner' }); @@ -1299,54 +1298,28 @@ function helperElapsed(helper) { return minutes < 1 ? t('helper.justStarted') : t('helper.minutes', { n: minutes }); } -/** The phase a helper is in. The rows are ordered by it: what runs, then what waits, then the queue. */ -function helperPhase(helper) { - if (helper.status === 'waiting_for_tasks') return 'waiting'; - if (helper.status === 'queued') return 'queued'; - return 'running'; -} - -const HELPER_PHASE_ORDER = { running: 0, waiting: 1, queued: 2 }; -/** How many queued tasks stay visible before the queue folds into one line. */ -const HELPER_QUEUE_PREVIEW = 3; -let helpersExpanded = false; - -/** Background helpers working for this chat: the task on top, its owner and state below. */ +/** Background helpers working for this chat: one line each, with a stop button. */ function renderHelpers() { if (!ui.helpers) return; - const items = [...state.helpers, ...state.tasks] - .filter((x) => [x.chatId, x.originChatId, x.resultChatId].includes(mounted) && ['queued', 'running', 'waiting_for_tasks'].includes(x.status)) - .sort((a, b) => HELPER_PHASE_ORDER[helperPhase(a)] - HELPER_PHASE_ORDER[helperPhase(b)]); - const queued = items.filter((x) => x.status === 'queued'); - const shown = helpersExpanded ? queued : queued.slice(0, HELPER_QUEUE_PREVIEW); - const hidden = queued.length - shown.length; + const items = [...state.helpers, ...state.tasks].filter((x) => [x.chatId, x.originChatId, x.resultChatId].includes(mounted) && ['queued', 'running', 'waiting_for_tasks'].includes(x.status)); ui.helpers.replaceChildren( - ...[...items.filter((x) => x.status !== 'queued'), ...shown].map((helper) => { + ...items.map((helper) => { const bot = botById(helper.botId); - const phase = helperPhase(helper); return h( 'div', - { class: `helper-row is-${phase}`, title: helper.task }, + { class: 'helper-row', title: helper.task }, h('span', { class: 'helper-dot', 'aria-hidden': 'true' }), h( 'span', - { class: 'helper-main' }, - h('span', { class: 'helper-title' }, helper.title || helper.task || ''), - h( - 'span', - { class: 'helper-meta' }, - h('span', { class: 'helper-who' }, bot?.name ?? 'Bot'), - h('span', { class: 'helper-state' }, t(`helper.state.${phase}`)), - helper.status === 'queued' ? null : h('span', { class: 'helper-time' }, helperElapsed(helper)), - ), + { class: 'helper-text' }, + t(helper.status === 'waiting_for_tasks' ? 'helper.waitingForTasks' : helper.status === 'queued' ? 'helper.queued' : 'helper.running', { name: bot?.name ?? 'Bot', title: helper.title }), ), + h('span', { class: 'helper-time' }, helper.status === 'queued' ? '' : helperElapsed(helper)), h( 'button', { type: 'button', - class: 'helper-stop', - title: t('helper.stop'), - 'aria-label': t('helper.stop'), + class: 'btn sm', onclick: guard(async () => { await api('POST', `/api/${helper.batchId ? 'tasks' : 'helpers'}/${helper.id}/stop`); }), @@ -1356,25 +1329,6 @@ function renderHelpers() { ); }), ); - // `replaceChildren` turns a null argument into the text "null", so the queue button is - // appended on its own and only when there is something to fold. - if (hidden > 0 || (helpersExpanded && queued.length > HELPER_QUEUE_PREVIEW)) { - ui.helpers.append( - h( - 'button', - { - type: 'button', - class: 'helper-more soft', - 'aria-expanded': String(helpersExpanded), - onclick: () => { - helpersExpanded = !helpersExpanded; - renderHelpers(); - }, - }, - hidden > 0 ? t('helper.more', { n: hidden }) : t('helper.collapse'), - ), - ); - } } setInterval(() => { if (mounted && state.helpers.some((x) => x.chatId === mounted && x.status === 'running')) renderHelpers(); diff --git a/plugins/minimax-ai/minimax-bot/miniapp/client/strings-app.js b/plugins/minimax-ai/minimax-bot/miniapp/client/strings-app.js index 4b0f674..9cdf92f 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/client/strings-app.js +++ b/plugins/minimax-ai/minimax-bot/miniapp/client/strings-app.js @@ -190,14 +190,13 @@ export default { 'live.listHelpers': '正在查看后台任务', 'live.stopHelper': '正在停止后台任务', 'helper.strip': '后台任务', - 'helper.state.running': '执行中', - 'helper.state.waiting': '等待子任务', - 'helper.state.queued': '排队中', + 'helper.waitingForTasks': '{name} · 等待子任务:{title}', + 'helper.strip': '后台任务', + 'helper.running': '{name} 正在做「{title}」,你可以继续聊', + 'helper.queued': '{name} 的任务在排队「{title}」', 'helper.justStarted': '刚开始', 'helper.minutes': '{n} 分钟', 'helper.stop': '停止', - 'helper.more': '还有 {n} 个任务在排队', - 'helper.collapse': '收起队列', 'fold.show': '展开 {n} 条过程消息', 'fold.hide': '收起过程消息', 'fold.hint': '共 {n} 条过程消息,展开后在固定高度内滚动查看', @@ -464,14 +463,13 @@ export default { 'live.listHelpers': 'Checking background tasks', 'live.stopHelper': 'Stopping a background task', 'helper.strip': 'Background tasks', - 'helper.state.running': 'Running', - 'helper.state.waiting': 'Waiting for subtasks', - 'helper.state.queued': 'Queued', + 'helper.waitingForTasks': '{name} · Awaiting dependencies: {title}', + 'helper.strip': 'Background tasks', + 'helper.running': '{name} is working on "{title}" — you can keep chatting', + 'helper.queued': '{name} has this queued: "{title}"', 'helper.justStarted': 'just started', 'helper.minutes': '{n} min', 'helper.stop': 'Stop', - 'helper.more': '{n} more tasks queued', - 'helper.collapse': 'Collapse queue', 'fold.show': 'Show {n} earlier messages', 'fold.hide': 'Hide earlier messages', 'fold.hint': '{n} earlier messages, they scroll inside the opened area', diff --git a/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs b/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs index 3dfcc6f..3383af9 100644 --- a/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs +++ b/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs @@ -744,7 +744,7 @@ test('group execution records keep each reply linked to its own session and part } }); -test('the background task strip leads with the task, separates its state and folds a long queue', async () => { +test('the background task strip keeps the official one line rows and their stop button', async () => { await reset(); // Nothing is running: the strip stays empty instead of printing a stray "null". assert.equal(document.querySelector('.helpers').textContent, ''); @@ -764,21 +764,19 @@ test('the background task strip leads with the task, separates its state and fol await settle(); const strip = document.querySelector('.helpers'); const rows = strip.querySelectorAll('.helper-row'); - // What runs comes first, then what waits, then the queue. - assert.deepEqual([...rows].map((row) => row.className), [ - 'helper-row is-running', 'helper-row is-waiting', 'helper-row is-queued', 'helper-row is-queued', 'helper-row is-queued', - ]); - // The task leads the row; its owner, state and elapsed time sit below it. + // Every job keeps a row of its own, in the order the Host sent them. + assert.equal(rows.length, 7); + // A row is one line: who is doing what, then the elapsed time, then the stop button. const running = rows[0]; - assert.equal(running.querySelector('.helper-title').textContent, 'Rebuild the truth table'); - assert.equal(running.querySelector('.helper-who').textContent, 'Rob'); - assert.equal(running.querySelector('.helper-state').textContent, 'Running'); + assert.equal(running.className, 'helper-row'); + assert.match(running.querySelector('.helper-text').textContent, /Rob is working on "Rebuild the truth table"/); assert.ok(running.querySelector('.helper-time').textContent); - // A long queue folds into one line instead of filling the strip. - const more = strip.querySelector('.helper-more'); - assert.match(more.textContent, /2 more tasks queued/); - more.click(); - await settle(); - assert.equal(strip.querySelectorAll('.helper-row').length, 7); - assert.equal(strip.querySelector('.helper-more').textContent, 'Collapse queue'); + assert.equal(running.querySelector('button').className, 'btn sm'); + assert.equal(running.querySelector('button').textContent, 'Stop'); + // The row carries the whole task as its hover hint. + assert.equal(running.title, 'Rebuild the truth table in the verifier'); + // A queued job says so, and has no elapsed time yet. + const queuedRow = rows[2]; + assert.match(queuedRow.querySelector('.helper-text').textContent, /Rob has this queued: "Queued task 1"/); + assert.equal(queuedRow.querySelector('.helper-time').textContent, ''); }); \ No newline at end of file From 78fc2d3ff3c4436479956bab8bbf02b1d0f75c16 Mon Sep 17 00:00:00 2001 From: 4Dim Date: Wed, 21 Oct 2026 11:29:27 +0800 Subject: [PATCH 8/8] docs(minimax-bot): describe the themed reaction indicator The README already says where the reply actions sit; the indicator above that row now wears the app's own tag colours, so the text says so too. --- plugins/minimax-ai/minimax-bot/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/minimax-ai/minimax-bot/README.md b/plugins/minimax-ai/minimax-bot/README.md index 07010b9..2aaa732 100644 --- a/plugins/minimax-ai/minimax-bot/README.md +++ b/plugins/minimax-ai/minimax-bot/README.md @@ -27,7 +27,7 @@ Copy this directory, including the hidden `.minimax-plugin/` directory, into the - **Chat filters**: the icon bar switches between all chats, unread, groups and direct chats, with unread counts; your choice is remembered. - **Language**: English and Chinese. By default it follows the operating system's preferred language (the first English or Chinese entry in `navigator.languages`; other languages fall back to English), or you can fix it under Settings → General → Language. The interface, errors, system notices, Chief's default setup, the context Bots receive and the MCP tool descriptions all switch; replies the Bots wrote and text you entered stay as they are. - **Reactions**: react to finished Bot replies in private and group chats. The quick bar offers 👍 ❤️ 🙌 😂 😭 😮, and more emoji can be browsed by category or searched in English or Chinese. Each message holds one reaction: picking a new one replaces it, and picking the same one again removes it. Reactions are saved locally, survive refreshes and restarts, and sync to other open windows. They do not trigger a Bot reply and are not sent to the model. -- **Reply actions**: the reaction, copy, quote-reply and open-session buttons sit at the bottom-left of a reply, under its last line. A long reply therefore keeps its actions within reach at the end of the text instead of pushing them off the top of the message. +- **Reply actions**: the reaction, copy, quote-reply and open-session buttons sit at the bottom-left of a reply, under its last line. A long reply therefore keeps its actions within reach at the end of the text instead of pushing them off the top of the message. The reaction indicator sits directly above that row and is painted with the app's own tag colours, so it follows the light and dark themes. - **Folded working messages**: the Host records a text part for every turn a Bot takes between two tool calls, so a reply that worked for a while arrives with many parts. The newest three bubbles stay in the open, exactly where the flat layout put them, the answer among them. The earlier ones keep their own shape but move into a folded area above them: one line says how many of them there are, and opening it shows them in a fixed height area with its own scrollbar, so the conversation above stays in reach. Hovering a bubble of such a reply tells what that bubble said, the way the task rows and the attachments do. ## 0.7.0 Reliable delegation and result delivery