fix(task-chat): keep messages sendable after a declined connection request (#19331)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The task chat composer shows a queue of saved input that waits for
the agent's next turn
> - When a person declines a connection request (for example Slack), the
server queues a read-only receipt so the agent learns the outcome
> - The composer showed that receipt with its raw JSON receipt in hover
text, and the receipt hid the person's next message
> - The next message could not be edited, discarded, or sent, so the
chat looked stuck after a simple "Decline"
> - This pull request shows a readable status for receipts and keeps the
person's own later messages visible and actionable
> - The benefit is that declining a connection resolves cleanly and the
conversation continues

## Linked Issues or Issue Description

**What happened?**
During an onboarding test on staging, a person declined the agent's
"Connect Slack" request. A queued steering item then stayed above the
composer. It could not be edited. Its hover text showed raw JSON.
Sending the next message produced an error.

**Steps to reproduce**
1. Give a task to a Paperclip runner agent whose run is active or waits
on a busy workspace.
2. Let the agent request a connection, then decline it.
3. See the queued item "Rejected: Connect Slack". Hover it: the title is
the full JSON receipt.
4. Send another message. The message never appears in the queue. Its row
controls stay disabled. Send or Interrupt on the receipt queue returns
`409 This queue does not support interruption`.

**Expected behavior**
A declined connection request resolves cleanly. It shows a readable
status. The next message can be sent.

**Evidence (staging wake diagnostics for the test task)**
- 17:52:09: the decline wake for the Slack request. It stayed deferred,
was never claimed, and was skipped at 18:21.
- 17:52:10: a later retry wake for the plan-approval continuation.
- `GET /issues/:id/queued-comments` projects the oldest matching wake.
From 17:52 to 18:21 the composer showed the Slack receipt.

Refs: related to the separate plan-approval queued-message fix, which
changes the recovery sweep. This PR only touches the queue projection
and the receipt label.

## What Changed

- `server/src/services/queued-interaction-response.ts`: add
`queuedInteractionSummary()`. A receipt now has one readable line, for
example `Declined: Slack connection`, `Connected: GitHub connection`, or
`Accepted: Build the app`. The entry `source.summary` carries it. The
steering body for the agent still includes the typed JSON receipt.
- `server/src/routes/issues.ts` (`findQueuedCommentWake`): a queued
user-message wake now takes precedence over a receipt wake. A receipt
shows only when it is the only queued input. The receipt wake itself is
unchanged and is still delivered to the agent.
- `server/src/modules/wake-queue/adapters/postgres.ts`
(`findExistingDeferredWake`): skip interaction-receipt wakes when a new
message picks the deferred wake it merges into. Before, the receipt was
the oldest deferred wake, the merge was skipped, and every later message
started its own wake. Only the first message then appeared in the queue.
Receipts still never absorb user input.
- `packages/shared/src/types/issue.ts`: add the optional `summary` field
to `IssueQueuedCommentEntry.source`.
- `ui/src/components/task-chat/TaskChatQueuedMessages.tsx`: add
`queuedMessageLabel()`. The row text, the hover title, and all
aria-labels use the summary. They never use the raw steering body.

## Verification

Red/green tests were written first and run against the unchanged code.

New tests:
- `server/src/__tests__/issue-queued-comments-routes.test.ts`
- `shows a declined connection receipt as a readable status, not its
JSON`
- `keeps the next message visible and actionable after a declined
connection (running run | stopped run)`: declines Slack, posts the next
message, and asserts that the queue lists that message with
`canEdit`/`canDiscard`, that the discard works, and that the receipt
wake is still pending for the agent.
- `queues every later message together after a declined connection
(legacy run | stopped run)`: two messages after a decline both appear in
one editable queue, and the receipt wake keeps no message ids.
- `ui/src/components/task-chat/TaskChatQueuedMessages.test.tsx`
- `shows a declined connection request as readable text, never its raw
JSON receipt (BUG-08)`

**Red (unchanged code):**
```
server: × shows a declined connection receipt as a readable status, not its JSON
        AssertionError: expected { kind: 'interaction', …(3) } to match object { …, summary: 'Declined: Slack connection' }
server: × keeps the next message visible and actionable after a declined connection (running run)
server: × keeps the next message visible and actionable after a declined connection (stopped run)
        AssertionError: ["Declined: Slack connection"]: expected undefined to match object { canEdit: true, canDiscard: true }
        (the queue lists only the receipt; the new message is missing)
        Tests  3 failed | 85 skipped (88)
ui:     × shows a declined connection request as readable text, never its raw JSON receipt (BUG-08)
        AssertionError: expected 'Reorder queued message: Rejected: Con…' not to contain '{'
        Tests  1 failed | 23 skipped (24)
```
An exploratory probe on the unchanged code also showed the send error on
this path: `POST /queued-comments/interrupt` on the receipt queue with a
running native run returns `409 {"error":"This queue does not support
interruption"}`. The exact UI text that the tester saw was not
available.

Second commit (review follow-up), unchanged `findExistingDeferredWake`:
```
server: × queues every later message together after a declined connection (legacy run)
        AssertionError: expected [ Array(1) ] to deeply equal [ …(2) ]
        Tests  1 failed | 1 passed | 88 skipped (90)
```
With the change: `Tests  2 passed | 88 skipped (90)`.

**Green (with this change):**
```
pnpm --filter server exec vitest run src/__tests__/issue-queued-comments-routes.test.ts
  Test Files  1 passed (1)   Tests  90 passed (90)
server: heartbeat-comment-wake-batching + src/modules/wake-queue
  Test Files  11 passed      Tests  322 passed (322)
server: retry-scheduling, wake-diagnostics, question-response-delivery, comment-wake-batching, dot-runner, responsible-user-invariant, stale-queue-invalidation, queued-comments routes
  Test Files  8 passed       Tests  357 passed (357)
pnpm --filter ui exec vitest run src/components/task-chat/TaskChatQueuedMessages.test.tsx src/lib/issue-queued-comment-queue.test.ts
  Test Files  2 passed (2)   Tests  31 passed (31)
server: question-response-delivery, connection-intents-service, satisfied-connection-intents
  Test Files  3 passed       Tests  199 passed (199)
```
A first full run of the route file hit a 30s `beforeAll` timeout while
embedded Postgres started on a loaded machine. One new stopped-run case
was also flaky, because the agent could dispatch the new message before
the assertion. I fixed the flake: the test now occupies the agent on
another task (`maxConcurrentRuns: 1`), which is the existing pattern in
this file. I confirmed again that this test fails without the route fix.

Typecheck: `pnpm --filter @paperclipai/shared typecheck`, `pnpm --filter
@paperclipai/server typecheck`, and `pnpm --filter @paperclipai/ui
typecheck` pass. `pnpm check:token-gates`: all gates clean. I did not
run the full `pnpm test:run` or `pnpm build` locally. CI runs them.

## Risks

- Behavior change: when a receipt and a user message wait at the same
time, the composer now shows the message queue. The receipt is no longer
shown there until it is the only queued input. The receipt wake and its
delivery do not change. The resolved card in the thread still shows the
outcome.
- The first line of the steering body for connection receipts changes
from `Rejected: Connect Slack` to `Declined: Slack connection`. The JSON
receipt for the agent does not change.
- Out of scope (unchanged on master, reproduced without any receipt):
while a native run is running, each new message gets its own queued run,
and the queue shows only the first one. Messages that join a
`scheduled_retry` run's context also do not appear in the queue
projection.
- Overlap: the plan-approval queued-message fix (BUG-03) changes the
recovery sweep, not this code. Both fixes touch the same symptom area.
They do not edit the same functions.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, browser inspection of staging, test execution).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-05 21:03:37 +00:00
1 parent cad3f8ae09
commit 00c7738513
7 files changed
+177 -9

