@link-assistant/hive-mind 1.77.1 → 1.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.78.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 9506f03: fix(telegram): de-duplicate `/queue` display and split long messages without breaking markdown (#1891)
8
+
9
+ The `/queue` (alias `/solve_queue`) detailed display repeated the same words on every
10
+ line — every executing row said `(processing, …)`, every waiting row said
11
+ `(waiting, …)`, and the (almost always identical) per-item waiting reason was printed
12
+ once per item. Empty queues were also still printed. This wasted vertical space and
13
+ pushed real data off screen.
14
+
15
+ Display changes (`formatDetailedStatus` + queue helpers):
16
+ - Executing rows now render compactly as `• owner/repo#number (▶️ <dur>)` and pending
17
+ rows as `• owner/repo#number (⏳ <dur>)` — the status word is replaced by the emoji
18
+ marker inside the duration parenthesis.
19
+ - Processing, pending, completed, and failed entries are split into distinct
20
+ compact lists per tool, with counts only on those list labels instead of a
21
+ duplicated `(pending: n, processing: n)` tool-header summary.
22
+ - The shared waiting reason is shown **once per tool** (only when all pending items
23
+ agree on it) instead of once per item.
24
+ - Empty queues are skipped entirely.
25
+ - All queued items are listed (no per-queue truncation on the active lists).
26
+
27
+ Message-splitting changes (`splitTelegramMessageText` in `telegram-safe-reply.lib.mjs`,
28
+ the single universal splitter every Telegram send path funnels through):
29
+ - Splitting now happens only on line boundaries, so inline Markdown entities
30
+ (bold/italic/links) are never cut in half.
31
+ - Fenced code blocks stay balanced per chunk: a split inside a code block closes the
32
+ fence at the end of one chunk and reopens it — repeating the language — at the start
33
+ of the next. The original fence marker (```vs`~~~`) and indentation are preserved.
34
+ - Pathologically long single lines are hard-split as a fallback.
35
+
36
+ Both behaviours are covered by extensive new tests
37
+ (`tests/test-telegram-message-split-1891.mjs`, `tests/test-queue-compact-display-1891.mjs`).
38
+
3
39
  ## 1.77.1
4
40
 
5
41
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.77.1",
3
+ "version": "1.78.0",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -61,27 +61,129 @@ function findTelegramSplitIndex(text, limit) {
61
61
  return limit;
62
62
  }
63
63
 
64
+ // A Markdown fenced code block opens/closes with ``` or ~~~ (optionally indented
65
+ // and, on the opening fence, followed by a language/info string). We track these
66
+ // so a split that lands inside a code block can close the fence on the current
67
+ // chunk and reopen it — repeating the language — on the next one (issue #1891).
68
+ const CODE_FENCE_RE = /^(\s*)(```+|~~~+)(.*)$/;
69
+
70
+ /**
71
+ * Parse a single line as a Markdown code-fence delimiter.
72
+ *
73
+ * @param {string} line
74
+ * @returns {{indent: string, marker: string, info: string}|null}
75
+ * The fence parts, or `null` when the line is not a fence.
76
+ */
77
+ export function parseCodeFence(line) {
78
+ const match = CODE_FENCE_RE.exec(line);
79
+ if (!match) return null;
80
+ return { indent: match[1], marker: match[2], info: match[3].trim() };
81
+ }
82
+
83
+ /**
84
+ * Hard-split a single physical line that is itself longer than `limit` into
85
+ * pieces that each fit, preferring a break at a natural separator near the end
86
+ * and falling back to a hard character cut. Used only for pathologically long
87
+ * lines (normal queue/help lines are short).
88
+ *
89
+ * @param {string} line
90
+ * @param {number} limit
91
+ * @returns {string[]}
92
+ */
93
+ function splitLongLine(line, limit) {
94
+ const pieces = [];
95
+ let remaining = line;
96
+ while (remaining.length > limit) {
97
+ let splitAt = findTelegramSplitIndex(remaining, limit);
98
+ if (splitAt <= 0 || splitAt > limit) splitAt = limit;
99
+ let piece = remaining.slice(0, splitAt);
100
+ if (!piece.trim()) {
101
+ splitAt = limit;
102
+ piece = remaining.slice(0, splitAt);
103
+ }
104
+ pieces.push(piece);
105
+ remaining = remaining.slice(splitAt);
106
+ }
107
+ if (remaining) pieces.push(remaining);
108
+ return pieces;
109
+ }
110
+
111
+ /**
112
+ * Split a (possibly oversized) Telegram message into chunks that each stay
113
+ * within `limit` characters.
114
+ *
115
+ * Splitting happens on line boundaries so inline Markdown entities (bold,
116
+ * italic, links — none of which may span a newline in Telegram's legacy
117
+ * Markdown) are never cut in half. Fenced code blocks, which *do* span lines,
118
+ * are kept valid across the split: when a break lands inside a code block the
119
+ * current chunk gets a closing fence appended and the next chunk re-opens the
120
+ * fence with the same marker and language (issue #1891).
121
+ *
122
+ * @param {string} text
123
+ * @param {number} [limit=TELEGRAM_TEXT_LIMIT]
124
+ * @returns {string[]} One or more chunks; always at least one element.
125
+ */
64
126
  export function splitTelegramMessageText(text, limit = TELEGRAM_TEXT_LIMIT) {
65
127
  const source = String(text ?? '');
66
128
  if (source.length <= limit) return [source];
67
129
 
130
+ // Reserve headroom on each physical line for a possible fence reopen/close
131
+ // pair so re-wrapping a code block never pushes a chunk past the limit.
132
+ const FENCE_HEADROOM = 16;
133
+ const lineLimit = Math.max(1, limit - FENCE_HEADROOM);
134
+
135
+ // Expand into physical lines, pre-splitting any line that alone exceeds the
136
+ // budget so the chunker below only ever deals with lines that fit.
137
+ const lines = [];
138
+ for (const raw of source.split('\n')) {
139
+ if (raw.length <= lineLimit) lines.push(raw);
140
+ else lines.push(...splitLongLine(raw, lineLimit));
141
+ }
142
+
68
143
  const chunks = [];
69
- let remaining = source;
144
+ let current = '';
145
+ let openFence = null; // { indent, marker, info } while inside a code block
70
146
 
71
- while (remaining.length > limit) {
72
- let splitAt = findTelegramSplitIndex(remaining, limit);
73
- let chunk = remaining.slice(0, splitAt).trimEnd();
147
+ const closeFenceLine = () => `${openFence.indent}${openFence.marker}`;
148
+ const reopenFenceLine = () => `${openFence.indent}${openFence.marker}${openFence.info}`;
74
149
 
75
- if (!chunk) {
76
- splitAt = limit;
77
- chunk = remaining.slice(0, splitAt);
150
+ const flush = () => {
151
+ let chunk = current;
152
+ if (openFence) {
153
+ // Close the still-open code block at the end of this chunk.
154
+ chunk = chunk.length ? `${chunk}\n${closeFenceLine()}` : closeFenceLine();
78
155
  }
79
-
80
156
  chunks.push(chunk);
81
- remaining = remaining.slice(splitAt).trimStart();
157
+ // Re-open the fence at the start of the next chunk so the code block (and
158
+ // its language) continues seamlessly.
159
+ current = openFence ? reopenFenceLine() : '';
160
+ };
161
+
162
+ for (const line of lines) {
163
+ const separatorLength = current.length ? 1 : 0;
164
+ const closeReserve = openFence ? closeFenceLine().length + 1 : 0;
165
+ const projected = current.length + separatorLength + line.length + closeReserve;
166
+
167
+ if (current.length > 0 && projected > limit) {
168
+ flush();
169
+ }
170
+
171
+ current = current.length ? `${current}\n${line}` : line;
172
+
173
+ const fence = parseCodeFence(line);
174
+ if (fence) {
175
+ // A fence line toggles code-block state: open when outside, close when
176
+ // already inside.
177
+ openFence = openFence ? null : fence;
178
+ }
179
+ }
180
+
181
+ // Defensive: close any fence left open by unbalanced input.
182
+ if (openFence && current.length) {
183
+ current = `${current}\n${closeFenceLine()}`;
82
184
  }
185
+ if (current.length || chunks.length === 0) chunks.push(current);
83
186
 
84
- if (remaining) chunks.push(remaining);
85
187
  return chunks;
86
188
  }
87
189
 
@@ -50,18 +50,18 @@ export function formatQueueItemLink(url) {
50
50
  * @param {boolean} [opts.withError] - Append `— error` when the item failed.
51
51
  * @returns {string} The formatted section (empty string when no items).
52
52
  */
53
- export function formatQueueHistorySection({ items, emoji, label, max, locale, withError = false }) {
53
+ export function formatQueueHistorySection({ items, emoji, label, max, locale, withError = false, indent = '' }) {
54
54
  if (!items || items.length === 0) return '';
55
- let section = `*${label}* (${items.length}):\n`;
55
+ let section = `${indent}*${label}* (${items.length}):\n`;
56
56
  for (const item of [...items].reverse().slice(0, max)) {
57
- section += ` ${emoji} ${formatQueueItemLink(item.url)}`;
57
+ section += `${indent} ${emoji} ${formatQueueItemLink(item.url)}`;
58
58
  if (withError && item.error) section += ` — ${item.error}`;
59
59
  section += '\n';
60
60
  }
61
61
  if (items.length > max) {
62
- section += ` ... ${lt('queue_and_more', { count: items.length - max }, { locale })}\n`;
62
+ section += `${indent} ... ${lt('queue_and_more', { count: items.length - max }, { locale })}\n`;
63
63
  }
64
- return `${section}\n`;
64
+ return section;
65
65
  }
66
66
 
67
67
  /**
@@ -128,25 +128,128 @@ export function collectExecutingItems({ processingItems = [], sessionItems = [],
128
128
  }
129
129
 
130
130
  /**
131
- * Render the per-tool "executing" lines (`▶️ link (status, elapsed)`) for the
132
- * detailed queue status, capped at `max` items with a localized "... and N more"
133
- * line (issue #1837).
131
+ * Render the per-tool "executing" lines for the detailed queue status as a
132
+ * compact, de-duplicated list (issue #1891):
133
+ *
134
+ * `• owner/repo#number (▶️ 2h 14m 16s)`
135
+ *
136
+ * The ▶️ emoji replaces the repeated literal "processing" status word that
137
+ * appeared on every line in the old format. Items are listed in full by
138
+ * default; pass a finite `max` to cap them with a localized "... and N more"
139
+ * line.
134
140
  *
135
141
  * @param {object} opts
136
142
  * @param {Array} opts.items - Output of {@link collectExecutingItems}.
137
- * @param {number} opts.max - Maximum items to list before collapsing.
143
+ * @param {number} [opts.max=Infinity] - Maximum items before collapsing.
144
+ * @param {string|null} opts.locale - Locale for labels/durations.
145
+ * @param {string} [opts.label] - Optional sub-list heading (e.g. "Processing").
146
+ * When set, a ` *Label* (count):` header is rendered above the items so each
147
+ * status gets its own clearly-labeled list (issue #1891 follow-up).
148
+ * @returns {string} The formatted lines (empty string when no items).
149
+ */
150
+ export function formatQueueExecutingItems({ items, max = Infinity, locale, label }) {
151
+ if (!items || items.length === 0) return '';
152
+ // Items nest one level under their section label when labeled (issue #1891).
153
+ const itemIndent = label ? ' ' : ' ';
154
+ let out = label ? ` *${label}* (${items.length}):\n` : '';
155
+ for (const item of items.slice(0, max)) {
156
+ out += `${itemIndent}• ${formatQueueItemLink(item.url)} (▶️ ${formatDuration(item.waitMs, { locale })})\n`;
157
+ }
158
+ if (items.length > max) {
159
+ out += `${itemIndent} ... ${lt('queue_and_more', { count: items.length - max }, { locale })}\n`;
160
+ }
161
+ return out;
162
+ }
163
+
164
+ /**
165
+ * Backwards-compatible alias for {@link formatQueueExecutingItems}.
166
+ * @deprecated Use {@link formatQueueExecutingItems}.
167
+ */
168
+ export const formatQueueProcessingItems = formatQueueExecutingItems;
169
+
170
+ /**
171
+ * Group queue history items (completed/failed) by their `tool` key so each tool
172
+ * queue can render its own Completed/Failed list (issue #1891 follow-up).
173
+ *
174
+ * @param {Array<{tool?: string}>} items
175
+ * @returns {Object<string, Array>} Map of tool key → items.
176
+ */
177
+ export function groupQueueItemsByTool(items) {
178
+ const byTool = {};
179
+ for (const item of items || []) {
180
+ const tool = item.tool || 'claude';
181
+ (byTool[tool] ||= []).push(item);
182
+ }
183
+ return byTool;
184
+ }
185
+
186
+ /**
187
+ * Render one tool queue's block for the detailed status as a set of *separate,
188
+ * individually-labeled lists* — Processing, Pending, Completed, Failed — instead
189
+ * of one merged bullet list (issue #1891 follow-up). Empty lists are omitted.
190
+ *
191
+ * @param {object} opts
192
+ * @param {string} opts.tool - Tool key (header label).
193
+ * @param {Array} opts.executing - Output of {@link collectExecutingItems}.
194
+ * @param {Array} opts.pendingItems - `{url, waitMs, waitingReason}` per pending item.
195
+ * @param {Array} opts.completed - Completed history items for this tool.
196
+ * @param {Array} opts.failed - Failed history items for this tool.
197
+ * @param {object} opts.labels - Localized labels (`pendingLower`, `processingLower`,
198
+ * `pending`, `processing`, `completed`, `failed`).
199
+ * @param {number} opts.max - Max history items before the "… and N more" collapse.
200
+ * @param {string|null} opts.locale - Locale for labels/durations.
201
+ * @returns {string} The formatted block (with a trailing blank line).
202
+ */
203
+ export function formatQueueToolSection({ tool, executing, pendingItems, completed, failed, labels, max, locale }) {
204
+ let block = `*${tool}*\n`;
205
+
206
+ // Processing list.
207
+ block += formatQueueExecutingItems({ items: executing, locale, label: labels.processing });
208
+
209
+ // Pending list + the shared waiting reason once (when all items agree on it).
210
+ block += formatQueuePendingItems({ items: pendingItems, locale, label: labels.pending });
211
+ const distinctReasons = [...new Set(pendingItems.map(item => item.waitingReason).filter(Boolean))];
212
+ if (distinctReasons.length === 1) {
213
+ block += ` ⏳ ${distinctReasons[0].replace(/\n+/g, '; ')}\n`;
214
+ }
215
+
216
+ // Completed / Failed lists for this tool (most-recent-first, capped), indented
217
+ // so the label sits under the tool header and items under it.
218
+ block += formatQueueHistorySection({ items: completed, emoji: '✅', label: labels.completed, max, locale, indent: ' ' });
219
+ block += formatQueueHistorySection({ items: failed, emoji: '❌', label: labels.failed, max, locale, withError: true, indent: ' ' });
220
+
221
+ return `${block}\n`;
222
+ }
223
+
224
+ /**
225
+ * Render the per-tool "pending/waiting" lines for the detailed queue status as a
226
+ * compact list (issue #1891):
227
+ *
228
+ * `• owner/repo#number (⏳ 5m 2s)`
229
+ *
230
+ * The per-item waiting *reason* is deliberately omitted here — it is almost
231
+ * always identical across pending items, so the caller shows it once for the
232
+ * whole tool instead of repeating it on every line.
233
+ *
234
+ * @param {object} opts
235
+ * @param {Array<{url: string, waitMs: number}>} opts.items - Pending items.
236
+ * @param {number} [opts.max=Infinity] - Maximum items before collapsing.
138
237
  * @param {string|null} opts.locale - Locale for labels/durations.
238
+ * @param {string} [opts.label] - Optional sub-list heading (e.g. "Pending").
239
+ * When set, a ` *Label* (count):` header is rendered above the items so each
240
+ * status gets its own clearly-labeled list (issue #1891 follow-up).
139
241
  * @returns {string} The formatted lines (empty string when no items).
140
242
  */
141
- export function formatQueueProcessingItems({ items, max, locale }) {
243
+ export function formatQueuePendingItems({ items, max = Infinity, locale, label }) {
142
244
  if (!items || items.length === 0) return '';
143
- let out = '';
245
+ // Items nest one level under their section label when labeled (issue #1891).
246
+ const itemIndent = label ? ' ' : ' ';
247
+ let out = label ? ` *${label}* (${items.length}):\n` : '';
144
248
  for (const item of items.slice(0, max)) {
145
- const label = item.queueStatus ? lt(`queue_status_${item.queueStatus}`, {}, { locale }) : lt('queue_processing', {}, { locale });
146
- out += ` ▶️ ${formatQueueItemLink(item.url)} (${label}, ${formatDuration(item.waitMs, { locale })})\n`;
249
+ out += `${itemIndent}• ${formatQueueItemLink(item.url)} (⏳ ${formatDuration(item.waitMs, { locale })})\n`;
147
250
  }
148
251
  if (items.length > max) {
149
- out += ` ... ${lt('queue_and_more', { count: items.length - max }, { locale })}\n`;
252
+ out += `${itemIndent} ... ${lt('queue_and_more', { count: items.length - max }, { locale })}\n`;
150
253
  }
151
254
  return out;
152
255
  }
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { getCachedClaudeLimits, getCachedCodexLimits, getCachedGitHubLimits, getCachedMemoryInfo, getCachedCpuInfo, getCachedDiskInfo, getLimitCache } from './limits.lib.mjs';
19
19
  export { formatDuration, getRunningAgentProcesses, getRunningClaudeProcesses, getRunningCodexProcesses, getRunningGeminiProcesses, getRunningProcesses, getRunningQwenProcesses } from './telegram-solve-queue.helpers.lib.mjs';
20
- import { collectExecutingItems, formatDuration, formatQueueHistorySection, formatQueueItemLink, formatQueueProcessingItems, formatWaitingReason, getRunningAgentProcesses, getRunningClaudeProcesses, getRunningCodexProcesses, getRunningGeminiProcesses, getRunningProcesses, getRunningQwenProcesses, getRunningSessionItems } from './telegram-solve-queue.helpers.lib.mjs';
20
+ import { collectExecutingItems, formatDuration, formatQueueToolSection, formatWaitingReason, getRunningAgentProcesses, getRunningClaudeProcesses, getRunningCodexProcesses, getRunningGeminiProcesses, getRunningProcesses, getRunningQwenProcesses, getRunningSessionItems, groupQueueItemsByTool } from './telegram-solve-queue.helpers.lib.mjs';
21
21
  export { QUEUE_CONFIG, THRESHOLD_STRATEGIES } from './queue-config.lib.mjs';
22
22
  import { QUEUE_CONFIG } from './queue-config.lib.mjs';
23
23
  import { formatExecutingWorkSessionMessage, formatStartingWorkSessionMessage } from './work-session-formatting.lib.mjs';
@@ -46,10 +46,6 @@ function appendRemainingDuration(reason, ms, locale) {
46
46
  return `${reason} (${lt('remaining', { duration: formatDuration(ms, { locale }) }, { locale })})`;
47
47
  }
48
48
 
49
- function queueStatusLabel(status, locale) {
50
- return lt(`queue_status_${status}`, {}, { locale });
51
- }
52
-
53
49
  /**
54
50
  * Queue item representing a /solve command request
55
51
  */
@@ -1324,65 +1320,63 @@ export class SolveQueue {
1324
1320
 
1325
1321
  /**
1326
1322
  * Format detailed queue status for Telegram message.
1327
- * Groups output by tool queue (clickable links per item), then lists the
1328
- * Completed and Failed history as clickable links, capped per section.
1329
1323
  *
1330
- * Processing count = max(actual AI CLI processes via pgrep, tracked
1331
- * `$ --status` executing screen-isolated sessions), not queue state.
1324
+ * Each tool queue renders as a set of *separate, individually-labeled lists* —
1325
+ * Processing, Pending, Completed, Failed instead of one merged bullet list
1326
+ * (issue #1891). Empty lists (and fully-empty tool queues) are skipped, items
1327
+ * render as compact `• link (emoji dur)` rows, and the shared waiting reason
1328
+ * is shown once per tool.
1332
1329
  *
1333
1330
  * @returns {Promise<string>}
1334
- * @see https://github.com/link-assistant/hive-mind/issues/1159
1335
1331
  * @see https://github.com/link-assistant/hive-mind/issues/1267
1336
1332
  * @see https://github.com/link-assistant/hive-mind/issues/1837
1333
+ * @see https://github.com/link-assistant/hive-mind/issues/1891
1337
1334
  */
1338
1335
  async formatDetailedStatus(options = {}) {
1339
1336
  const locale = getLocale(options);
1340
1337
  const stats = this.getStats();
1341
- const externalProcessing = await this.getExternalProcessingSnapshot(Object.keys(this.queues));
1342
1338
  // Currently-executing detached sessions (with issue/PR URLs). These are the
1343
1339
  // real running tasks; the queue's own `processing` Map is emptied once a task
1344
1340
  // is dispatched, so without this the executing items are never listed (#1837).
1345
1341
  const runningSessionItems = await this.getRunningSessionItemsFn(this.verbose);
1346
1342
 
1347
- // Get actual processing counts for each tool queue.
1348
- // This combines pgrep with tracked isolation status so users see detached
1349
- // screen-isolated work even when the direct AI CLI process count is lower.
1343
+ // Section labels: each per-tool sub-list uses a capitalized heading.
1344
+ const cap = s => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
1345
+ const labels = {
1346
+ pending: cap(lt('queue_pending', {}, { locale })),
1347
+ processing: cap(lt('queue_processing', {}, { locale })),
1348
+ completed: cap(lt('queue_completed', {}, { locale })),
1349
+ failed: cap(lt('queue_failed', {}, { locale })),
1350
+ };
1351
+
1352
+ // Group the (globally-capped) completed/failed history by tool so each tool
1353
+ // queue shows its own Completed/Failed list (issue #1891 follow-up).
1354
+ const completedByTool = groupQueueItemsByTool(this.completed);
1355
+ const failedByTool = groupQueueItemsByTool(this.failed);
1356
+
1350
1357
  let message = `📋 *${lt('solve_queue_status', {}, { locale })}*\n\n`;
1358
+ const max = QUEUE_CONFIG.MAX_DISPLAY_ITEMS_PER_QUEUE;
1351
1359
 
1352
- // Show per-tool queue breakdown with items grouped by queue
1353
- for (const [tool, toolQueue] of Object.entries(this.queues)) {
1354
- const pending = toolQueue.length;
1355
- const processing = externalProcessing.byTool[tool] || 0;
1356
- message += `*${tool}* (${lt('queue_pending', {}, { locale })}: ${pending}, ${lt('queue_processing', {}, { locale })}: ${processing})\n`;
1360
+ // Every tool with *any* activity (queued, processing, or in history) so
1361
+ // per-tool history shows even after a tool's live queue has drained.
1362
+ const tools = [...new Set([...Object.keys(this.queues), ...Object.keys(completedByTool), ...Object.keys(failedByTool)])];
1357
1363
 
1358
- // List the tasks this tool is actively executing as clickable links. We
1359
- // merge the queue's in-memory processing Map with the externally-tracked
1360
- // running sessions (detached screen/isolation work), deduped by URL, so
1361
- // executing tasks are listed even after dispatch (issue #1837).
1364
+ for (const tool of tools) {
1365
+ const toolQueue = this.queues[tool] || [];
1366
+ const pending = toolQueue.length;
1367
+ // Executing tasks: merge the in-memory processing Map with tracked
1368
+ // detached sessions, deduped by URL, so they list even after dispatch
1369
+ // (issue #1837).
1362
1370
  const executing = collectExecutingItems({ processingItems: this.processing.values(), sessionItems: runningSessionItems, tool });
1363
- message += formatQueueProcessingItems({ items: executing, max: QUEUE_CONFIG.MAX_DISPLAY_ITEMS_PER_QUEUE, locale });
1364
-
1365
- // Show first queued items for this tool with clickable links
1366
- const displayItems = toolQueue.slice(0, QUEUE_CONFIG.MAX_DISPLAY_ITEMS_PER_QUEUE);
1367
- for (const item of displayItems) {
1368
- const waitTime = formatDuration(item.getWaitTime(), { locale });
1369
- message += ` • ${formatQueueItemLink(item.url)} (${queueStatusLabel(item.status, locale)}, ${waitTime})\n`;
1370
- if (item.waitingReason) {
1371
- message += ` └ ${item.waitingReason}\n`;
1372
- }
1373
- }
1374
- if (toolQueue.length > QUEUE_CONFIG.MAX_DISPLAY_ITEMS_PER_QUEUE) {
1375
- message += ` ... ${lt('queue_and_more', { count: toolQueue.length - QUEUE_CONFIG.MAX_DISPLAY_ITEMS_PER_QUEUE }, { locale })}\n`;
1376
- }
1371
+ const completed = completedByTool[tool] || [];
1372
+ const failed = failedByTool[tool] || [];
1377
1373
 
1378
- message += '\n';
1379
- }
1374
+ // Skip tools with nothing to show in any list.
1375
+ if (pending === 0 && executing.length === 0 && completed.length === 0 && failed.length === 0) continue;
1380
1376
 
1381
- // Completed / Failed lists - clickable links to the executed issues/PRs,
1382
- // most-recent-first so the newest results are easy to find (issue #1837).
1383
- const max = QUEUE_CONFIG.MAX_DISPLAY_ITEMS_PER_QUEUE;
1384
- message += formatQueueHistorySection({ items: this.completed, emoji: '✅', label: lt('queue_completed', {}, { locale }), max, locale });
1385
- message += formatQueueHistorySection({ items: this.failed, emoji: '❌', label: lt('queue_failed', {}, { locale }), max, locale, withError: true });
1377
+ const pendingItems = toolQueue.map(item => ({ url: item.url, waitMs: item.getWaitTime(), waitingReason: item.waitingReason }));
1378
+ message += formatQueueToolSection({ tool, executing, pendingItems, completed, failed, labels, max, locale });
1379
+ }
1386
1380
 
1387
1381
  // Summary stats
1388
1382
  message += `${lt('queue_completed', {}, { locale })}: ${stats.completed}, ${lt('queue_failed', {}, { locale })}: ${stats.failed}\n`;