diff --git a/README.md b/README.md index 10d1b80d2ba..e3170e14561 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ **Node 22+ required.** ```bash -npx denchclaw +npx denchclaw@latest ``` Opens at `localhost:3100` after completing onboarding wizard. @@ -41,8 +41,8 @@ Opens at `localhost:3100` after completing onboarding wizard. ## Commands ```bash -npx denchclaw # runs onboarding again for openclaw --profile dench -npx denchclaw update # updates denchclaw with current settings as is +npx denchclaw@latest # runs onboarding again for openclaw --profile dench +npx denchclaw@latest update # updates denchclaw web-runtime with current settings as is npx denchclaw restart # restarts denchclaw web server npx denchclaw start # starts denchclaw web server npx denchclaw stop # stops denchclaw web server diff --git a/apps/web/app/api/gateway/channels/route.ts b/apps/web/app/api/gateway/channels/route.ts new file mode 100644 index 00000000000..d4e2c67ab4e --- /dev/null +++ b/apps/web/app/api/gateway/channels/route.ts @@ -0,0 +1,64 @@ +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { resolveOpenClawStateDir } from "@/lib/workspace"; +import { callGatewayRpc } from "@/lib/agent-runner"; +import type { ChannelStatus } from "@/lib/gateway-transcript"; + +export const dynamic = "force-dynamic"; + +const KNOWN_CHANNELS = [ + "whatsapp", "telegram", "discord", "googlechat", + "slack", "signal", "imessage", "nostr", +] as const; + +function readConfiguredChannels(): Record { + const configPath = join(resolveOpenClawStateDir(), "openclaw.json"); + if (!existsSync(configPath)) return {}; + try { + const config = JSON.parse(readFileSync(configPath, "utf-8")); + return (config.channels ?? {}) as Record; + } catch { + return {}; + } +} + +export async function GET() { + const configuredChannels = readConfiguredChannels(); + + let gatewayStatus: Record> = {}; + try { + const res = await callGatewayRpc("channels.status", { probe: false }); + if (res.ok && res.payload) { + const payload = res.payload as Record; + const channelMeta = payload.channelMeta as Record> | undefined; + if (channelMeta) { + gatewayStatus = channelMeta; + } + } + } catch { + // Gateway might be unavailable; fall back to config-only status + } + + const channels: ChannelStatus[] = []; + + for (const channelId of KNOWN_CHANNELS) { + const channelConfig = configuredChannels[channelId]; + const gwStatus = gatewayStatus[channelId]; + + const configured = !!channelConfig; + if (!configured && !gwStatus) continue; + + const enabled = channelConfig?.enabled !== false; + + channels.push({ + id: channelId, + configured, + running: enabled && (gwStatus?.running as boolean ?? false), + connected: gwStatus?.connected as boolean ?? false, + error: gwStatus?.error as string | undefined, + lastMessage: gwStatus?.lastMessageAt as number | undefined, + }); + } + + return Response.json({ channels }); +} diff --git a/apps/web/app/api/gateway/chat/route.ts b/apps/web/app/api/gateway/chat/route.ts new file mode 100644 index 00000000000..88f2029f7ba --- /dev/null +++ b/apps/web/app/api/gateway/chat/route.ts @@ -0,0 +1,88 @@ +import { + startSubscribeRun, + getActiveRun, + subscribeToRun, + reactivateSubscribeRun, + type SseEvent, +} from "@/lib/active-runs"; + +export const runtime = "nodejs"; + +export async function POST(req: Request) { + const { sessionKey, message }: { sessionKey: string; message: string } = await req.json(); + + if (!sessionKey || !message?.trim()) { + return new Response("sessionKey and message are required", { status: 400 }); + } + + let run = getActiveRun(sessionKey); + if (run?.status === "running") { + return new Response("Active run already in progress for this session", { status: 409 }); + } + + if (run) { + reactivateSubscribeRun(sessionKey, message); + } else { + const sessionLabel = sessionKey.split(":").slice(2).join(":"); + run = startSubscribeRun({ + sessionKey, + parentSessionId: sessionKey, + task: message.slice(0, 200), + label: sessionLabel, + }); + reactivateSubscribeRun(sessionKey, message); + } + + const encoder = new TextEncoder(); + let closed = false; + let unsubscribe: (() => void) | null = null; + let keepalive: ReturnType | null = null; + + const stream = new ReadableStream({ + start(controller) { + keepalive = setInterval(() => { + if (closed) return; + try { + controller.enqueue(encoder.encode(": keepalive\n\n")); + } catch { /* ignore */ } + }, 15_000); + + unsubscribe = subscribeToRun( + sessionKey, + (event: SseEvent | null) => { + if (closed) return; + if (event === null) { + closed = true; + if (keepalive) { clearInterval(keepalive); keepalive = null; } + try { controller.close(); } catch { /* already closed */ } + return; + } + try { + const json = JSON.stringify(event); + controller.enqueue(encoder.encode(`data: ${json}\n\n`)); + } catch { /* ignore */ } + }, + { replay: false }, + ); + + if (!unsubscribe) { + closed = true; + if (keepalive) { clearInterval(keepalive); keepalive = null; } + controller.close(); + } + }, + cancel() { + closed = true; + if (keepalive) { clearInterval(keepalive); keepalive = null; } + unsubscribe?.(); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }, + }); +} diff --git a/apps/web/app/api/gateway/chat/stream/route.ts b/apps/web/app/api/gateway/chat/stream/route.ts new file mode 100644 index 00000000000..c1491dcfe2b --- /dev/null +++ b/apps/web/app/api/gateway/chat/stream/route.ts @@ -0,0 +1,87 @@ +import { + getActiveRun, + startSubscribeRun, + subscribeToRun, + type SseEvent, +} from "@/lib/active-runs"; + +export const runtime = "nodejs"; + +export async function GET(req: Request) { + const url = new URL(req.url); + const sessionKey = url.searchParams.get("sessionKey"); + + if (!sessionKey) { + return new Response("sessionKey query parameter required", { status: 400 }); + } + + let run = getActiveRun(sessionKey); + + if (!run) { + const sessionLabel = sessionKey.split(":").slice(2).join(":"); + run = startSubscribeRun({ + sessionKey, + parentSessionId: sessionKey, + task: `Channel session: ${sessionLabel}`, + label: sessionLabel, + }); + } + + if (!run) { + return Response.json({ active: false }, { status: 404 }); + } + + const encoder = new TextEncoder(); + let closed = false; + let unsubscribe: (() => void) | null = null; + let keepalive: ReturnType | null = null; + + const stream = new ReadableStream({ + start(controller) { + keepalive = setInterval(() => { + if (closed) return; + try { + controller.enqueue(encoder.encode(": keepalive\n\n")); + } catch { /* ignore */ } + }, 15_000); + + unsubscribe = subscribeToRun( + sessionKey, + (event: SseEvent | null) => { + if (closed) return; + if (event === null) { + closed = true; + if (keepalive) { clearInterval(keepalive); keepalive = null; } + try { controller.close(); } catch { /* already closed */ } + return; + } + try { + const json = JSON.stringify(event); + controller.enqueue(encoder.encode(`data: ${json}\n\n`)); + } catch { /* ignore */ } + }, + { replay: true }, + ); + + if (!unsubscribe) { + closed = true; + if (keepalive) { clearInterval(keepalive); keepalive = null; } + controller.close(); + } + }, + cancel() { + closed = true; + if (keepalive) { clearInterval(keepalive); keepalive = null; } + unsubscribe?.(); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Run-Active": run.status === "running" || run.status === "waiting-for-subagents" ? "true" : "false", + }, + }); +} diff --git a/apps/web/app/api/gateway/sessions/[id]/route.ts b/apps/web/app/api/gateway/sessions/[id]/route.ts new file mode 100644 index 00000000000..3ca815de659 --- /dev/null +++ b/apps/web/app/api/gateway/sessions/[id]/route.ts @@ -0,0 +1,21 @@ +import { readFileSync } from "node:fs"; +import { findSessionTranscriptFile, parseTranscriptToMessages } from "@/lib/gateway-transcript"; + +export const dynamic = "force-dynamic"; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + + const transcriptFile = findSessionTranscriptFile(id); + if (!transcriptFile) { + return Response.json({ error: "Session not found" }, { status: 404 }); + } + + const content = readFileSync(transcriptFile, "utf-8"); + const messages = parseTranscriptToMessages(content); + + return Response.json({ id, messages }); +} diff --git a/apps/web/app/api/gateway/sessions/route.ts b/apps/web/app/api/gateway/sessions/route.ts new file mode 100644 index 00000000000..f086ee04353 --- /dev/null +++ b/apps/web/app/api/gateway/sessions/route.ts @@ -0,0 +1,30 @@ +import { resolveActiveAgentId } from "@/lib/workspace"; +import { readGatewaySessionsForAgent, listAllAgentIds } from "@/lib/gateway-transcript"; + +export const dynamic = "force-dynamic"; + +export async function GET(req: Request) { + const url = new URL(req.url); + const channelFilter = url.searchParams.get("channel"); + const activeAgentId = resolveActiveAgentId(); + + const agentIds = listAllAgentIds(); + const prioritized = [activeAgentId, ...agentIds.filter((id) => id !== activeAgentId)]; + + let sessions = prioritized.flatMap((agentId) => readGatewaySessionsForAgent(agentId)); + + const seen = new Set(); + sessions = sessions.filter((s) => { + if (seen.has(s.sessionKey)) return false; + seen.add(s.sessionKey); + return true; + }); + + if (channelFilter) { + sessions = sessions.filter((s) => s.channel === channelFilter); + } + + sessions.sort((a, b) => b.updatedAt - a.updatedAt); + + return Response.json({ sessions }); +} diff --git a/apps/web/app/api/web-sessions/route.ts b/apps/web/app/api/web-sessions/route.ts index d4240fcba85..e88d8b61127 100644 --- a/apps/web/app/api/web-sessions/route.ts +++ b/apps/web/app/api/web-sessions/route.ts @@ -15,15 +15,19 @@ export const dynamic = "force-dynamic"; /** GET /api/web-sessions — list web chat sessions. * ?filePath=... → returns only sessions scoped to that file. + * ?includeAll=true → returns all sessions (including file-scoped). * No filePath → returns only global (non-file) sessions. */ export async function GET(req: Request) { const url = new URL(req.url); const filePath = url.searchParams.get("filePath"); + const includeAll = url.searchParams.get("includeAll") === "true"; const all = readIndex(); - const sessions = filePath - ? all.filter((s) => s.filePath === filePath) - : all.filter((s) => !s.filePath); + const sessions = includeAll + ? all + : filePath + ? all.filter((s) => s.filePath === filePath) + : all.filter((s) => !s.filePath); return Response.json({ sessions }); } diff --git a/apps/web/app/components/chat-panel.tsx b/apps/web/app/components/chat-panel.tsx index 2d07d2cdd9b..db56d43ddcd 100644 --- a/apps/web/app/components/chat-panel.tsx +++ b/apps/web/app/components/chat-panel.tsx @@ -829,6 +829,12 @@ type ChatPanelProps = { hideHeaderActions?: boolean; /** Called whenever the panel's runtime state changes. */ onRuntimeStateChange?: (state: ChatPanelRuntimeState) => void; + /** Gateway session key for channel sessions (telegram, discord, etc.). */ + gatewaySessionKey?: string; + /** Gateway session UUID for loading transcripts. */ + gatewaySessionId?: string; + /** Channel identifier for the gateway session (e.g. "telegram"). */ + gatewayChannel?: string; }; export const ChatPanel = forwardRef( @@ -852,10 +858,14 @@ export const ChatPanel = forwardRef( onBack, hideHeaderActions, onRuntimeStateChange, + gatewaySessionKey, + gatewaySessionId, + gatewayChannel: _gatewayChannel, }, ref, ) { const isSubagentMode = !!subagentSessionKey; + const isGatewayMode = !!gatewaySessionKey; const editorRef = useRef(null); const [editorEmpty, setEditorEmpty] = useState(true); const [currentSessionId, setCurrentSessionId] = useState< @@ -1095,11 +1105,15 @@ export const ChatPanel = forwardRef( reconnectAbortRef.current = abort; try { - const streamParam = options?.sessionKey - ? `sessionKey=${encodeURIComponent(options.sessionKey)}` - : `sessionId=${encodeURIComponent(sessionId)}`; + const sk = options?.sessionKey; + const isGwSession = sk && !sk.includes(":subagent:") && !sk.includes(":web:"); + const streamUrl = isGwSession + ? `/api/gateway/chat/stream?sessionKey=${encodeURIComponent(sk)}` + : sk + ? `/api/chat/stream?sessionKey=${encodeURIComponent(sk)}` + : `/api/chat/stream?sessionId=${encodeURIComponent(sessionId)}`; const res = await fetch( - `/api/chat/stream?${streamParam}`, + streamUrl, { signal: abort.signal }, ); if (!res.ok || !res.body) { @@ -1334,7 +1348,7 @@ export const ChatPanel = forwardRef( const initialSessionHandled = useRef(false); const lastInitialSessionRef = useRef(null); useEffect(() => { - if (filePath || isSubagentMode || !initialSessionId) { + if (filePath || isSubagentMode || isGatewayMode || !initialSessionId) { return; } if (initialSessionHandled.current && initialSessionId === lastInitialSessionRef.current) { @@ -1425,6 +1439,57 @@ export const ChatPanel = forwardRef( // eslint-disable-next-line react-hooks/exhaustive-deps -- stable setters }, [subagentSessionKey, subagentTask, attemptReconnect]); + // ── Gateway session mode: load transcript + reconnect to active stream ── + useEffect(() => { + if (!gatewaySessionKey || !gatewaySessionId) return; + let cancelled = false; + + reconnectAbortRef.current?.abort(); + void stop(); + savedMessageIdsRef.current.clear(); + setQueuedMessages([]); + setLoadingSession(true); + + void (async () => { + let baseMessages: Array<{ id: string; role: "user" | "assistant"; parts: UIMessage["parts"] }> = []; + try { + const res = await fetch(`/api/gateway/sessions/${encodeURIComponent(gatewaySessionId)}`); + if (cancelled) return; + if (res.ok) { + const data = await res.json(); + const sessionMessages: Array<{ + id: string; + role: "user" | "assistant"; + content: string; + parts?: Array>; + }> = data.messages || []; + + const uiMessages = sessionMessages.map((msg) => { + savedMessageIdsRef.current.add(msg.id); + return { + id: msg.id, + role: msg.role, + parts: (msg.parts ?? [{ type: "text" as const, text: msg.content }]) as UIMessage["parts"], + }; + }); + baseMessages = uiMessages; + if (!cancelled) setMessages(baseMessages); + } + } catch { /* ignore */ } + + if (!cancelled) { + setLoadingSession(false); + await attemptReconnect(gatewaySessionKey, baseMessages, { sessionKey: gatewaySessionKey }); + } + })(); + + return () => { + cancelled = true; + reconnectAbortRef.current?.abort(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gatewaySessionKey, gatewaySessionId, attemptReconnect]); + // ── Poll for subagent spawns during active streaming ── const [hasRunningSubagents, setHasRunningSubagents] = useState(false); @@ -1614,7 +1679,7 @@ export const ChatPanel = forwardRef( } let sessionId = currentSessionId; - if (!sessionId && !isSubagentMode) { + if (!sessionId && !isSubagentMode && !isGatewayMode) { const titleSource = userText || "File attachment"; const title = @@ -1662,7 +1727,28 @@ export const ChatPanel = forwardRef( pendingHtmlRef.current = html; userScrolledAwayRef.current = false; - void sendMessage({ text: messageText }); + + if (gatewaySessionKey) { + const userMsg = { + id: `user-${Date.now()}`, + role: "user" as const, + parts: [{ type: "text" as const, text: messageText }] as UIMessage["parts"], + }; + setMessages((prev) => [...prev, userMsg]); + + try { + const res = await fetch("/api/gateway/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionKey: gatewaySessionKey, message: messageText }), + }); + if (res.ok && res.body) { + await attemptReconnect(gatewaySessionKey, [], { sessionKey: gatewaySessionKey }); + } + } catch { /* ignore */ } + } else { + void sendMessage({ text: messageText }); + } }, [ attachedFiles, @@ -1674,6 +1760,8 @@ export const ChatPanel = forwardRef( filePath, fileContext, sendMessage, + gatewaySessionKey, + attemptReconnect, ], ); diff --git a/apps/web/app/components/workspace/chat-sessions-sidebar.tsx b/apps/web/app/components/workspace/chat-sessions-sidebar.tsx index 8e1d6f12937..2ffb1e47182 100644 --- a/apps/web/app/components/workspace/chat-sessions-sidebar.tsx +++ b/apps/web/app/components/workspace/chat-sessions-sidebar.tsx @@ -15,6 +15,7 @@ export type WebSession = { createdAt: number; updatedAt: number; messageCount: number; + filePath?: string; }; export type SidebarSubagentInfo = { @@ -26,109 +27,99 @@ export type SidebarSubagentInfo = { status?: "running" | "completed" | "error"; }; +export type SidebarGatewaySession = { + sessionKey: string; + sessionId: string; + channel: string; + title: string; + updatedAt: number; + origin?: { + label?: string; + provider?: string; + }; +}; + +export type SidebarChannelStatus = { + id: string; + configured: boolean; + running: boolean; + connected: boolean; + error?: string; +}; + +type SidebarTab = { + id: string; + label: string; + icon?: () => React.JSX.Element; + iconColor?: string; + count?: number; +}; + type ChatSessionsSidebarProps = { sessions: WebSession[]; activeSessionId: string | null; - /** Title of the currently active session (shown in the header). */ activeSessionTitle?: string; - /** Session IDs with an actively running agent stream. */ streamingSessionIds?: Set; - /** Subagents spawned by chat sessions. */ subagents?: SidebarSubagentInfo[]; - /** Currently selected subagent session key (if viewing a subagent). */ activeSubagentKey?: string | null; onSelectSession: (sessionId: string) => void; onNewSession: () => void; - /** Called when a subagent is selected in the sidebar. */ onSelectSubagent?: (sessionKey: string) => void; - /** When true, renders as a mobile overlay drawer instead of a static sidebar. */ mobile?: boolean; - /** Close the mobile drawer. */ onClose?: () => void; - /** Fixed width in px when not mobile (overrides default 260). */ width?: number; - /** Called when the user deletes a session from the sidebar menu. */ onDeleteSession?: (sessionId: string) => void; - /** Called when the user renames a session from the sidebar menu. */ onRenameSession?: (sessionId: string, newTitle: string) => void; - /** Called when the user stops an actively running parent session. */ onStopSession?: (sessionId: string) => void; - /** Called when the user stops an actively running subagent session. */ onStopSubagent?: (sessionKey: string) => void; - /** Called when the user clicks the collapse/hide sidebar button. */ onCollapse?: () => void; - /** When true, show a loader instead of empty state (e.g. initial sessions fetch). */ loading?: boolean; - /** When true, renders just the content without the aside wrapper (for embedding in another sidebar). */ embedded?: boolean; + gatewaySessions?: SidebarGatewaySession[]; + channelStatuses?: SidebarChannelStatus[]; + activeGatewaySessionKey?: string | null; + onSelectGatewaySession?: (sessionKey: string, sessionId: string) => void; + fileScopedSessions?: WebSession[]; + heartbeatInfo?: { intervalMs: number; nextDueEstimateMs: number | null } | null; }; -/** Format a timestamp into a human-readable relative time string. */ function timeAgo(ts: number): string { const now = Date.now(); const diff = now - ts; const seconds = Math.floor(diff / 1000); - if (seconds < 60) {return "just now";} + if (seconds < 60) return "just now"; const minutes = Math.floor(seconds / 60); - if (minutes < 60) {return `${minutes}m ago`;} + if (minutes < 60) return `${minutes}m ago`; const hours = Math.floor(minutes / 60); - if (hours < 24) {return `${hours}h ago`;} + if (hours < 24) return `${hours}h ago`; const days = Math.floor(hours / 24); - if (days < 30) {return `${days}d ago`;} + if (days < 30) return `${days}d ago`; const months = Math.floor(days / 30); - if (months < 12) {return `${months}mo ago`;} + if (months < 12) return `${months}mo ago`; return `${Math.floor(months / 12)}y ago`; } +// ── Icon components ── + function PlusIcon() { return ( - - - + + ); } function SubagentIcon() { return ( - - - - + + ); } function ChatBubbleIcon() { return ( - + ); @@ -136,37 +127,312 @@ function ChatBubbleIcon() { function MoreHorizontalIcon() { return ( - - - - + + ); } function StopIcon() { return ( - + + + ); +} + +function ConnectionDot({ status }: { status: "connected" | "running" | "configured" | "error" }) { + const color = status === "connected" ? "#22c55e" + : status === "running" ? "#eab308" + : status === "error" ? "#ef4444" + : "var(--color-text-muted)"; + return ( + + ); +} + +// ── Reusable session row for web sessions ── + +function WebSessionRow({ + session, isActive, isHovered, isStreaming, sessionSubagents, + activeSubagentKey, renamingId, renameValue, + onHover, onLeave, onSelect, onStartRename, onCommitRename, onCancelRename, onRenameChange, + onDelete, onStop, onSelectSubagent, onStopSubagent, showFilePath, +}: { + session: WebSession; isActive: boolean; isHovered: boolean; isStreaming: boolean; + sessionSubagents?: SidebarSubagentInfo[]; activeSubagentKey?: string | null; + renamingId: string | null; renameValue: string; + onHover: (id: string) => void; onLeave: () => void; + onSelect: (id: string) => void; + onStartRename?: (id: string, title: string) => void; + onCommitRename?: () => void; onCancelRename?: () => void; + onRenameChange?: (val: string) => void; + onDelete?: (id: string) => void; onStop?: (id: string) => void; + onSelectSubagent?: (key: string) => void; onStopSubagent?: (key: string) => void; + showFilePath?: boolean; +}) { + const showMore = isHovered || isStreaming; + return ( +
onHover(session.id)} + onMouseLeave={onLeave} + > +
+ {renamingId === session.id ? ( +
{ e.preventDefault(); onCommitRename?.(); }}> + onRenameChange?.(e.target.value)} + onBlur={onCommitRename} + onKeyDown={(e) => { if (e.key === "Escape") onCancelRename?.(); }} + autoFocus + className="w-full text-xs font-medium px-1 py-0.5 rounded outline-none border" + style={{ color: "var(--color-text)", background: "var(--color-surface)", borderColor: "var(--color-border)" }} + /> +
+ ) : ( + + )} +
+ {isStreaming && onStop && ( + + )} + {onDelete && ( + + e.stopPropagation()} + className="flex items-center justify-center w-6 h-6 rounded-md" + style={{ color: "var(--color-text-muted)" }} title="More options" aria-label="More options"> + + + + {onStartRename && ( + onStartRename(session.id, session.title)}> + + Rename + + )} + onDelete(session.id)}> + + Delete + + + + )} +
+
+ {sessionSubagents && sessionSubagents.length > 0 && ( +
+ {sessionSubagents.map((sa) => { + const isSubActive = activeSubagentKey === sa.childSessionKey; + const isSubRunning = sa.status === "running"; + const subLabel = sa.label || sa.task; + const truncated = subLabel.length > 40 ? subLabel.slice(0, 40) + "..." : subLabel; + return ( +
+ + {isSubRunning && onStopSubagent && ( + + )} +
+ ); + })} +
+ )} +
+ ); +} + +// ── Gateway session row ── + +function GatewaySessionRow({ + gs, isActive, isHovered, onHover, onLeave, onSelect, +}: { + gs: SidebarGatewaySession; isActive: boolean; isHovered: boolean; + onHover: (key: string) => void; onLeave: () => void; + onSelect: (key: string, id: string) => void; +}) { + return ( +
onHover(gs.sessionKey)} onMouseLeave={onLeave}> + +
+ ); +} + export function ChatSessionsSidebar({ sessions, activeSessionId, @@ -187,31 +453,30 @@ export function ChatSessionsSidebar({ width: widthProp, loading = false, embedded = false, + gatewaySessions, + channelStatuses, + activeGatewaySessionKey, + onSelectGatewaySession, + fileScopedSessions, + heartbeatInfo, }: ChatSessionsSidebarProps) { const [hoveredId, setHoveredId] = useState(null); const [renamingId, setRenamingId] = useState(null); const [renameValue, setRenameValue] = useState(""); + const [activeFilter, setActiveFilter] = useState("denchclaw"); const handleSelect = useCallback( - (id: string) => { - onSelectSession(id); - onClose?.(); - }, + (id: string) => { onSelectSession(id); onClose?.(); }, [onSelectSession, onClose], ); const handleSelectSubagentItem = useCallback( - (sessionKey: string) => { - onSelectSubagent?.(sessionKey); - onClose?.(); - }, + (sessionKey: string) => { onSelectSubagent?.(sessionKey); onClose?.(); }, [onSelectSubagent, onClose], ); const handleDeleteSession = useCallback( - (sessionId: string) => { - onDeleteSession?.(sessionId); - }, + (sessionId: string) => { onDeleteSession?.(sessionId); }, [onDeleteSession], ); @@ -221,334 +486,361 @@ export function ChatSessionsSidebar({ }, []); const handleCommitRename = useCallback(() => { - if (renamingId && renameValue.trim()) { - onRenameSession?.(renamingId, renameValue.trim()); - } + if (renamingId && renameValue.trim()) onRenameSession?.(renamingId, renameValue.trim()); setRenamingId(null); setRenameValue(""); }, [renamingId, renameValue, onRenameSession]); - // Index subagents by parent session ID + const handleCancelRename = useCallback(() => { + setRenamingId(null); + setRenameValue(""); + }, []); + + const handleSelectGateway = useCallback( + (sessionKey: string, sessionId: string) => { onSelectGatewaySession?.(sessionKey, sessionId); onClose?.(); }, + [onSelectGatewaySession, onClose], + ); + const subagentsByParent = useMemo(() => { const map = new Map(); - if (!subagents) {return map;} + if (!subagents) return map; for (const sa of subagents) { let list = map.get(sa.parentSessionId); - if (!list) { - list = []; - map.set(sa.parentSessionId, list); - } + if (!list) { list = []; map.set(sa.parentSessionId, list); } list.push(sa); } return map; }, [subagents]); - const filteredSessions = useMemo( - () => sessions.filter((s) => !s.id.includes(":subagent:")), + const denchClawSessions = useMemo( + () => sessions.filter((s) => !s.id.includes(":subagent:") && !s.filePath), [sessions], ); - // Group sessions: today, yesterday, this week, this month, older - const grouped = groupSessions(filteredSessions); + const grouped = groupSessions(denchClawSessions); + + const channelStatusMap = useMemo(() => { + const map = new Map(); + if (!channelStatuses) return map; + for (const cs of channelStatuses) map.set(cs.id, cs); + return map; + }, [channelStatuses]); + + const gatewayByChannel = useMemo(() => { + const map = new Map(); + if (!gatewaySessions) return map; + for (const gs of gatewaySessions) { + let list = map.get(gs.channel); + if (!list) { list = []; map.set(gs.channel, list); } + list.push(gs); + } + return map; + }, [gatewaySessions]); + + const fileScopedGrouped = useMemo( + () => groupSessions(fileScopedSessions ?? []), + [fileScopedSessions], + ); + + const cronSessions = gatewayByChannel.get("cron"); + + // ── Dynamic tab list ── + const tabs = useMemo(() => { + const result: SidebarTab[] = [ + { id: "denchclaw", label: "DenchClaw", count: denchClawSessions.length }, + ]; + const channelOrder = ["telegram", "whatsapp", "discord", "slack", "signal", "imessage", "googlechat", "nostr"]; + for (const channel of channelOrder) { + const channelSessions = gatewayByChannel.get(channel); + if (!channelSessions?.length) continue; + const meta = CHANNEL_META[channel]; + result.push({ + id: channel, + label: meta?.label ?? channel, + icon: meta?.icon, + iconColor: meta?.color, + count: channelSessions.length, + }); + } + for (const [channel, channelSessions] of gatewayByChannel.entries()) { + if (channelOrder.includes(channel) || channel === "cron" || channel === "unknown") continue; + const meta = CHANNEL_META[channel]; + result.push({ + id: channel, + label: meta?.label ?? channel, + icon: meta?.icon, + iconColor: meta?.color, + count: channelSessions.length, + }); + } + if (cronSessions?.length) { + result.push({ id: "cron", label: "Crons", icon: CronIcon, count: cronSessions.length }); + result.push({ id: "heartbeat", label: "Heartbeat", icon: HeartbeatIcon }); + } + if ((fileScopedSessions?.length ?? 0) > 0) { + result.push({ id: "other", label: "Other", icon: FileIcon, count: fileScopedSessions!.length }); + } + return result; + }, [denchClawSessions, gatewayByChannel, cronSessions, fileScopedSessions]); + + const hasTabs = tabs.length > 1; const width = mobile ? "280px" : (widthProp ?? 260); const headerHeight = embedded ? 36 : 40; - const content = ( -
-
- {loading && sessions.length === 0 ? ( -
- -

- Loading… -

-
- ) : sessions.length === 0 ? ( + const filterHeight = hasTabs ? 30 : 0; + + // ── Content renderer per active filter ── + const renderContent = () => { + if (loading && sessions.length === 0 && !(gatewaySessions?.length)) { + return ( +
+ +

Loading…

+
+ ); + } + + if (activeFilter === "denchclaw") { + if (denchClawSessions.length === 0) { + return (
-
+
-

- No conversations yet. -
- Start a new chat to begin. +

+ No conversations yet.
Start a new chat to begin.

- ) : ( -
- {grouped.map((group) => ( -
-
- {group.label} -
- {group.sessions.map((session) => { - const isActive = session.id === activeSessionId && !activeSubagentKey; - const isHovered = session.id === hoveredId; - const isStreamingSession = streamingSessionIds?.has(session.id) ?? false; - const showMore = isHovered || isStreamingSession; - const sessionSubagents = subagentsByParent.get(session.id); - return ( -
setHoveredId(session.id)} - onMouseLeave={() => setHoveredId(null)} - > -
- {renamingId === session.id ? ( -
{ e.preventDefault(); handleCommitRename(); }} - > - setRenameValue(e.target.value)} - onBlur={handleCommitRename} - onKeyDown={(e) => { if (e.key === "Escape") { setRenamingId(null); setRenameValue(""); } }} - autoFocus - className="w-full text-xs font-medium px-1 py-0.5 rounded outline-none border" - style={{ color: "var(--color-text)", background: "var(--color-surface)", borderColor: "var(--color-border)" }} - /> -
- ) : ( - - )} -
- {isStreamingSession && onStopSession && ( - - )} - {onDeleteSession && ( - - e.stopPropagation()} - className="flex items-center justify-center w-6 h-6 rounded-md" - style={{ color: "var(--color-text-muted)" }} - title="More options" - aria-label="More options" - > - - - - handleStartRename(session.id, session.title)} - > - - Rename - - handleDeleteSession(session.id)} - > - - Delete - - - - )} -
-
- {/* Subagent sub-items */} - {sessionSubagents && sessionSubagents.length > 0 && ( -
- {sessionSubagents.map((sa) => { - const isSubActive = activeSubagentKey === sa.childSessionKey; - const isSubRunning = sa.status === "running"; - const subLabel = sa.label || sa.task; - const truncated = subLabel.length > 40 ? subLabel.slice(0, 40) + "..." : subLabel; - return ( -
- - {isSubRunning && onStopSubagent && ( - - )} -
- ); - })} -
- )} -
- ); - })} + ); + } + return ( +
+ {grouped.map((group) => ( +
+
+ {group.label}
- ))} + {group.sessions.map((session) => ( + setHoveredId(null)} + onSelect={handleSelect} + onStartRename={onRenameSession ? handleStartRename : undefined} + onCommitRename={handleCommitRename} onCancelRename={handleCancelRename} + onRenameChange={setRenameValue} + onDelete={onDeleteSession ? handleDeleteSession : undefined} + onStop={onStopSession} + onSelectSubagent={handleSelectSubagentItem} + onStopSubagent={onStopSubagent} + /> + ))} +
+ ))} +
+ ); + } + + if (activeFilter === "heartbeat") { + return ( +
+ {/* Gateway health card */} +
+
+ c.connected) ? "connected" : channelStatuses?.some((c) => c.running) ? "running" : "configured"} /> + + Gateway {channelStatuses?.some((c) => c.connected) ? "Connected" : "Disconnected"} + +
+ {heartbeatInfo && ( +
+
+ Interval: {Math.round(heartbeatInfo.intervalMs / 60000)}m +
+ {heartbeatInfo.nextDueEstimateMs && ( +
+ Next: {timeAgo(heartbeatInfo.nextDueEstimateMs).replace(" ago", "")} from now +
+ )} +
+ )} + {!heartbeatInfo && ( +
No heartbeat data
+ )} +
+ {/* Cron sessions */} + {cronSessions?.map((gs) => ( + setHoveredId(null)} + onSelect={handleSelectGateway} + /> + ))} + {!cronSessions?.length && ( +
+

No cron sessions

+
+ )} +
+ ); + } + + if (activeFilter === "other") { + if (!fileScopedSessions?.length) { + return ( +
+

No file-scoped sessions

+
+ ); + } + return ( +
+ {fileScopedGrouped.map((group) => ( +
+
+ {group.label} +
+ {group.sessions.map((session) => ( + setHoveredId(null)} + onSelect={handleSelect} + onCommitRename={handleCommitRename} onCancelRename={handleCancelRename} + onRenameChange={setRenameValue} + onDelete={onDeleteSession ? handleDeleteSession : undefined} + showFilePath + /> + ))} +
+ ))} +
+ ); + } + + // Channel-specific tab (telegram, whatsapp, discord, cron, etc.) + const channelSessions = gatewayByChannel.get(activeFilter); + const status = channelStatusMap.get(activeFilter); + const connectionState = status?.connected ? "connected" + : status?.running ? "running" + : status?.error ? "error" + : status?.configured ? "configured" + : undefined; + + if (!channelSessions?.length) { + return ( +
+

