From 011c41802622999a58b80321d417a841ee0972e3 Mon Sep 17 00:00:00 2001 From: 4Dim Date: Wed, 21 Oct 2026 11:51:22 +0800 Subject: [PATCH 1/6] fix(minimax-bot): fold a long queue of background jobs behind one line The task strip had no way to shrink a busy queue: the rows kept the official shape, but thirty jobs filled the whole strip and the last ones were only reachable by scrolling inside it. The rows stay the ones the official build draws, in its order. What is new is a layer on top: the first five rows stay in the open, the rest fold into one line that says how many jobs are left, and a click brings them back. The button sits below the rows' own scroll area, so it stays reachable however long the queue is, and the strip keeps the height budget the official one had. --- .../minimax-bot/miniapp/client/app.css | 25 +++++++++- .../minimax-bot/miniapp/client/app.js | 47 ++++++++++++++++++- .../minimax-bot/miniapp/client/strings-app.js | 4 ++ .../tests/client-reliability.test.mjs | 14 ++++-- 4 files changed, 83 insertions(+), 7 deletions(-) diff --git a/plugins/minimax-ai/minimax-bot/miniapp/client/app.css b/plugins/minimax-ai/minimax-bot/miniapp/client/app.css index 7c94d5c..fac3ade 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/client/app.css +++ b/plugins/minimax-ai/minimax-bot/miniapp/client/app.css @@ -2765,14 +2765,30 @@ fieldset.field { display: flex; flex-direction: column; gap: 4px; - /* A few rows stay fully visible. More than the cap scrolls here instead of pushing the transcript off screen. */ flex: 0 1 auto; min-height: 0; - max-height: min(280px, 40%); +} + +/* The rows keep the scroll area the official strip had: a few of them stay fully visible, the + rest scroll here instead of pushing the transcript off screen. The cap is a viewport share + because the strip sits inside a box that has no height of its own. */ +.helper-rows { + display: flex; + flex-direction: column; + gap: 4px; + flex: 0 1 auto; + min-height: 0; + max-height: min(280px, 40vh); overflow-y: auto; overscroll-behavior: contain; } +/* The fold button sits below the scroll area and takes a row of its own, so the strip keeps + the height budget the official one had. */ +.helpers.has-fold .helper-rows { + max-height: min(248px, calc(40vh - 32px)); +} + .helpers:empty { display: none; } @@ -2821,6 +2837,11 @@ fieldset.field { flex: none; } +/* The fold of a long queue: the same small button as the stop action, under the rows it folds. */ +.helper-more { + align-self: flex-start; +} + /* attachments waiting to be sent, above the input */ .attach-btn { width: 32px; diff --git a/plugins/minimax-ai/minimax-bot/miniapp/client/app.js b/plugins/minimax-ai/minimax-bot/miniapp/client/app.js index 52ffcf6..ad332e5 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' }); @@ -1298,12 +1299,33 @@ function helperElapsed(helper) { return minutes < 1 ? t('helper.justStarted') : t('helper.minutes', { n: minutes }); } +/** How many job rows stay in the open before the rest folds into one line. */ +const HELPER_ROWS_PREVIEW = 5; +let helpersExpanded = false; + /** 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)); - ui.helpers.replaceChildren( - ...items.map((helper) => { + if (!items.length) { + ui.helpers.replaceChildren(); + ui.helpers.classList.remove('has-fold'); + return; + } + // The rows are the ones the official build draws, in the order it draws them. A busy chat + // keeps the first few in the open and folds the rest into one line, so the strip never + // grows into a wall of rows. + const preview = helpersExpanded ? items : items.slice(0, HELPER_ROWS_PREVIEW); + const hidden = items.length - preview.length; + // The rows keep the scroll area they had, and the fold button sits below it, so the button + // stays reachable however long the queue is. + let rows = ui.helpers.querySelector('.helper-rows'); + if (!rows) { + rows = h('div', { class: 'helper-rows' }); + ui.helpers.replaceChildren(rows); + } + rows.replaceChildren( + ...preview.map((helper) => { const bot = botById(helper.botId); return h( 'div', @@ -1329,6 +1351,27 @@ function renderHelpers() { ); }), ); + ui.helpers.classList.toggle('has-fold', hidden > 0 || (helpersExpanded && items.length > HELPER_ROWS_PREVIEW)); + // `replaceChildren` renders a null argument as the text "null", so the fold button is appended + // on its own, and only when there is something to fold. + ui.helpers.querySelector('.helper-more')?.remove(); + if (hidden > 0 || (helpersExpanded && items.length > HELPER_ROWS_PREVIEW)) { + ui.helpers.append( + h( + 'button', + { + type: 'button', + class: 'btn sm helper-more', + '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 9cdf92f..18e25eb 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/client/strings-app.js +++ b/plugins/minimax-ai/minimax-bot/miniapp/client/strings-app.js @@ -197,6 +197,8 @@ export default { 'helper.justStarted': '刚开始', 'helper.minutes': '{n} 分钟', 'helper.stop': '停止', + 'helper.more': '还有 {n} 个任务', + 'helper.collapse': '收起队列', 'fold.show': '展开 {n} 条过程消息', 'fold.hide': '收起过程消息', 'fold.hint': '共 {n} 条过程消息,展开后在固定高度内滚动查看', @@ -470,6 +472,8 @@ export default { 'helper.justStarted': 'just started', 'helper.minutes': '{n} min', 'helper.stop': 'Stop', + 'helper.more': '{n} more tasks', + '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 3383af9..a2743af 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 keeps the official one line rows and their stop button', async () => { +test('the background task strip keeps the official one line rows 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, ''); @@ -764,8 +764,9 @@ test('the background task strip keeps the official one line rows and their stop await settle(); const strip = document.querySelector('.helpers'); const rows = strip.querySelectorAll('.helper-row'); - // Every job keeps a row of its own, in the order the Host sent them. - assert.equal(rows.length, 7); + // The rows are the ones the official build draws, in its order: what runs, what waits, then + // the first of the queue. + assert.equal(rows.length, 5); // A row is one line: who is doing what, then the elapsed time, then the stop button. const running = rows[0]; assert.equal(running.className, 'helper-row'); @@ -779,4 +780,11 @@ test('the background task strip keeps the official one line rows and their stop 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, ''); + // The queue behind the third waiting job folds into one line. + const more = strip.querySelector('.helper-more'); + assert.match(more.textContent, /2 more tasks/); + 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 c85ddf8488b4314a292457a969c234d1e5a153ed Mon Sep 17 00:00:00 2001 From: 4Dim Date: Wed, 21 Oct 2026 11:57:26 +0800 Subject: [PATCH 2/6] fix(minimax-bot): keep three job rows in the open in the task strip Three matches the number the folded reply bubbles keep, and it leaves the transcript more room when a queue is long. --- plugins/minimax-ai/minimax-bot/miniapp/client/app.js | 2 +- .../minimax-bot/tests/client-reliability.test.mjs | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/minimax-ai/minimax-bot/miniapp/client/app.js b/plugins/minimax-ai/minimax-bot/miniapp/client/app.js index ad332e5..d0ed652 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/client/app.js +++ b/plugins/minimax-ai/minimax-bot/miniapp/client/app.js @@ -1300,7 +1300,7 @@ function helperElapsed(helper) { } /** How many job rows stay in the open before the rest folds into one line. */ -const HELPER_ROWS_PREVIEW = 5; +const HELPER_ROWS_PREVIEW = 3; let helpersExpanded = false; /** Background helpers working for this chat: one line each, with a stop button. */ 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 a2743af..e433801 100644 --- a/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs +++ b/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs @@ -764,9 +764,9 @@ test('the background task strip keeps the official one line rows and folds a lon await settle(); const strip = document.querySelector('.helpers'); const rows = strip.querySelectorAll('.helper-row'); - // The rows are the ones the official build draws, in its order: what runs, what waits, then - // the first of the queue. - assert.equal(rows.length, 5); + // The rows are the ones the official build draws, in its order. The first three stay in the + // open, the rest fold into one line. + assert.equal(rows.length, 3); // A row is one line: who is doing what, then the elapsed time, then the stop button. const running = rows[0]; assert.equal(running.className, 'helper-row'); @@ -780,9 +780,9 @@ test('the background task strip keeps the official one line rows and folds a lon 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, ''); - // The queue behind the third waiting job folds into one line. + // The queue behind the third row folds into one line. const more = strip.querySelector('.helper-more'); - assert.match(more.textContent, /2 more tasks/); + assert.match(more.textContent, /4 more tasks/); more.click(); await settle(); assert.equal(strip.querySelectorAll('.helper-row').length, 7); From f93265ffcc4351d54b293ad057b5240c47d5d244 Mon Sep 17 00:00:00 2001 From: 4Dim Date: Wed, 21 Oct 2026 12:02:48 +0800 Subject: [PATCH 3/6] chore(minimax-bot): bump the version to 0.7.6 The task strip fold is a build of its own on top of the merged 0.7.5. --- plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json | 2 +- plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs | 2 +- plugins/minimax-ai/minimax-bot/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json b/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json index ae87257..1bbcabb 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.5", + "version": "0.7.6", "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/miniapp/node/mcp.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs index 25536f6..7b3e2da 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.5' }, + serverInfo: { name: 'minimax-bot', version: '0.7.6' }, instructions: t('mcp.instructions'), }; break; diff --git a/plugins/minimax-ai/minimax-bot/package.json b/plugins/minimax-ai/minimax-bot/package.json index 9584fc9..205dca2 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.5", + "version": "0.7.6", "private": true, "type": "module", "mcode": { From 045eade91c9a56b5a91dc437392a771cf217980e Mon Sep 17 00:00:00 2001 From: 4Dim Date: Wed, 21 Oct 2026 12:10:52 +0800 Subject: [PATCH 4/6] fix(minimax-bot): drop the folded working messages, separate the bubbles The fold took a long reply's earlier bubbles behind a summary line. The queue of background jobs was the thing that needed folding, so the bubbles go back to the flat layout the official build draws: one bubble per recorded part, in the order it records them. What was missing there was a gap between them. One reply with many parts drew its bubbles flush against each other, so they read as one block of text; a small gap, the one the task rows use, tells them apart. --- plugins/minimax-ai/minimax-bot/README.md | 2 +- .../minimax-bot/miniapp/client/app.css | 52 +------------- .../minimax-bot/miniapp/client/app.js | 71 +------------------ .../minimax-bot/miniapp/client/strings-app.js | 6 -- .../tests/client-reliability.test.mjs | 65 ++++------------- 5 files changed, 18 insertions(+), 178 deletions(-) diff --git a/plugins/minimax-ai/minimax-bot/README.md b/plugins/minimax-ai/minimax-bot/README.md index 2aaa732..4875280 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. 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. +- **Working messages of one reply**: 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, and the page draws one bubble per part. The bubbles keep the official shape and order, and a small gap between them tells them apart instead of letting them read as one block of text. ## 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 fac3ade..250feca 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/client/app.css +++ b/plugins/minimax-ai/minimax-bot/miniapp/client/app.css @@ -816,58 +816,12 @@ button { display: flex; flex-direction: column; align-items: flex-start; + /* One reply can carry many bubbles, one per recorded part. Without a gap between them they + read as one block of text; a small one tells them apart, the way the task rows do. */ + gap: 4px; 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 d0ed652..9f89285 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/client/app.js +++ b/plugins/minimax-ai/minimax-bot/miniapp/client/app.js @@ -939,90 +939,21 @@ function legacyParts(m) { return parts; } -/** - * 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. - */ -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', 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; - }, - })); - 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', 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')), - ), - body, - ), - 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; - }, - }; -} - /** 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 lastText = parts.findLastIndex((part) => part.type === 'text' && part.text?.trim()); const views = []; const presentation = presentationKey(); - const textParts = []; - parts.forEach((part, index) => { - if (part.type === 'text' && part.text?.trim()) textParts.push(index); - }); - // 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) => { const key = `part:${index}`; if (part.type === 'text' && part.text?.trim()) { - if (foldedSet.has(index)) return; 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', title: folded.length ? hintOf(part.text) : undefined }, markdownNode(part.text, m.chatId)), m, index === lastText), + create: () => updateBubbleControls(h('div', { class: 'bubble' }, 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 18e25eb..ba9897c 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/client/strings-app.js +++ b/plugins/minimax-ai/minimax-bot/miniapp/client/strings-app.js @@ -199,9 +199,6 @@ export default { 'helper.stop': '停止', 'helper.more': '还有 {n} 个任务', 'helper.collapse': '收起队列', - 'fold.show': '展开 {n} 条过程消息', - 'fold.hide': '收起过程消息', - 'fold.hint': '共 {n} 条过程消息,展开后在固定高度内滚动查看', 'event.helperStarted': '开始了:{title}', 'event.helper.done': '做完了:{title}', 'event.helper.blocked': '卡住了,需要你定一下:{title}', @@ -474,9 +471,6 @@ export default { 'helper.stop': 'Stop', 'helper.more': '{n} more tasks', '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 e433801..000180e 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 newest three in the open', async () => { +test('a long reply keeps every bubble in the open, one per recorded part', async () => { const parts = []; for (let index = 0; index < 12; index += 1) { parts.push({ type: 'text', text: `Step ${index}` }, { type: 'tool', name: 'read', state: 'done' }); @@ -237,62 +237,23 @@ test('a long reply folds its earlier bubbles away and keeps the newest three in parts.push({ type: 'text', text: 'The answer' }); const item = message('m-long-reply', 'The answer', { parts, userReactions: ['👍'], reactionsRevision: 1 }); await reset([item]); - // 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, /10/); - assert.equal(fold.querySelectorAll('.bubble').length, 10); - // The newest three stay in the open, the answer last, with the reaction indicator on it. + // The bubbles stay the ones the official build draws, in the order it draws them. + assert.equal(document.querySelector('.bubble-fold'), null); const wrap = document.querySelector('article.msg .bubble-wrap'); const open = [...wrap.children].filter((child) => child.classList.contains('bubble')); - 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, 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' }], - }); - await reset([item]); - assert.equal(document.querySelector('.bubble-fold'), null); - assert.equal(document.querySelectorAll('article.msg .bubble').length, 2); + assert.equal(open.length, 13); + assert.match(open[0].textContent, /Step 0/); + assert.match(open[12].textContent, /The answer/); + // The answer is the last bubble, so the reaction indicator sits on it. + assert.equal(open[12].querySelector('.reaction-chip').dataset.emoji, '👍'); }); -test('the folded area keeps a fixed height and scrolls inside itself', async () => { +test('the bubbles of one reply keep a small gap between them', 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/); + const rule = css.match(/\n\.bubble-wrap \{[^}]*\}/)?.[0] ?? ''; + assert.ok(rule, 'the column of bubbles needs a rule of its own'); + // Without a gap the bubbles of one reply read as one block of text. + assert.match(rule, /gap: 4px/); }); test('a multi-line reply keeps its actions below the text and its reaction indicator above them', async () => { From f2966101132e47e75418e7024f03615179f92a8c Mon Sep 17 00:00:00 2001 From: 4Dim Date: Wed, 21 Oct 2026 13:09:41 +0800 Subject: [PATCH 5/6] fix(minimax-bot): keep the working jobs in the open, hold the fold button The fold of the job queue had three rough edges. The button was removed and rebuilt on every render, so the keyboard focus fell back to the body as soon as the queue changed or the clock ticked. The same node is kept now; only its text and its state follow the queue. Which rows stayed in the open was decided by the order the jobs arrived in, so a job that was already working could be the row that folded away, behind a queue of jobs that had not started yet. The rows that stay in the open are chosen by status now, the working one first, while the rows themselves keep the order the official build gives them. An open queue stayed open after it shrank to a few jobs. It ends with the queue now, and the next busy chat starts folded again. The gap between the bubbles of one reply belongs to the bubbles alone. The rule names them instead of the whole column, so the action row, the notes and the event lines keep the spacing the official build gave them. The height budget of the strip and the row the fold button takes from it are two variables now. --- .../minimax-bot/miniapp/client/app.css | 19 ++++-- .../minimax-bot/miniapp/client/app.js | 64 ++++++++++++------- .../tests/client-reliability.test.mjs | 61 ++++++++++++++---- 3 files changed, 105 insertions(+), 39 deletions(-) diff --git a/plugins/minimax-ai/minimax-bot/miniapp/client/app.css b/plugins/minimax-ai/minimax-bot/miniapp/client/app.css index 250feca..ea0bd68 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/client/app.css +++ b/plugins/minimax-ai/minimax-bot/miniapp/client/app.css @@ -816,12 +816,17 @@ button { display: flex; flex-direction: column; align-items: flex-start; - /* One reply can carry many bubbles, one per recorded part. Without a gap between them they - read as one block of text; a small one tells them apart, the way the task rows do. */ - gap: 4px; max-width: 100%; } +/* One reply can carry many bubbles, one per recorded part. When they touch they read as one + block of text, so the bubbles that follow each other get a small gap, the way the task rows + do. The rule names the bubbles alone: the notes, the event lines and the action row keep the + spacing the official build gave them. */ +.bubble-wrap > .bubble + .bubble { + margin-top: 4px; +} + .msg.user .bubble-wrap { align-items: flex-end; /* The width bound belongs here, not on .bubble: this wrapper is shrink-to-fit, so a @@ -2721,6 +2726,10 @@ fieldset.field { gap: 4px; flex: 0 1 auto; min-height: 0; + /* The height budget the official strip had, and the row the fold button takes from it: the + button itself (28px) plus the gap above it. */ + --helper-rows-max: 280px; + --helper-fold-row: 32px; } /* The rows keep the scroll area the official strip had: a few of them stay fully visible, the @@ -2732,7 +2741,7 @@ fieldset.field { gap: 4px; flex: 0 1 auto; min-height: 0; - max-height: min(280px, 40vh); + max-height: min(var(--helper-rows-max), 40vh); overflow-y: auto; overscroll-behavior: contain; } @@ -2740,7 +2749,7 @@ fieldset.field { /* The fold button sits below the scroll area and takes a row of its own, so the strip keeps the height budget the official one had. */ .helpers.has-fold .helper-rows { - max-height: min(248px, calc(40vh - 32px)); + max-height: min(calc(var(--helper-rows-max) - var(--helper-fold-row)), calc(40vh - var(--helper-fold-row))); } .helpers:empty { diff --git a/plugins/minimax-ai/minimax-bot/miniapp/client/app.js b/plugins/minimax-ai/minimax-bot/miniapp/client/app.js index 9f89285..75c4336 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/client/app.js +++ b/plugins/minimax-ai/minimax-bot/miniapp/client/app.js @@ -1234,6 +1234,20 @@ function helperElapsed(helper) { const HELPER_ROWS_PREVIEW = 3; let helpersExpanded = false; +/** A job that is already working comes first; one that still waits can wait a little longer. */ +const HELPER_STATUS_ORDER = { running: 0, waiting_for_tasks: 1, queued: 2 }; + +/** + * The rows that stay in the open. Which rows those are is our choice, the order they appear in + * is not: a job that is already working comes first, so the row the reader cares about is never + * the one that folds away, and the rows keep the order the official build gives them. + */ +function visibleHelperRows(items) { + const ranked = [...items].sort((a, b) => HELPER_STATUS_ORDER[a.status] - HELPER_STATUS_ORDER[b.status]); + const keep = new Set(ranked.slice(0, HELPER_ROWS_PREVIEW)); + return items.filter((item) => keep.has(item)); +} + /** Background helpers working for this chat: one line each, with a stop button. */ function renderHelpers() { if (!ui.helpers) return; @@ -1241,13 +1255,15 @@ function renderHelpers() { if (!items.length) { ui.helpers.replaceChildren(); ui.helpers.classList.remove('has-fold'); + helpersExpanded = false; return; } - // The rows are the ones the official build draws, in the order it draws them. A busy chat - // keeps the first few in the open and folds the rest into one line, so the strip never - // grows into a wall of rows. - const preview = helpersExpanded ? items : items.slice(0, HELPER_ROWS_PREVIEW); + // A queue that fits in the open rows has nothing to fold, so an open queue ends with it and + // the next busy chat starts folded again. + if (items.length <= HELPER_ROWS_PREVIEW) helpersExpanded = false; + const preview = helpersExpanded ? items : visibleHelperRows(items); const hidden = items.length - preview.length; + const foldable = items.length > HELPER_ROWS_PREVIEW; // The rows keep the scroll area they had, and the fold button sits below it, so the button // stays reachable however long the queue is. let rows = ui.helpers.querySelector('.helper-rows'); @@ -1282,27 +1298,31 @@ function renderHelpers() { ); }), ); - ui.helpers.classList.toggle('has-fold', hidden > 0 || (helpersExpanded && items.length > HELPER_ROWS_PREVIEW)); - // `replaceChildren` renders a null argument as the text "null", so the fold button is appended - // on its own, and only when there is something to fold. - ui.helpers.querySelector('.helper-more')?.remove(); - if (hidden > 0 || (helpersExpanded && items.length > HELPER_ROWS_PREVIEW)) { - ui.helpers.append( - h( - 'button', - { - type: 'button', - class: 'btn sm helper-more', - 'aria-expanded': String(helpersExpanded), - onclick: () => { - helpersExpanded = !helpersExpanded; - renderHelpers(); - }, + ui.helpers.classList.toggle('has-fold', foldable); + // The fold button is the same node from one render to the next, so the keyboard focus stays + // on it when a new job arrives, when the clock ticks or when the button itself is pressed. + let more = ui.helpers.querySelector('.helper-more'); + if (!foldable) { + more?.remove(); + return; + } + if (!more) { + more = h( + 'button', + { + type: 'button', + class: 'btn sm helper-more', + 'aria-expanded': String(helpersExpanded), + onclick: () => { + helpersExpanded = !helpersExpanded; + renderHelpers(); }, - hidden > 0 ? t('helper.more', { n: hidden }) : t('helper.collapse'), - ), + }, ); + ui.helpers.append(more); } + more.textContent = hidden > 0 ? t('helper.more', { n: hidden }) : t('helper.collapse'); + more.setAttribute('aria-expanded', String(helpersExpanded)); } 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 000180e..907665e 100644 --- a/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs +++ b/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs @@ -246,14 +246,20 @@ test('a long reply keeps every bubble in the open, one per recorded part', async assert.match(open[12].textContent, /The answer/); // The answer is the last bubble, so the reaction indicator sits on it. assert.equal(open[12].querySelector('.reaction-chip').dataset.emoji, '👍'); + // The bubbles follow each other directly, so the gap rule reaches every one of them. + assert.equal(wrap.querySelectorAll('.bubble + .bubble').length, 12); }); test('the bubbles of one reply keep a small gap between them', async () => { const css = await readFile(new URL('../miniapp/client/app.css', import.meta.url), 'utf8'); - const rule = css.match(/\n\.bubble-wrap \{[^}]*\}/)?.[0] ?? ''; - assert.ok(rule, 'the column of bubbles needs a rule of its own'); - // Without a gap the bubbles of one reply read as one block of text. - assert.match(rule, /gap: 4px/); + const column = css.match(/\n\.bubble-wrap \{[^}]*\}/)?.[0] ?? ''; + assert.ok(column, 'the column of bubbles needs a rule of its own'); + // The gap belongs to the bubbles alone. The action row, the notes and the event lines keep + // the spacing the official build gave them, so the column itself cannot carry the gap. + assert.doesNotMatch(column, /gap:/); + const gap = css.match(/\n\.bubble-wrap > \.bubble \+ \.bubble \{[^}]*\}/)?.[0] ?? ''; + assert.ok(gap, 'the bubbles that follow each other need a gap of their own'); + assert.match(gap, /margin-top: 4px/); }); test('a multi-line reply keeps its actions below the text and its reaction indicator above them', async () => { @@ -714,14 +720,12 @@ test('the background task strip keeps the official one line rows and folds a lon 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, - ], - }); + const 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, + ]; + emit({ type: 'helpers.changed', helpers }); await settle(); const strip = document.querySelector('.helpers'); const rows = strip.querySelectorAll('.helper-row'); @@ -748,4 +752,37 @@ test('the background task strip keeps the official one line rows and folds a lon await settle(); assert.equal(strip.querySelectorAll('.helper-row').length, 7); assert.equal(strip.querySelector('.helper-more').textContent, 'Collapse queue'); + + // The button is the same node from one render to the next, so a redraw does not take the + // keyboard focus away from it. + const button = strip.querySelector('.helper-more'); + button.focus(); + assert.equal(document.activeElement, button, 'the fold button takes the focus'); + emit({ type: 'helpers.changed', helpers }); + await settle(); + assert.equal(strip.querySelector('.helper-more'), button, 'the redraw reuses the button node'); + assert.equal(document.activeElement, button, 'the redraw keeps the focus on it'); + + // A job that is already working is never the one that folds away, even when the queue in + // front of it is long. + button.click(); + await settle(); + emit({ + type: 'tasks.changed', + tasks: [{ id: 'tsk_run', batchId: 'batch_1', botId: 'bot_a', chatId: chat, title: 'Answer the ticket', task: 'Answer the ticket', status: 'running', startedAt: Date.now() - 30000 }], + }); + await settle(); + const visible = [...strip.querySelectorAll('.helper-row')].map((row) => row.textContent); + assert.ok(visible.some((text) => /Answer the ticket/.test(text)), 'a running task stays in the open'); + assert.ok(!visible.some((text) => /Queued task/.test(text)), 'a queued job gives way to the running one'); + + // The open queue ends with the queue: when it shrinks to three jobs the fold button goes, + // and the next busy chat starts folded again. + emit({ type: 'tasks.changed', tasks: [] }); + emit({ type: 'helpers.changed', helpers: helpers.slice(0, 3) }); + await settle(); + assert.equal(strip.querySelector('.helper-more'), null, 'a queue of three needs no fold button'); + emit({ type: 'helpers.changed', helpers }); + await settle(); + assert.match(strip.querySelector('.helper-more').textContent, /4 more tasks/, 'the queue starts folded again'); }); \ No newline at end of file From e030957a1bc01061f13904cbea9ad756afd03141 Mon Sep 17 00:00:00 2001 From: 4Dim Date: Wed, 21 Oct 2026 13:09:42 +0800 Subject: [PATCH 6/6] docs(minimax-bot): describe the folded job queue The README described the working messages of a reply but not the strip above the composer, so the fold of a long queue was left undocumented. --- 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 4875280..287d3f8 100644 --- a/plugins/minimax-ai/minimax-bot/README.md +++ b/plugins/minimax-ai/minimax-bot/README.md @@ -14,7 +14,7 @@ Copy this directory, including the hidden `.minimax-plugin/` directory, into the - **Chief Bot**: created on first launch to help you build your team. Chief can create, edit and delete Bots and groups; other Bots are refused when they try to do so. - **Group chats**: the group admin answers by default. `@Name` calls on a specific member, and a Bot that `@`-mentions another member in its reply hands the turn over. Members with nothing to add reply `[PASS]` and are skipped. Each member keeps a separate session per group, and groups cannot see private chats. There is no fixed reply cap. Discussions finish naturally, or wait for more instructions after repeated handoffs without tool progress. - **Hand-offs between Bots**: `message_bot` hands work to another Bot and immediately returns an admission receipt; the completed result returns to the source chat. `post_group` is **private chat only**: it posts to a group and returns immediately while the discussion continues according to the group's reply mode. From a private chat, Chief can post to any group without joining it; other Bots can only post to groups they belong to. During a group turn, `post_group` is disabled for everyone, including Chief and admins, for both same-group and cross-group posts. Use `@Name` in a reply to hand off within the group; start cross-group work from a private chat. -- **Background work**: when an isolated execution context is useful, a Bot calls `start_helper` to run it in the background in a fresh, separate session; the Bot's turn ends at once and you can keep chatting. The Bot simply says something like "let me look into it" without internal jargon or IDs. When the job finishes, its report comes back to the Bot as a new turn, and the Bot reviews it and passes it on in its own words (in groups too, where it can keep handing off with `@`). At most 2 background jobs run at once (one slot is always kept free for chat) and the rest queue; each Bot can have at most 4 queued or running. Running jobs are shown above the chat input and can be stopped at any time; tool approvals from a background job appear in its chat; jobs still running when the Mini App restarts are marked interrupted. Bots can also manage jobs with `list_helpers` / `stop_helper`. "Allow background helpers automatically" is on by default in Bot settings; when off, a Bot only does so when you explicitly ask. +- **Background work**: when an isolated execution context is useful, a Bot calls `start_helper` to run it in the background in a fresh, separate session; the Bot's turn ends at once and you can keep chatting. The Bot simply says something like "let me look into it" without internal jargon or IDs. When the job finishes, its report comes back to the Bot as a new turn, and the Bot reviews it and passes it on in its own words (in groups too, where it can keep handing off with `@`). At most 2 background jobs run at once (one slot is always kept free for chat) and the rest queue; each Bot can have at most 4 queued or running. Running jobs are shown above the chat input and can be stopped at any time; when more than three are queued, the jobs that are already working stay in the open and the rest fold into one line that says how many are left, and one button below the rows opens the whole queue; tool approvals from a background job appear in its chat; jobs still running when the Mini App restarts are marked interrupted. Bots can also manage jobs with `list_helpers` / `stop_helper`. "Allow background helpers automatically" is on by default in Bot settings; when off, a Bot only does so when you explicitly ask. - **Per-Bot model and reasoning effort**: pick a model and reasoning effort in Bot settings (only the levels the model supports are listed, and thinking can be turned off where supported; Bots that follow the default model can still set their own effort). Chief can also set them with the `model` (a model ID, `provider/model` or display name; `default` follows the default) and `effort` (`low`, `high`, `off`, ...) parameters of `create_bot` / `update_bot`. Requires a MiniMax Code version with `agent.models()` / `run({ model })`; older versions prompt you to upgrade. - **Images and files**: `` and `![](absolute path)` in a Bot reply render as images, audio/video players or file cards. Local HTML/HTM cards open an isolated preview inside the Mini App; MD/Markdown cards show a formatted reading view (plain text above 2 MiB). The owning Mini App fetches the file so a regular browser tab does not lose Host authentication and show a blank 401 page. Failed loads show an error and Retry; files above 16 MiB can be saved with the preview toolbar’s Download action. Other files still download. Local files are served through `/api/media`, limited to paths the Bot referenced in that conversation. HTML previews allow page scripts and HTTP(S) styles and display resources in an isolated sandbox, without access to Bot storage, fetch API requests or form submissions. Other local files referenced by HTML are not exposed; embed those resources in the document. Remote links follow the destination server response. - **Attachments**: attach files to your message; the Bot receives their local paths.