Agent Manager Claude Opus 5 commited on
Commit
4344b4e
·
1 Parent(s): 7e74308

Reader: show the prompts typed while the agent was working

Browse files

> i can't see the last two prompts (api feedback, screenshot bug report) in the
> reader

Confirmed against the transcript named in the report. A prompt typed mid-turn is
not written as a `user` message: Claude Code records

{"type":"queue-operation","operation":"enqueue","content":"<the prompt>"}

and the text exists nowhere else. `queue-operation` appeared nowhere in this
repo, so those prompts were invisible in the reader — permanently, not late.

Two corrections to the diagnosis, both from the data, because they change the
fix:

**There are three operations, not two.** 91 `enqueue`, 86 `dequeue`, 5 `remove`
in the reference transcript. `dequeue` carries no content and means the prompt
was popped into a request — it then arrives as an ordinary user message, and 40
of 45 distinct enqueued texts do exactly that. `remove` names its text and means
nothing else will carry it: those 5 are the invisible ones, and 3 of them are
verifiably prompts the operator sent (the meta-row bug I was handed two rounds
ago is one). So the fix cannot be "render every enqueue" — that double-renders
the common path. My first attempt matched enqueued text against later messages
instead, and it showed one prompt six times: a window held six enqueues of a
looped self-prompt and two of its messages. Deciding from the records themselves
is window-safe, because enqueue and its dequeue/remove sit seconds apart.

**One of the five removes is an enqueued `<task-notification>`.** Queued harness
noise is still harness noise, so the same filter ordinary user text gets applies
here: `<…>` and `[Request interrupted` are not prompts.

The rule, then: emit a queued prompt, and drop it again unless a `remove` proves
it is the only copy. A prompt still queued when the window ends is left to its
message for the same reason.

**Where it goes.** Where it was typed — inside the turn it interrupted, so it
opens a new exchange there and that turn keeps only the work it had done. That is
the order it happened in, and the answer that follows usually addresses it. The
alternative, holding it until the turn ends, would place it after work it
predates — the bug #85 was about.

**And the band says `queued`,** because the records cannot distinguish a prompt
that was consumed from one that was cancelled. All 5 removes here were real, and
no cancellation appears in 5,287 lines, but the label means a cancelled prompt
would read as "typed and queued" rather than as one the agent answered. If Claude
Code ever distinguishes them, the label is where that goes.

Checked live, not only on load: appending an enqueue+remove pair to a transcript
under an open reader has the prompt appear on the next poll (bands 2 → 3).

`server/test/queued-prompts.test.mjs` pins all of it: the removed path is shown
and labelled, the dequeued path is shown once by its real message, both paths in
one turn keep their order, the same words queued twice keep one copy each, an
enqueued `<task-notification>` is not a prompt, and a still-queued prompt waits.
Mutation-checked — ignoring the records fails 5, showing every enqueue fails 3,
dropping the noise filter fails 1.

Left alone deliberately: the digest (`scanClaude`) still counts only real user
messages, so the Overview card's "last prompt" can lag a queued one. Same
records, different surface, and worth its own change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

docs/conversation-view.md CHANGED
@@ -435,6 +435,28 @@ pane with nothing to render (a shell) simply stays a terminal.
435
  history recall, a slash-command menu — and duplicated markup is how one surface quietly gets
436
  them and the other does not. `onPasteFiles` and `above` are the seam an attachment strip plugs
437
  into, so it arrives in both places at once or in neither.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
438
  - **A half-typed reply is kept** (`drafts.ts`, `useDraft.ts`). Reported from a phone: start typing
439
  an answer, switch apps, come back, and the text is gone. The pane is not what loses it — App.tsx
440
  keeps a dozen panes warm, so an in-app trip to the session list already survived — the
 
435
  history recall, a slash-command menu — and duplicated markup is how one surface quietly gets
436
  them and the other does not. `onPasteFiles` and `above` are the seam an attachment strip plugs
437
  into, so it arrives in both places at once or in neither.
