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:
committed by
GitHub
parent
c84305bf7e
commit
0f830f8804
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user