diff --git a/agent/dashboard/message_adapter.py b/agent/dashboard/message_adapter.py index 1180fcdf..d6314eca 100644 --- a/agent/dashboard/message_adapter.py +++ b/agent/dashboard/message_adapter.py @@ -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: diff --git a/tests/test_dashboard_message_adapter.py b/tests/test_dashboard_message_adapter.py index b8e112c1..415f437f 100644 --- a/tests/test_dashboard_message_adapter.py +++ b/tests/test_dashboard_message_adapter.py @@ -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"}, diff --git a/ui/src/components/agents/ported/MessageView.tsx b/ui/src/components/agents/ported/MessageView.tsx index 4934d097..64541f25 100644 --- a/ui/src/components/agents/ported/MessageView.tsx +++ b/ui/src/components/agents/ported/MessageView.tsx @@ -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({ ); + case "reply-item": + return ( +
+ +
+ ); + case "tool-item": return (
diff --git a/ui/src/components/agents/ported/ReplyCard.tsx b/ui/src/components/agents/ported/ReplyCard.tsx new file mode 100644 index 00000000..7611cbe2 --- /dev/null +++ b/ui/src/components/agents/ported/ReplyCard.tsx @@ -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 { + const nodes: Array = [] + 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( + + {sigil} + {label || target.slice(1)} + + ) + } else if (target.startsWith("!")) { + nodes.push( + + @{label || target.slice(1)} + + ) + } else { + nodes.push( + + {label || target} + + ) + } + 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 ( +
+
+ + {headerLabel(isLinear, chunk.status)} +
+ {body && ( +
+
+ {isLinear ? ( + + ) : ( +
+ {renderSlackBody(body)} +
+ )} +
+
+ )} +
+ ) +}) diff --git a/ui/src/lib/agents/types.ts b/ui/src/lib/agents/types.ts index 804c441a..83c5e834 100644 --- a/ui/src/lib/agents/types.ts +++ b/ui/src/lib/agents/types.ts @@ -29,6 +29,8 @@ export type AcpToolKind = | "execute" | "think" | "fetch" + | "slack" + | "linear" | "other"; export type AcpToolStatus = "pending" | "in_progress" | "completed" | "error";