438
+ - **A prompt typed mid-turn is still a prompt.** Claude Code does not write one as a `user`
439
+ message: while the agent is working it records `{"type":"queue-operation","operation":"enqueue",
440
+ "content":"…"}`, and the text exists nowhere else until the queue is consumed. `queue-operation`
441
+ appeared nowhere in this repo, so every such prompt was invisible in the reader — permanently,
442
+ not late. Two of the operator's own prompts were lost that way.
443
+
444
+ The two consumption paths need opposite treatment, and they are distinguishable from the records
445
+ alone (which matters, because a window can hold the enqueue and not what follows it): `dequeue`
446
+ carries no text and pops the oldest queued prompt — the prompt arrives as an ordinary message
447
+ right after, so the queued copy stands down; `remove` names the text it takes out, and nothing
448
+ else will ever carry it, so that copy is what the reader shows. In the reference transcript that
449
+ split is 86 dequeues to 5 removes, and the 5 are exactly the prompts with no message anywhere.
450
+ A prompt still in the queue when the window ends is left to its message for the same reason.
451
+
452
+ It is shown where it was typed — inside the turn it interrupted, which means it opens a new
453
+ exchange there and the interrupted turn keeps only the work it had done so far. That is the
454
+ honest order: the operator typed it then, and the answer that follows usually addresses it. And
455
+ the band says **queued**, because the records cannot tell a prompt that was consumed from one
456
+ that was cancelled — so the reader states what it knows rather than implying the agent replied.
457
+ Harness noise is filtered exactly as it is for ordinary user text: one of those five removes is
458
+ an enqueued `<task-notification>`, and showing that as something the operator typed would be a
459
+ new bug in place of the old one.
460
  - **A half-typed reply is kept** (`drafts.ts`, `useDraft.ts`). Reported from a phone: start typing
461
  an answer, switch apps, come back, and the text is gone. The pane is not what loses it — App.tsx
462
  keeps a dozen panes warm, so an in-app trip to the session list already survived — the
server/package.json CHANGED
@@ -16,7 +16,7 @@
16
  "test:ui": "node terminal-ui.test.mjs && node screenshot-input.test.mjs && node reader-info.test.mjs",
17
  "test:screenshots": "node screenshot-input.test.mjs",
18
  "test:mobile": "node mobile.test.mjs",
19
- "test": "node ../scripts/run-suites.mjs"
20
  },
21
  "engines": {
22
  "node": ">=20.19"
 
16
  "test:ui": "node terminal-ui.test.mjs && node screenshot-input.test.mjs && node reader-info.test.mjs",
17
  "test:screenshots": "node screenshot-input.test.mjs",
18
  "test:mobile": "node mobile.test.mjs",
19
+ "test": "node test/queued-prompts.test.mjs && node ../scripts/run-suites.mjs"
20
  },