No sessions

+
+ ); + } + + return ( +
+ {connectionState && ( +
+ + + {connectionState === "connected" ? "Connected" : connectionState === "running" ? "Running" : connectionState === "error" ? "Error" : "Configured"} +
)} + {channelSessions.map((gs) => ( + setHoveredId(null)} + onSelect={handleSelectGateway} + /> + ))}
- {/* Header overlay: backdrop blur + 80% bg; list scrolls under it */} + ); + }; + + const content = ( +
+
+ {renderContent()} +
+ + {/* Header */}
-
- {onCollapse && ( - - )} - - Chats - +
+
+ {onCollapse && ( + + )} + + Chats + +
+
- + {/* Dynamic tab strip */} + {hasTabs && ( +
+ {tabs.map((tab) => { + const isActive = activeFilter === tab.id; + const TabIcon = tab.icon; + return ( + + ); + })} +
+ )}
); @@ -571,7 +863,7 @@ export function ChatSessionsSidebar({ ); - if (!mobile) { return sidebar; } + if (!mobile) { return sidebar; } return (
void onClose?.()}> @@ -605,18 +897,18 @@ function groupSessions(sessions: WebSession[]): SessionGroup[] { for (const s of sessions) { const t = s.updatedAt; - if (t >= todayStart) {today.push(s);} - else if (t >= yesterdayStart) {yesterday.push(s);} - else if (t >= weekStart) {thisWeek.push(s);} - else if (t >= monthStart) {thisMonth.push(s);} - else {older.push(s);} + if (t >= todayStart) today.push(s); + else if (t >= yesterdayStart) yesterday.push(s); + else if (t >= weekStart) thisWeek.push(s); + else if (t >= monthStart) thisMonth.push(s); + else older.push(s); } const groups: SessionGroup[] = []; - if (today.length > 0) {groups.push({ label: "Today", sessions: today });} - if (yesterday.length > 0) {groups.push({ label: "Yesterday", sessions: yesterday });} - if (thisWeek.length > 0) {groups.push({ label: "This Week", sessions: thisWeek });} - if (thisMonth.length > 0) {groups.push({ label: "This Month", sessions: thisMonth });} - if (older.length > 0) {groups.push({ label: "Older", sessions: older });} + if (today.length > 0) groups.push({ label: "Today", sessions: today }); + if (yesterday.length > 0) groups.push({ label: "Yesterday", sessions: yesterday }); + if (thisWeek.length > 0) groups.push({ label: "This Week", sessions: thisWeek }); + if (thisMonth.length > 0) groups.push({ label: "This Month", sessions: thisMonth }); + if (older.length > 0) groups.push({ label: "Older", sessions: older }); return groups; } diff --git a/apps/web/app/components/workspace/workspace-sidebar.tsx b/apps/web/app/components/workspace/workspace-sidebar.tsx index eede4bc3827..e2bd5773bf9 100644 --- a/apps/web/app/components/workspace/workspace-sidebar.tsx +++ b/apps/web/app/components/workspace/workspace-sidebar.tsx @@ -6,7 +6,7 @@ import { FileManagerTree, type TreeNode } from "./file-manager-tree"; import { ProfileSwitcher } from "./profile-switcher"; import { CreateWorkspaceDialog } from "./create-workspace-dialog"; import { UnicodeSpinner } from "../unicode-spinner"; -import { ChatSessionsSidebar, type WebSession, type SidebarSubagentInfo } from "./chat-sessions-sidebar"; +import { ChatSessionsSidebar, type WebSession, type SidebarSubagentInfo, type SidebarGatewaySession, type SidebarChannelStatus } from "./chat-sessions-sidebar"; /** Shape returned by /api/workspace/suggest-files */ type SuggestItem = { @@ -67,6 +67,12 @@ type WorkspaceSidebarProps = { onSelectChatSubagent?: (sessionKey: string) => void; onDeleteChatSession?: (sessionId: string) => void; onRenameChatSession?: (sessionId: string, newTitle: string) => void; + chatGatewaySessions?: SidebarGatewaySession[]; + chatChannelStatuses?: SidebarChannelStatus[]; + chatActiveGatewaySessionKey?: string | null; + onSelectGatewayChatSession?: (sessionKey: string, sessionId: string) => void; + chatFileScopedSessions?: WebSession[]; + chatHeartbeatInfo?: { intervalMs: number; nextDueEstimateMs: number | null } | null; /** Which tab is active. Controlled from parent if provided. */ activeTab?: "files" | "chats"; onTabChange?: (tab: "files" | "chats") => void; diff --git a/apps/web/app/workspace/workspace-content.tsx b/apps/web/app/workspace/workspace-content.tsx index fde3b7d649d..0380a67a0cc 100644 --- a/apps/web/app/workspace/workspace-content.tsx +++ b/apps/web/app/workspace/workspace-content.tsx @@ -43,7 +43,7 @@ import { autoDetectViewField, } from "@/lib/object-filters"; import { UnicodeSpinner } from "../components/unicode-spinner"; -import { ChatSessionsSidebar } from "../components/workspace/chat-sessions-sidebar"; +import { ChatSessionsSidebar, type SidebarGatewaySession, type SidebarChannelStatus } from "../components/workspace/chat-sessions-sidebar"; import { DropdownMenu, DropdownMenuContent, @@ -68,6 +68,7 @@ import { isChatTab, openOrFocusParentChatTab, openOrFocusSubagentChatTab, + openOrFocusGatewayChatTab, resolveChatIdentityForTab, syncParentChatTabTitles, syncSubagentChatTabTitles, @@ -218,6 +219,7 @@ type WebSession = { createdAt: number; updatedAt: number; messageCount: number; + filePath?: string; }; const LEFT_SIDEBAR_MIN = 200; @@ -455,8 +457,14 @@ function WorkspacePageInner() { const [subagents, setSubagents] = useState([]); const [activeSubagentKey, setActiveSubagentKey] = useState(null); + // Gateway channel sessions + const [gatewaySessions, setGatewaySessions] = useState([]); + const [channelStatuses, setChannelStatuses] = useState([]); + const [activeGatewaySessionKey, setActiveGatewaySessionKey] = useState(null); + // Cron jobs state const [cronJobs, setCronJobs] = useState([]); + const [heartbeatInfo, setHeartbeatInfo] = useState<{ intervalMs: number; nextDueEstimateMs: number | null } | null>(null); // Cron URL-backed view state const [cronView, setCronView] = useState("overview"); @@ -538,7 +546,7 @@ function WorkspacePageInner() { [tabState], ); const mainChatTabs = useMemo( - () => tabState.tabs.filter((tab) => tab.id !== HOME_TAB_ID && isChatTab(tab)), + () => tabState.tabs.filter((tab) => tab.id !== HOME_TAB_ID && (tab.type === "chat" || tab.type === "gateway-chat")), [tabState.tabs], ); @@ -572,10 +580,28 @@ function WorkspacePageInner() { setTabState((prev) => openOrFocusSubagentChatTab(prev, params)); }, []); + const openGatewayChatTab = useCallback((sessionKey: string, sessionId: string, channel?: string, title?: string) => { + setActivePath(null); + setContent({ kind: "none" }); + setActiveSessionId(null); + setActiveSubagentKey(null); + setActiveGatewaySessionKey(sessionKey); + setTabState((prev) => openOrFocusGatewayChatTab(prev, { + sessionKey, + sessionId, + channel: channel ?? "unknown", + title: title ?? "Channel Chat", + })); + }, []); + const visibleMainChatTabId = useMemo(() => { - if (isChatTab(activeTab)) { + if (activeTab.type === "chat" || activeTab.type === "gateway-chat") { return activeTab.id; } + if (activeGatewaySessionKey) { + const matchingGwTab = mainChatTabs.find((tab) => tab.type === "gateway-chat" && tab.sessionKey === activeGatewaySessionKey); + if (matchingGwTab) return matchingGwTab.id; + } if (activeSubagentKey) { const matchingSubagentTab = mainChatTabs.find((tab) => tab.sessionKey === activeSubagentKey); if (matchingSubagentTab) { @@ -593,15 +619,16 @@ function WorkspacePageInner() { if (blankTab) return blankTab.id; } return mainChatTabs[0]?.id ?? null; - }, [activeTab, activeSessionId, activeSubagentKey, mainChatTabs, tabState.activeTabId]); + }, [activeTab, activeSessionId, activeSubagentKey, activeGatewaySessionKey, mainChatTabs, tabState.activeTabId]); useEffect(() => { - if (!isChatTab(activeTab)) { + if (activeTab.type !== "chat" && activeTab.type !== "gateway-chat") { return; } const identity = resolveChatIdentityForTab(activeTab); setActiveSessionId((prev) => prev === identity.sessionId ? prev : identity.sessionId); setActiveSubagentKey((prev) => prev === identity.subagentKey ? prev : identity.subagentKey); + setActiveGatewaySessionKey((prev) => prev === identity.gatewaySessionKey ? prev : identity.gatewaySessionKey); }, [activeTab]); const setMainChatPanelRef = useCallback((tabId: string, handle: ChatPanelHandle | null) => { @@ -806,12 +833,16 @@ function WorkspacePageInner() { }, [refreshContext]); // Fetch chat sessions + const [fileScopedSessions, setFileScopedSessions] = useState([]); + const fetchSessions = useCallback(async () => { setSessionsLoading(true); try { - const res = await fetch("/api/web-sessions"); + const res = await fetch("/api/web-sessions?includeAll=true"); const data = await res.json(); - setSessions(data.sessions ?? []); + const all: Array = data.sessions ?? []; + setSessions(all.filter((s) => !s.filePath)); + setFileScopedSessions(all.filter((s) => !!s.filePath)); } catch { // ignore } finally { @@ -827,6 +858,41 @@ function WorkspacePageInner() { setSidebarRefreshKey((k) => k + 1); }, []); + // Fetch gateway channel sessions + const fetchGatewaySessions = useCallback(async () => { + try { + const res = await fetch("/api/gateway/sessions"); + const data = await res.json(); + const sessions: SidebarGatewaySession[] = (data.sessions ?? []).map( + (s: { sessionKey: string; sessionId: string; channel: string; origin?: { label?: string; provider?: string }; updatedAt: number }) => ({ + sessionKey: s.sessionKey, + sessionId: s.sessionId, + channel: s.channel, + title: s.origin?.label || `${s.channel.charAt(0).toUpperCase() + s.channel.slice(1)} Session`, + updatedAt: s.updatedAt, + origin: s.origin, + }), + ); + setGatewaySessions(sessions); + } catch { /* ignore */ } + }, []); + + const fetchChannelStatuses = useCallback(async () => { + try { + const res = await fetch("/api/gateway/channels"); + const data = await res.json(); + setChannelStatuses(data.channels ?? []); + } catch { /* ignore */ } + }, []); + + useEffect(() => { + void fetchGatewaySessions(); + void fetchChannelStatuses(); + const gwInterval = setInterval(fetchGatewaySessions, 10_000); + const chInterval = setInterval(fetchChannelStatuses, 30_000); + return () => { clearInterval(gwInterval); clearInterval(chInterval); }; + }, [fetchGatewaySessions, fetchChannelStatuses]); + const handleWorkspaceChanged = useCallback(() => { resetWorkspaceStateOnSwitch({ setBrowseDir, @@ -946,6 +1012,7 @@ function WorkspacePageInner() { const res = await fetch("/api/cron/jobs"); const data: CronJobsResponse = await res.json(); setCronJobs(data.jobs ?? []); + if (data.heartbeat) setHeartbeatInfo(data.heartbeat); } catch { // ignore - cron might not be configured } @@ -2091,7 +2158,7 @@ function WorkspacePageInner() { }, [stopParentSession, stopSubagentSession, tabState.tabs]); // Whether to show the main chat workspace instead of file/object content. - const showMainChat = activeTab.type === "chat" || activeTab.id === HOME_TAB_ID || (!activePath || content.kind === "none"); + const showMainChat = activeTab.type === "chat" || activeTab.type === "gateway-chat" || activeTab.id === HOME_TAB_ID || (!activePath || content.kind === "none"); return ( // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions @@ -2142,6 +2209,16 @@ function WorkspacePageInner() { onSelectChatSubagent={handleSelectSubagent} onDeleteChatSession={handleDeleteSession} onRenameChatSession={handleRenameSession} + chatGatewaySessions={gatewaySessions} + chatChannelStatuses={channelStatuses} + chatActiveGatewaySessionKey={activeGatewaySessionKey} + onSelectGatewayChatSession={(sessionKey, sessionId) => { + const gs = gatewaySessions.find((s) => s.sessionKey === sessionKey); + openGatewayChatTab(sessionKey, sessionId, gs?.channel, gs?.title); + setSidebarOpen(false); + }} + chatFileScopedSessions={fileScopedSessions} + chatHeartbeatInfo={heartbeatInfo} activeTab={sidebarTab} onTabChange={setSidebarTab} mobile @@ -2203,6 +2280,15 @@ function WorkspacePageInner() { onSelectChatSubagent={handleSelectSubagent} onDeleteChatSession={handleDeleteSession} onRenameChatSession={handleRenameSession} + chatGatewaySessions={gatewaySessions} + chatChannelStatuses={channelStatuses} + chatActiveGatewaySessionKey={activeGatewaySessionKey} + onSelectGatewayChatSession={(sessionKey, sessionId) => { + const gs = gatewaySessions.find((s) => s.sessionKey === sessionKey); + openGatewayChatTab(sessionKey, sessionId, gs?.channel, gs?.title); + }} + chatFileScopedSessions={fileScopedSessions} + chatHeartbeatInfo={heartbeatInfo} activeTab={sidebarTab} onTabChange={setSidebarTab} /> @@ -2478,7 +2564,8 @@ function WorkspacePageInner() { style={{ background: "var(--color-main-bg)" }} > {mainChatTabs.map((tab) => { - const subagent = tab.sessionKey + const isGateway = tab.type === "gateway-chat"; + const subagent = !isGateway && tab.sessionKey ? subagents.find((entry) => entry.childSessionKey === tab.sessionKey) : null; const isVisible = tab.id === visibleMainChatTabId; @@ -2490,20 +2577,23 @@ function WorkspacePageInner() { setMainChatPanelRef(tab.id, handle)} sessionTitle={tab.title} - initialSessionId={tab.sessionKey ? undefined : tab.sessionId ?? undefined} - onActiveSessionChange={tab.sessionKey ? undefined : (id) => handleChatTabSessionChange(tab.id, id)} - onSessionsChange={refreshSessions} + initialSessionId={isGateway ? undefined : (tab.sessionKey ? undefined : tab.sessionId ?? undefined)} + onActiveSessionChange={isGateway || tab.sessionKey ? undefined : (id) => handleChatTabSessionChange(tab.id, id)} + onSessionsChange={isGateway ? undefined : refreshSessions} onSubagentClick={handleSubagentClickFromChat} onFilePathClick={handleFilePathClickFromChat} - onDeleteSession={tab.sessionKey ? undefined : handleDeleteSession} - onRenameSession={tab.sessionKey ? undefined : handleRenameSession} + onDeleteSession={isGateway || tab.sessionKey ? undefined : handleDeleteSession} + onRenameSession={isGateway || tab.sessionKey ? undefined : handleRenameSession} compact={isMobile} - sessionKey={tab.sessionKey ?? undefined} + sessionKey={isGateway ? undefined : (tab.sessionKey ?? undefined)} subagentTask={subagent?.task} subagentLabel={subagent?.label} - onBack={tab.sessionKey ? handleBackFromSubagent : undefined} + onBack={tab.sessionKey && !isGateway ? handleBackFromSubagent : undefined} hideHeaderActions={!isMobile} onRuntimeStateChange={(runtime) => handleChatRuntimeStateChange(tab.id, runtime)} + gatewaySessionKey={isGateway ? tab.sessionKey : undefined} + gatewaySessionId={isGateway ? tab.sessionId : undefined} + gatewayChannel={isGateway ? tab.channel : undefined} />
); @@ -2584,6 +2674,15 @@ function WorkspacePageInner() { onRenameSession={handleRenameSession} onStopSession={(sessionId) => { void stopParentSession(sessionId); }} onStopSubagent={(sessionKey) => { void stopSubagentSession(sessionKey); }} + gatewaySessions={gatewaySessions} + channelStatuses={channelStatuses} + activeGatewaySessionKey={activeGatewaySessionKey} + onSelectGatewaySession={(sessionKey, sessionId) => { + const gs = gatewaySessions.find((s) => s.sessionKey === sessionKey); + openGatewayChatTab(sessionKey, sessionId, gs?.channel, gs?.title); + }} + fileScopedSessions={fileScopedSessions} + heartbeatInfo={heartbeatInfo} embedded />
@@ -2649,6 +2748,16 @@ function WorkspacePageInner() { onRenameSession={handleRenameSession} onStopSession={(sessionId) => { void stopParentSession(sessionId); }} onStopSubagent={(sessionKey) => { void stopSubagentSession(sessionKey); }} + gatewaySessions={gatewaySessions} + channelStatuses={channelStatuses} + activeGatewaySessionKey={activeGatewaySessionKey} + onSelectGatewaySession={(sessionKey, sessionId) => { + const gs = gatewaySessions.find((s) => s.sessionKey === sessionKey); + openGatewayChatTab(sessionKey, sessionId, gs?.channel, gs?.title); + setMobileChatSessionsOpen(false); + }} + fileScopedSessions={fileScopedSessions} + heartbeatInfo={heartbeatInfo} mobile width={280} onClose={() => setMobileChatSessionsOpen(false)} diff --git a/apps/web/lib/active-runs.ts b/apps/web/lib/active-runs.ts index d3aa6cb076e..a89facf2382 100644 --- a/apps/web/lib/active-runs.ts +++ b/apps/web/lib/active-runs.ts @@ -9,7 +9,7 @@ * - New HTTP connections can re-attach to a running stream. */ import { createInterface } from "node:readline"; -import { join } from "node:path"; +import { join, resolve, basename } from "node:path"; import { readFileSync, writeFileSync, @@ -207,15 +207,28 @@ const activeRuns: Map = const fileMutationQueues = new Map>(); -async function pathExistsAsync(path: string): Promise { +async function pathExistsAsync(filePath: string): Promise { try { - await access(path); + await access(filePath); return true; } catch { return false; } } +/** + * Build a `.jsonl` path for `sessionId` that is guaranteed to live inside + * `webChatDir()`. `basename()` strips any directory-traversal segments. + */ +function safeSessionFilePath(sessionId: string): string { + const dir = resolve(webChatDir()); + const safe = resolve(dir, basename(sessionId) + ".jsonl"); + if (!safe.startsWith(dir + "/")) { + throw new Error("Invalid session id"); + } + return safe; +} + async function queueFileMutation( filePath: string, mutate: () => Promise, @@ -425,7 +438,7 @@ export async function persistSubscribeUserMessage( // Write the user message to the session JSONL (same as persistUserMessage // does for parent sessions) so it survives page reloads. try { - const fp = join(webChatDir(), `${sessionKey}.jsonl`); + const fp = safeSessionFilePath(sessionKey); await ensureDir(); await queueFileMutation(fp, async () => { if (!await pathExistsAsync(fp)) {await writeFile(fp, "");} @@ -1248,7 +1261,7 @@ function deferredTranscriptEnrich(sessionKey: string, pinnedAgentId?: string): v if (textToTools.size === 0) {return;} // Read and enrich web-chat JSONL - const fp = join(webChatDir(), `${sessionKey}.jsonl`); + const fp = safeSessionFilePath(sessionKey); if (!existsSync(fp)) {return;} const lines = readFileSync(fp, "utf-8").split("\n").filter((l) => l.trim()); const messages = lines.map((l) => { try { return JSON.parse(l) as Record; } catch { return null; } }).filter(Boolean) as Array>; @@ -1296,7 +1309,7 @@ export async function persistUserMessage( msg: { id: string; content: string; parts?: unknown[]; html?: string }, ): Promise { await ensureDir(); - const filePath = join(webChatDir(), `${sessionId}.jsonl`); + const filePath = safeSessionFilePath(sessionId); const line = JSON.stringify({ id: msg.id, @@ -2218,7 +2231,7 @@ async function upsertMessage( message: Record, ) { await ensureDir(); - const fp = join(webChatDir(), `${sessionId}.jsonl`); + const fp = safeSessionFilePath(sessionId); const msgId = message.id as string; let found = false; await queueFileMutation(fp, async () => { diff --git a/apps/web/lib/chat-tabs.ts b/apps/web/lib/chat-tabs.ts index 00e9074de1d..2478bd5e3c8 100644 --- a/apps/web/lib/chat-tabs.ts +++ b/apps/web/lib/chat-tabs.ts @@ -158,21 +158,61 @@ export function closeChatTabsForSession( }; } +export function createGatewayChatTab(params: { + sessionKey: string; + sessionId: string; + channel: string; + title?: string; +}): Tab { + return { + id: generateTabId(), + type: "gateway-chat", + title: params.title || "Channel Chat", + sessionKey: params.sessionKey, + sessionId: params.sessionId, + channel: params.channel, + }; +} + +export function isGatewayChatTab(tab: Tab | undefined | null): tab is Tab { + return tab?.type === "gateway-chat"; +} + +export function openOrFocusGatewayChatTab( + state: TabState, + params: { sessionKey: string; sessionId: string; channel: string; title?: string }, +): TabState { + return openTab(state, createGatewayChatTab(params)); +} + export function resolveChatIdentityForTab(tab: Tab | undefined | null): { sessionId: string | null; subagentKey: string | null; + gatewaySessionKey: string | null; } { - if (!tab || tab.type !== "chat") { - return { sessionId: null, subagentKey: null }; + if (!tab) { + return { sessionId: null, subagentKey: null, gatewaySessionKey: null }; + } + if (tab.type === "gateway-chat") { + return { + sessionId: tab.sessionId ?? null, + subagentKey: null, + gatewaySessionKey: tab.sessionKey ?? null, + }; + } + if (tab.type !== "chat") { + return { sessionId: null, subagentKey: null, gatewaySessionKey: null }; } if (tab.sessionKey) { return { sessionId: tab.parentSessionId ?? null, subagentKey: tab.sessionKey, + gatewaySessionKey: null, }; } return { sessionId: tab.sessionId ?? null, subagentKey: null, + gatewaySessionKey: null, }; } diff --git a/apps/web/lib/gateway-transcript.ts b/apps/web/lib/gateway-transcript.ts new file mode 100644 index 00000000000..307b56a88c3 --- /dev/null +++ b/apps/web/lib/gateway-transcript.ts @@ -0,0 +1,289 @@ +import { readFileSync, existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { resolveOpenClawStateDir } from "./workspace"; + +export type GatewaySessionEntry = { + sessionKey: string; + sessionId: string; + channel: string; + origin: { + label?: string; + provider: string; + surface: string; + chatType: string; + from?: string; + to?: string; + accountId?: string; + }; + updatedAt: number; + chatType: string; +}; + +export type ChannelStatus = { + id: string; + configured: boolean; + running: boolean; + connected: boolean; + error?: string; + lastMessage?: number; +}; + +export type TranscriptMessage = { + id: string; + role: "user" | "assistant"; + content: string; + parts?: Array>; + timestamp: string; +}; + +function deriveChannelFromKey(sessionKey: string, lastChannel?: string): string { + if (lastChannel && lastChannel !== "unknown") return lastChannel; + const parts = sessionKey.split(":"); + if (parts.length >= 3) { + const segment = parts[2]; + if (segment === "web" || segment === "main") return "webchat"; + if (segment === "cron") return "cron"; + if (segment === "telegram" || segment === "whatsapp" || segment === "discord" + || segment === "slack" || segment === "signal" || segment === "imessage" + || segment === "nostr" || segment === "googlechat") return segment; + } + return lastChannel || "unknown"; +} + +export function readGatewaySessionsForAgent(agentId: string): GatewaySessionEntry[] { + const stateDir = resolveOpenClawStateDir(); + const sessionsFile = join(stateDir, "agents", agentId, "sessions", "sessions.json"); + if (!existsSync(sessionsFile)) return []; + + let data: Record>; + try { + data = JSON.parse(readFileSync(sessionsFile, "utf-8")); + } catch { + return []; + } + + const entries: GatewaySessionEntry[] = []; + for (const [key, val] of Object.entries(data)) { + if (key.includes(":subagent:")) continue; + + const channel = deriveChannelFromKey(key, val.lastChannel as string | undefined); + if (channel === "webchat" || channel === "unknown") continue; + + const origin = (val.origin ?? {}) as Record; + entries.push({ + sessionKey: key, + sessionId: val.sessionId as string, + channel, + origin: { + label: origin.label as string | undefined, + provider: (origin.provider ?? channel) as string, + surface: (origin.surface ?? channel) as string, + chatType: (origin.chatType ?? val.chatType ?? "direct") as string, + from: origin.from as string | undefined, + to: origin.to as string | undefined, + accountId: origin.accountId as string | undefined, + }, + updatedAt: val.updatedAt as number ?? 0, + chatType: (val.chatType ?? "direct") as string, + }); + } + + entries.sort((a, b) => b.updatedAt - a.updatedAt); + return entries; +} + +export function listAllAgentIds(): string[] { + const stateDir = resolveOpenClawStateDir(); + const agentsDir = join(stateDir, "agents"); + if (!existsSync(agentsDir)) return []; + try { + return readdirSync(agentsDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + } catch { + return []; + } +} + +export function findSessionTranscriptFile(sessionId: string): string | null { + const stateDir = resolveOpenClawStateDir(); + const agentsDir = join(stateDir, "agents"); + if (!existsSync(agentsDir)) return null; + try { + for (const d of readdirSync(agentsDir, { withFileTypes: true })) { + if (!d.isDirectory()) continue; + const p = join(agentsDir, d.name, "sessions", `${sessionId}.jsonl`); + if (existsSync(p)) return p; + } + } catch { /* ignore */ } + return null; +} + +export function parseTranscriptToMessages(content: string): TranscriptMessage[] { + const lines = content.trim().split("\n").filter((l) => l.trim()); + const messages: TranscriptMessage[] = []; + const pendingToolCalls = new Map(); + let currentAssistant: TranscriptMessage | null = null; + + const flushAssistant = () => { + if (!currentAssistant) return; + const textSummary = (currentAssistant.parts ?? []) + .filter((part) => part.type === "text" && typeof part.text === "string") + .map((part) => part.text as string) + .join("\n") + .slice(0, 200); + currentAssistant.content = textSummary; + messages.push(currentAssistant); + currentAssistant = null; + }; + + for (const line of lines) { + let entry: Record; + try { entry = JSON.parse(line); } catch { continue; } + if (entry.type !== "message" || !entry.message) continue; + + const msg = entry.message as Record; + const role = msg.role as string; + + if (role === "toolResult") { + const toolCallId = (msg.toolCallId as string) ?? ""; + const rawContent = msg.content; + const outputText = typeof rawContent === "string" + ? rawContent + : Array.isArray(rawContent) + ? (rawContent as Array<{ type: string; text?: string }>) + .filter((c) => c.type === "text").map((c) => c.text ?? "").join("\n") + : JSON.stringify(rawContent ?? ""); + let result: unknown; + try { result = JSON.parse(outputText); } catch { result = { output: outputText.slice(0, 5000) }; } + + const assistantParts = currentAssistant?.parts; + if (assistantParts) { + const tc = assistantParts.find( + (p) => p.type === "tool-invocation" && p.toolCallId === toolCallId, + ); + if (tc) { + delete tc.state; + tc.result = result; + continue; + } + } + + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role !== "assistant") continue; + const tc = messages[i].parts?.find( + (p) => p.type === "tool-invocation" && p.toolCallId === toolCallId, + ); + if (tc) { + delete tc.state; + tc.result = result; + } + break; + } + continue; + } + + if (role === "user") { + flushAssistant(); + } + + if (role !== "user" && role !== "assistant") continue; + + const parts: Array> = []; + + if (Array.isArray(msg.content)) { + for (const part of msg.content as Array>) { + if (part.type === "text" && typeof part.text === "string" && part.text.trim()) { + parts.push({ type: "text", text: part.text }); + } else if (part.type === "thinking" && typeof part.thinking === "string" && part.thinking.trim()) { + parts.push({ type: "reasoning", text: part.thinking }); + } else if (part.type === "toolCall") { + const toolName = (part.name ?? part.toolName ?? "unknown") as string; + const toolCallId = (part.id ?? part.toolCallId ?? `tool-${Date.now()}-${Math.random().toString(36).slice(2)}`) as string; + const args = part.arguments ?? part.input ?? part.args ?? {}; + pendingToolCalls.set(toolCallId, { toolName, args }); + parts.push({ type: "tool-invocation", toolCallId, toolName, args }); + } else if (part.type === "tool_use" || part.type === "tool-call") { + const toolName = (part.name ?? part.toolName ?? "unknown") as string; + const toolCallId = (part.id ?? part.toolCallId ?? `tool-${Date.now()}-${Math.random().toString(36).slice(2)}`) as string; + const args = part.input ?? part.args ?? {}; + pendingToolCalls.set(toolCallId, { toolName, args }); + parts.push({ type: "tool-invocation", toolCallId, toolName, args }); + } else if (part.type === "tool_result" || part.type === "tool-result") { + const toolCallId = (part.tool_use_id ?? part.toolCallId ?? "") as string; + const pending = pendingToolCalls.get(toolCallId); + const raw = part.content ?? part.output; + const outputText = typeof raw === "string" + ? raw + : Array.isArray(raw) + ? (raw as Array<{ type: string; text?: string }>).filter((c) => c.type === "text").map((c) => c.text ?? "").join("\n") + : JSON.stringify(raw ?? ""); + let result: unknown; + try { result = JSON.parse(outputText); } catch { result = { output: outputText.slice(0, 5000) }; } + + const existingMsg = messages[messages.length - 1]; + if (existingMsg) { + const tc = existingMsg.parts?.find( + (p) => p.type === "tool-invocation" && p.toolCallId === toolCallId, + ); + if (tc) { + delete tc.state; + tc.result = result; + continue; + } + } + parts.push({ + type: "tool-invocation", + toolCallId, + toolName: pending?.toolName ?? "tool", + args: pending?.args ?? {}, + result, + }); + } + } + } else if (typeof msg.content === "string" && msg.content.trim()) { + parts.push({ type: "text", text: msg.content }); + } + + if (parts.length > 0) { + const timestamp = (entry.timestamp as string) ?? new Date((entry.ts as number) ?? Date.now()).toISOString(); + if (role === "assistant") { + if (!currentAssistant) { + currentAssistant = { + id: (entry.id as string) ?? `msg-${messages.length}`, + role: "assistant", + content: "", + parts: [], + timestamp, + }; + } + currentAssistant.parts = [...(currentAssistant.parts ?? []), ...parts]; + currentAssistant.timestamp = timestamp; + } else { + messages.push({ + id: (entry.id as string) ?? `msg-${messages.length}`, + role: "user", + content: parts + .filter((part) => part.type === "text" && typeof part.text === "string") + .map((part) => part.text as string) + .join("\n") + .slice(0, 200), + parts, + timestamp, + }); + } + } + } + flushAssistant(); + return messages; +} + +export function sessionDisplayTitle(entry: GatewaySessionEntry): string { + if (entry.origin.label) return entry.origin.label; + if (entry.channel === "cron") { + const cronMatch = entry.sessionKey.match(/cron:([^:]+)/); + return cronMatch ? `Cron: ${cronMatch[1].slice(0, 8)}` : "Cron Job"; + } + const channelName = entry.channel.charAt(0).toUpperCase() + entry.channel.slice(1); + return `${channelName} Session`; +} diff --git a/apps/web/lib/tab-state.ts b/apps/web/lib/tab-state.ts index dc60d268aa3..18662c235b1 100644 --- a/apps/web/lib/tab-state.ts +++ b/apps/web/lib/tab-state.ts @@ -5,7 +5,7 @@ * The URL reflects only the active tab's content (backward compatible). */ -export type TabType = "home" | "file" | "chat" | "app" | "object" | "cron"; +export type TabType = "home" | "file" | "chat" | "app" | "object" | "cron" | "gateway-chat"; export const HOME_TAB_ID = "__home__"; @@ -26,6 +26,8 @@ export type Tab = { sessionKey?: string; parentSessionId?: string; pinned?: boolean; + /** Channel identifier for gateway-chat tabs (e.g. "telegram", "discord"). */ + channel?: string; }; export type TabState = { diff --git a/package.json b/package.json index d06edc82fe8..41af6d8a8bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "denchclaw", - "version": "2.3.14", + "version": "2.3.15", "description": "Fully Managed OpenClaw Framework for managing your CRM, Sales Automation and Outreach agents. The only local productivity tool you need.", "keywords": [], "homepage": "https://github.com/DenchHQ/DenchClaw#readme",