Skip to content

Commit 87be0fe

Browse files
committed
feat: mentions
1 parent bb9b5ea commit 87be0fe

File tree

11 files changed

+453
-54
lines changed

11 files changed

+453
-54
lines changed

apps/web/index.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,10 @@
6767
}
6868
}
6969
}
70+
71+
/* Tiptap */
72+
73+
.mention {
74+
@apply bg-blue-500/5 text-blue-500 border border-blue-500/20 rounded-lg px-1.5
75+
py-0.5 box-decoration-clone;
76+
}

apps/web/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
"@hookform/resolvers": "^5.0.1",
1919
"@tanstack/react-query": "^5.72.0",
2020
"@tiptap/extension-placeholder": "^2.11.7",
21+
"@tiptap/extension-mention": "^2.11.7",
22+
"tippy.js": "^6.3.7",
2123
"@tiptap/react": "^2.11.7",
2224
"@tiptap/starter-kit": "^2.11.7",
2325
"dockview-react": "^4.2.1",

apps/web/src/components/chat/ChatInput.tsx

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
AgentNotFoundError,
33
LEGACY_API_SERVER_URL,
4+
listTools,
45
MODELS,
56
useAgent,
67
useWriteFile,
@@ -10,11 +11,12 @@ import { Icon } from "@deco/ui/components/icon.tsx";
1011
import { cn } from "@deco/ui/lib/utils.ts";
1112
import { Suspense, useEffect, useRef, useState } from "react";
1213
import { ErrorBoundary } from "../../ErrorBoundary.tsx";
14+
import { useSelectedModel } from "../../hooks/useSelectedModel.ts";
15+
import { useChatContext } from "./context.tsx";
16+
import type { MentionItem } from "./extensions/Mention.ts";
1317
import { ModelSelector } from "./ModelSelector.tsx";
1418
import { RichTextArea } from "./RichText.tsx";
15-
import { useChatContext } from "./context.tsx";
1619
import ToolsButton from "./ToolsButton.tsx";
17-
import { useSelectedModel } from "../../hooks/useSelectedModel.ts";
1820