21
  "engines": {
22
  "node": ">=20.19"
server/src/traces.js CHANGED
@@ -1012,13 +1012,66 @@ async function* rangeJsonLines(file, range) {
1012
  // same usage. parseClaude() above dedupes by dropping repeats; a viewer must
1013
  // instead MERGE them, or half the assistant text disappears. So: first line for
1014
  // an id creates the message and owns the usage, later lines append blocks.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1015
  async function normalizeClaude(file, out, range) {
1016
  const stitch = makeStitcher();
1017
  const byMsgId = new Map();
 
1018
  for await (const j of jsonLines(file, range)) {
1019
  // These embed whole file contents; share.js drops them and so do we.
1020
  if (j.type === 'file-history-snapshot' || j.type === 'file-history-delta') continue;
1021
  if (j.isMeta || j.sourceToolUseID) continue;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1022
  if (j.type !== 'user' && j.type !== 'assistant') continue;
1023
 
1024
  const m = j.message;
@@ -1083,6 +1136,14 @@ async function normalizeClaude(file, out, range) {
1083
  if (id) byMsgId.set(id, msg);
1084
  }
1085
  }
 
 
 
 
 
 
 
 
1086
  }
1087
 
1088
  // Codex — $CODEX_HOME/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl
 
1012
  // same usage. parseClaude() above dedupes by dropping repeats; a viewer must
1013
  // instead MERGE them, or half the assistant text disappears. So: first line for
1014
  // an id creates the message and owns the usage, later lines append blocks.
1015
+ // A prompt typed while the agent is mid-turn is not written as a `user` message
1016
+ // at all. Claude Code queues it:
1017
+ // {"type":"queue-operation","operation":"enqueue","content":"<the prompt>"}
1018
+ // and the text exists NOWHERE else until the queue is consumed. Two things then
1019
+ // happen, and they need opposite treatment:
1020
+ // - `dequeue` (86 of 91 in the reference transcript): the prompt arrives as an
1021
+ // ordinary user message afterwards, so the queued copy must give way to it
1022
+ // or the reader shows the same prompt twice;
1023
+ // - `remove` (5 of 91): it never arrives. The queue record is the only copy
1024
+ // there will ever be — which is why those prompts were invisible in the
1025
+ // reader, permanently, not late.
1026
+ // The two paths are distinguishable from the records alone, which matters
1027
+ // because a window can contain the enqueue and not the message that follows it:
1028
+ // `dequeue` carries no content and pops the oldest queued prompt (the real
1029
+ // message is coming, so the queued copy stands down), while `remove` names the
1030
+ // text it takes out (nothing else will carry it, so the queued copy is what the
1031
+ // reader gets). Text matching against later messages was the first attempt and
1032
+ // it double-rendered a prompt the operator had queued eight times in a loop:
1033
+ // the window held six enqueues and two of the messages.
1034
+ const queueKey = (s) => String(s || '').replace(/\s+/g, ' ').trim();
1035
+ // The same filter the rest of this file applies to user text: an enqueued
1036
+ // `<task-notification>` is the harness talking to itself, not something the
1037
+ // operator typed. One of the five removes in the reference transcript is exactly
1038
+ // that, and showing it as a prompt would be a new bug in place of the old one.
1039
+ const isHarnessText = (t) => t.startsWith('<') || t.startsWith('[Request interrupted');
1040
+
1041
  async function normalizeClaude(file, out, range) {
1042
  const stitch = makeStitcher();
1043
  const byMsgId = new Map();
1044
+ const pending = []; // queued prompts, oldest first, until dequeued or removed
1045
  for await (const j of jsonLines(file, range)) {
1046
  // These embed whole file contents; share.js drops them and so do we.
1047
  if (j.type === 'file-history-snapshot' || j.type === 'file-history-delta') continue;
1048
  if (j.isMeta || j.sourceToolUseID) continue;
1049
+ if (j.type === 'queue-operation') {
1050
+ const text = queueKey(j.content);
1051
+ if (j.operation === 'enqueue') {
1052
+ // Harness noise is not a prompt: one of the five removes in the
1053
+ // reference transcript is an enqueued <task-notification>, and showing
1054
+ // that as something the operator typed would be a new bug for an old one.
1055
+ if (!text || isHarnessText(text)) continue;
1056
+ const msg = {
1057
+ role: 'user',
1058
+ ts: j.timestamp ? Date.parse(j.timestamp) || undefined : undefined,
1059
+ queued: true,
1060
+ blocks: [textBlock('text', String(j.content))],
1061
+ };
1062
+ out.push(msg);
1063
+ pending.push({ text, msg });
1064
+ msg.superseded = true; // until a `remove` proves it is the only copy
1065
+ } else if (j.operation === 'dequeue') {
1066
+ // popped into a request: the ordinary user message is on its way, and
1067
+ // that one keeps the prompt. Positional, because dequeue names no text.
1068
+ pending.shift();
1069
+ } else if (j.operation === 'remove') {
1070
+ const at = pending.findIndex((p) => p.text === text);
1071
+ if (at >= 0) pending.splice(at, 1)[0].msg.superseded = false;
1072
+ }
1073
+ continue;
1074
+ }
1075
  if (j.type !== 'user' && j.type !== 'assistant') continue;
1076
 
1077
  const m = j.message;
 
1136
  if (id) byMsgId.set(id, msg);
1137
  }
1138
  }
1139
+ // Dropped at the end rather than never pushed: which path a queued prompt took
1140
+ // is not known until its dequeue or remove has been read. Anything still
1141
+ // pending when the window ends stays dropped — it is about to be dequeued, and
1142
+ // the message that follows is the copy the reader should have.
1143
+ if (out.messages.some((m) => m.superseded)) {
1144
+ out.messages = out.messages.filter((m) => !m.superseded);
1145
+ }
1146
+ for (const m of out.messages) delete m.superseded;
1147
  }
1148
 
1149
  // Codex — $CODEX_HOME/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl
server/test/queued-prompts.test.mjs ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // A prompt typed while the agent is mid-turn.
2
+ //
3
+ // Claude Code does not write it as a `user` message. It writes
4
+ // {"type":"queue-operation","operation":"enqueue","content":"<the prompt>"}
5
+ // and then either `dequeue` (the prompt arrives as an ordinary user message
6
+ // afterwards) or `remove` (it never does). Before this, `queue-operation`
7
+ // appeared nowhere in the repo, so every mid-turn prompt was invisible in the
8
+ // reader — permanently, not late. Two of the operator's own prompts were lost
9
+ // that way, which is the regression nobody would notice coming back.
10
+ //
11
+ // Run with: node test/queued-prompts.test.mjs
12
+ import assert from 'node:assert/strict';
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import { readTraceByPath } from '../src/traces.js';
17
+
18
+ let failed = 0;
19
+ const check = (what, fn) => {
20
+ try { fn(); console.log(` ok ${what}`); } catch (e) {
21
+ failed++;
22
+ console.log(` FAIL ${what}\n ${e.message.split('\n')[0]}`);
23
+ }
24
+ };
25
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'am-queued-'));
26
+ const norm = (s) => (s || '').replace(/\s+/g, ' ').trim();
27
+ const textOf = (m) => norm((m.blocks || []).filter((b) => b.type === 'text').map((b) => b.text).join(' '));
28
+
29
+ let clock = Date.UTC(2026, 7, 20, 8, 0, 0);
30
+ const at = () => new Date((clock += 5000)).toISOString();
31
+ const user = (text) => ({ type: 'user', timestamp: at(), cwd: '/w', message: { role: 'user', content: [{ type: 'text', text }] } });
32
+ const assistant = (text) => ({ type: 'assistant', timestamp: at(), cwd: '/w', message: { id: `m${clock}`, role: 'assistant', model: 'claude-test', usage: { input_tokens: 10, output_tokens: 5 }, content: [{ type: 'text', text }] } });
33
+ const tool = (name) => ({ type: 'assistant', timestamp: at(), cwd: '/w', message: { id: `t${clock}`, role: 'assistant', model: 'claude-test', content: [{ type: 'tool_use', id: `u${clock}`, name, input: { file_path: '/w/x.js' } }] } });
34
+ const queue = (operation, content) => ({ type: 'queue-operation', operation, sessionId: 's', timestamp: at(), ...(content === undefined ? {} : { content }) });
35
+
36
+ const write = (name, records) => {
37
+ const f = path.join(dir, `${name}.jsonl`);
38
+ fs.writeFileSync(f, `${records.map((r) => JSON.stringify(r)).join('\n')}\n`);
39
+ return f;
40
+ };
41
+ const read = async (f) => {
42
+ const page = await readTraceByPath(f, { limit: 500 });
43
+ return (page.turns || []);
44
+ };
45
+
46
+ // ---- the operator's case: queued, then removed, and never written as a message
47
+ {
48
+ const f = write('removed', [
49
+ user('start the nightly job'),
50
+ tool('Bash'),
51
+ queue('enqueue', 'ok, now API feedback. the user should be excluded by default'),
52
+ queue('remove', 'ok, now API feedback. the user should be excluded by default'),
53
+ assistant('Done — the job is running.'),
54
+ ]);
55
+ const turns = await read(f);
56
+ const prompts = turns.filter((m) => m.role === 'user' && textOf(m));
57
+ check('a queued prompt that was removed is shown', () => {
58
+ assert.equal(prompts.length, 2, `expected the real prompt and the queued one, got ${prompts.map(textOf)}`);
59
+ assert.match(textOf(prompts[1]), /^ok, now API feedback/);
60
+ });
61
+ check('…and it says it was queued', () => assert.equal(prompts[1].queued, true));
62
+ check('…while an ordinary prompt does not', () => assert.equal(prompts[0].queued, undefined));
63
+ check('…and it keeps the time it was typed', () => assert.ok(prompts[1].ts > prompts[0].ts));
64
+ }
65
+
66
+ // ---- the common case: queued, dequeued, and the real message arrives
67
+ {
68
+ const f = write('dequeued', [
69
+ user('start the nightly job'),
70
+ tool('Bash'),
71
+ queue('enqueue', 'and check the watcher after'),
72
+ queue('dequeue'),
73
+ user('and check the watcher after'),
74
+ assistant('Both done.'),
75
+ ]);
76
+ const prompts = (await read(f)).filter((m) => m.role === 'user' && textOf(m));
77
+ check('a dequeued prompt is shown once, by its real message', () => {
78
+ assert.equal(prompts.length, 2, `expected no duplicate, got ${prompts.map(textOf)}`);
79
+ assert.equal(textOf(prompts[1]), 'and check the watcher after');
80
+ assert.equal(prompts[1].queued, undefined, 'the real message should be the copy that survives');
81
+ });
82
+ }
83
+
84
+ // ---- two queued at once, one taken and one removed: order and pairing hold
85
+ {
86
+ const f = write('both-paths', [
87
+ user('first'),
88
+ tool('Read'),
89
+ queue('enqueue', 'second, queued and taken'),
90
+ queue('enqueue', 'third, queued and removed'),
91
+ queue('dequeue'),
92
+ user('second, queued and taken'),
93
+ queue('remove', 'third, queued and removed'),
94
+ assistant('all three answered'),
95
+ ]);
96
+ const prompts = (await read(f)).filter((m) => m.role === 'user' && textOf(m));
97
+ check('the taken one comes from its message, the removed one from the queue', () => {
98
+ assert.deepEqual(prompts.map(textOf), ['first', 'third, queued and removed', 'second, queued and taken']);
99
+ assert.deepEqual(prompts.map((m) => !!m.queued), [false, true, false]);
100
+ });
101
+ }
102
+
103
+ // ---- the same words queued twice: FIFO, not "drop them all"
104
+ {
105
+ const f = write('twice', [
106
+ user('go'),
107
+ queue('enqueue', 'same words'),
108
+ queue('enqueue', 'same words'),
109
+ queue('dequeue'),
110
+ user('same words'),
111
+ queue('remove', 'same words'),
112
+ assistant('ok'),
113
+ ]);
114
+ const prompts = (await read(f)).filter((m) => m.role === 'user' && textOf(m));
115
+ check('one copy survives per prompt, not none and not four', () => {
116
+ assert.equal(prompts.filter((m) => textOf(m) === 'same words').length, 2);
117
+ assert.equal(prompts.filter((m) => m.queued).length, 1);
118
+ });
119
+ }
120
+
121
+ // ---- harness noise must not become a prompt
122
+ {
123
+ const f = write('noise', [
124
+ user('go'),
125
+ queue('enqueue', '<task-notification>\n<task-id>abc</task-id>\n</task-notification>'),
126
+ queue('remove', '<task-notification>\n<task-id>abc</task-id>\n</task-notification>'),
127
+ queue('enqueue', '[Request interrupted by user]'),
128
+ queue('remove', '[Request interrupted by user]'),
129
+ assistant('ok'),
130
+ ]);
131
+ const prompts = (await read(f)).filter((m) => m.role === 'user' && textOf(m));
132
+ check('an enqueued task-notification is not shown as something the operator typed', () => {
133
+ assert.equal(prompts.length, 1, `only the real prompt should be a prompt, got ${prompts.map((m) => textOf(m).slice(0, 24))}`);
134
+ assert.equal(textOf(prompts[0]), 'go');
135
+ });
136
+ }
137
+
138
+ // ---- still in the queue when the window ends: the message is coming, so wait
139
+ {
140
+ const f = write('pending', [
141
+ user('go'),
142
+ tool('Bash'),
143
+ queue('enqueue', 'typed while it works, not taken yet'),
144
+ ]);
145
+ const prompts = (await read(f)).filter((m) => m.role === 'user' && textOf(m));
146
+ check('a prompt still in the queue is left to its message rather than shown twice', () => {
147
+ assert.equal(prompts.length, 1);
148
+ assert.equal(textOf(prompts[0]), 'go');
149
+ });
150
+ }
151
+
152
+ fs.rmSync(dir, { recursive: true, force: true });
153
+ console.log(failed ? `\n${failed} failed` : '\nall checks passed');
154
+ process.exit(failed ? 1 : 0);
web/src/api.ts CHANGED
@@ -490,6 +490,14 @@ export type TraceBlock =
490
  export interface TraceTurn {
491
  role: 'user' | 'assistant' | 'system';
492
  kind?: 'final' | 'update';
 
 
 
 
 
 
 
 
493
  ts?: number;
494
  model?: string;
495
  usage?: { in: number; out: number; cacheRead?: number };
 
490
  export interface TraceTurn {
491
  role: 'user' | 'assistant' | 'system';
492
  kind?: 'final' | 'update';
493
+ /**
494
+ * A prompt typed while the agent was mid-turn, read from Claude Code's queue
495
+ * records because it is written nowhere else (server/src/traces.js). The
496
+ * reader says so on the band: the records cannot tell a prompt that was
497
+ * consumed from one that was cancelled, so it reports what it knows — that
498
+ * this was typed and queued — rather than claiming it was answered.
499
+ */
500
+ queued?: boolean;
501
  ts?: number;
502
  model?: string;
503
  usage?: { in: number; out: number; cacheRead?: number };
web/src/components/conversation/Exchange.tsx CHANGED
@@ -246,7 +246,15 @@ export function ExchangeView({
246
 
247
  return (
248
  <section className={`cx${dim ? ' dim' : ''}`}>
249
- {prompt ? <div className="cx-prompt"><Hi text={prompt} q={q} /></div> : null}
 
 
 
 
 
 
 
 
250
 
251
  {/* Everything about the turn on ONE line, under the prompt: what the work
252
  was on the left, which turn it is on the right. Nothing above.
 
246
 
247
  return (
248
  <section className={`cx${dim ? ' dim' : ''}`}>
249
+ {prompt ? (
250
+ <div className="cx-prompt">
251
+ <Hi text={prompt} q={q} />
252
+ {/* Read from a queue record rather than a message: it was typed while
253
+ the agent was working. Labelled because those records cannot say
254
+ whether it was then consumed or cancelled — see TraceTurn.queued. */}
255
+ {x.prompt?.queued ? <span className="cx-queued mono" title="Typed while the agent was working, so it waited in the queue">queued</span> : null}
256
+ </div>
257
+ ) : null}
258
 
259
  {/* Everything about the turn on ONE line, under the prompt: what the work
260
  was on the left, which turn it is on the right. Nothing above.
web/src/conversation.css CHANGED
@@ -50,6 +50,11 @@
50
  font-size: 1em; font-weight: 550; color: var(--text);
51
  white-space: pre-wrap; overflow-wrap: break-word;
52
  }
 
 
 
 
 
53
  .cx-prompt::before {
54
  content: '❯'; position: absolute; left: 0.23em; top: 6px;
55
  font-family: var(--font-mono); color: var(--accent); font-weight: 700;
 
50
  font-size: 1em; font-weight: 550; color: var(--text);
51
  white-space: pre-wrap; overflow-wrap: break-word;
52
  }
53
+ /* the queue marker: a quiet aside on the band, not a second voice */
54
+ .cx-queued {
55
+ margin-left: 7px; padding: 0 4px; border: 1px solid var(--border); border-radius: var(--r-sm);
56
+ font-size: 0.78em; font-weight: 500; color: var(--muted); vertical-align: 1px; white-space: nowrap;
57
+ }
58
  .cx-prompt::before {
59
  content: '❯'; position: absolute; left: 0.23em; top: 6px;
60
  font-family: var(--font-mono); color: var(--accent); font-weight: 700;