Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
61 changes: 55 additions & 6 deletions desktop/src/apps/MessagesApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import { useVisualViewport } from "@/hooks/use-visual-viewport";
import { useDropTarget } from "@/shell/dnd/use-drop-target";
import { resolveAgentEmoji } from "@/lib/agent-emoji";
import { ChannelSettingsPanel } from "./chat/ChannelSettingsPanel";
import { HelpPanel } from "./chat/HelpPanel";
import { AllThreadsList } from "./chat/AllThreadsList";
import { AgentContextMenu } from "./chat/AgentContextMenu";
import { SlashMenu, type SlashCommandsBySlug } from "./chat/SlashMenu";
import { TypingFooter, type AgentTyping } from "./chat/TypingFooter";
Expand Down Expand Up @@ -90,6 +92,7 @@ interface Channel {
archived_agent_id?: string;
archived_agent_slug?: string;
muted?: string[];
ephemeral_ttl_seconds?: number | null;
};
}

Expand Down Expand Up @@ -169,6 +172,13 @@ type WsStatus = "connecting" | "connected" | "disconnected";
/* Helpers */
/* ------------------------------------------------------------------ */

function formatTTL(seconds: number): string {
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
if (seconds < 86400) return `${Math.round(seconds / 3600)}h`;
if (seconds < 604800 * 4) return `${Math.round(seconds / 86400)}d`;
return `${Math.round(seconds / 604800)}w`;
}

function relativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60000);
Expand Down Expand Up @@ -250,6 +260,8 @@ export function MessagesApp({ windowId: _windowId, title }: { windowId: string;
const [newChannel, setNewChannel] = useState({ name: "", type: "topic" as "topic" | "group", description: "" });
const [prefillBanner, setPrefillBanner] = useState<{ promptName: string; agentName?: string } | null>(null);
const [showSettings, setShowSettings] = useState(false);
const [showHelp, setShowHelp] = useState(false);
const [showAllThreads, setShowAllThreads] = useState(false);
const [contextMenu, setContextMenu] = useState<{ slug: string; x: number; y: number } | null>(null);
const [agentInfoPopover, setAgentInfoPopover] = useState<
{ slug: string; framework: string; model: string; status: string; x: number; y: number } | null
Expand Down Expand Up @@ -643,15 +655,22 @@ 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 ---- */
/* ---- mutex: settings vs thread panel vs all-threads ---- */
const handleOpenSettings = () => {
closeThread();
setShowAllThreads(false);
setShowSettings(true);
};
const handleOpenThreadFor = (channelId: string, parentId: string) => {
setShowSettings(false);
setShowAllThreads(false);
openThreadFor(channelId, parentId);
};
const handleOpenAllThreads = () => {
setShowSettings(false);
closeThread();
setShowAllThreads(true);
};

/* ---- send message ---- */
const sendMessage = async () => {
Expand Down Expand Up @@ -1285,13 +1304,20 @@ export function MessagesApp({ windowId: _windowId, title }: { windowId: string;
className="ml-1 opacity-60 hover:opacity-100"
>ⓘ</button>
)}
<a
{currentChannel?.settings?.ephemeral_ttl_seconds && (
<span
aria-label={`Disappearing messages: ${formatTTL(currentChannel.settings.ephemeral_ttl_seconds)}`}
className="ml-1 text-[11px] text-amber-300/80 bg-amber-300/10 rounded px-1 py-0.5 leading-none"
title="Disappearing messages enabled"
>
⏳ {formatTTL(currentChannel.settings.ephemeral_ttl_seconds)}
</span>
)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<button
aria-label="Open chat guide"
href="https://github.com/jaylfc/tinyagentos/blob/master/docs/chat-guide.md"
target="_blank"
rel="noreferrer"
onClick={() => setShowHelp(true)}
className="ml-1 opacity-60 hover:opacity-100 text-[12px]"
>?</a>
>?</button>
<div className="relative">
<PinBadge
count={pinnedMessages.length}
Expand All @@ -1318,6 +1344,14 @@ export function MessagesApp({ windowId: _windowId, title }: { windowId: string;
<div className="text-[11px] text-white/35 truncate">{currentChannel.description}</div>
)}
</div>
{currentChannel && currentChannel.type !== "dm" && (
<button
aria-label="All threads"
onClick={handleOpenAllThreads}
className="opacity-60 hover:opacity-100 text-[12px]"
title="All threads"
>💬</button>
)}
{currentChannel?.members && (
<div className="text-[11px] text-white/30 flex items-center gap-1">
<Users size={12} /> {currentChannel.members.length}
Expand Down Expand Up @@ -1773,6 +1807,21 @@ export function MessagesApp({ windowId: _windowId, title }: { windowId: string;
);
})()}

{/* ---- Help Panel ---- */}
{showHelp && <HelpPanel onClose={() => setShowHelp(false)} />}

{/* ---- All Threads Panel ---- */}
{showAllThreads && currentChannel && (
<AllThreadsList
channelId={currentChannel.id}
onClose={() => setShowAllThreads(false)}
onJumpToThread={(parentId) => {
setShowAllThreads(false);
handleOpenThreadFor(currentChannel.id, parentId);
}}
/>
)}

{/* ---- Channel Settings Panel ---- */}
{showSettings && currentChannel && (
<ChannelSettingsPanel
Expand Down
109 changes: 109 additions & 0 deletions desktop/src/apps/chat/AllThreadsList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { useEffect, useState } from "react";

interface ThreadSummary {
id: string;
author_id: string;
content: string;
reply_count: number;
last_reply_at: number | null;
}

function relativeTs(ts: number | null): string {
if (!ts) return "";
const diff = Date.now() - ts * 1000;
const mins = Math.floor(diff / 60000);
if (mins < 1) return "now";
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days < 7) return `${days}d ago`;
return new Date(ts * 1000).toLocaleDateString();
}

export function AllThreadsList({
channelId,
onClose,
onJumpToThread,
}: {
channelId: string;
onClose: () => void;
onJumpToThread: (parentId: string) => void;
}) {
const [threads, setThreads] = useState<ThreadSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
const ac = new AbortController();
setLoading(true);
setError(null);
fetch(`/api/chat/channels/${channelId}/threads`, { signal: ac.signal })
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then((data) => setThreads(data.threads ?? []))
.catch((e) => {
if ((e as Error).name === "AbortError") return;
setError(e instanceof Error ? e.message : "failed");
})
.finally(() => {
// ac.signal.aborted is true when we've been superseded
if (!ac.signal.aborted) setLoading(false);
});
return () => ac.abort();
}, [channelId]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return (
<aside
role="complementary"
aria-label="All threads"
className="fixed top-0 right-0 h-full w-[360px] bg-shell-surface border-l border-white/10 shadow-xl flex flex-col z-40"
>
<header className="flex items-center justify-between px-4 py-3 border-b border-white/10">
<h2 className="text-sm font-semibold">All threads</h2>
<button onClick={onClose} aria-label="Close" className="text-lg leading-none opacity-60 hover:opacity-100">×</button>
</header>

<div className="flex-1 overflow-y-auto px-2 py-2">
{loading && (
<div className="px-2 py-4 text-xs text-shell-text-tertiary">Loading…</div>
)}
{error && (
<div role="alert" className="text-xs text-red-300 bg-red-500/10 border border-red-500/30 rounded px-2 py-2 mx-2">
{error}
</div>
)}
{!loading && !error && threads.length === 0 && (
<div className="px-2 py-4 text-xs text-shell-text-tertiary">No threads yet.</div>
)}
{!loading && !error && threads.length > 0 && (
<ul>
{threads.map((t) => (
<li key={t.id}>
<button
className="w-full text-left px-3 py-2.5 rounded hover:bg-white/5 flex flex-col gap-0.5"
onClick={() => onJumpToThread(t.id)}
>
<span className="text-xs text-shell-text-secondary line-clamp-2">{t.content}</span>
<div className="flex items-center gap-2 text-[11px] text-shell-text-tertiary">
<span>@{t.author_id}</span>
<span>·</span>
<span>{t.reply_count} {t.reply_count === 1 ? "reply" : "replies"}</span>
{t.last_reply_at && (
<>
<span>·</span>
<span>{relativeTs(t.last_reply_at)}</span>
</>
)}
</div>
</button>
</li>
))}
</ul>
)}
</div>
</aside>
);
}
98 changes: 89 additions & 9 deletions desktop/src/apps/chat/AttachmentLightbox.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
// desktop/src/apps/chat/AttachmentLightbox.tsx
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { AttachmentRecord } from "@/lib/chat-attachments-api";

const MIN_ZOOM = 1;
const MAX_ZOOM = 4;

export function AttachmentLightbox({
images, startIndex, onClose,
}: {
Expand All @@ -10,15 +13,57 @@ export function AttachmentLightbox({
onClose: () => void;
}) {
const [idx, setIdx] = useState(startIndex);
const [zoom, setZoom] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const dragRef = useRef<{ startX: number; startY: number; panX: number; panY: number } | null>(null);

const resetZoom = () => { setZoom(1); setPan({ x: 0, y: 0 }); };

const clampZoom = (z: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z));

const navigate = (delta: number) => {
setIdx((i) => Math.max(0, Math.min(images.length - 1, i + delta)));
resetZoom();
};

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));
if (e.key === "ArrowLeft") navigate(-1);
if (e.key === "ArrowRight") navigate(1);
if (e.key === "+" || e.key === "=") setZoom((z) => clampZoom(z * 1.2));
if (e.key === "-") setZoom((z) => clampZoom(z / 1.2));
if (e.key === "0") resetZoom();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [images.length, onClose]);
}, [images.length, onClose]); // eslint-disable-line react-hooks/exhaustive-deps

const handleWheel = (e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY < 0 ? 1.15 : 1 / 1.15;
setZoom((z) => clampZoom(z * delta));
};

const handleDoubleClick = () => {
setZoom((z) => z > 1 ? 1 : 2);
setPan({ x: 0, y: 0 });
};

const handlePointerDown = (e: React.PointerEvent<HTMLImageElement>) => {
if (zoom <= 1) return;
e.currentTarget.setPointerCapture(e.pointerId);
dragRef.current = { startX: e.clientX, startY: e.clientY, panX: pan.x, panY: pan.y };
};

const handlePointerMove = (e: React.PointerEvent<HTMLImageElement>) => {
if (!dragRef.current) return;
const dx = e.clientX - dragRef.current.startX;
const dy = e.clientY - dragRef.current.startY;
setPan({ x: dragRef.current.panX + dx, y: dragRef.current.panY + dy });
};

const handlePointerUp = () => { dragRef.current = null; };

const current = images[idx]!;
return (
Expand All @@ -27,18 +72,53 @@ export function AttachmentLightbox({
aria-label="Image viewer"
className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center"
onClick={onClose}
onWheel={handleWheel}
>
<img src={current.url} alt={current.filename}
className="max-w-[90vw] max-h-[90vh]"
onClick={(e) => e.stopPropagation()} />
<div className="absolute top-4 right-4 flex gap-2">
<img
src={current.url}
alt={current.filename}
className="max-w-[90vw] max-h-[90vh] select-none"
style={{
transform: `scale(${zoom}) translate(${pan.x / zoom}px, ${pan.y / zoom}px)`,
cursor: zoom > 1 ? "grab" : "default",
transition: dragRef.current ? "none" : "transform 0.1s ease",
}}
onClick={(e) => e.stopPropagation()}
onDoubleClick={handleDoubleClick}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
draggable={false}
/>
<div className="absolute top-4 right-4 flex gap-2" onClick={(e) => e.stopPropagation()}>
{zoom !== 1 && (
<button
onClick={resetZoom}
className="bg-white/10 hover:bg-white/20 rounded px-3 py-1 text-sm"
aria-label="Reset zoom"
>
{Math.round(zoom * 100)}%
</button>
)}
<a href={current.url} download={current.filename}
onClick={(e) => e.stopPropagation()}
className="bg-white/10 hover:bg-white/20 rounded px-3 py-1 text-sm">Download</a>
<button onClick={onClose} className="bg-white/10 hover:bg-white/20 rounded px-3 py-1 text-sm">Close</button>
</div>
{images.length > 1 && (
<div className="absolute bottom-4 text-white/70 text-xs">{idx + 1} / {images.length}</div>
<>
<button
aria-label="Previous image"
onClick={(e) => { e.stopPropagation(); navigate(-1); }}
className="absolute left-4 top-1/2 -translate-y-1/2 bg-white/10 hover:bg-white/20 rounded-full w-9 h-9 flex items-center justify-center text-lg"
>‹</button>
<button
aria-label="Next image"
onClick={(e) => { e.stopPropagation(); navigate(1); }}
className="absolute right-4 top-1/2 -translate-y-1/2 bg-white/10 hover:bg-white/20 rounded-full w-9 h-9 flex items-center justify-center text-lg"
>›</button>
<div className="absolute bottom-4 text-white/70 text-xs">{idx + 1} / {images.length}</div>
</>
)}
</div>
);
Expand Down
Loading
Loading