-
Notifications
You must be signed in to change notification settings - Fork 7
Chat Phase 2b-1 — threads, attachments, shared file picker, chat guide #236
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
a9d115d
ac668ec
f4f68bd
2e518a6
a96a923
a8b3442
01225b4
9051297
1bfdb69
5ce898f
e761018
9e754f7
1116081
6f092fb
0a4da28
f51b154
6fb9aa2
bbc7071
da3d40d
0fe768e
4375207
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<string, string[]>; | ||
| 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<string[]>([]); | ||
| const [typingAgents, setTypingAgents] = useState<string[]>([]); | ||
| const [sendError, setSendError] = useState<string | null>(null); | ||
| const [hoveredMessageId, setHoveredMessageId] = useState<string | null>(null); | ||
| const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]); | ||
| const { openThread, openThreadFor, closeThread } = useThreadPanel(); | ||
|
|
||
| const wsRef = useRef<WebSocket | null>(null); | ||
| const messagesEndRef = useRef<HTMLDivElement>(null); | ||
|
|
@@ -529,10 +542,64 @@ 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 (!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, validate via REST before sending over WS | ||
| if (text.startsWith("/")) { | ||
| try { | ||
|
|
@@ -586,26 +653,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 +1073,17 @@ export function MessagesApp({ windowId: _windowId, title }: { windowId: string; | |
| {currentChannel && currentChannel.type !== "dm" && ( | ||
| <button | ||
| aria-label="Channel settings" | ||
| onClick={() => setShowSettings(true)} | ||
| onClick={handleOpenSettings} | ||
| className="ml-1 opacity-60 hover:opacity-100" | ||
| >ⓘ</button> | ||
| )} | ||
| <a | ||
| aria-label="Open chat guide" | ||
| href="https://github.com/jaylfc/tinyagentos/blob/master/docs/chat-guide.md" | ||
| target="_blank" | ||
| rel="noreferrer" | ||
| className="ml-1 opacity-60 hover:opacity-100 text-[12px]" | ||
| >?</a> | ||
| </div> | ||
| {currentChannel?.description && ( | ||
| <div className="text-[11px] text-white/35 truncate">{currentChannel.description}</div> | ||
|
|
@@ -1020,6 +1101,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 && ( | ||
| <div className="flex items-center justify-center h-full text-white/20 text-sm"> | ||
|
|
@@ -1049,6 +1141,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 && ( | ||
| <div | ||
|
|
@@ -1152,15 +1246,28 @@ export function MessagesApp({ windowId: _windowId, title }: { windowId: string; | |
| )} | ||
|
|
||
| {/* hover actions */} | ||
| <div className="absolute right-2 -top-3 hidden group-hover:flex bg-zinc-800 border border-white/10 rounded-md shadow-lg overflow-hidden"> | ||
| <button | ||
| onClick={() => setShowEmoji(showEmoji === msg.id ? null : msg.id)} | ||
| className="p-1.5 hover:bg-white/10 text-white/40 hover:text-white/70 transition-colors" | ||
| aria-label="Add reaction" | ||
| > | ||
| <SmilePlus size={14} /> | ||
| </button> | ||
| </div> | ||
| {hoveredMessageId === msg.id && ( | ||
| <div className="absolute top-0 right-2 -translate-y-1/2 z-10"> | ||
| <MessageHoverActions | ||
| onReact={() => 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 }); | ||
| } | ||
| }} | ||
| /> | ||
| </div> | ||
| )} | ||
| <AttachmentGallery attachments={(msg.attachments as AttachmentRecord[] | undefined) || []} /> | ||
| {typeof msg.reply_count === "number" && msg.reply_count > 0 && ( | ||
| <ThreadIndicator | ||
| replyCount={msg.reply_count} | ||
| lastReplyAt={msg.last_reply_at ?? null} | ||
| onOpen={() => handleOpenThreadFor(msg.channel_id ?? selectedChannel ?? "", msg.id)} | ||
| /> | ||
| )} | ||
|
|
||
| {/* emoji picker */} | ||
| {showEmoji === msg.id && ( | ||
|
|
@@ -1224,6 +1331,15 @@ export function MessagesApp({ windowId: _windowId, title }: { windowId: string; | |
| </div> | ||
| )} | ||
|
|
||
| {/* pending attachments bar */} | ||
| <AttachmentsBar | ||
| items={pendingAttachments} | ||
| onRemove={(id) => 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 */} | ||
| <div className="px-4 py-3 border-t border-white/[0.06] shrink-0"> | ||
| <div className="relative"> | ||
|
|
@@ -1254,6 +1370,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 +1392,7 @@ export function MessagesApp({ windowId: _windowId, title }: { windowId: string; | |
| <Button | ||
| size="icon" | ||
| onClick={sendMessage} | ||
| disabled={!input.trim() || isCurrentArchived} | ||
| disabled={(!input.trim() && pendingAttachments.length === 0) || isCurrentArchived || pendingAttachments.some(a => a.uploading)} | ||
| className="h-8 w-8 shrink-0 mb-0.5" | ||
| aria-label="Send message" | ||
| > | ||
|
|
@@ -1359,6 +1488,30 @@ export function MessagesApp({ windowId: _windowId, title }: { windowId: string; | |
| /> | ||
| )} | ||
|
|
||
| {/* ---- Thread Panel ---- */} | ||
| {openThread && ( | ||
| <ThreadPanel | ||
| channelId={openThread.channelId} | ||
| parentId={openThread.parentId} | ||
| onClose={closeThread} | ||
| onSend={async (content, attachments) => { | ||
| await fetch("/api/chat/messages", { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify({ | ||
| channel_id: openThread.channelId, | ||
| author_id: "user", | ||
| author_type: "user", | ||
| content, | ||
| content_type: "text", | ||
| thread_id: openThread.parentId, | ||
| attachments, | ||
| }), | ||
| }); | ||
|
Comment on lines
+1497
to
+1510
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Surface failed thread sends back to
Suggested fix <ThreadPanel
channelId={openThread.channelId}
parentId={openThread.parentId}
onClose={closeThread}
onSend={async (content, attachments) => {
- await fetch("/api/chat/messages", {
+ const r = await fetch("/api/chat/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
channel_id: openThread.channelId,
author_id: "user",
@@
attachments,
}),
});
+ if (!r.ok) {
+ const body = await r.json().catch(() => ({}));
+ throw new Error((body as { error?: string }).error || "send failed");
+ }
}}
/>🤖 Prompt for AI Agents |
||
| }} | ||
| /> | ||
| )} | ||
|
|
||
| {/* ---- Agent Context Menu ---- */} | ||
| {contextMenu && ( | ||
| <AgentContextMenu | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| // desktop/src/apps/chat/AttachmentGallery.tsx | ||
| import { useState } from "react"; | ||
| import type { AttachmentRecord } from "@/lib/chat-attachments-api"; | ||
| import { AttachmentLightbox } from "./AttachmentLightbox"; | ||
|
|
||
| export function AttachmentGallery({ attachments }: { attachments: AttachmentRecord[] }) { | ||
| const [lightboxStart, setLightboxStart] = useState<number | null>(null); | ||
| if (!attachments?.length) return null; | ||
| const images = attachments.filter((a) => a.mime_type?.startsWith("image/")); | ||
| const files = attachments.filter((a) => !a.mime_type?.startsWith("image/")); | ||
|
|
||
| const gridClass = images.length > 1 ? "grid grid-cols-2 gap-1 max-w-md" : ""; | ||
|
|
||
| return ( | ||
| <div className="flex flex-col gap-2 mt-1"> | ||
| {images.length > 0 && ( | ||
| <div className={gridClass}> | ||
| {images.slice(0, 4).map((img, i) => ( | ||
| <button key={img.url} onClick={() => setLightboxStart(i)} className="relative block"> | ||
| <img | ||
| src={img.url} | ||
| alt={img.filename} | ||
| className={images.length === 1 | ||
| ? "max-w-[560px] max-h-[400px] rounded" | ||
| : "object-cover w-full h-32 rounded"} | ||
| /> | ||
| {images.length > 4 && i === 3 && ( | ||
| <span className="absolute inset-0 bg-black/60 flex items-center justify-center text-white"> | ||
| +{images.length - 4} more | ||
| </span> | ||
| )} | ||
| </button> | ||
| ))} | ||
| </div> | ||
| )} | ||
| {files.length > 0 && ( | ||
| <div className="flex flex-col gap-1"> | ||
| {files.map((f) => ( | ||
| <a key={f.url} href={f.url} target="_blank" rel="noreferrer" | ||
| className="flex items-center gap-2 bg-white/5 hover:bg-white/10 rounded px-2 py-1 text-sm max-w-sm"> | ||
| <span aria-hidden>📄</span> | ||
| <span className="truncate">{f.filename}</span> | ||
| <span className="ml-auto text-xs opacity-60"> | ||
| {Math.max(1, Math.round(f.size / 1024))} KB | ||
| </span> | ||
| </a> | ||
| ))} | ||
| </div> | ||
| )} | ||
| {lightboxStart !== null && ( | ||
| <AttachmentLightbox | ||
| images={images} | ||
| startIndex={lightboxStart} | ||
| onClose={() => setLightboxStart(null)} | ||
| /> | ||
| )} | ||
| </div> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't gate the HTTP send paths on WebSocket state.
This early return blocks attachment sends and other REST-backed flows whenever the socket drops, even though they do not need WS. Only the final text-only WS fallback should require
readyState === 1.Suggested fix
const sendMessage = async () => { const text = input.trim(); if (!text && pendingAttachments.length === 0) return; - if (!selectedChannel || !wsRef.current || wsRef.current.readyState !== 1) return; + if (!selectedChannel) return; // Block send while uploads are in-flight if (pendingAttachments.some((a) => a.uploading)) { setSendError("waiting for uploads to finish…"); return; @@ - if (!text) return; + if (!text) return; + if (!wsRef.current || wsRef.current.readyState !== 1) return;🤖 Prompt for AI Agents