diff --git a/desktop/src/apps/MessagesApp.tsx b/desktop/src/apps/MessagesApp.tsx index 415b0fb5..211390c7 100644 --- a/desktop/src/apps/MessagesApp.tsx +++ b/desktop/src/apps/MessagesApp.tsx @@ -6,7 +6,6 @@ import { Plus, Send, Paperclip, - SmilePlus, Bot, X, AtSign, @@ -36,6 +35,14 @@ import { AgentContextMenu } from "./chat/AgentContextMenu"; import { SlashMenu, type SlashCommandsBySlug } from "./chat/SlashMenu"; import { TypingFooter } from "./chat/TypingFooter"; import { useTypingEmitter } from "@/lib/use-typing-emitter"; +import { MessageHoverActions } from "./chat/MessageHoverActions"; +import { ThreadIndicator } from "./chat/ThreadIndicator"; +import { ThreadPanel } from "./chat/ThreadPanel"; +import { AttachmentsBar, type PendingAttachment } from "./chat/AttachmentsBar"; +import { AttachmentGallery } from "./chat/AttachmentGallery"; +import { uploadDiskFile, attachmentFromPath, type AttachmentRecord } from "@/lib/chat-attachments-api"; +import { useThreadPanel } from "@/lib/use-thread-panel"; +import { openFilePicker } from "@/shell/file-picker-api"; /* ------------------------------------------------------------------ */ /* Types */ @@ -135,6 +142,9 @@ interface Message { created_at: string; reactions?: Record; edited_at?: string; + attachments?: AttachmentRecord[]; + reply_count?: number; + last_reply_at?: number | null; } type WsStatus = "connecting" | "connected" | "disconnected"; @@ -206,6 +216,9 @@ export function MessagesApp({ windowId: _windowId, title }: { windowId: string; const [typingHumans, setTypingHumans] = useState([]); const [typingAgents, setTypingAgents] = useState([]); const [sendError, setSendError] = useState(null); + const [hoveredMessageId, setHoveredMessageId] = useState(null); + const [pendingAttachments, setPendingAttachments] = useState([]); + const { openThread, openThreadFor, closeThread } = useThreadPanel(); const wsRef = useRef(null); const messagesEndRef = useRef(null); @@ -529,11 +542,67 @@ export function MessagesApp({ windowId: _windowId, title }: { windowId: string; const showSlash = input.startsWith("/"); const slashQuery = showSlash ? input.slice(1).split(/\s/, 1)[0] || "" : ""; + /* ---- mutex: settings vs thread panel ---- */ + const handleOpenSettings = () => { + closeThread(); + setShowSettings(true); + }; + const handleOpenThreadFor = (channelId: string, parentId: string) => { + setShowSettings(false); + openThreadFor(channelId, parentId); + }; + /* ---- send message ---- */ const sendMessage = async () => { const text = input.trim(); - if (!text || !selectedChannel || !wsRef.current || wsRef.current.readyState !== 1) return; - // If slash input, validate via REST before sending over WS + if (!text && pendingAttachments.length === 0) return; + if (!selectedChannel || !wsRef.current || wsRef.current.readyState !== 1) return; + + // Block send while uploads are in-flight + if (pendingAttachments.some((a) => a.uploading)) { + setSendError("waiting for uploads to finish…"); + return; + } + + const readyAttachments = pendingAttachments + .filter((a) => a.record && !a.error) + .map((a) => a.record!); + + if (readyAttachments.length > 0) { + // HTTP POST for messages with attachments (WS schema doesn't carry them) + try { + const r = await fetch("/api/chat/messages", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + channel_id: selectedChannel, + author_id: "user", + author_type: "user", + content: text, + content_type: "text", + attachments: readyAttachments, + }), + }); + if (!r.ok) { + const body = await r.json().catch(() => ({})); + setSendError((body as { error?: string }).error || "couldn't send message"); + return; + } + setInput(""); + setPendingAttachments([]); + if (inputRef.current) inputRef.current.style.height = "auto"; + autoScrollRef.current = true; + return; + } catch (e) { + setSendError((e as Error).message || "send failed"); + return; + } + } + + if (!text) return; + // If slash input, POST via REST. The server handles `/help` in-app and + // guards bare slash in non-DMs. A 200 with `handled` means the message + // was fully processed server-side — skip the WS send to avoid double-post. if (text.startsWith("/")) { try { const r = await fetch("/api/chat/messages", { @@ -546,6 +615,16 @@ export function MessagesApp({ windowId: _windowId, title }: { windowId: string; setSendError((body as { error?: string }).error || "couldn't send message"); return; } + if (r.ok) { + const body = await r.json().catch(() => ({})); + if ((body as { handled?: string }).handled) { + setSendError(null); + setInput(""); + autoScrollRef.current = true; + if (inputRef.current) inputRef.current.style.height = "auto"; + return; + } + } } catch { /* network error — fall through to WS send */ } @@ -586,26 +665,33 @@ export function MessagesApp({ windowId: _windowId, title }: { windowId: string; }; /* ---- file upload ---- */ - const handleFileUpload = () => { - const fileInput = document.createElement("input"); - fileInput.type = "file"; - fileInput.onchange = async () => { - const file = fileInput.files?.[0]; - if (!file) return; - const form = new FormData(); - form.append("file", file); + const handleFileUpload = async () => { + const selections = await openFilePicker({ + sources: ["disk", "workspace", "agent-workspace"], + multi: true, + }); + for (const sel of selections) { + const id = Math.random().toString(36).slice(2); + const filename = sel.source === "disk" ? sel.file.name : sel.path.split("/").pop() || ""; + const size = sel.source === "disk" ? sel.file.size : 0; + setPendingAttachments((p) => [...p, { id, filename, size, uploading: true }]); try { - const res = await fetch("/api/chat/upload", { method: "POST", body: form }); - if (res.ok) { - const data = await res.json(); - setInput((prev) => prev + (prev ? "\n" : "") + `[${data.filename}](${data.url})`); - inputRef.current?.focus(); - } - } catch { - /* ignore */ + const rec = sel.source === "disk" + ? await uploadDiskFile(sel.file, selectedChannel ?? undefined) + : await attachmentFromPath({ + path: sel.path, + source: sel.source, + slug: sel.source === "agent-workspace" ? sel.slug : undefined, + }); + setPendingAttachments((p) => + p.map((x) => (x.id === id ? { ...x, record: rec, uploading: false } : x)) + ); + } catch (e) { + setPendingAttachments((p) => + p.map((x) => (x.id === id ? { ...x, uploading: false, error: (e as Error).message } : x)) + ); } - }; - fileInput.click(); + } }; /* ---- reaction toggle ---- */ @@ -999,10 +1085,17 @@ export function MessagesApp({ windowId: _windowId, title }: { windowId: string; {currentChannel && currentChannel.type !== "dm" && ( )} + ? {currentChannel?.description && (
{currentChannel.description}
@@ -1020,6 +1113,17 @@ export function MessagesApp({ windowId: _windowId, title }: { windowId: string; ref={messageListRef} onScroll={handleScroll} className="flex-1 overflow-y-auto px-4 py-3 space-y-0.5" + onDragOver={(e) => e.preventDefault()} + onDrop={(e) => { + e.preventDefault(); + for (const f of Array.from(e.dataTransfer.files)) { + const id = Math.random().toString(36).slice(2); + setPendingAttachments((p) => [...p, { id, filename: f.name, size: f.size, uploading: true }]); + uploadDiskFile(f, selectedChannel ?? undefined) + .then((rec) => setPendingAttachments((p) => p.map((x) => x.id === id ? { ...x, record: rec, uploading: false } : x))) + .catch((err) => setPendingAttachments((p) => p.map((x) => x.id === id ? { ...x, uploading: false, error: (err as Error).message } : x))); + } + }} > {messages.length === 0 && (
@@ -1049,6 +1153,8 @@ export function MessagesApp({ windowId: _windowId, title }: { windowId: string; className={`group relative px-3 py-1 rounded-md transition-colors hover:bg-white/[0.03] ${ isAgent && !isDeadAgent ? "bg-blue-500/[0.04]" : "" } ${showAuthor ? "mt-3" : ""}`} + onMouseEnter={() => setHoveredMessageId(msg.id)} + onMouseLeave={() => setHoveredMessageId((id) => id === msg.id ? null : id)} > {showAuthor && (
- -
+ {hoveredMessageId === msg.id && ( +
+ setShowEmoji(showEmoji === msg.id ? null : msg.id)} + onReplyInThread={() => handleOpenThreadFor(msg.channel_id ?? selectedChannel ?? "", msg.id)} + onMore={(e) => { + e.preventDefault(); + if (msg.author_type === "agent") { + setContextMenu({ slug: msg.author_id, x: e.clientX, y: e.clientY }); + } + }} + /> +
+ )} + + {typeof msg.reply_count === "number" && msg.reply_count > 0 && ( + handleOpenThreadFor(msg.channel_id ?? selectedChannel ?? "", msg.id)} + /> + )} {/* emoji picker */} {showEmoji === msg.id && ( @@ -1224,6 +1343,15 @@ export function MessagesApp({ windowId: _windowId, title }: { windowId: string;
)} + {/* pending attachments bar */} + setPendingAttachments((p) => p.filter((x) => x.id !== id))} + onRetry={(id) => { + setPendingAttachments((p) => p.map((x) => x.id === id ? { ...x, uploading: false, error: "retry not yet supported — remove and re-add" } : x)); + }} + /> + {/* input area */}
@@ -1254,6 +1382,19 @@ export function MessagesApp({ windowId: _windowId, title }: { windowId: string; value={input} onChange={(e) => !isCurrentArchived && handleInputChange(e.target.value)} onKeyDown={(e) => !isCurrentArchived && handleKeyDown(e)} + onPaste={(e) => { + if (!e.clipboardData) return; + const files = Array.from(e.clipboardData.files).filter((f) => f.type.startsWith("image/")); + if (files.length === 0) return; + e.preventDefault(); + for (const f of files) { + const id = Math.random().toString(36).slice(2); + setPendingAttachments((p) => [...p, { id, filename: f.name || "pasted.png", size: f.size, uploading: true }]); + uploadDiskFile(f, selectedChannel ?? undefined) + .then((rec) => setPendingAttachments((p) => p.map((x) => x.id === id ? { ...x, record: rec, uploading: false } : x))) + .catch((err) => setPendingAttachments((p) => p.map((x) => x.id === id ? { ...x, uploading: false, error: (err as Error).message } : x))); + } + }} placeholder={isCurrentArchived ? "This chat is archived" : `Message #${currentChannel?.name ?? ""}...`} rows={1} disabled={isCurrentArchived} @@ -1263,7 +1404,7 @@ export function MessagesApp({ windowId: _windowId, title }: { windowId: string; + ))} +
+ )} + {files.length > 0 && ( + + )} + {lightboxStart !== null && ( + setLightboxStart(null)} + /> + )} +
+ ); +} diff --git a/desktop/src/apps/chat/AttachmentLightbox.tsx b/desktop/src/apps/chat/AttachmentLightbox.tsx new file mode 100644 index 00000000..5b1a9983 --- /dev/null +++ b/desktop/src/apps/chat/AttachmentLightbox.tsx @@ -0,0 +1,45 @@ +// desktop/src/apps/chat/AttachmentLightbox.tsx +import { useEffect, useState } from "react"; +import type { AttachmentRecord } from "@/lib/chat-attachments-api"; + +export function AttachmentLightbox({ + images, startIndex, onClose, +}: { + images: AttachmentRecord[]; + startIndex: number; + onClose: () => void; +}) { + const [idx, setIdx] = useState(startIndex); + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + if (e.key === "ArrowLeft") setIdx((i) => Math.max(0, i - 1)); + if (e.key === "ArrowRight") setIdx((i) => Math.min(images.length - 1, i + 1)); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [images.length, onClose]); + + const current = images[idx]!; + return ( +
+ {current.filename} e.stopPropagation()} /> + + {images.length > 1 && ( +
{idx + 1} / {images.length}
+ )} +
+ ); +} diff --git a/desktop/src/apps/chat/AttachmentsBar.tsx b/desktop/src/apps/chat/AttachmentsBar.tsx new file mode 100644 index 00000000..6c2c433e --- /dev/null +++ b/desktop/src/apps/chat/AttachmentsBar.tsx @@ -0,0 +1,41 @@ +import type { AttachmentRecord } from "@/lib/chat-attachments-api"; + +export type PendingAttachment = { + id: string; + filename: string; + size: number; + mime_type?: string; + record?: AttachmentRecord; // set once upload completes + error?: string; + uploading?: boolean; +}; + +export function AttachmentsBar({ + items, + onRemove, + onRetry, +}: { + items: PendingAttachment[]; + onRemove: (id: string) => void; + onRetry: (id: string) => void; +}) { + if (items.length === 0) return null; + return ( +
+ {items.map((it) => ( +
+ {it.filename} + {Math.max(1, Math.round(it.size / 1024))} KB + {it.uploading && } + {it.error && ( + + )} + +
+ ))} +
+ ); +} diff --git a/desktop/src/apps/chat/MessageHoverActions.tsx b/desktop/src/apps/chat/MessageHoverActions.tsx new file mode 100644 index 00000000..680d1a36 --- /dev/null +++ b/desktop/src/apps/chat/MessageHoverActions.tsx @@ -0,0 +1,21 @@ +export function MessageHoverActions({ + onReact, + onReplyInThread, + onMore, +}: { + onReact: () => void; + onReplyInThread: () => void; + onMore: (e: React.MouseEvent) => void; +}) { + return ( +
+ + + +
+ ); +} diff --git a/desktop/src/apps/chat/ThreadIndicator.tsx b/desktop/src/apps/chat/ThreadIndicator.tsx new file mode 100644 index 00000000..d7577d50 --- /dev/null +++ b/desktop/src/apps/chat/ThreadIndicator.tsx @@ -0,0 +1,30 @@ +export function ThreadIndicator({ + replyCount, + lastReplyAt, + onOpen, +}: { + replyCount: number; + lastReplyAt?: number | null; + onOpen: () => void; +}) { + if (replyCount === 0) return null; + const label = lastReplyAt + ? `💬 ${replyCount} repl${replyCount === 1 ? "y" : "ies"} · last reply ${relative(lastReplyAt)}` + : `💬 ${replyCount} repl${replyCount === 1 ? "y" : "ies"}`; + return ( + + ); +} + +function relative(ts: number): string { + const now = Date.now() / 1000; + const delta = Math.max(0, now - ts); + if (delta < 60) return "just now"; + if (delta < 3600) return `${Math.floor(delta / 60)}m ago`; + if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`; + return `${Math.floor(delta / 86400)}d ago`; +} diff --git a/desktop/src/apps/chat/ThreadPanel.tsx b/desktop/src/apps/chat/ThreadPanel.tsx new file mode 100644 index 00000000..4cd9836e --- /dev/null +++ b/desktop/src/apps/chat/ThreadPanel.tsx @@ -0,0 +1,102 @@ +import { useEffect, useRef, useState } from "react"; +import type { AttachmentRecord } from "@/lib/chat-attachments-api"; + +type Msg = { + id: string; + author_id: string; + content: string; + created_at?: number; + [key: string]: unknown; +}; + +export function ThreadPanel({ + channelId, + parentId, + onClose, + onSend, +}: { + channelId: string; + parentId: string; + onClose: () => void; + onSend: (content: string, attachments: AttachmentRecord[]) => Promise; +}) { + const [parent, setParent] = useState(null); + const [msgs, setMsgs] = useState([]); + const [input, setInput] = useState(""); + const inputRef = useRef(null); + + useEffect(() => { + let alive = true; + fetch(`/api/chat/messages/${parentId}`) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { if (alive) setParent(d); }); + return () => { alive = false; }; + }, [parentId]); + + useEffect(() => { + let alive = true; + fetch(`/api/chat/channels/${channelId}/threads/${parentId}/messages`) + .then((r) => r.json()) + .then((d) => { if (alive) setMsgs(d.messages || []); }); + return () => { alive = false; }; + }, [channelId, parentId]); + + async function submit() { + const content = input.trim(); + if (!content) return; + setInput(""); + await onSend(content, []); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + submit(); + } + } + + return ( +
+
+ Thread + +
+ +
+ {parent && ( +
+
{parent.author_id}
+
{parent.content}
+
+ )} + {msgs.map((m) => ( +
+
{m.author_id}
+
{m.content}
+
+ ))} +
+ +
+