Merge pull request #110 from DenchHQ/kumareth/channels

feat(channels): show gateway channel sessions in chat sidebar
This commit is contained in:
Kumar Abhirup 2026-03-20 09:59:54 -07:00 committed by GitHub
commit df63d6d9b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1564 additions and 431 deletions

View File

@ -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

View File

@ -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<string, { enabled?: boolean }> {
const configPath = join(resolveOpenClawStateDir(), "openclaw.json");
if (!existsSync(configPath)) return {};
try {
const config = JSON.parse(readFileSync(configPath, "utf-8"));
return (config.channels ?? {}) as Record<string, { enabled?: boolean }>;
} catch {
return {};
}
}
export async function GET() {
const configuredChannels = readConfiguredChannels();
let gatewayStatus: Record<string, Record<string, unknown>> = {};
try {
const res = await callGatewayRpc("channels.status", { probe: false });
if (res.ok && res.payload) {
const payload = res.payload as Record<string, unknown>;
const channelMeta = payload.channelMeta as Record<string, Record<string, unknown>> | 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 });
}

View File

@ -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<typeof setInterval> | 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",
},
});
}

View File

@ -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<typeof setInterval> | 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",
},
});
}

View File

@ -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 });
}

View File

@ -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<string>();
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 });
}

View File

@ -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 });
}

View File

@ -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<ChatPanelHandle, ChatPanelProps>(
@ -852,10 +858,14 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
onBack,
hideHeaderActions,
onRuntimeStateChange,
gatewaySessionKey,
gatewaySessionId,
gatewayChannel: _gatewayChannel,
},
ref,
) {
const isSubagentMode = !!subagentSessionKey;
const isGatewayMode = !!gatewaySessionKey;
const editorRef = useRef<ChatEditorHandle>(null);
const [editorEmpty, setEditorEmpty] = useState(true);
const [currentSessionId, setCurrentSessionId] = useState<
@ -1095,11 +1105,15 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
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<ChatPanelHandle, ChatPanelProps>(
const initialSessionHandled = useRef(false);
const lastInitialSessionRef = useRef<string | null>(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<ChatPanelHandle, ChatPanelProps>(
// 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<Record<string, unknown>>;
}> = 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<ChatPanelHandle, ChatPanelProps>(
}
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<ChatPanelHandle, ChatPanelProps>(
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<ChatPanelHandle, ChatPanelProps>(
filePath,
fileContext,
sendMessage,
gatewaySessionKey,
attemptReconnect,
],
);

File diff suppressed because it is too large Load Diff

View File

@ -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;

View File

@ -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<SubagentSpawnInfo[]>([]);
const [activeSubagentKey, setActiveSubagentKey] = useState<string | null>(null);
// Gateway channel sessions
const [gatewaySessions, setGatewaySessions] = useState<SidebarGatewaySession[]>([]);
const [channelStatuses, setChannelStatuses] = useState<SidebarChannelStatus[]>([]);
const [activeGatewaySessionKey, setActiveGatewaySessionKey] = useState<string | null>(null);
// Cron jobs state
const [cronJobs, setCronJobs] = useState<CronJob[]>([]);
const [heartbeatInfo, setHeartbeatInfo] = useState<{ intervalMs: number; nextDueEstimateMs: number | null } | null>(null);
// Cron URL-backed view state
const [cronView, setCronView] = useState<import("@/lib/workspace-links").CronDashboardView>("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<WebSession[]>([]);
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<WebSession & { filePath?: string }> = 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() {
<ChatPanel
ref={(handle) => 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}
/>
</div>
);
@ -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
/>
</div>
@ -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)}

View File

@ -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<string, ActiveRun> =
const fileMutationQueues = new Map<string, Promise<void>>();
async function pathExistsAsync(path: string): Promise<boolean> {
async function pathExistsAsync(filePath: string): Promise<boolean> {
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<T>(
filePath: string,
mutate: () => Promise<T>,
@ -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<string, unknown>; } catch { return null; } }).filter(Boolean) as Array<Record<string, unknown>>;
@ -1296,7 +1309,7 @@ export async function persistUserMessage(
msg: { id: string; content: string; parts?: unknown[]; html?: string },
): Promise<void> {
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<string, unknown>,
) {
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 () => {

View File

@ -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,
};
}

View File

@ -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<Record<string, unknown>>;
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<string, Record<string, unknown>>;
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<string, unknown>;
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<string, { toolName: string; args?: unknown }>();
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<string, unknown>;
try { entry = JSON.parse(line); } catch { continue; }
if (entry.type !== "message" || !entry.message) continue;
const msg = entry.message as Record<string, unknown>;
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<Record<string, unknown>> = [];
if (Array.isArray(msg.content)) {
for (const part of msg.content as Array<Record<string, unknown>>) {
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`;
}

View File

@ -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 = {

View File

@ -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",