Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -186,3 +186,4 @@ jobs:
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
parallel-finished: true
fail-on-error: false
315 changes: 226 additions & 89 deletions web/src/components/SessionDetailPanel.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Fragment, useState, useEffect, useRef } from 'react';
import { Fragment, useState, useEffect, useRef, useCallback } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import {
XMarkIcon,
Expand All @@ -16,6 +16,8 @@ import logger from '../utils/logger';

// Gap threshold for detecting a new isolated run boundary
const RUN_GAP_MS = 5 * 60 * 1000;
const SESSION_REFRESH_INTERVAL_MS = 2000;
const CRON_REFRESH_INTERVAL_MS = 5000;

/** Short hash display with copy-on-click for UUIDs/IDs. */
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
Expand Down Expand Up @@ -108,6 +110,19 @@ function groupMessagesIntoRuns(messages) {
return runs;
}

function getMessageFingerprint(list) {
if (!Array.isArray(list) || list.length === 0) return '0';
const last = list[list.length - 1];
const contentLength =
typeof last?.content === 'string'
? last.content.length
: Array.isArray(last?.content)
? last.content.length
: 0;
const blocksLength = Array.isArray(last?.blocks) ? last.blocks.length : 0;
return `${list.length}|${last?.timestamp || ''}|${last?.role || ''}|${contentLength}|${blocksLength}`;
}

/** Helper to format tool call summary for display. */
function toolCallSummary(tc) {
const args = tc.arguments || {};
Expand Down Expand Up @@ -306,6 +321,10 @@ export default function SessionDetailPanel({ isOpen, onClose, session, latestRun
const [error, setError] = useState(null);
const [latestRun, setLatestRun] = useState(null);
const scrollContainerRef = useRef(null);
const refreshTimerRef = useRef(null);
const refreshInFlightByIdentityRef = useRef(new Map());
const activeSessionIdentityRef = useRef('');
const latestRequestIdRef = useRef(0);

const getAgentById = useAgentStore((state) => state.getAgentById);
const agent = session?.agent ? getAgentById(session.agent) : null;
Expand All @@ -318,110 +337,228 @@ export default function SessionDetailPanel({ isOpen, onClose, session, latestRun
// latestRunOnly: only show the last run's messages (used by Task Manager)
const runs = latestRunOnly && allRuns ? allRuns.slice(-1) : allRuns;

// For cron sessions, fetch the latest run history first, then load its messages
useEffect(() => {
if (isOpen && session?.kind === 'cron') {
loadCronRunHistory();
} else if (isOpen && session?.key) {
loadMessages();
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- loadMessages/loadCronRunHistory depend on session, which is in deps
}, [isOpen, session?.key, session?.kind]);
const sessionIdentity =
session?.kind === 'cron'
? `cron:${session?.jobId || getCronJobIdFromKey(session?.key) || ''}`
: `session:${session?.key || ''}`;

// Scroll to bottom when messages finish loading
useEffect(() => {
if (!isLoading && !error && messages.length > 0 && scrollContainerRef.current) {
const el = scrollContainerRef.current;
requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight;
});
}
}, [isLoading, error, messages.length]);
const isRequestStale = useCallback((requestId, identity) => {
return requestId !== latestRequestIdRef.current || identity !== activeSessionIdentityRef.current;
}, []);

// Load cron run history and fetch messages from the latest run
const loadCronRunHistory = async () => {
setIsLoading(true);
setError(null);
setSessionNotLoaded(false);
setLatestRun(null);
setMessages([]);
const loadCronRunHistory = useCallback(
async ({ silent = false } = {}) => {
const identity =
session?.kind === 'cron'
? `cron:${session?.jobId || getCronJobIdFromKey(session?.key) || ''}`
: `session:${session?.key || ''}`;

try {
const jobId = session?.jobId || getCronJobIdFromKey(session?.key);
if (!jobId) {
setError('Could not determine cron job ID from session.');
setIsLoading(false);
return;
}
if (!identity || identity === 'cron:' || identity === 'session:') return;
if (refreshInFlightByIdentityRef.current.get(identity)) return;

logger.info('Fetching cron run history', { jobId });
const runsData = await getCronJobRuns(jobId, { limit: 25 });
const runs = runsData.runs || [];
const requestId = ++latestRequestIdRef.current;
refreshInFlightByIdentityRef.current.set(identity, true);

if (runs.length === 0) {
setError('No run history found for this cron job.');
setIsLoading(false);
return;
if (!silent) {
setIsLoading(true);
setError(null);
setSessionNotLoaded(false);
setLatestRun(null);
setMessages([]);
}

// Get the latest run (last in array, as runs are in chronological order)
const latest = runs[runs.length - 1];
setLatestRun(latest);
try {
const jobId = session?.jobId || getCronJobIdFromKey(session?.key);
if (!jobId) {
if (!isRequestStale(requestId, identity)) {
setError('Could not determine cron job ID from session.');
}
return;
}

// If the latest run has a sessionKey, fetch its messages
if (latest.sessionKey) {
logger.info('Fetching latest run messages', { sessionKey: latest.sessionKey });
const messagesData = await getSessionMessages(latest.sessionKey, {
limit: 200,
includeTools: true,
});
setMessages(messagesData.messages || []);
logger.info('Latest run messages loaded', {
messageCount: messagesData.messages?.length || 0,
logger.info('Fetching cron run history', { jobId, silent });
const runsData = await getCronJobRuns(jobId, { limit: 25 });
const cronRuns = runsData.runs || [];

if (isRequestStale(requestId, identity)) return;

if (cronRuns.length === 0) {
setLatestRun(null);
setMessages([]);
setError('No run history found for this cron job.');
return;
}

// Get the latest run (last in array, as runs are in chronological order)
const latest = cronRuns[cronRuns.length - 1];
setLatestRun((prev) => {
if (
prev?.runId === latest?.runId &&
prev?.updatedAt === latest?.updatedAt &&
prev?.status === latest?.status &&
prev?.summary === latest?.summary &&
prev?.error === latest?.error
) {
return prev;
}
return latest;
});
} else {
setMessages([]);
logger.warn('Latest run has no sessionKey', { run: latest });

// If the latest run has a sessionKey, fetch its messages
if (latest.sessionKey) {
logger.info('Fetching latest run messages', { sessionKey: latest.sessionKey, silent });
const messagesData = await getSessionMessages(latest.sessionKey, {
limit: 200,
includeTools: true,
});

if (isRequestStale(requestId, identity)) return;

const nextMessages = messagesData.messages || [];
setMessages((prev) =>
getMessageFingerprint(prev) === getMessageFingerprint(nextMessages) ? prev : nextMessages,
);

setError(null);
logger.info('Latest run messages loaded', {
messageCount: nextMessages.length,
silent,
});
} else {
setMessages([]);
setError(null);
logger.warn('Latest run has no sessionKey', { run: latest });
}
} catch (err) {
if (isRequestStale(requestId, identity)) return;
logger.error('Failed to load cron run history', err);
setError('Failed to load cron run history. Please try again.');
} finally {
refreshInFlightByIdentityRef.current.delete(identity);
if (!silent && !isRequestStale(requestId, identity)) {
setIsLoading(false);
}
}
} catch (err) {
logger.error('Failed to load cron run history', err);
setError('Failed to load cron run history. Please try again.');
} finally {
setIsLoading(false);
}
};
},
[isRequestStale, session?.jobId, session?.key, session?.kind],
);

const loadMessages = async () => {
setIsLoading(true);
setError(null);
setSessionNotLoaded(false);
const loadMessages = useCallback(
async ({ silent = false } = {}) => {
const identity = `session:${session?.key || ''}`;
if (!session?.key || identity === 'session:') return;
if (refreshInFlightByIdentityRef.current.get(identity)) return;

try {
logger.info('Fetching session messages', { sessionKey: session.key });
const data = await getSessionMessages(session.key, { limit: 100, includeTools: true });
setMessages(data.messages || []);
setSessionMetadata(data.session || null);
setSessionNotLoaded(data.sessionNotLoaded === true);
logger.info('Session messages loaded', { messageCount: data.messages?.length || 0 });
} catch (err) {
logger.error('Failed to load session messages', err);

// Check for agent-to-agent access error
if (
err.response?.status === 403 &&
err.response?.data?.error?.code === 'AGENT_TO_AGENT_DISABLED'
) {
setError(
'Agent session history is not accessible. Agent-to-agent access is disabled in OpenClaw Gateway. ' +
'Contact your administrator to enable this feature.',
const requestId = ++latestRequestIdRef.current;
refreshInFlightByIdentityRef.current.set(identity, true);

if (!silent) {
setIsLoading(true);
setError(null);
setSessionNotLoaded(false);
}

try {
logger.info('Fetching session messages', { sessionKey: session.key, silent });
const data = await getSessionMessages(session.key, { limit: 100, includeTools: true });
if (isRequestStale(requestId, identity)) return;
const nextMessages = data.messages || [];

setMessages((prev) =>
getMessageFingerprint(prev) === getMessageFingerprint(nextMessages) ? prev : nextMessages,
);
} else {
setError('Failed to load session messages. Please try again.');
setSessionMetadata(data.session || null);
setSessionNotLoaded(data.sessionNotLoaded === true);
setError(null);

logger.info('Session messages loaded', { messageCount: nextMessages.length, silent });
} catch (err) {
if (isRequestStale(requestId, identity)) return;
logger.error('Failed to load session messages', err);

// Check for agent-to-agent access error
if (
err.response?.status === 403 &&
err.response?.data?.error?.code === 'AGENT_TO_AGENT_DISABLED'
) {
setError(
'Agent session history is not accessible. Agent-to-agent access is disabled in OpenClaw Gateway. ' +
'Contact your administrator to enable this feature.',
);
} else {
setError('Failed to load session messages. Please try again.');
}
} finally {
refreshInFlightByIdentityRef.current.delete(identity);
if (!silent && !isRequestStale(requestId, identity)) {
setIsLoading(false);
}
}
} finally {
setIsLoading(false);
},
[isRequestStale, session?.key],
);

// Initial load + live refresh while the drawer is open.
useEffect(() => {
if (!isOpen) return undefined;
activeSessionIdentityRef.current = sessionIdentity;
const inFlightMap = refreshInFlightByIdentityRef.current;

const load = async (silent = false) => {
if (session?.kind === 'cron') {
await loadCronRunHistory({ silent });
} else if (session?.key) {
await loadMessages({ silent });
}
};

load(false);

const hasLiveStreamTarget = session?.kind === 'cron' || Boolean(session?.key);
if (hasLiveStreamTarget) {
const intervalMs =
session?.kind === 'cron' ? CRON_REFRESH_INTERVAL_MS : SESSION_REFRESH_INTERVAL_MS;
refreshTimerRef.current = setInterval(() => {
if (document.visibilityState !== 'visible') return;
load(true);
}, intervalMs);

const onVisibilityChange = () => {
if (document.visibilityState === 'visible') {
load(true);
}
};
document.addEventListener('visibilitychange', onVisibilityChange);

return () => {
if (refreshTimerRef.current) {
clearInterval(refreshTimerRef.current);
refreshTimerRef.current = null;
}
document.removeEventListener('visibilitychange', onVisibilityChange);
inFlightMap.clear();
};
}
Comment thread
moltar-bot marked this conversation as resolved.
};

return () => {
if (refreshTimerRef.current) {
clearInterval(refreshTimerRef.current);
refreshTimerRef.current = null;
}
inFlightMap.clear();
};
}, [isOpen, sessionIdentity, session?.key, session?.kind, loadMessages, loadCronRunHistory]);

// Scroll to bottom when messages finish loading
useEffect(() => {
if (!isLoading && !error && messages.length > 0 && scrollContainerRef.current) {
const el = scrollContainerRef.current;
requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight;
});
}
}, [isLoading, error, messages.length]);

const getStatusColor = (status) => {
switch (status) {
Expand Down
Loading
Loading