feat(dashboard): render Slack/Linear replies as a card in chat (#1399)

Slack thread replies and Linear comments showed only the bare tool name in the dashboard chat. Map them to dedicated 'slack'/'linear' toolKinds and render the message body in a ReplyCard, so Open-in-Web shows what the agent actually posted.
This commit is contained in:
Johannes du Plessis 2026-06-03 15:05:47 -07:00 • committed by GitHub
parent dee78e7e84
commit 27677c4949
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 155 additions and 0 deletions

View file

@ -36,6 +36,10 @@ def _message_type(message: dict[str, Any]) -> str:
def _tool_kind(name: str) -> str:
lowered = name.lower()
if lowered == "slack_thread_reply":
return "slack"
if lowered == "linear_comment":
return "linear"
if lowered in _EDIT_TOOLS or any(token in lowered for token in ("edit", "write", "replace")):
return "edit"
if lowered in _EXECUTE_TOOLS:

View file

@ -39,6 +39,39 @@ def test_state_messages_to_ui_maps_user_and_tool_calls() -> None:
assert tool_chunk["output"] == "print('hi')"
def test_state_messages_to_ui_tags_slack_and_linear_replies() -> None:
messages = [
{"type": "human", "id": "u1", "content": "ping"},
{
"type": "ai",
"id": "a1",
"content": "",
"tool_calls": [
{
"id": "call-slack",
"name": "slack_thread_reply",
"args": {"message": "Done! Opened a PR."},
},
{
"id": "call-linear",
"name": "linear_comment",
"args": {"comment_body": "Done! Opened a PR.", "ticket_id": "abc"},
},
],
},
]
ui = state_messages_to_ui(messages)
chunks = ui[-1]["chunks"]
slack_chunk = next(c for c in chunks if c["toolCallId"] == "call-slack")
linear_chunk = next(c for c in chunks if c["toolCallId"] == "call-linear")
assert slack_chunk["toolKind"] == "slack"
assert slack_chunk["input"]["message"] == "Done! Opened a PR."
assert linear_chunk["toolKind"] == "linear"
assert linear_chunk["input"]["comment_body"] == "Done! Opened a PR."
def test_state_messages_to_ui_merges_agent_turn_and_hides_internal_tools() -> None:
messages = [
{"type": "human", "id": "u1", "content": "hello"},

View file

@ -8,6 +8,7 @@ import { CodeBlock } from "./CodeBlock";
import { Markdown } from "./Markdown";
import { ToolExecution } from "./ToolExecution";
import { ShellCommand } from "./ShellCommand";
import { ReplyCard } from "./ReplyCard";
import type {
Chunk,
Message,
@ -21,6 +22,7 @@ type RenderItem =
| { type: "explored-group"; key: string; id: string; chunks: ToolExecutionChunk[] }
| { type: "edit-item"; key: string; chunk: ToolExecutionChunk }
| { type: "shell-item"; key: string; chunk: ToolExecutionChunk }
| { type: "reply-item"; key: string; chunk: ToolExecutionChunk }
| { type: "tool-item"; key: string; chunk: ToolExecutionChunk };
function getChunkRenderKey(chunk: Chunk, sourceIndex: number): string {
@ -61,6 +63,10 @@ function isShellTool(chunk: ToolExecutionChunk): boolean {
return chunk.toolKind === "execute";
}
function isReplyTool(chunk: ToolExecutionChunk): boolean {
return chunk.toolKind === "slack" || chunk.toolKind === "linear";
}
function buildRenderItems(chunks: Chunk[]): RenderItem[] {
const items: RenderItem[] = [];
let exploredBuffer: ToolExecutionChunk[] = [];
@ -96,6 +102,8 @@ function buildRenderItems(chunks: Chunk[]): RenderItem[] {
items.push({ type: "edit-item", key: `tool-${chunk.toolCallId}`, chunk });
} else if (isShellTool(chunk)) {
items.push({ type: "shell-item", key: `tool-${chunk.toolCallId}`, chunk });
} else if (isReplyTool(chunk)) {
items.push({ type: "reply-item", key: `tool-${chunk.toolCallId}`, chunk });
} else {
items.push({ type: "tool-item", key: `tool-${chunk.toolCallId}`, chunk });
}
@ -578,6 +586,13 @@ function AgentMessage({
</div>
);
case "reply-item":
return (
<div key={item.key}>
<ReplyCard chunk={item.chunk} />
</div>
);
case "tool-item":
return (
<div key={item.key}>

View file

@ -0,0 +1,101 @@
import { memo } from "react"
import { MessageCircle } from "lucide-react"
import { Markdown } from "./Markdown"
import type { ReactNode } from "react"
import type { ToolExecutionChunk } from "@/lib/agents/types"
interface ReplyCardProps {
chunk: ToolExecutionChunk
}
function headerLabel(
isLinear: boolean,
status: ToolExecutionChunk["status"]
): string {
const pending = status === "in_progress" || status === "pending"
if (isLinear) return pending ? "Commenting on Linear…" : "Commented on Linear"
return pending ? "Replying in Slack…" : "Replied in Slack"
}
const SLACK_TOKEN = /<([^>]+)>/g
const LINK_CLASS =
"text-[color:var(--ui-accent)] underline decoration-[color:var(--ui-accent)]/50 break-words [overflow-wrap:anywhere]"
// Slack mrkdwn isn't standard Markdown — rewrite its link/mention syntax for display
// rather than feeding it to the Markdown renderer (which mis-renders *bold* etc.).
function renderSlackBody(text: string): Array<ReactNode> {
const nodes: Array<ReactNode> = []
let lastIndex = 0
let key = 0
SLACK_TOKEN.lastIndex = 0
for (
let match = SLACK_TOKEN.exec(text);
match;
match = SLACK_TOKEN.exec(text)
) {
if (match.index > lastIndex) nodes.push(text.slice(lastIndex, match.index))
const token = match[1] ?? ""
const sep = token.indexOf("|")
const target = sep === -1 ? token : token.slice(0, sep)
const label = sep === -1 ? "" : token.slice(sep + 1)
if (target.startsWith("@") || target.startsWith("#")) {
const sigil = target[0]
nodes.push(
<span key={key++} className="text-[color:var(--ui-accent)]">
{sigil}
{label || target.slice(1)}
</span>
)
} else if (target.startsWith("!")) {
nodes.push(
<span key={key++} className="text-[color:var(--ui-accent)]">
@{label || target.slice(1)}
</span>
)
} else {
nodes.push(
<a
key={key++}
href={target}
target="_blank"
rel="noreferrer"
className={LINK_CLASS}
>
{label || target}
</a>
)
}
lastIndex = match.index + match[0].length
}
if (lastIndex < text.length) nodes.push(text.slice(lastIndex))
return nodes
}
export const ReplyCard = memo(function ReplyCard({ chunk }: ReplyCardProps) {
const isLinear = chunk.toolKind === "linear"
const body =
((isLinear ? chunk.input?.comment_body : chunk.input?.message) as string) ||
""
return (
<div className="my-1">
<div className="flex items-center gap-1.5 py-1 text-[12px] text-[color:var(--ui-text-muted)]">
<MessageCircle className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span>{headerLabel(isLinear, chunk.status)}</span>
</div>
{body && (
<div className="overflow-hidden rounded-xl bg-[var(--ui-accent-bubble)]">
<div className="max-h-[250px] overflow-auto px-3 py-2 text-[13px] text-[color:var(--ui-text)]">
{isLinear ? (
<Markdown content={body} />
) : (
<div className="[overflow-wrap:anywhere] break-words whitespace-pre-wrap">
{renderSlackBody(body)}
</div>
)}
</div>
</div>
)}
</div>
)
})

View file

@ -29,6 +29,8 @@ export type AcpToolKind =
| "execute"
| "think"
| "fetch"
| "slack"
| "linear"
| "other";
export type AcpToolStatus = "pending" | "in_progress" | "completed" | "error";