1921
export function ChatInput({ withoutTools }: { withoutTools?: boolean }) {
2022
return (
@@ -44,6 +46,7 @@ ChatInput.UI = (
4446
const {
4547
agentRoot,
4648
chat: { stop, input, handleInputChange, handleSubmit, status },
49+
setStreamTools,
4750
} = useChatContext();
4851
const [isUploading, setIsUploading] = useState(false);
4952
const [files, setFiles] = useState<FileList | undefined>(undefined);
@@ -68,10 +71,35 @@ ChatInput.UI = (
6871

6972
const writeFileMutation = useWriteFile();
7073

71-
const handleRichTextChange = (markdown: string) => {
74+
const handleRichTextChange = async (
75+
markdown: string,
76+
mentions?: MentionItem[],
77+
) => {
7278
handleInputChange(
7379
{ target: { value: markdown } } as React.ChangeEvent<HTMLTextAreaElement>,
7480
);
81+
82+
if (mentions?.length) {
83+
const tools = await mentions.reduce(async (promise, mention) => {
84+
const acc = await promise;
85+
const integrationTools = await listTools(mention.connection);
86+
if (integrationTools.tools.length > 0) {
87+
const integrationId = mention.id;
88+
acc[integrationId] = integrationTools.tools.map((tool) => tool.name);
89+
}
90+
return acc;
91+
}, Promise.resolve({} as Record<string, string[]>));
92+
93+
// Only update stream tools if we have actual tools
94+
if (Object.keys(tools).length > 0) {
95+
setStreamTools(tools);
96+
} else {
97+
setStreamTools(null);
98+
}
99+
} else {
100+
// If no mentions, remove the tools
101+
setStreamTools(null);
102+
}
75103
};
76104

77105
// Auto-focus when loading state changes from true to false

apps/web/src/components/chat/ChatMessages.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ export function ChatMessages() {
9393
key={message.id}
9494
message={message}
9595
isStreaming={isStreaming}
96+
agentId={agentId}
9697
isLastMessage={messages.length === index + 1}
9798
/>
9899
))}

apps/web/src/components/chat/Message.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import type { Message } from "@ai-sdk/react";
2+
import { useAgent } from "@deco/sdk";
23
import { Button } from "@deco/ui/components/button.tsx";
34
import { Icon } from "@deco/ui/components/icon.tsx";
45
import { cn } from "@deco/ui/lib/utils.ts";
56
import { useMemo } from "react";
7+
import { AgentAvatar } from "../common/Avatar.tsx";
68
import { MemoizedMarkdown } from "./Markdown.tsx";
7-
import { ToolMessage } from "./ToolMessage.tsx";
89
import { ReasoningPart } from "./ReasoningPart.tsx";
10+
import { ToolMessage } from "./ToolMessage.tsx";
911

1012
interface ChatMessageProps {
1113
message: Message;
14+
agentId: string;
1215
isStreaming?: boolean;
1316
isLastMessage?: boolean;
1417
}
@@ -111,8 +114,11 @@ function mergeParts(parts: Part[] | undefined): MessagePart[] {
111114
}
112115

113116
export function ChatMessage(
114-
{ message, isStreaming = false, isLastMessage = false }: ChatMessageProps,
117+
{ message, agentId, isStreaming = false, isLastMessage = false }:
118+
ChatMessageProps,
115119
) {
120+
const { data: agent } = useAgent(agentId);
121+
116122
const isUser = message.role === "user";
117123
const timestamp = new Date(message.createdAt || Date.now())
118124
.toLocaleTimeString([], {
@@ -167,17 +173,27 @@ export function ChatMessage(
167173
return (
168174
<div
169175
className={cn(
170-
"w-full group relative flex items-start gap-4 px-4 z-20 text-slate-700 group",
176+
"w-full group relative flex items-start gap-3 px-4 z-20 text-slate-700 group",
171177
isUser ? "flex-row-reverse py-4" : "flex-row",
172178
)}
173179
>
180+
{!isUser && (
181+
<div className="flex-shrink-0">
182+
<AgentAvatar
183+
name={agent?.name}
184+
avatar={agent?.avatar}
185+
className="h-8 w-8 rounded-lg"
186+
/>
187+
</div>
188+
)}
174189
<div
175190
className={cn(
176191
"flex flex-col gap-1",
177192
isUser ? "items-end max-w-[70%]" : "w-full items-start",
178193
)}
179194
>
180195
<div className="flex items-center gap-2 text-xs text-muted-foreground">
196+
{!isUser && <span className="font-medium">{agent?.name}</span>}
181197
<span>{timestamp}</span>
182198
</div>
183199

apps/web/src/components/chat/RichText.tsx

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,27 @@
1+
import { type Integration, useIntegrations } from "@deco/sdk";
12
import { cn } from "@deco/ui/lib/utils.ts";
23
import Placeholder from "@tiptap/extension-placeholder";
34
import { EditorContent, useEditor } from "@tiptap/react";
45
import StarterKit from "@tiptap/starter-kit";
56
import { useEffect } from "react";
67
import { Markdown } from "tiptap-markdown";
8+
import {
9+
Mention,
10+
type MentionItem,
11+
useMentionSuggestion,
12+
} from "./extensions/Mention.ts";
713
import { NoNewLine } from "./extensions/NoNewLine.ts";
814

9-
export interface Mention {
10-
id: string;
11-
type: string;
12-
content?: string;
13-
label?: string;
14-
title?: string;
15-
models?: Array<{
16-
model: string;
17-
instructions: string;
18-
}>;
19-
selectedModel?: string;
20-
}
21-
2215
interface RichTextAreaProps {
2316
value: string;
24-
onChange: (markdown: string) => void;
17+
onChange: (markdown: string, mentions?: MentionItem[]) => void;
2518
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
2619
onKeyUp?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
2720
onPaste?: (event: React.ClipboardEvent) => void;
2821
disabled?: boolean;
2922
placeholder?: string;
3023
className?: string;
24+
integrations?: Integration[];
3125
}
3226

3327
export function RichTextArea({
@@ -40,13 +34,17 @@ export function RichTextArea({
4034
placeholder,
4135
className,
4236
}: RichTextAreaProps) {
37+
const { data: integrations } = useIntegrations();
38+
const suggestion = useMentionSuggestion({ integrations });
39+
4340
const editor = useEditor({
4441
extensions: [
4542
StarterKit,
4643
Markdown.configure({
4744
html: true,
4845
}),
4946
NoNewLine,
47+
Mention(suggestion),
5048
Placeholder.configure({
5149
placeholder: placeholder ?? "Type a message...",
5250
}),
@@ -58,7 +56,12 @@ export function RichTextArea({
5856
html: true,
5957
});
6058

61-
onChange(markdown);
59+
const mentions = editor.getJSON().content?.flatMap((node) =>
60+
node.content?.filter((child) => child.type === "mention")
61+
.map((child) => child.attrs) || []
62+
).filter(Boolean) as MentionItem[];
63+
64+
onChange(markdown, mentions);
6265
},
6366
editorProps: {
6467
attributes: {

apps/web/src/components/chat/ToolMessage.tsx

Lines changed: 21 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import type { Message } from "@ai-sdk/react";
2+
import { Button } from "@deco/ui/components/button.tsx";
23
import { Icon } from "@deco/ui/components/icon.tsx";
34
import { Spinner } from "@deco/ui/components/spinner.tsx";
4-
import { Button } from "@deco/ui/components/button.tsx";
55
import { cn } from "@deco/ui/lib/utils.ts";
6-
import { useEffect, useRef, useState } from "react";
7-
import { openPanel } from "../dock/index.tsx";
6+
import { useRef, useState } from "react";
87
import { useChatContext } from "./context.tsx";
98
import { Picker } from "./Picker.tsx";
109
import { AgentCard } from "./tools/AgentCard.tsx";
10+
import { HandoffResponse } from "./tools/HandoffResponse.tsx";
1111
import { Preview } from "./tools/Preview.tsx";
1212
import { parseHandoffTool } from "./utils/parse.ts";
1313

@@ -28,6 +28,7 @@ const CUSTOM_UI_TOOLS = [
2828
"CONFIRM",
2929
"CONFIGURE",
3030
"AGENT_CREATE",
31+
"HANDOFF_",
3132
] as const;
3233
type CustomUITool = typeof CUSTOM_UI_TOOLS[number];
3334

@@ -43,7 +44,7 @@ interface ToolInvocation {
4344
}
4445

4546
function isCustomUITool(toolName: string): toolName is CustomUITool {
46-
return CUSTOM_UI_TOOLS.includes(toolName as CustomUITool);
47+
return CUSTOM_UI_TOOLS.some((tool) => toolName.startsWith(tool));
4748
}
4849

4950
function ToolStatus({
@@ -89,32 +90,6 @@ function ToolStatus({
8990
).replace(/"(\w+)":/g, '"$1":');
9091
};
9192

92-
useEffect(() => {
93-
if (
94-
tool.state === "result" &&
95-
tool.result?.data &&
96-
tool.toolName.startsWith("HANDOFF_")
97-
) {
98-
const { threadId, agentId } = tool.result.data as {
99-
threadId: string;
100-
agentId: string;
101-
};
102-
103-
const panelId = `chat-${threadId}`;
104-
105-
openPanel({
106-
id: panelId,
107-
component: "chatView",
108-
title: parseHandoffTool(tool.toolName),
109-
params: {
110-
threadId,
111-
agentId,
112-
key: `${panelId}-${Date.now()}`,
113-
},
114-
});
115-
}
116-
}, [tool.state]);
117-
11893
const onClick = () => {
11994
setIsExpanded((prev) => {
12095
const newState = !prev;
@@ -224,8 +199,16 @@ function CustomToolUI({ tool, isLastMessage }: {
224199
}) {
225200
const { select } = useChatContext();
226201

227-
if (tool.state !== "result" || !tool.result?.data) return null;
202+
if (tool.toolName.startsWith("HANDOFF_") && tool.state !== "result") {
203+
return (
204+
<div className="flex items-center gap-2 text-sm text-muted-foreground p-4 border border-slate-200 rounded-2xl">
205+
<Spinner size="xs" variant="default" />
206+
<span>Delegating to {parseHandoffTool(tool.toolName)}</span>
207+
</div>
208+
);
209+
}
228210

211+
if (tool.state !== "result" || !tool.result?.data) return null;
229212
switch (tool.toolName) {
230213
case "RENDER": {
231214
return (
@@ -268,6 +251,13 @@ function CustomToolUI({ tool, isLastMessage }: {
268251
);
269252
}
270253
default: {
254+
if (tool.toolName.startsWith("HANDOFF_")) {
255+
const { threadId, agentId } = tool.result.data as {
256+
threadId: string;
257+
agentId: string;
258+
};
259+
return <HandoffResponse agentId={agentId} threadId={threadId} />;
260+
}
271261
return null;
272262
}
273263
}

0 commit comments

Comments
 (0)