fix: stream bash output and harden OpenCode connectivity (#2522)

* fix(ui): stream bash tool output while running

* perf(ui): render streaming bash output incrementally

* fix(ui): keep tool duration timer running

* fix(web): recover stalled OpenCode SSE streams

* fix(web): prevent OpenCode restart storms

* fix: address streaming recovery review
This commit is contained in:
Bohdan Triapitsyn
2026-07-29 17:26:11 +03:00
committed by GitHub
parent c84305bf7e
commit 0f830f8804
12 changed files with 372 additions and 41 deletions
@@ -55,6 +55,8 @@ import { areRenderRelevantPartsEqual } from '../renderCompare';
import { useI18n } from '@/lib/i18n';
import { getDiffPatchEntries, getPatchText, type DiffPatchEntry } from './toolDiffUtils';
import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat';
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
import { getStreamingOutputAppend, getToolOutput } from './toolOutput';
const TOOL_ROW_TEXT_CLASS = '!text-[length:var(--text-meta)] !leading-5 sm:!leading-6 tracking-normal';
const TOOL_ROW_TITLE_CLASS = cn('typography-meta font-medium', TOOL_ROW_TEXT_CLASS);
@@ -170,7 +172,6 @@ const normalizeToolName = (toolName: string | undefined | null): string => {
return trimmed;
};
const MAX_DURATION_MS = 5 * 60 * 1000; // 5 minutes cap
const GIT_REFRESH_MUTATING_TOOLS = new Set([
'bash',
'edit',
@@ -181,7 +182,7 @@ const GIT_REFRESH_MUTATING_TOOLS = new Set([
]);
const formatDuration = (start: number, end?: number, now: number = Date.now()) => {
const duration = Math.min(Math.max(0, (end ?? now) - start), MAX_DURATION_MS);
const duration = Math.max(0, (end ?? now) - start);
const seconds = duration / 1000;
const displaySeconds = seconds < 0.05 && end !== undefined ? 0.1 : seconds;
@@ -798,6 +799,7 @@ interface ToolScrollableSectionProps {
className?: string;
outerClassName?: string;
disableHorizontal?: boolean;
followKey?: string;
}
const ToolScrollableSection: React.FC<ToolScrollableSectionProps> = ({
@@ -806,22 +808,54 @@ const ToolScrollableSection: React.FC<ToolScrollableSectionProps> = ({
className,
outerClassName,
disableHorizontal = false,
}) => (
<div className={cn('w-full min-w-0 flex-none overflow-hidden', outerClassName)}>
<ScrollShadow
className={cn(
'tool-output-surface p-2 rounded-xl w-full min-w-0',
maxHeightClass,
disableHorizontal ? 'overflow-y-auto overflow-x-hidden' : 'overflow-auto',
className,
)}
>
<div className="w-full min-w-0">
{children}
</div>
</ScrollShadow>
</div>
);
followKey,
}) => {
const scrollRef = React.useRef<HTMLElement>(null);
const isFollowingRef = React.useRef(true);
React.useLayoutEffect(() => {
const element = scrollRef.current;
if (followKey === undefined) {
isFollowingRef.current = true;
return;
}
if (!element || !isFollowingRef.current) {
return;
}
element.scrollTop = element.scrollHeight;
}, [followKey]);
return (
<div className={cn('w-full min-w-0 flex-none overflow-hidden', outerClassName)}>
<ScrollShadow
ref={scrollRef}
data-scrollable="true"
onWheelCapture={(event) => {
if (followKey !== undefined && event.deltaY < 0) {
isFollowingRef.current = false;
}
}}
onScroll={(event) => {
if (followKey === undefined) {
return;
}
const element = event.currentTarget;
isFollowingRef.current = element.scrollHeight - element.scrollTop - element.clientHeight <= 2;
}}
className={cn(
'tool-output-surface p-2 rounded-xl w-full min-w-0',
maxHeightClass,
disableHorizontal ? 'overflow-y-auto overflow-x-hidden' : 'overflow-auto',
className,
)}
>
<div className="w-full min-w-0">
{children}
</div>
</ScrollShadow>
</div>
);
};
const getToolOutputLanguage = (
output: string,
@@ -848,12 +882,53 @@ const getToolOutputText = (
return formatEditOutput(output, part.tool, metadata);
};
const StreamingPlainTextOutput: React.FC<{ output: string }> = ({ output }) => {
const preRef = React.useRef<HTMLPreElement>(null);
const previousOutputRef = React.useRef('');
React.useLayoutEffect(() => {
const element = preRef.current;
if (!element) {
return;
}
const firstChild = element.firstChild;
const textNode = firstChild instanceof globalThis.Text
? firstChild
: document.createTextNode('');
if (textNode !== firstChild) {
element.replaceChildren(textNode);
}
const append = getStreamingOutputAppend(previousOutputRef.current, output);
if (append === undefined) {
textNode.data = output;
} else if (append.length > 0) {
textNode.appendData(append);
}
previousOutputRef.current = output;
}, [output]);
return (
<pre
ref={preRef}
className="m-0 whitespace-pre-wrap break-words"
style={{
...TOOL_COLLAPSED_CUSTOM_STYLE,
lineHeight: 'round(var(--code-block-line-height), 1px)',
overflowWrap: 'break-word',
}}
/>
);
};
const ToolScrollableTextOutput: React.FC<{
output: string;
part: ToolPartType;
metadata: Record<string, unknown> | undefined;
input: Record<string, unknown> | undefined;
}> = ({ output, part, metadata, input }) => {
isStreaming?: boolean;
}> = ({ output, part, metadata, input, isStreaming = false }) => {
const { t } = useI18n();
const renderedOutput = getToolOutputText(output, part, metadata);
const outputLanguage = getToolOutputLanguage(output, part, metadata, input);
@@ -884,6 +959,14 @@ const ToolScrollableTextOutput: React.FC<{
}
}, [renderedOutput, t]);
if (part.tool === 'bash' && isStreaming) {
return (
<div className="typography-code text-muted-foreground/90">
<StreamingPlainTextOutput output={renderedOutput} />
</div>
);
}
if (jsonResult.isJson) {
return (
<div className="tool-output-surface relative p-2 rounded-xl w-full min-w-0">
@@ -1509,9 +1592,17 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
const stateWithData = state as ToolStateWithMetadata;
const metadata = stateWithData.metadata;
const input = stateWithData.input;
const rawOutput = stateWithData.output;
const rawOutput = getToolOutput(part.tool, stateWithData.output, metadata?.output);
const hasStringOutput = typeof rawOutput === 'string' && rawOutput.length > 0;
const outputString = typeof rawOutput === 'string' ? rawOutput : '';
const rawOutputString = typeof rawOutput === 'string' ? rawOutput : '';
const isStreamingBash = part.tool === 'bash' && state.status === 'running';
const throttledOutputString = useStreamingTextThrottle({
text: rawOutputString,
isStreaming: isStreamingBash,
identityKey: part.id,
allowTextReplacement: isStreamingBash,
});
const outputString = isStreamingBash ? throttledOutputString : rawOutputString;
const attachments = stateWithData.attachments;
const fileDiff = isRecord(metadata?.filediff) ? metadata.filediff : undefined;
const diffContent = getPatchText((metadata as { patch?: unknown } | undefined)?.patch)
@@ -1577,13 +1668,14 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
const renderScrollableBlock = (
content: React.ReactNode,
options?: { maxHeightClass?: string; className?: string; disableHorizontal?: boolean; outerClassName?: string }
options?: { maxHeightClass?: string; className?: string; disableHorizontal?: boolean; outerClassName?: string; followKey?: string }
) => (
<ToolScrollableSection
maxHeightClass={options?.maxHeightClass}
className={options?.className}
disableHorizontal={options?.disableHorizontal}
outerClassName={options?.outerClassName}
followKey={options?.followKey}
>
{content}
</ToolScrollableSection>
@@ -1799,16 +1891,22 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
}
if (hasStringOutput && outputString.trim()) {
return renderScrollableBlock(
const output = (
<ToolScrollableTextOutput
output={coerceToText(outputString)}
part={part}
metadata={metadata}
input={input}
/>,
isStreaming={isStreamingBash}
/>
);
return renderScrollableBlock(
output,
{
className: part.tool === 'bash' ? 'p-1 rounded-none' : 'p-1',
maxHeightClass: part.tool === 'bash' ? 'max-h-[46vh]' : undefined,
maxHeightClass: isStreamingBash ? 'h-[46vh]' : part.tool === 'bash' ? 'max-h-[46vh]' : undefined,
followKey: isStreamingBash ? outputString : undefined,
}
);
}
@@ -1819,6 +1917,10 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
);
};
const hasVisibleOutput = outputString.trim().length > 0;
const shouldRenderResult = (state.status === 'completed' && 'output' in state)
|| (part.tool === 'bash' && hasVisibleOutput);
if (isTodoTool) {
if (state.status === 'error' && 'error' in state) {
return (
@@ -1897,7 +1999,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
</div>
) : null}
{state.status === 'completed' && 'output' in state && (
{shouldRenderResult && (
<div>
{(part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch' || part.tool === 'write') && hasVisualDiffEntry ? (
<div className="mb-1 flex items-center justify-end gap-2">