* 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
279 lines
7.3 KiB
TypeScript
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,
|
|
},
|
|
};
|
|
});
|