mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-03 03:23:25 +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:
|
def _tool_kind(name: str) -> str:
|
||||||
lowered = name.lower()
|
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")):
|
if lowered in _EDIT_TOOLS or any(token in lowered for token in ("edit", "write", "replace")):
|
||||||
return "edit"
|
return "edit"
|
||||||
if lowered in _EXECUTE_TOOLS:
|
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')"
|
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:
|
def test_state_messages_to_ui_merges_agent_turn_and_hides_internal_tools() -> None:
|
||||||
messages = [
|
messages = [
|
||||||
{"type": "human", "id": "u1", "content": "hello"},
|
{"type": "human", "id": "u1", "content": "hello"},
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { CodeBlock } from "./CodeBlock";
|
||||||
import { Markdown } from "./Markdown";
|
import { Markdown } from "./Markdown";
|
||||||
import { ToolExecution } from "./ToolExecution";
|
import { ToolExecution } from "./ToolExecution";
|
||||||
import { ShellCommand } from "./ShellCommand";
|
import { ShellCommand } from "./ShellCommand";
|
||||||
|
import { ReplyCard } from "./ReplyCard";
|
||||||
import type {
|
import type {
|
||||||
Chunk,
|
Chunk,
|
||||||
Message,
|
Message,
|
||||||
|
|
@ -21,6 +22,7 @@ type RenderItem =
|
||||||
| { type: "explored-group"; key: string; id: string; chunks: ToolExecutionChunk[] }
|
| { type: "explored-group"; key: string; id: string; chunks: ToolExecutionChunk[] }
|
||||||
| { type: "edit-item"; key: string; chunk: ToolExecutionChunk }
|
| { type: "edit-item"; key: string; chunk: ToolExecutionChunk }
|
||||||
| { type: "shell-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 };
|
| { type: "tool-item"; key: string; chunk: ToolExecutionChunk };
|
||||||
|
|
||||||
function getChunkRenderKey(chunk: Chunk, sourceIndex: number): string {
|
function getChunkRenderKey(chunk: Chunk, sourceIndex: number): string {
|
||||||
|
|
@ -61,6 +63,10 @@ function isShellTool(chunk: ToolExecutionChunk): boolean {
|
||||||
return chunk.toolKind === "execute";
|
return chunk.toolKind === "execute";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isReplyTool(chunk: ToolExecutionChunk): boolean {
|
||||||
|
return chunk.toolKind === "slack" || chunk.toolKind === "linear";
|
||||||
|
}
|
||||||
|
|
||||||
function buildRenderItems(chunks: Chunk[]): RenderItem[] {
|
function buildRenderItems(chunks: Chunk[]): RenderItem[] {
|
||||||
const items: RenderItem[] = [];
|
const items: RenderItem[] = [];
|
||||||
let exploredBuffer: ToolExecutionChunk[] = [];
|
let exploredBuffer: ToolExecutionChunk[] = [];
|
||||||
|
|
@ -96,6 +102,8 @@ function buildRenderItems(chunks: Chunk[]): RenderItem[] {
|
||||||
items.push({ type: "edit-item", key: `tool-${chunk.toolCallId}`, chunk });
|
items.push({ type: "edit-item", key: `tool-${chunk.toolCallId}`, chunk });
|
||||||
} else if (isShellTool(chunk)) {
|
} else if (isShellTool(chunk)) {
|
||||||
items.push({ type: "shell-item", key: `tool-${chunk.toolCallId}`, 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 {
|
} else {
|
||||||
items.push({ type: "tool-item", key: `tool-${chunk.toolCallId}`, chunk });
|
items.push({ type: "tool-item", key: `tool-${chunk.toolCallId}`, chunk });
|
||||||
}
|
}
|
||||||
|
|
@ -578,6 +586,13 @@ function AgentMessage({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case "reply-item":
|
||||||
|
return (
|
||||||
|
<div key={item.key}>
|
||||||
|
<ReplyCard chunk={item.chunk} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
case "tool-item":
|
case "tool-item":
|
||||||
return (
|
return (
|
||||||
<div key={item.key}>
|
<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"
|
| "execute"
|
||||||
| "think"
|
| "think"
|
||||||
| "fetch"
|
| "fetch"
|
||||||
|
| "slack"
|
||||||
|
| "linear"
|
||||||
| "other";
|
| "other";
|
||||||
|
|
||||||
export type AcpToolStatus = "pending" | "in_progress" | "completed" | "error";
|
export type AcpToolStatus = "pending" | "in_progress" | "completed" | "error";
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue