diff --git a/nix/deepagent-code.nix b/nix/deepagent-code.nix index 5166f141..a5c55569 100644 --- a/nix/deepagent-code.nix +++ b/nix/deepagent-code.nix @@ -18,6 +18,11 @@ stdenvNoCC.mkDerivation (finalAttrs: { inherit (node_modules) version src; inherit node_modules; + patchPhase = '' + BUN_VERSION=$(bun --version) + sed -i "s/const expectedBunVersionRange.*/const expectedBunVersionRange = \"^$BUN_VERSION\";/" packages/script/src/index.ts + ''; + nativeBuildInputs = [ bun nodejs # for patchShebangs node_modules diff --git a/nix/hashes.json b/nix/hashes.json index cf1467c9..17fbfc4d 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,6 +1,6 @@ { "nodeModules": { - "x86_64-linux": "sha256-OttgLsPJSYW1b9RGqiYSi7qPdDC3GuD2FE7k5tPe6Iw=", + "x86_64-linux": "sha256-vB4imi5LtCxnDuOaJFQh+asK51RUDCnIVCkIzBQN12o=", "aarch64-linux": "sha256-VsmW8tTiT3VkTxp5oiYc/maJLSsBMxK6VVUSVshWVT8=", "aarch64-darwin": "sha256-/MaQBX2zoJMtLA8jHt8+uKpIFve/eec3RfcKT3FvXC8=", "x86_64-darwin": "sha256-3ggap9rgR2TvVbYgnXsgkg6Rj1TqNZu32QgTVRdN0f0=" diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 2c4fd7b1..f23fd5fa 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -31,6 +31,7 @@ import { CommandProvider } from "@/context/command" import { CommentsProvider } from "@/context/comments" import { DebugProvider } from "@/context/debug" import { FileProvider } from "@/context/file" +import { GatewayProvider } from "@/context/gateway" import { ServerSDKProvider } from "@/context/server-sdk" import { ServerSyncProvider } from "@/context/server-sync" import { GlobalProvider } from "@/context/global" @@ -311,39 +312,41 @@ export function AppInterface(props: { disableHealthCheck?: boolean }) { return ( - - - - ( - - - - - - {routerProps.children} - - - - - - )} - > - - + + + + + ( + + + + + + {routerProps.children} + + + + + + )} + > + + } /> - - - - - + + + + + + ) } diff --git a/packages/app/src/components/im/agent-status-chip.tsx b/packages/app/src/components/im/agent-status-chip.tsx new file mode 100644 index 00000000..500935db --- /dev/null +++ b/packages/app/src/components/im/agent-status-chip.tsx @@ -0,0 +1,49 @@ +import { createMemo } from "solid-js" + +interface AgentStatusChipProps { + agentID: string + status: string +} + +export function AgentStatusChip(props: AgentStatusChipProps) { + const getStatusColor = createMemo(() => { + switch (props.status) { + case "started": + case "running": + return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200" + case "success": + return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200" + case "failed": + return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200" + case "timeout": + return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200" + default: + return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200" + } + }) + + const getStatusIcon = createMemo(() => { + switch (props.status) { + case "started": + case "running": + return "⏳" + case "success": + return "✓" + case "failed": + return "✗" + case "timeout": + return "⏱" + default: + return "•" + } + }) + + return ( +
+ {getStatusIcon()} + Agent {props.agentID} + + {props.status} +
+ ) +} diff --git a/packages/app/src/components/im/group-chat-panel.tsx b/packages/app/src/components/im/group-chat-panel.tsx new file mode 100644 index 00000000..196a1079 --- /dev/null +++ b/packages/app/src/components/im/group-chat-panel.tsx @@ -0,0 +1,126 @@ +import { createSignal, createEffect, onMount, Show } from "solid-js" +import { MessageList } from "./message-list" +import { MessageComposer } from "./message-composer" +import { useIMWebSocket } from "@/hooks/use-im-websocket" +import type { LocalMessage } from "@/hooks/use-im-websocket" +import { useIMClient } from "@/utils/im-client" +import type { AgentDescriptor } from "./types" + +interface GroupChatPanelProps { + groupID: string +} + +export function GroupChatPanel(props: GroupChatPanelProps) { + const client = useIMClient() + const [historicalMessages, setHistoricalMessages] = createSignal([]) + const [agents, setAgents] = createSignal([]) + const [loading, setLoading] = createSignal(true) + + const { connected, messages: realtimeMessages, agentStatuses, typingMembers, send } = useIMWebSocket( + client, + () => props.groupID, + ) + + // The single desktop user identity, as the server assigns it. Used to exclude + // our own echoed typing/read events from the UI. + const SELF_MEMBER_ID = "server" + + const othersTyping = () => Array.from(typingMembers()).filter((id) => id !== SELF_MEMBER_ID) + + // Load historical messages + createEffect(() => { + const groupID = props.groupID + setLoading(true) + client + .listMessages(groupID, 50) + .then((data) => { + // Server returns messages newest-first (desc). Reverse to ascending so + // they render oldest→newest in the list. + const historical: LocalMessage[] = (data.messages || []).slice().reverse() + setHistoricalMessages(historical) + setLoading(false) + }) + .catch((error) => { + console.error("Failed to load messages:", error) + setLoading(false) + }) + }) + + // Load available agents + onMount(() => { + client + .listAgents() + .then((data) => { + setAgents(data || []) + }) + .catch((error) => { + console.error("Failed to load agents:", error) + }) + }) + + // Merge historical and realtime messages, de-duplicating by id (a message the + // user sent appears both in the historical fetch and the realtime broadcast) + // and sorting ascending by creation time. + const allMessages = () => { + const byId = new Map() + for (const m of historicalMessages()) byId.set(m.id, m) + for (const m of realtimeMessages()) byId.set(m.id, m) + return Array.from(byId.values()).sort((a, b) => a.createdAt - b.createdAt) + } + + // Mark the group read whenever new messages land (and on first load). Sends + // both the HTTP mark-read (persisted last_read_at) and a realtime receipt. + createEffect(() => { + const msgs = allMessages() + if (loading() || msgs.length === 0) return + const readAt = Date.now() + client.markRead(props.groupID, readAt).catch((error) => { + console.error("Failed to mark read:", error) + }) + send({ type: "read_receipt", data: { groupID: props.groupID, memberID: SELF_MEMBER_ID, readAt } }) + }) + + const handleTyping = (typing: boolean) => { + send({ type: "typing", data: { groupID: props.groupID, memberID: SELF_MEMBER_ID, typing } }) + } + + const handleSendMessage = async (content: string) => { + // Send via HTTP API; the created message arrives back over the WebSocket. + try { + await client.createMessage(props.groupID, { content, type: "text" }) + } catch (error) { + console.error("Failed to send message:", error) + alert("Failed to send message") + } + } + + return ( + +
Loading messages...
+ + } + > +
+
+

Group Chat

+
+ {connected() ? "Connected" : "Disconnected"} +
+
+ + + + 0}> +
+ {othersTyping().length === 1 ? "Someone is typing…" : "Several people are typing…"} +
+
+ + +
+
+ ) +} diff --git a/packages/app/src/components/im/message-composer.tsx b/packages/app/src/components/im/message-composer.tsx new file mode 100644 index 00000000..ca12ac5d --- /dev/null +++ b/packages/app/src/components/im/message-composer.tsx @@ -0,0 +1,244 @@ +import { createSignal, createMemo, createEffect, onCleanup, Show, For } from "solid-js" +import type { AgentDescriptor } from "./types" + +interface MessageComposerProps { + onSend: (content: string) => void + onTyping?: (typing: boolean) => void + agents: AgentDescriptor[] + disabled?: boolean +} + +export function MessageComposer(props: MessageComposerProps) { + const [content, setContent] = createSignal("") + const [showMentionMenu, setShowMentionMenu] = createSignal(false) + const [mentionQuery, setMentionQuery] = createSignal("") + const [mentionPosition, setMentionPosition] = createSignal({ top: 0, left: 0 }) + const [selectedIndex, setSelectedIndex] = createSignal(0) + let textareaRef: HTMLTextAreaElement | undefined + let typingActive = false + let typingStopTimer: ReturnType | undefined + + // Emit a throttled typing signal: "true" on first keystroke, "false" after a + // short idle gap. Cleaned up on unmount. + const signalTyping = () => { + if (!props.onTyping) return + if (!typingActive) { + typingActive = true + props.onTyping(true) + } + if (typingStopTimer) clearTimeout(typingStopTimer) + typingStopTimer = setTimeout(() => { + typingActive = false + props.onTyping?.(false) + }, 3000) + } + + const stopTyping = () => { + if (typingStopTimer) clearTimeout(typingStopTimer) + if (typingActive) { + typingActive = false + props.onTyping?.(false) + } + } + + onCleanup(stopTyping) + + // Filter agents based on mention query + const filteredAgents = createMemo(() => { + const query = mentionQuery() + if (!query) return props.agents + const lowerQuery = query.toLowerCase() + return props.agents.filter((agent) => + agent.id.toLowerCase().includes(lowerQuery) || + agent.displayName?.toLowerCase().includes(lowerQuery) || + agent.description?.toLowerCase().includes(lowerQuery) + ) + }) + + // Detect @ mentions and show autocomplete + createEffect(() => { + const currentContent = content() + if (!textareaRef) return + + const cursorPos = textareaRef.selectionStart + const textBeforeCursor = currentContent.slice(0, cursorPos) + + // Check if we're in a mention context + const mentionMatch = textBeforeCursor.match(/@(\w*)$/) + + if (mentionMatch) { + setMentionQuery(mentionMatch[1]) + setShowMentionMenu(true) + setSelectedIndex(0) + + // Calculate position for the dropdown + const coords = getCaretCoordinates(textareaRef, cursorPos) + setMentionPosition({ + top: coords.top - textareaRef.scrollTop, + left: coords.left, + }) + } else { + setShowMentionMenu(false) + setMentionQuery("") + } + }) + + const handleKeyDown = (e: KeyboardEvent) => { + if (showMentionMenu() && filteredAgents().length > 0) { + if (e.key === "ArrowDown") { + e.preventDefault() + setSelectedIndex((prev) => (prev + 1) % filteredAgents().length) + return + } + if (e.key === "ArrowUp") { + e.preventDefault() + setSelectedIndex((prev) => (prev - 1 + filteredAgents().length) % filteredAgents().length) + return + } + if (e.key === "Tab" || e.key === "Enter") { + if (e.key === "Enter" && !e.shiftKey) { + // Only handle Tab and Shift+Enter for mention completion + e.preventDefault() + insertMention(filteredAgents()[selectedIndex()]) + return + } + if (e.key === "Tab") { + e.preventDefault() + insertMention(filteredAgents()[selectedIndex()]) + return + } + } + if (e.key === "Escape") { + e.preventDefault() + setShowMentionMenu(false) + return + } + } + + // Normal message sending (Enter without Shift) + if (e.key === "Enter" && !e.shiftKey && !showMentionMenu()) { + e.preventDefault() + if (content().trim() && !props.disabled) { + props.onSend(content()) + setContent("") + stopTyping() + } + } + } + + const insertMention = (agent: AgentDescriptor) => { + if (!textareaRef) return + + const cursorPos = textareaRef.selectionStart + const textBeforeCursor = content().slice(0, cursorPos) + const textAfterCursor = content().slice(cursorPos) + + // Remove the partial mention and insert the full one + const mentionMatch = textBeforeCursor.match(/@(\w*)$/) + if (mentionMatch) { + const beforeMention = textBeforeCursor.slice(0, mentionMatch.index) + const newContent = `${beforeMention}@${agent.id} ${textAfterCursor}` + setContent(newContent) + + // Set cursor position after the mention + setTimeout(() => { + if (!textareaRef) return + const newCursorPos = beforeMention.length + agent.id.length + 2 + textareaRef.setSelectionRange(newCursorPos, newCursorPos) + textareaRef.focus() + }, 0) + } + + setShowMentionMenu(false) + } + + return ( +
+