No files matched your search

+2
View File
@@ -1101,6 +1101,8 @@ export interface IssueQueuedCommentEntry {
interactionId: string;
interactionKind: string;
requiresFreshSession?: boolean;
/** Readable one-line status, such as "Declined: Slack connection". */
summary?: string;
};
comment: IssueComment;
position: number;
@@ -1846,4 +1846,108 @@ describeEmbeddedPostgres("issue queued-comment routes", () => {
.get(`/api/issues/${seeded.issueId}/queued-comments`);
expect(after.body.entries.map((entry: any) => entry.comment.id)).toEqual(seeded.commentIds);
});
// BUG-08: a declined connection request (Slack) queued its read-only receipt
// ahead of the user's next message. The receipt showed raw JSON, could not be
// edited or discarded, and the next message never appeared in the queue.
async function seedDeclinedConnectionReceipt(runState: "running" | "legacy" | "stopped") {
const seeded = await seedQueue();
const interactionId = randomUUID();
await db.insert(issueThreadInteractions).values({
id: interactionId, companyId: seeded.companyId, issueId: seeded.issueId,
kind: "connection_intent", status: "rejected", createdByAgentId: seeded.agentId,
sourceRunId: seeded.runId, resolvedByUserId: "queue-owner", resolvedAt: new Date(),
continuationPolicy: "wake_assignee", requestedResolverPolicy: "human_only",
effectiveResolverPolicy: "human_only", resolverPolicyProvenance: "explicit",
title: "Connect Slack", summary: "The agent needs this connection to continue.",
payload: { version: 1, serviceSlug: "slack", serviceName: "Slack", phase: "requested",
requestingAgentId: seeded.agentId, requestingAgentName: "Paperclip Runner" },
result: { version: 1, outcome: "declined", reason: null },
});
// Same receipt shape as wakeConnectionIntentAfterResolution.
await db.update(agentWakeupRequests).set({ payload: { issueId: seeded.issueId,
mutation: "interaction", interactionId, interactionKind: "connection_intent", interactionStatus: "rejected",
_paperclipWakeContext: { issueId: seeded.issueId, interactionId, interactionKind: "connection_intent",
interactionStatus: "rejected", mutation: "interaction", refreshTools: true } },
}).where(eq(agentWakeupRequests.id, seeded.wakeId));
if (runState === "legacy") {
// A busy legacy run defers each new message behind the issue execution.
await db.update(agents).set({ adapterType: "claude_local",
runtimeConfig: { heartbeat: { maxConcurrentRuns: 1 } } }).where(eq(agents.id, seeded.agentId));
await db.update(heartbeatRuns).set({ runtimeMode: "legacy" }).where(eq(heartbeatRuns.id, seeded.runId));
const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });
testProcesses.set(seeded.runId, child);
runningProcesses.set(seeded.runId, { child, graceSec: 1, processGroupId: null });
}
if (runState === "stopped") {
await db.update(heartbeatRuns).set({ status: "cancelled", finishedAt: new Date(), errorCode: "workspace_busy" })
.where(eq(heartbeatRuns.id, seeded.runId));
await db.update(issues).set({ executionRunId: null }).where(eq(issues.id, seeded.issueId));
// Occupy the agent on another task so admission holds new work without
// invoking a provider (the staging agent was busy on ONB-2/ONB-3).
await db.update(agents).set({ runtimeConfig: { heartbeat: { maxConcurrentRuns: 1 } } })
.where(eq(agents.id, seeded.agentId));
await db.insert(heartbeatRuns).values({ companyId: seeded.companyId, agentId: seeded.agentId,
status: "running", contextSnapshot: { issueId: randomUUID() } });
}
return { ...seeded, interactionId };
}
it("shows a declined connection receipt as a readable status, not its JSON", async () => {
const seeded = await seedDeclinedConnectionReceipt("running");
const queue = await request(app(seeded.companyId)).get(`/api/issues/${seeded.issueId}/queued-comments`).expect(200);
expect(queue.body.entries).toHaveLength(1);
const [entry] = queue.body.entries;
expect(entry.source).toMatchObject({ kind: "interaction", interactionKind: "connection_intent",
summary: "Declined: Slack connection" });
// The agent still receives the typed receipt in the steering body.
expect(entry.comment.body).toContain('"outcome": "declined"');
});
it.each(["running", "stopped"] as const)(
"keeps the next message visible and actionable after a declined connection (%s run)",
async (runState) => {
const seeded = await seedDeclinedConnectionReceipt(runState);
const client = app(seeded.companyId);
const sent = await request(client).post(`/api/issues/${seeded.issueId}/comments`)
.send({ body: "Skip Slack for now and continue" }).expect(201);
await vi.waitFor(async () => expect(await db.select().from(agentWakeupRequests)).toHaveLength(2));
const queue = await request(client).get(`/api/issues/${seeded.issueId}/queued-comments`).expect(200);
const next = queue.body.entries.find((entry: any) => entry.comment.id === sent.body.id);
expect(next, JSON.stringify(queue.body.entries.map((entry: any) => entry.comment.body.split("\n")[0])))
.toMatchObject({ canEdit: true, canDiscard: true });
expect(next.source).toBeUndefined();
expect(queue.body.queueId).not.toBe(seeded.wakeId);
// The message is the user's own queue entry, so its controls work.
await request(client).delete(`/api/issues/${seeded.issueId}/queued-comments/${sent.body.id}`)
.send({ queueId: queue.body.queueId, revision: queue.body.revision }).expect(200);
// Declining the connection still reaches the agent on its own receipt.
const [receipt] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, seeded.wakeId));
expect(receipt.status).toBe("deferred_issue_execution");
await new Promise(resolve => setTimeout(resolve, 250));
},
);
it.each(["legacy", "stopped"] as const)(
"queues every later message together after a declined connection (%s run)",
async (runState) => {
const seeded = await seedDeclinedConnectionReceipt(runState);
const client = app(seeded.companyId);
const first = await request(client).post(`/api/issues/${seeded.issueId}/comments`)
.send({ body: "Skip Slack for now" }).expect(201);
await vi.waitFor(async () => expect(await db.select().from(agentWakeupRequests)).toHaveLength(2));
const second = await request(client).post(`/api/issues/${seeded.issueId}/comments`)
.send({ body: "Use email updates instead" }).expect(201);
await vi.waitFor(async () => {
const queue = await request(client).get(`/api/issues/${seeded.issueId}/queued-comments`).expect(200);
expect(queue.body.entries.map((entry: any) => entry.comment.id)).toEqual([first.body.id, second.body.id]);
});
const queue = await request(client).get(`/api/issues/${seeded.issueId}/queued-comments`).expect(200);
expect(queue.body.entries.every((entry: any) => entry.canEdit && entry.canDiscard)).toBe(true);
// The receipt is never merged with user input; it keeps its own wake.
const [receipt] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, seeded.wakeId));
expect(receipt.status).toBe("deferred_issue_execution");
expect(receipt.payload?._paperclipWakeContext).not.toHaveProperty("wakeCommentIds");
await new Promise(resolve => setTimeout(resolve, 250));
},
);
});
@@ -929,6 +929,11 @@ export function createWakeAdmissionReader(): WakeAdmissionReader {
eq(agentWakeupRequests.agentId, agentId),
eq(agentWakeupRequests.status, DEFERRED_WAKE_STATUS),
sql`${agentWakeupRequests.payload} ->> 'issueId' = ${issueId}`,
// A saved card response (interaction receipt) never absorbs user
// input. Skip it so a later message joins the existing message
// wake instead of starting another one behind the receipt.
sql`${agentWakeupRequests.payload} ->> 'interactionId' is null`,
sql`${agentWakeupRequests.payload} -> ${DEFERRED_WAKE_CONTEXT_KEY}::text ->> 'interactionId' is null`,
...(chatConversationId
? [
sql`coalesce(${agentWakeupRequests.payload} -> ${DEFERRED_WAKE_CONTEXT_KEY}::text ->> ${CHAT_CONVERSATION_ID_KEY}::text, ${chatConversationId}) = ${chatConversationId}`,
+10 -2
View File
@@ -7442,13 +7442,20 @@ export function issueRoutes(
)
.orderBy(asc(agentWakeupRequests.requestedAt));
// A saved card response (an approval, an answer, a declined connection)
// is a read-only receipt. It must never hide the user's own later
// messages, which are separately queued and remain editable and sendable.
let receipt: IssueQueueState | null = null;
for (const wake of rows) {
if (
readObject(wake.payload).issueId !== issue.id ||
(queuedCommentIdsFromWakePayload(wake.payload).length === 0 && !queuedInteractionId(wake.payload))
)
continue;
const isReceipt = Boolean(queuedInteractionId(wake.payload));
if (isReceipt && receipt) continue;
if (wake.status === "deferred_issue_execution") {
if (isReceipt) { receipt = { wake, state: "deferred", queueRun: null }; continue; }
return { wake, state: "deferred", queueRun: null };
}
if (!wake.runId) continue;
@@ -7466,9 +7473,10 @@ export function issueRoutes(
)
.limit(1)
.then((runRows) => runRows[0] ?? null);
if (queueRun) return { wake, state: "queued", queueRun };
if (queueRun && isReceipt) receipt = { wake, state: "queued", queueRun };
else if (queueRun) return { wake, state: "queued", queueRun };
}
return null;
return receipt;
}
async function queueCommentsForWake(
@@ -16,6 +16,24 @@ export function queuedInteractionId(payload: unknown): string | null {
? p.interactionId : null;
}
/**
* One readable line for a queued response. The steering body also carries the
* typed JSON receipt for the agent; people only ever see this line.
*/
export function queuedInteractionSummary(interaction: {
kind: string; status: string; title?: string | null; payload?: unknown;
}): string {
const p = record(interaction.payload);
if (interaction.kind === "connection_intent") {
const service = typeof p.serviceName === "string" && p.serviceName.trim() ? p.serviceName.trim() : null;
const subject = service ? `${service} connection` : interaction.title ?? "Connection request";
return interaction.status === "rejected" ? `Declined: ${subject}` : `Connected: ${subject}`;
}
const label = interaction.status === "accepted" ? "Accepted" : interaction.status === "rejected" ? "Rejected" : "Answered";
const subject = interaction.title ?? (typeof p.prompt === "string" ? p.prompt : null) ?? "Questions";
return `${label}: ${subject}`;
}
export async function readQueuedInteractionResponse(db: Db, companyId: string, issueId: string, payload: unknown) {
const id = queuedInteractionId(payload);
if (!id) return null;
@@ -27,8 +45,7 @@ export async function readQueuedInteractionResponse(db: Db, companyId: string, i
if (!interaction || interaction.status !== record(payload).interactionStatus) return null;
const p = record(interaction.payload);
const result = record(interaction.result);
const label = interaction.status === "accepted" ? "Accepted" : interaction.status === "rejected" ? "Rejected" : "Answered";
const heading = `${label}: ${interaction.title ?? p.prompt ?? "Questions"}`;
const heading = queuedInteractionSummary(interaction);
// Preserve the question labels, selected options, custom answers, approval
// target and exact revision. This is also the explicit steering message.
const body = `${heading}\n\n${JSON.stringify({
@@ -44,6 +61,7 @@ export async function readQueuedInteractionResponse(db: Db, companyId: string, i
const source: NonNullable<IssueQueuedCommentEntry["source"]> = {
kind: "interaction", interactionId: interaction.id, interactionKind: interaction.kind,
requiresFreshSession: record(record(payload)._paperclipWakeContext).forceFreshSession === true,
summary: heading,
};
return { comment, source };
}
@@ -126,6 +126,27 @@ describe("TaskChatQueuedMessages", () => {
else expect(props.onInterrupt).toHaveBeenCalledOnce();
});
it("shows a declined connection request as readable text, never its raw JSON receipt (BUG-08)", () => {
const receipt = JSON.stringify({ interactionId: "slack-1", kind: "connection_intent", status: "rejected",
payload: { serviceSlug: "slack", serviceName: "Slack" }, result: { outcome: "declined" } }, null, 2);
const entry = { ...queue.entries[0], canEdit: false, canDiscard: false,
comment: { ...queue.entries[0].comment, id: "slack-1", body: `Rejected: Connect Slack\n\n${receipt}` },
source: { kind: "interaction" as const, interactionId: "slack-1", interactionKind: "connection_intent",
summary: "Declined: Slack connection" } };
render({ queue: { ...queue, entries: [entry], steeringDisposition: "temporarily_unavailable" } });
const row = container.querySelector('[data-testid="task-chat-queued-message-slack-1"]')!;
const hoverAndLabels = [...row.querySelectorAll("[title], [aria-label]")]
.flatMap((element) => [element.getAttribute("title"), element.getAttribute("aria-label")])
.filter((text): text is string => Boolean(text));
expect(hoverAndLabels.length).toBeGreaterThan(0);
for (const text of hoverAndLabels) {
expect(text).not.toContain("{");
expect(text).not.toContain("interactionId");
}
expect(row.textContent).toContain("Declined: Slack connection");
expect(row.querySelector('[title="Declined: Slack connection"]')).not.toBeNull();
});
it("reorders the complete queue and rewrites contiguous positions", () => {
const next = reorderQueuedMessageEntries(
queue.entries,
@@ -54,6 +54,16 @@ export function reorderQueuedMessageEntries(
}));
}
/**
* People see one readable line. A saved card response also carries a typed
* JSON receipt for the agent; it never reaches a label or a hover.
*/
export function queuedMessageLabel(entry: IssueQueuedCommentEntry): string {
if (entry.source?.kind !== "interaction") return entry.comment.body;
const summary = entry.source.summary?.trim();
return summary || entry.comment.body.split("\n")[0] || "Saved response";
}
function queueActionErrorCode(error: unknown): string | null {
if (typeof error !== "object" || error === null) return null;
const body = (error as { body?: unknown }).body;
@@ -97,7 +107,7 @@ function SortableQueuedMessage({
onDiscard: () => void;
}) {
const immutableResponse = entry.source?.kind === "interaction";
const label = immutableResponse ? entry.comment.body.split("\n")[0] : entry.comment.body;
const label = queuedMessageLabel(entry);
const sortable = useSortable({
id: entry.comment.id,
disabled: queueMutationDisabled || immutableResponse,
@@ -132,7 +142,7 @@ function SortableQueuedMessage({
{...sortable.attributes}
{...sortable.listeners}
disabled={queueMutationDisabled || immutableResponse}
aria-label={`Reorder queued message: ${entry.comment.body}`}
aria-label={`Reorder queued message: ${label}`}
className="flex h-7 w-7 shrink-0 cursor-grab items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:bg-accent hover:text-foreground active:cursor-grabbing disabled:cursor-default disabled:opacity-40"
>
<GripVertical className="h-3.5 w-3.5" aria-hidden />
@@ -142,7 +152,7 @@ function SortableQueuedMessage({
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
aria-hidden
/>
<span className="min-w-0 flex-1 truncate px-1" title={entry.comment.body}>
<span className="min-w-0 flex-1 truncate px-1" title={label}>
{label}
</span>
@@ -190,7 +200,7 @@ function SortableQueuedMessage({
!entry.canDiscard
}
title="Discard queued message"
aria-label={`Discard queued message: ${entry.comment.body}`}
aria-label={`Discard queued message: ${label}`}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-40"
data-testid={`task-chat-queued-discard-${entry.comment.id}`}
>
@@ -207,7 +217,7 @@ function SortableQueuedMessage({
type="button"
disabled={queueMutationDisabled || immutableResponse}
title="Queued message actions"
aria-label={`Queued message actions: ${entry.comment.body}`}
aria-label={`Queued message actions: ${label}`}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
>
<MoreHorizontal className="h-4 w-4" aria-hidden />