Files
PaperClipAI/ui/src/components/task-chat/task-chat-adapter.ts
T
DottaandPaperclip 8cb0ce0de5 fix(ui): restore queued message interrupt action (#11374)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The task thread lets an operator add guidance while an agent run is
active
> - A new message can wait behind that active run as a queued message
> - The classic task view lets the operator interrupt the target run
from that queued message
> - The redesigned task view did not expose the same action
> - This pull request restores the action and keeps it bound to the
exact target run
> - The benefit is that operators can apply urgent guidance without
switching task views

## Linked Issues or Issue Description

**What happened?**

The redesigned task view showed `Queued` for a queued operator message,
but it did not show the existing interrupt action.

**Expected behavior**

The queued message must show `Interrupt` next to `Queued`. The action
must stop the exact run that the message is waiting behind.

**Steps to reproduce**

1. Open a task in the redesigned task view while an agent run is active.
2. Send a new operator message so it enters the queued state.
3. Observe that the queued message has no interrupt action.

**Paperclip version or commit**

`bc0b5a1642`

**Deployment mode**

All deployment modes that use the redesigned task view.

## What Changed

- Preserve persisted queued state and the target run ID in the
redesigned thread model.
- Render a token-compliant `Interrupt` action beside the queued state.
- Reuse the existing exact-run interrupt callback and show a disabled
`Interrupting…` state during the request.
- Keep an assigned queue target immutable so an in-flight comment cannot
rebind its interrupt action to a replacement run.
- Add regression tests for persisted queued messages, replacement-run
races, and the in-progress action state.
- No documentation update was required because this restores existing
behavior.

## Verification

- `pnpm exec vitest run ui/src/components/TaskChatThread.test.tsx`
- `pnpm exec vitest run ui/src/components/TaskChatThread.test.tsx
ui/src/components/task-chat/task-chat-adapter.test.ts
ui/src/pages/IssueDetail.test.tsx -t 'queued message actions|queues
messages against a queued live run and interrupts that exact
run|commentsToTaskChatItems'`
- `pnpm exec vitest run ui/src/pages/IssueDetail.test.tsx -t 'queued
message|queues messages'`
- `pnpm exec vitest run ui/src/pages/IssueDetail.test.tsx` (52 passed)
- `pnpm -r typecheck`
- `pnpm test:run` (all server and UI groups passed; the CLI group passed
after inherited static AWS credential variables were omitted)
- `env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY pnpm exec vitest
run cli/src/__tests__/secrets.test.ts`
- `pnpm build`
- `pnpm check:token-gates`

## Risks

Low risk. The change only adds an action to queued messages that have a
target run and an interrupt callback. Messages without both values keep
the current rendering.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex with GPT-5. The runtime does not expose a more specific
deployment ID or context-window size. The model used reasoning,
repository tools, code execution, and 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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-14 14:26:18 -04:00

107 lines
4.4 KiB
TypeScript

/**
* Live adapter: normalize the existing IssueChatComment stream (including
* optimistic echoes) into the redesign's TaskChatItem[] model. This is the
* seam that lets the new render layer show live task data without touching the
* comment data pipeline. Rich agent-run streaming (thinking/tool/diff) is
* demonstrated in the harness and layered onto this adapter as a later step;
* the baseline live thread renders the author-typed message history + optimistic
* echo, which is the core legibility win.
*/
import type { Agent } from "@paperclipai/shared";
import type { IssueChatComment } from "@/lib/issue-chat-messages";
import { resolveCommentAttribution } from "@/lib/comment-attribution";
import type { TaskChatAuthorKind, TaskChatItem } from "./task-chat-model";
export interface TaskChatAdapterContext {
agentMap?: Map<string, Agent>;
userLabelMap?: ReadonlyMap<string, string> | null;
currentUserId?: string | null;
/**
* Task's current assignee. Agent comments from anyone else are cross-issue
* writes and get a "for {user}" attribution chip (the open cross-task write design (attribution)).
*/
issueAssigneeAgentId?: string | null;
}
function effectiveAgentId(comment: IssueChatComment): string | null {
return comment.authorAgentId ?? comment.derivedAuthorAgentId ?? null;
}
function authorKind(comment: IssueChatComment): TaskChatAuthorKind {
// System authorship wins over any derivable run→agent linkage (PAP-443):
// recovery notices carry a derivedAuthorAgentId but must not render as
// agent bubbles.
if (comment.authorType === "system") return "system";
if (effectiveAgentId(comment)) return "agent";
if (comment.authorType === "user") return "human";
return "agent";
}
/** Shared bubble-footer time format ("2:34 PM") — also used by the description bubble (PAP-375). */
export function formatTaskChatTimestamp(value: unknown): string | undefined {
if (!value) return undefined;
const d = value instanceof Date ? value : new Date(value as string);
if (Number.isNaN(d.getTime())) return undefined;
return d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
}
export function commentsToTaskChatItems(
comments: IssueChatComment[],
ctx: TaskChatAdapterContext = {},
): TaskChatItem[] {
const items: TaskChatItem[] = [];
for (const comment of comments) {
if (comment.deletedAt) continue;
const kind = authorKind(comment);
let authorName: string | undefined;
let agentIcon: string | null | undefined;
let onBehalfOfUserName: string | undefined;
if (kind === "agent") {
const agentId = effectiveAgentId(comment);
authorName = (agentId && ctx.agentMap?.get(agentId)?.name) || "Agent";
agentIcon = agentId ? ctx.agentMap?.get(agentId)?.icon : undefined;
onBehalfOfUserName = resolveCommentAttribution({
authorAgentId: agentId,
onBehalfOfUserId: comment.onBehalfOfUserId ?? null,
issueAssigneeAgentId: ctx.issueAssigneeAgentId,
resolveUserLabel: (userId) => ctx.userLabelMap?.get(userId),
})?.userName;
} else if (kind === "human") {
authorName =
(comment.authorUserId && ctx.userLabelMap?.get(comment.authorUserId)) || undefined;
}
const queued = comment.queueState === "queued" || comment.clientStatus === "queued";
const optimistic =
queued
? "queued"
: comment.clientStatus === "pending"
? "pending"
: undefined;
const createdAtIso =
comment.createdAt instanceof Date
? comment.createdAt.toISOString()
: comment.createdAt
? String(comment.createdAt)
: undefined;
items.push({
id: comment.id || comment.clientId || `${comment.createdAt}`,
kind: "message",
author: kind,
authorName,
text: comment.body,
timestamp: formatTaskChatTimestamp(comment.createdAt),
optimistic,
queueTargetRunId: queued ? comment.queueTargetRunId ?? null : null,
agentIcon,
onBehalfOfUserName,
// System notices carry their structured hints through to the render
// layer (PAP-443); other authors keep the item lean.
presentation: kind === "system" ? comment.presentation ?? null : undefined,
metadata: kind === "system" ? comment.metadata ?? null : undefined,
runAgentId: kind === "system" ? comment.runAgentId ?? null : undefined,
createdAtIso: kind === "system" ? createdAtIso : undefined,
});
}
return items;
}