Files
paseo/packages/app/src/components/diff-viewer.tsx
T
Mohamed BoudraandGitHub 1b8af2e58e Refine tool call summaries and scrolling (#2069)
* feat(app): refine tool call summaries and scrolling

Keep summary groups stable from the first tool call, preserve live activity feedback, and scroll expanded groups to the latest entry. Consolidate web scrollbar styling around the native browser mechanism.

* refactor(app): reuse tool call height constant

* fix(app): preserve tool call summary state

* fix(app): refresh web tool groups on expand

* fix(app): preserve grouped tool errors
2026-07-14 21:16:29 +02:00

279 lines
7.3 KiB
TypeScript

import React from "react";
import { useTranslation } from "react-i18next";
import { View, Text, ScrollView as RNScrollView } from "react-native";
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
import type { DiffLine } from "@/utils/tool-call-parsers";
import { diffLinePrefix } from "@/utils/diff-highlight";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { getCodeInsets } from "./code-insets";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { isWeb } from "@/constants/platform";
const ScrollView = isWeb ? RNScrollView : GHScrollView;
interface DiffViewerProps {
diffLines: DiffLine[];
maxHeight?: number;
emptyLabel?: string;
fillAvailableHeight?: boolean;
}
function DiffLineRow({ line }: { line: DiffLine }) {
const lineContainerStyle = React.useMemo(
() => [
styles.line,
line.type === "header" && styles.headerLine,
line.type === "add" && styles.addLine,
line.type === "remove" && styles.removeLine,
line.type === "context" && styles.contextLine,
],
[line.type],
);
const plainLineTextStyle = React.useMemo(
() => [
styles.lineText,
line.type === "header" && styles.headerText,
line.type === "add" && styles.addText,
line.type === "remove" && styles.removeText,
line.type === "context" && styles.contextText,
],
[line.type],
);
const prefixStyle = React.useMemo(
() => [
line.type === "add" && styles.addText,
line.type === "remove" && styles.removeText,
line.type === "context" && styles.contextText,
],
[line.type],
);
if (line.tokens) {
return (
<View style={lineContainerStyle}>
<Text style={styles.lineText}>
<Text style={prefixStyle}>{diffLinePrefix(line)}</Text>
<DiffTokens tokens={line.tokens} />
</Text>
</View>
);
}
return (
<View style={lineContainerStyle}>
{line.segments ? (
<Text style={styles.lineText}>
<Text style={line.type === "add" ? styles.addText : styles.removeText}>
{line.content[0]}
</Text>
{line.segments.map((segment) => (
<DiffSegment
key={`${segment.changed ? "c" : "u"}:${segment.text}`}
segment={segment}
lineType={line.type}
/>
))}
</Text>
) : (
<Text style={plainLineTextStyle}>{line.content}</Text>
)}
</View>
);
}
function DiffTokens({ tokens }: { tokens: NonNullable<DiffLine["tokens"]> }) {
const keyed = React.useMemo(
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
[tokens],
);
return (
<>
{keyed.map(({ key, token }) => (
<Text key={key} style={token.style ? syntaxTokenStyleFor(token.style) : undefined}>
{token.text}
</Text>
))}
</>
);
}
function DiffSegment({
segment,
lineType,
}: {
segment: NonNullable<DiffLine["segments"]>[number];
lineType: DiffLine["type"];
}) {
const segmentStyle = React.useMemo(
() => [
lineType === "add" ? styles.addText : styles.removeText,
segment.changed && (lineType === "add" ? styles.addHighlight : styles.removeHighlight),
],
[lineType, segment.changed],
);
return <Text style={segmentStyle}>{segment.text}</Text>;
}
export function DiffViewer({
diffLines,
maxHeight,
emptyLabel,
fillAvailableHeight = false,
}: DiffViewerProps) {
const { t } = useTranslation();
const [scrollViewWidth, setScrollViewWidth] = React.useState(0);
const resolvedEmptyLabel = emptyLabel ?? t("diffViewer.empty");
const handleInnerLayout = React.useCallback(
(e: { nativeEvent: { layout: { width: number } } }) =>
setScrollViewWidth(e.nativeEvent.layout.width),
[],
);
const outerScrollStyle = React.useMemo(
() => [
styles.verticalScroll,
maxHeight !== undefined && inlineUnistylesStyle({ maxHeight }),
fillAvailableHeight && styles.fillHeight,
],
[maxHeight, fillAvailableHeight],
);
const linesContainerStyle = React.useMemo(
() => [
styles.linesContainer,
scrollViewWidth > 0 && inlineUnistylesStyle({ minWidth: scrollViewWidth }),
],
[scrollViewWidth],
);
const keyedDiffLines = React.useMemo(
() => diffLines.map((line, index) => ({ key: `${index}-${line.type}-${line.content}`, line })),
[diffLines],
);
const webVerticalContentStyle = React.useMemo(
() => [styles.verticalContent, fillAvailableHeight && styles.fillHeight],
[fillAvailableHeight],
);
if (!diffLines.length) {
return (
<View style={styles.emptyState}>
<Text style={styles.emptyText}>{resolvedEmptyLabel}</Text>
</View>
);
}
const lines = (
<View style={linesContainerStyle} dataSet={CODE_SURFACE_DATASET}>
{keyedDiffLines.map(({ key, line }) => (
<DiffLineRow key={key} line={line} />
))}
</View>
);
const horizontalScroll = (
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
contentContainerStyle={styles.horizontalContent}
onLayout={handleInnerLayout}
>
{lines}
</ScrollView>
);
const content = (
<ScrollView
style={outerScrollStyle}
contentContainerStyle={webVerticalContentStyle}
nestedScrollEnabled
showsVerticalScrollIndicator
>
{horizontalScroll}
</ScrollView>
);
return content;
}
const styles = StyleSheet.create((theme) => {
const insets = getCodeInsets(theme);
return {
verticalScroll: {},
fillHeight: {
flex: 1,
minHeight: 0,
},
verticalContent: {
flexGrow: 1,
paddingBottom: insets.extraBottom,
},
horizontalContent: {
flexDirection: "column" as const,
paddingRight: insets.extraRight,
},
linesContainer: {
alignSelf: "flex-start",
padding: insets.padding,
},
line: {
minWidth: "100%",
paddingHorizontal: 0,
paddingVertical: theme.spacing[1],
},
lineText: {
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
color: theme.colors.foreground,
...(isWeb
? {
whiteSpace: "pre",
overflowWrap: "normal",
}
: null),
},
headerLine: {
backgroundColor: theme.colors.surface1,
},
headerText: {
color: theme.colors.foregroundMuted,
},
addLine: {
backgroundColor: "rgba(46, 160, 67, 0.15)",
},
addText: {
color: theme.colors.foreground,
},
removeLine: {
backgroundColor: "rgba(248, 81, 73, 0.1)",
},
removeText: {
color: theme.colors.foreground,
},
addHighlight: {
backgroundColor: "rgba(46, 160, 67, 0.4)",
},
removeHighlight: {
backgroundColor: "rgba(248, 81, 73, 0.35)",
},
contextLine: {
backgroundColor: theme.colors.surface1,
},
contextText: {
color: theme.colors.foregroundMuted,
},
emptyState: {
padding: theme.spacing[4],
alignItems: "center" as const,
justifyContent: "center" as const,
},
emptyText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
};
});