mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 11:33:14 +00:00
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:
parent
dee78e7e84
commit
27677c4949
5 changed files with 155 additions and 0 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
101
ui/src/components/agents/ported/ReplyCard.tsx
Normal file
101
ui/src/components/agents/ported/ReplyCard.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
|
|
@ -29,6 +29,8 @@ export type AcpToolKind =
|
|||
| "execute"
|
||||
| "think"
|
||||
| "fetch"
|
||||
| "slack"
|
||||
| "linear"
|
||||
| "other";
|
||||
|
||||
export type AcpToolStatus = "pending" | "in_progress" | "completed" | "error";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue