diff --git a/desktop/src/apps/MessagesApp.tsx b/desktop/src/apps/MessagesApp.tsx index 3fc3b3c..31ed4f6 100644 --- a/desktop/src/apps/MessagesApp.tsx +++ b/desktop/src/apps/MessagesApp.tsx @@ -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"; @@ -90,6 +92,7 @@ interface Channel { archived_agent_id?: string; archived_agent_slug?: string; muted?: string[]; + ephemeral_ttl_seconds?: number | null; }; } @@ -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); @@ -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 @@ -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 () => { @@ -1285,13 +1304,20 @@ export function MessagesApp({ windowId: _windowId, title }: { windowId: string; className="ml-1 opacity-60 hover:opacity-100" >ⓘ )} - + ⏳ {formatTTL(currentChannel.settings.ephemeral_ttl_seconds)} + + )} +
{currentChannel.description}
)} + {currentChannel && currentChannel.type !== "dm" && ( + + )} {currentChannel?.members && (
{currentChannel.members.length} @@ -1773,6 +1807,21 @@ export function MessagesApp({ windowId: _windowId, title }: { windowId: string; ); })()} + {/* ---- Help Panel ---- */} + {showHelp && setShowHelp(false)} />} + + {/* ---- All Threads Panel ---- */} + {showAllThreads && currentChannel && ( + setShowAllThreads(false)} + onJumpToThread={(parentId) => { + setShowAllThreads(false); + handleOpenThreadFor(currentChannel.id, parentId); + }} + /> + )} + {/* ---- Channel Settings Panel ---- */} {showSettings && currentChannel && ( void; + onJumpToThread: (parentId: string) => void; +}) { + const [threads, setThreads] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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]); + + return ( + + ); +} diff --git a/desktop/src/apps/chat/AttachmentLightbox.tsx b/desktop/src/apps/chat/AttachmentLightbox.tsx index 5b1a998..7761b05 100644 --- a/desktop/src/apps/chat/AttachmentLightbox.tsx +++ b/desktop/src/apps/chat/AttachmentLightbox.tsx @@ -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, }: { @@ -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) => { + 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) => { + 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 ( @@ -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} > - {current.filename} e.stopPropagation()} /> -
+ {current.filename} 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} + /> +
e.stopPropagation()}> + {zoom !== 1 && ( + + )} e.stopPropagation()} className="bg-white/10 hover:bg-white/20 rounded px-3 py-1 text-sm">Download
{images.length > 1 && ( -
{idx + 1} / {images.length}
+ <> + + +
{idx + 1} / {images.length}
+ )}
); diff --git a/desktop/src/apps/chat/ChannelSettingsPanel.tsx b/desktop/src/apps/chat/ChannelSettingsPanel.tsx index ac9fa97..f137275 100644 --- a/desktop/src/apps/chat/ChannelSettingsPanel.tsx +++ b/desktop/src/apps/chat/ChannelSettingsPanel.tsx @@ -14,6 +14,7 @@ type Channel = { max_hops?: number; cooldown_seconds?: number; muted?: string[]; + ephemeral_ttl_seconds?: number | null; }; }; @@ -32,6 +33,7 @@ export function ChannelSettingsPanel({ const [mode, setMode] = useState(channel.settings.response_mode ?? "quiet"); const [hops, setHops] = useState(channel.settings.max_hops ?? 3); const [cooldown, setCooldown] = useState(channel.settings.cooldown_seconds ?? 5); + const [ephemeralTtl, setEphemeralTtl] = useState(channel.settings.ephemeral_ttl_seconds ?? null); const [err, setErr] = useState(null); // Keep local state in sync if the parent pushes an updated channel @@ -41,6 +43,7 @@ export function ChannelSettingsPanel({ setMode(channel.settings.response_mode ?? "quiet"); setHops(channel.settings.max_hops ?? 3); setCooldown(channel.settings.cooldown_seconds ?? 5); + setEphemeralTtl(channel.settings.ephemeral_ttl_seconds ?? null); }, [channel]); const apply = async (patch: Parameters[1], rollback: () => void) => { @@ -168,6 +171,29 @@ export function ChannelSettingsPanel({
+
+

Disappearing messages

+ +
+

Advanced