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
@@ -5,10 +5,20 @@ interface UseStreamingTextThrottleInput {
|
||||
isStreaming: boolean;
|
||||
throttleMs?: number;
|
||||
identityKey?: string;
|
||||
allowTextReplacement?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_STREAMING_TEXT_THROTTLE_MS = 100;
|
||||
|
||||
export const getStreamingThrottleText = (
|
||||
current: string,
|
||||
next: string,
|
||||
isStreaming: boolean,
|
||||
allowTextReplacement: boolean,
|
||||
): string => {
|
||||
return isStreaming && !allowTextReplacement && current.length > next.length ? current : next;
|
||||
};
|
||||
|
||||
const computeStreamingThrottleDelay = (lastEmitAt: number, now: number, throttleMs: number): number => {
|
||||
const elapsed = now - lastEmitAt;
|
||||
return Math.max(0, throttleMs - elapsed);
|
||||
@@ -33,6 +43,7 @@ export const useStreamingTextThrottle = ({
|
||||
isStreaming,
|
||||
throttleMs = DEFAULT_STREAMING_TEXT_THROTTLE_MS,
|
||||
identityKey,
|
||||
allowTextReplacement = false,
|
||||
}: UseStreamingTextThrottleInput): string => {
|
||||
const [throttledText, setThrottledText] = React.useState(text);
|
||||
const latestTextRef = React.useRef(text);
|
||||
@@ -64,7 +75,7 @@ export const useStreamingTextThrottle = ({
|
||||
const state = stateRef.current;
|
||||
state.pendingText = text;
|
||||
const currentThrottled = throttledTextRef.current;
|
||||
const stableText = isStreaming && currentThrottled.length > text.length ? currentThrottled : text;
|
||||
const stableText = getStreamingThrottleText(currentThrottled, text, isStreaming, allowTextReplacement);
|
||||
|
||||
if (!isStreaming) {
|
||||
clearTimer(state);
|
||||
@@ -88,17 +99,14 @@ export const useStreamingTextThrottle = ({
|
||||
state.timer = null;
|
||||
state.lastEmitAt = Date.now();
|
||||
setThrottledText((prev) => {
|
||||
if (isStreaming && prev.length > state.pendingText.length) {
|
||||
return prev;
|
||||
}
|
||||
return state.pendingText;
|
||||
return getStreamingThrottleText(prev, state.pendingText, isStreaming, allowTextReplacement);
|
||||
});
|
||||
}, remaining);
|
||||
|
||||
return () => {
|
||||
clearTimer(state);
|
||||
};
|
||||
}, [isStreaming, text, throttleMs]);
|
||||
}, [allowTextReplacement, isStreaming, text, throttleMs]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const state = stateRef.current;
|
||||
|
||||
@@ -55,6 +55,7 @@ Use this doc when you ask an agent to change tool/header/description behavior.
|
||||
- Every other tool, including search/fetch, OpenCode built-ins, custom tools, plugins, and MCP tools, is **expandable** and renders through `ToolPart`.
|
||||
- The managed `openchamber` plugin tool uses the expandable path and hides its broad protocol input. The plugin supplies the selected action's human description as the native tool title; the UI renders that metadata without owning an action map. The full versioned result envelope renders through the same neutral JSON summary/tree/raw views as other tools, without a tool-specific output card.
|
||||
- `ToolPart` defers expanded content after a user toggle, preventing large tool input/output payloads from mounting during the initial chat render.
|
||||
- Running bash output falls back to `state.metadata.output` until canonical `state.output` arrives. Its fixed-height output viewport follows new output until the user scrolls up, then resumes following when the user returns to the bottom. Live output appends or replaces rewritten snapshots as plain text without worker highlighting; finalized output bypasses the throttle and receives the normal one-time highlighted rendering.
|
||||
- Thinking/Justification duration is hidden in `sorted` mode (handled in `ReasoningPart.tsx` + `JustificationBlock.tsx`).
|
||||
|
||||
## "I want to change description for Perplexity" (example recipe)
|
||||
|
||||
@@ -2,6 +2,46 @@ import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { readTaskTagSessionIdFromOutput } from './taskSessionIdParser';
|
||||
import { tryParseJsonOutput } from '../toolRenderers';
|
||||
import { getStreamingThrottleText } from '../../hooks/useStreamingTextThrottle';
|
||||
import { getStreamingOutputAppend, getToolOutput } from './toolOutput';
|
||||
|
||||
describe('getToolOutput', () => {
|
||||
test('prefers authoritative state output', () => {
|
||||
expect(getToolOutput('bash', 'final output', 'streamed output')).toBe('final output');
|
||||
expect(getToolOutput('bash', '', 'streamed output')).toBe('');
|
||||
});
|
||||
|
||||
test('falls back to streamed metadata output for bash', () => {
|
||||
expect(getToolOutput('bash', undefined, 'streamed output')).toBe('streamed output');
|
||||
expect(getToolOutput('bash', undefined, '')).toBe(undefined);
|
||||
});
|
||||
|
||||
test('does not expose metadata output for other tools', () => {
|
||||
expect(getToolOutput('read', undefined, 'metadata output')).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStreamingOutputAppend', () => {
|
||||
test('returns only newly appended output', () => {
|
||||
expect(getStreamingOutputAppend('first\n', 'first\nsecond\n')).toBe('second\n');
|
||||
});
|
||||
|
||||
test('requires replacement when output is rewritten or shortened', () => {
|
||||
expect(getStreamingOutputAppend('progress 10%', 'progress 20%')).toBe(undefined);
|
||||
expect(getStreamingOutputAppend('long output', 'short')).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('streaming output transitions', () => {
|
||||
test('allows bash snapshots to be rewritten or shortened while running', () => {
|
||||
expect(getStreamingThrottleText('progress 10%', 'progress 20%', true, true)).toBe('progress 20%');
|
||||
expect(getStreamingThrottleText('long output', 'short', true, true)).toBe('short');
|
||||
});
|
||||
|
||||
test('preserves monotonic streaming text by default', () => {
|
||||
expect(getStreamingThrottleText('long output', 'short', true, false)).toBe('long output');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readTaskTagSessionIdFromOutput', () => {
|
||||
test('parses task tags without state attributes', () => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export const getToolOutput = (
|
||||
tool: string,
|
||||
stateOutput: unknown,
|
||||
metadataOutput: unknown,
|
||||
): string | undefined => {
|
||||
if (typeof stateOutput === 'string') {
|
||||
return stateOutput;
|
||||
}
|
||||
|
||||
if (tool === 'bash' && typeof metadataOutput === 'string' && metadataOutput.length > 0) {
|
||||
return metadataOutput;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getStreamingOutputAppend = (previous: string, next: string): string | undefined => {
|
||||
return next.startsWith(previous) ? next.slice(previous.length) : undefined;
|
||||
};
|
||||
Reference in New Issue
Block a user