Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a9d115d
docs: implementation plan for chat Phase 2b-1 — threads + attachments…
jaylfc Apr 19, 2026
ac668ec
feat(chat): attachments column on chat_messages + persist/parse round…
jaylfc Apr 19, 2026
f4f68bd
feat(chat): POST /api/chat/attachments/from-path for workspace file refs
jaylfc Apr 19, 2026
2e518a6
feat(chat): POST /api/chat/messages accepts attachments[] with valida…
jaylfc Apr 19, 2026
a96a923
feat(chat): get_thread_messages + GET /channels/{id}/threads/{parent}…
jaylfc Apr 19, 2026
a8b3442
feat(chat): threads.resolve_thread_recipients for narrow thread routing
jaylfc Apr 19, 2026
01225b4
feat(chat): router integrates thread-aware recipients + per-thread po…
jaylfc Apr 19, 2026
9051297
feat(chat): /help command — overview + per-topic cheat sheets
jaylfc Apr 19, 2026
1bfdb69
feat(chat): /help intercept in POST /api/chat/messages (bypasses bare…
jaylfc Apr 19, 2026
5ce898f
test(bridge): verify thread_id + attachments pass through enqueue_use…
jaylfc Apr 19, 2026
e761018
feat(bridges): append attachment footer to LLM context prompt
jaylfc Apr 19, 2026
9e754f7
refactor(desktop): add shared VfsBrowser flat-listing component for p…
jaylfc Apr 19, 2026
1116081
feat(desktop): SharedFilePickerDialog (shell primitive) + openFilePic…
jaylfc Apr 19, 2026
6f092fb
feat(desktop): chat-attachments-api client (upload + from-path)
jaylfc Apr 19, 2026
0a4da28
feat(desktop): AttachmentsBar + AttachmentGallery + AttachmentLightbox
jaylfc Apr 19, 2026
f51b154
feat(chat): thread UI — MessageHoverActions + ThreadIndicator + Threa…
jaylfc Apr 19, 2026
6fb9aa2
fix(chat): ThreadPanel guards parent fetch on r.ok to avoid setting e…
jaylfc Apr 19, 2026
bbc7071
docs: canonical chat guide — retroactive P1 + 2a + 2b-1 coverage
jaylfc Apr 19, 2026
da3d40d
feat(desktop): integrate threads, attachments, hover actions, ? icon …
jaylfc Apr 19, 2026
0fe768e
build: rebuild desktop bundle for chat Phase 2b-1
jaylfc Apr 19, 2026
4375207
test(e2e): chat Phase 2b-1 — thread panel, paperclip picker, /help
jaylfc Apr 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
215 changes: 184 additions & 31 deletions desktop/src/apps/MessagesApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
Plus,
Send,
Paperclip,
SmilePlus,
Bot,
X,
AtSign,
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;

Comment on lines 556 to +560
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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
Verify each finding against the current code and only fix it if needed.

In `@desktop/src/apps/MessagesApp.tsx` around lines 556 - 560, The sendMessage
function currently returns early when wsRef.current.readyState !== 1, which
incorrectly blocks REST-backed flows (e.g., pendingAttachments) — remove the
global readyState guard and instead only require wsRef.current.readyState === 1
for the text-only WebSocket fallback path. Concretely: in sendMessage, keep the
checks for input/pendingAttachments and selectedChannel, but do not return based
on wsRef.readyState; later, when choosing the send path, branch so that
attachment/HTTP flows call the REST functions regardless of wsRef state and only
the WS fallback path checks wsRef.current.readyState before sending.

// 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 {
Expand Down Expand Up @@ -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 ---- */
Expand Down Expand Up @@ -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>
Expand All @@ -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">
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 && (
Expand Down Expand Up @@ -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">
Expand Down Expand Up @@ -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}
Expand All @@ -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"
>
Expand Down Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Surface failed thread sends back to ThreadPanel.

ThreadPanel relies on onSend throwing when a send fails, but this callback never checks response.ok. Right now a 4xx/5xx reply is treated as success and the panel cannot preserve the draft or show the error.

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
Verify each finding against the current code and only fix it if needed.

In `@desktop/src/apps/MessagesApp.tsx` around lines 1497 - 1510, The onSend
callback in MessagesApp.tsx currently POSTs to "/api/chat/messages" but never
checks the fetch Response, so failed 4xx/5xx responses are treated as success
and ThreadPanel doesn't get an exception to preserve drafts or display errors;
update the onSend handler (the async function passed to onSend) to inspect the
fetch Response (response.ok) and, if not ok, read error details (e.g.,
response.text() or response.json()) and throw an Error containing that
information so ThreadPanel receives a thrown error and can handle the failure
appropriately.

}}
/>
)}

{/* ---- Agent Context Menu ---- */}
{contextMenu && (
<AgentContextMenu
Expand Down
59 changes: 59 additions & 0 deletions desktop/src/apps/chat/AttachmentGallery.tsx
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>
);
}
Loading
Loading