From 00c77385138db810d826bdd12450ac07cb11a7b0 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:03:37 -0600 Subject: [PATCH] fix(task-chat): keep messages sendable after a declined connection request (#19331) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- packages/shared/src/types/issue.ts | 2 + .../issue-queued-comments-routes.test.ts | 104 ++++++++++++++++++ .../modules/wake-queue/adapters/postgres.ts | 5 + server/src/routes/issues.ts | 12 +- .../services/queued-interaction-response.ts | 22 +++- .../task-chat/TaskChatQueuedMessages.test.tsx | 21 ++++ .../task-chat/TaskChatQueuedMessages.tsx | 20 +++- 7 files changed, 177 insertions(+), 9 deletions(-) diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 1b5563bd74e..cae9b747bba 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -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; diff --git a/server/src/__tests__/issue-queued-comments-routes.test.ts b/server/src/__tests__/issue-queued-comments-routes.test.ts index 6c49b8a6db2..4d045bb25cc 100644 --- a/server/src/__tests__/issue-queued-comments-routes.test.ts +++ b/server/src/__tests__/issue-queued-comments-routes.test.ts @@ -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)); + }, + ); }); diff --git a/server/src/modules/wake-queue/adapters/postgres.ts b/server/src/modules/wake-queue/adapters/postgres.ts index 121befc8345..2161b80f28c 100644 --- a/server/src/modules/wake-queue/adapters/postgres.ts +++ b/server/src/modules/wake-queue/adapters/postgres.ts @@ -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}`, diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 16ff5201e4b..f7b5513d3fb 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -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( diff --git a/server/src/services/queued-interaction-response.ts b/server/src/services/queued-interaction-response.ts index ec0794a80b5..553ed0efdaa 100644 --- a/server/src/services/queued-interaction-response.ts +++ b/server/src/services/queued-interaction-response.ts @@ -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 = { kind: "interaction", interactionId: interaction.id, interactionKind: interaction.kind, requiresFreshSession: record(record(payload)._paperclipWakeContext).forceFreshSession === true, + summary: heading, }; return { comment, source }; } diff --git a/ui/src/components/task-chat/TaskChatQueuedMessages.test.tsx b/ui/src/components/task-chat/TaskChatQueuedMessages.test.tsx index f5284d14b8b..0423904878d 100644 --- a/ui/src/components/task-chat/TaskChatQueuedMessages.test.tsx +++ b/ui/src/components/task-chat/TaskChatQueuedMessages.test.tsx @@ -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, diff --git a/ui/src/components/task-chat/TaskChatQueuedMessages.tsx b/ui/src/components/task-chat/TaskChatQueuedMessages.tsx index f4bc42deded..a35eff3b80d 100644 --- a/ui/src/components/task-chat/TaskChatQueuedMessages.tsx +++ b/ui/src/components/task-chat/TaskChatQueuedMessages.tsx @@ -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" > @@ -142,7 +152,7 @@ function SortableQueuedMessage({ className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden /> - + {label} @@ -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" >