feat(chat): surface failed turns and add error diagnostics to the status report
A turn that OpenCode stopped could end with nothing on screen: the session.error event was only turned into a sidebar badge, its message was dropped (the notification expected a different shape than OpenCode sends), and a send that was accepted but never answered looked the same as success. - The chat shows what OpenCode reported under the last message while that turn is the latest one, and names a user message an idle session has left unanswered for five seconds. - The last 20 session errors are kept in memory and listed in the status report (Ctrl/Cmd+Shift+L, also `__opencodeDebug.statusReport()`), next to rejected sends, the managed OpenCode process's last error and stderr tail, and the OpenCode and desktop log file locations. - The OpenCode health probe hits /global/health instead of a route that does not exist, and probe URLs resolve against the page for web runtimes.
This commit is contained in:
@@ -28,6 +28,7 @@ import { QuestionCard } from './QuestionCard';
|
||||
import { hasActiveQuestionToolInCurrentTurn, recoverPendingQuestionWithRetry } from '@/sync/question-recovery';
|
||||
import { StatusRowContainer } from './StatusRowContainer';
|
||||
import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer';
|
||||
import { SessionErrorNotice } from '@/components/chat/SessionErrorNotice';
|
||||
import ScrollToBottomButton from './components/ScrollToBottomButton';
|
||||
import { PromptNavigatorRail } from './components/PromptNavigatorRail';
|
||||
import { useAuthSessionStore } from '@/lib/runtime-auth-expiry';
|
||||
@@ -375,6 +376,7 @@ const ChatViewport = React.memo(({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SessionErrorNotice sessionId={currentSessionId} directory={directory} />
|
||||
<SessionRecapNote sessionId={currentSessionId} directory={directory} isMobile={isMobile} />
|
||||
|
||||
<div className="flex-shrink-0" style={{ height: isMobile ? '40px' : '10vh' }} aria-hidden="true" />
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import React from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useLatestSessionError } from '@/sync/notification-store';
|
||||
import { useDirectoryStore, useSessionStatus } from '@/sync/sync-context';
|
||||
|
||||
interface SessionErrorNoticeProps {
|
||||
sessionId: string;
|
||||
directory?: string;
|
||||
}
|
||||
|
||||
// How long a user message may sit unanswered on an idle session before the
|
||||
// notice calls it a reply that never began.
|
||||
const UNANSWERED_AFTER_MS = 5_000;
|
||||
|
||||
type LastMessageState = {
|
||||
role: string;
|
||||
timestamp: number;
|
||||
hasError: boolean;
|
||||
} | null;
|
||||
|
||||
// The last message of a session, with whether it already carries an error of
|
||||
// its own: an assistant message that OpenCode marked failed renders its error
|
||||
// inline, so the session-level notice must not repeat it.
|
||||
const useLastMessageState = (sessionId: string, directory?: string): LastMessageState => {
|
||||
const store = useDirectoryStore(directory);
|
||||
const cacheRef = React.useRef<LastMessageState>(null);
|
||||
const getSnapshot = React.useCallback((): LastMessageState => {
|
||||
if (!sessionId) return null;
|
||||
const messages = store.getState().message[sessionId];
|
||||
const last = messages && messages.length > 0 ? messages[messages.length - 1] : null;
|
||||
// SAFETY: store messages are SDK `Message` records; `error` is the optional
|
||||
// assistant-message error the SDK types carry, read here only for presence.
|
||||
const info = last as { role?: string; time?: { completed?: number; created?: number }; error?: unknown } | null;
|
||||
if (!info) {
|
||||
cacheRef.current = null;
|
||||
return null;
|
||||
}
|
||||
const next: LastMessageState = {
|
||||
role: typeof info.role === 'string' ? info.role : '',
|
||||
timestamp: info.time?.completed ?? info.time?.created ?? 0,
|
||||
hasError: Boolean(info.error),
|
||||
};
|
||||
const cached = cacheRef.current;
|
||||
if (cached && cached.role === next.role && cached.timestamp === next.timestamp && cached.hasError === next.hasError) {
|
||||
return cached;
|
||||
}
|
||||
cacheRef.current = next;
|
||||
return next;
|
||||
}, [sessionId, store]);
|
||||
const subscribe = React.useCallback((notify: () => void) => {
|
||||
if (!sessionId) return () => undefined;
|
||||
return store.subscribe(notify);
|
||||
}, [sessionId, store]);
|
||||
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows what OpenCode reported when it stopped a turn without producing a
|
||||
* reply. Rendered under the last message, only while that turn is the latest
|
||||
* one: sending again moves the last message past the error and hides it.
|
||||
*/
|
||||
export const SessionErrorNotice: React.FC<SessionErrorNoticeProps> = ({ sessionId, directory }) => {
|
||||
const { t } = useI18n();
|
||||
const latestError = useLatestSessionError(sessionId);
|
||||
const status = useSessionStatus(sessionId, directory);
|
||||
const lastMessage = useLastMessageState(sessionId, directory);
|
||||
|
||||
const isIdle = !status || status.type === 'idle';
|
||||
const reportedError = latestError && isIdle
|
||||
&& (!lastMessage || latestError.time >= lastMessage.timestamp)
|
||||
&& !(lastMessage?.role === 'assistant' && lastMessage.hasError)
|
||||
? latestError
|
||||
: null;
|
||||
// A user message that the session is idle on, with nothing after it for a
|
||||
// while, is a reply that never began: the send was accepted but OpenCode
|
||||
// produced neither a message nor an error for it.
|
||||
const unansweredSince = !reportedError && isIdle && lastMessage?.role === 'user' ? lastMessage.timestamp : null;
|
||||
const [now, setNow] = React.useState(() => Date.now());
|
||||
React.useEffect(() => {
|
||||
if (unansweredSince === null) return undefined;
|
||||
const remaining = UNANSWERED_AFTER_MS - (Date.now() - unansweredSince);
|
||||
if (remaining <= 0) return undefined;
|
||||
const timer = window.setTimeout(() => setNow(Date.now()), remaining + 50);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [unansweredSince]);
|
||||
const unanswered = unansweredSince !== null && Math.max(now, Date.now()) - unansweredSince >= UNANSWERED_AFTER_MS;
|
||||
|
||||
if (!reportedError && !unanswered) return null;
|
||||
|
||||
const detail = reportedError
|
||||
? (reportedError.error?.message ?? t('chat.sessionError.noDetails'))
|
||||
: t('chat.sessionError.noDetails');
|
||||
const name = reportedError?.error?.name;
|
||||
|
||||
return (
|
||||
<div className="chat-message-column">
|
||||
<div
|
||||
role="status"
|
||||
className="mt-3 max-w-full break-words rounded-2xl border border-[var(--status-error-border)] bg-[var(--status-error-background)] px-4 py-3 text-base leading-relaxed"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon name="error-warning" className="mt-0.5 size-4 shrink-0 text-[var(--status-error)]" />
|
||||
<div className="min-w-0 flex-1 break-words">
|
||||
<div className="font-medium text-foreground">{reportedError ? t('chat.sessionError.title') : t('chat.sessionError.noReply')}</div>
|
||||
<div className="mt-1 text-foreground/80">{name ? `${name}: ${detail}` : detail}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user