* 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
505 lines
14 KiB
TypeScript
505 lines
14 KiB
TypeScript
import React, { useEffect, useMemo, useRef } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
|
import {
|
|
ActivityIndicator,
|
|
Image as RNImage,
|
|
ScrollView as RNScrollView,
|
|
Text,
|
|
View,
|
|
} from "react-native";
|
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
import { useTranslation } from "react-i18next";
|
|
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
|
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
|
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
|
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
|
import { lineNumberGutterWidth } from "@/components/code-insets";
|
|
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
|
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
|
import type { AttachmentMetadata } from "@/attachments/types";
|
|
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
|
import { persistAttachmentFromBytes } from "@/attachments/service";
|
|
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
|
|
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
|
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
|
|
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
|
import { useRetainedPanelActive } from "@/components/retained-panel";
|
|
import { useAppVisible } from "@/hooks/use-app-visible";
|
|
import { isFileQueryEnabled } from "@/components/file-pane-enabled";
|
|
|
|
interface CodeLineProps {
|
|
tokens: HighlightToken[];
|
|
lineNumber: number;
|
|
gutterWidth: number;
|
|
highlighted: boolean;
|
|
}
|
|
|
|
interface FilePreviewBodyProps {
|
|
preview: ExplorerFile | null;
|
|
isLoading: boolean;
|
|
isMobile: boolean;
|
|
location: WorkspaceFileLocation;
|
|
imagePreviewUri: string | null;
|
|
}
|
|
|
|
function trimNonEmpty(value: string | null | undefined): string | null {
|
|
if (typeof value !== "string") {
|
|
return null;
|
|
}
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
|
|
interface FileLineSelection {
|
|
lineStart: number;
|
|
lineEnd: number;
|
|
}
|
|
|
|
function formatFileSize({ size }: { size: number }): string {
|
|
if (size < 1024) {
|
|
return `${size} B`;
|
|
}
|
|
if (size < 1024 * 1024) {
|
|
return `${(size / 1024).toFixed(1)} KB`;
|
|
}
|
|
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
async function createFilePanePreview(file: FileReadResult | null): Promise<{
|
|
file: ExplorerFile | null;
|
|
imageAttachment: AttachmentMetadata | null;
|
|
}> {
|
|
if (!file) {
|
|
return { file: null, imageAttachment: null };
|
|
}
|
|
|
|
const explorerFile = explorerFileFromReadResult(file);
|
|
if (file.kind !== "image") {
|
|
return { file: explorerFile, imageAttachment: null };
|
|
}
|
|
|
|
const imageAttachment = await persistAttachmentFromBytes({
|
|
id: createPreviewAttachmentId({
|
|
mimeType: file.mime,
|
|
path: file.path,
|
|
size: file.size,
|
|
modifiedAt: file.modifiedAt,
|
|
contentLength: file.bytes.byteLength,
|
|
}),
|
|
bytes: file.bytes,
|
|
mimeType: file.mime,
|
|
fileName: getFileNameFromPath(file.path),
|
|
});
|
|
|
|
return {
|
|
file: explorerFile,
|
|
imageAttachment,
|
|
};
|
|
}
|
|
|
|
function clampLineSelection(input: {
|
|
lineStart?: number;
|
|
lineEnd?: number;
|
|
lineCount: number;
|
|
}): FileLineSelection | null {
|
|
if (!input.lineStart || input.lineStart <= 0 || input.lineCount <= 0) {
|
|
return null;
|
|
}
|
|
const lineStart = Math.min(Math.floor(input.lineStart), input.lineCount);
|
|
const rawLineEnd =
|
|
input.lineEnd && input.lineEnd >= input.lineStart ? input.lineEnd : input.lineStart;
|
|
const lineEnd = Math.min(Math.floor(rawLineEnd), input.lineCount);
|
|
return { lineStart, lineEnd: Math.max(lineStart, lineEnd) };
|
|
}
|
|
|
|
const CodeLine = React.memo(function CodeLine({
|
|
tokens,
|
|
lineNumber,
|
|
gutterWidth,
|
|
highlighted,
|
|
}: CodeLineProps) {
|
|
const gutterStyle = useMemo(
|
|
() => [codeLineStyles.gutter, inlineUnistylesStyle({ width: gutterWidth })],
|
|
[gutterWidth],
|
|
);
|
|
const lineStyle = useMemo(
|
|
() => [codeLineStyles.line, highlighted && codeLineStyles.highlightedLine],
|
|
[highlighted],
|
|
);
|
|
const keyedTokens = useMemo(
|
|
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
|
|
[tokens],
|
|
);
|
|
return (
|
|
<View style={lineStyle}>
|
|
<View style={gutterStyle}>
|
|
<Text numberOfLines={1} style={codeLineStyles.gutterText}>
|
|
{String(lineNumber)}
|
|
</Text>
|
|
</View>
|
|
<Text selectable style={codeLineStyles.lineText}>
|
|
{keyedTokens.map(({ key, token }) => (
|
|
<CodeLineToken key={key} token={token} />
|
|
))}
|
|
</Text>
|
|
</View>
|
|
);
|
|
});
|
|
|
|
interface CodeLineTokenProps {
|
|
token: HighlightToken;
|
|
}
|
|
|
|
function CodeLineToken({ token }: CodeLineTokenProps) {
|
|
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
|
|
}
|
|
|
|
const codeLineStyles = StyleSheet.create((theme) => ({
|
|
line: {
|
|
flexDirection: "row",
|
|
},
|
|
highlightedLine: {
|
|
backgroundColor: theme.colors.accentBorder,
|
|
},
|
|
gutter: {
|
|
alignItems: "flex-end",
|
|
paddingRight: theme.spacing[3],
|
|
flexShrink: 0,
|
|
},
|
|
gutterText: {
|
|
color: theme.colors.foreground,
|
|
fontFamily: theme.fontFamily.mono,
|
|
fontSize: theme.fontSize.code,
|
|
lineHeight: theme.fontSize.code * 1.45,
|
|
opacity: 0.4,
|
|
userSelect: "none",
|
|
},
|
|
lineText: {
|
|
fontFamily: theme.fontFamily.mono,
|
|
fontSize: theme.fontSize.code,
|
|
lineHeight: theme.fontSize.code * 1.45,
|
|
flex: 1,
|
|
},
|
|
}));
|
|
|
|
function FilePreviewBody({
|
|
preview,
|
|
isLoading,
|
|
isMobile,
|
|
location,
|
|
imagePreviewUri,
|
|
}: FilePreviewBodyProps) {
|
|
const { theme } = useUnistyles();
|
|
const { t } = useTranslation();
|
|
const filePath = location.path;
|
|
const isMarkdownFile =
|
|
preview?.kind === "text" && isRenderedMarkdownFile(filePath) && !location.lineStart;
|
|
|
|
const previewScrollRef = useRef<RNScrollView>(null);
|
|
|
|
const highlightedLines = useMemo(() => {
|
|
if (!preview || preview.kind !== "text" || isMarkdownFile) {
|
|
return null;
|
|
}
|
|
|
|
return highlightCode(preview.content ?? "", filePath);
|
|
}, [isMarkdownFile, preview, filePath]);
|
|
|
|
const gutterWidth = useMemo(() => {
|
|
if (!highlightedLines) return 0;
|
|
return lineNumberGutterWidth(highlightedLines.length, theme.fontSize.code);
|
|
}, [highlightedLines, theme.fontSize.code]);
|
|
const lineHeight = theme.fontSize.code * 1.45;
|
|
const lineSelection = useMemo(() => {
|
|
if (!highlightedLines) {
|
|
return null;
|
|
}
|
|
return clampLineSelection({
|
|
lineStart: location.lineStart,
|
|
lineEnd: location.lineEnd,
|
|
lineCount: highlightedLines.length,
|
|
});
|
|
}, [highlightedLines, location.lineEnd, location.lineStart]);
|
|
|
|
const imageSource = useMemo(
|
|
() => (imagePreviewUri ? { uri: imagePreviewUri } : null),
|
|
[imagePreviewUri],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!lineSelection) {
|
|
return;
|
|
}
|
|
const timeout = setTimeout(() => {
|
|
previewScrollRef.current?.scrollTo({
|
|
y: Math.max(0, (lineSelection.lineStart - 1) * lineHeight),
|
|
animated: false,
|
|
});
|
|
}, 0);
|
|
return () => clearTimeout(timeout);
|
|
}, [lineHeight, lineSelection]);
|
|
|
|
if (isLoading && !preview) {
|
|
return (
|
|
<View style={styles.centerState}>
|
|
<ActivityIndicator size="small" />
|
|
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (!preview) {
|
|
return (
|
|
<View style={styles.centerState}>
|
|
<Text style={styles.emptyText}>{t("panels.file.noPreview")}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (preview.kind === "text") {
|
|
if (isMarkdownFile) {
|
|
return (
|
|
<View style={styles.previewScrollContainer}>
|
|
<RNScrollView
|
|
ref={previewScrollRef}
|
|
style={styles.previewContent}
|
|
contentContainerStyle={styles.previewMarkdownScrollContent}
|
|
showsVerticalScrollIndicator
|
|
>
|
|
<MarkdownRenderer text={preview.content ?? ""} />
|
|
</RNScrollView>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]];
|
|
const keyedLines = lines.map((tokens, index) => ({
|
|
key: `line-${index}`,
|
|
tokens,
|
|
lineNumber: index + 1,
|
|
}));
|
|
const codeLines = (
|
|
<View dataSet={CODE_SURFACE_DATASET}>
|
|
{keyedLines.map(({ key, tokens, lineNumber }) => (
|
|
<CodeLine
|
|
key={key}
|
|
tokens={tokens}
|
|
lineNumber={lineNumber}
|
|
gutterWidth={gutterWidth}
|
|
highlighted={
|
|
Boolean(lineSelection) &&
|
|
lineNumber >= (lineSelection?.lineStart ?? 0) &&
|
|
lineNumber <= (lineSelection?.lineEnd ?? 0)
|
|
}
|
|
/>
|
|
))}
|
|
</View>
|
|
);
|
|
|
|
return (
|
|
<View style={styles.previewScrollContainer}>
|
|
<RNScrollView
|
|
ref={previewScrollRef}
|
|
style={styles.previewContent}
|
|
showsVerticalScrollIndicator
|
|
>
|
|
{isMobile ? (
|
|
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
|
|
) : (
|
|
<RNScrollView
|
|
horizontal
|
|
nestedScrollEnabled
|
|
showsHorizontalScrollIndicator
|
|
contentContainerStyle={styles.previewCodeScrollContent}
|
|
>
|
|
{codeLines}
|
|
</RNScrollView>
|
|
)}
|
|
</RNScrollView>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (preview.kind === "image") {
|
|
if (!imagePreviewUri) {
|
|
return (
|
|
<View style={styles.centerState}>
|
|
<ActivityIndicator size="small" />
|
|
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View style={styles.previewScrollContainer}>
|
|
<RNScrollView
|
|
ref={previewScrollRef}
|
|
style={styles.previewContent}
|
|
contentContainerStyle={styles.previewImageScrollContent}
|
|
showsVerticalScrollIndicator
|
|
>
|
|
<RNImage
|
|
source={imageSource ?? undefined}
|
|
style={styles.previewImage}
|
|
resizeMode="contain"
|
|
/>
|
|
</RNScrollView>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View style={styles.centerState}>
|
|
<Text style={styles.emptyText}>{t("panels.file.binaryPreviewUnavailable")}</Text>
|
|
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
export function FilePane({
|
|
serverId,
|
|
workspaceRoot,
|
|
location,
|
|
}: {
|
|
serverId: string;
|
|
workspaceRoot: string;
|
|
location: WorkspaceFileLocation;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const isMobile = useIsCompactFormFactor();
|
|
|
|
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
|
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
|
const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]);
|
|
const readTarget = useMemo(
|
|
() =>
|
|
normalizedFilePath
|
|
? resolveFilePreviewReadTarget({
|
|
path: normalizedFilePath,
|
|
workspaceRoot: normalizedWorkspaceRoot,
|
|
})
|
|
: null,
|
|
[normalizedFilePath, normalizedWorkspaceRoot],
|
|
);
|
|
|
|
// Re-read the file when this pane becomes visible again (#445). `isActive`
|
|
// covers tab switches, `isAppVisible` the whole-app background/foreground; the
|
|
// gate itself lives in isFileQueryEnabled.
|
|
const isActive = useRetainedPanelActive();
|
|
const isAppVisible = useAppVisible();
|
|
|
|
const query = useQuery({
|
|
queryKey: ["workspaceFile", serverId, readTarget?.cwd ?? null, readTarget?.path ?? null],
|
|
enabled: isFileQueryEnabled({
|
|
hasReadTarget: Boolean(client && readTarget),
|
|
isTabActive: isActive,
|
|
isAppVisible,
|
|
}),
|
|
queryFn: async () => {
|
|
if (!client || !readTarget) {
|
|
return {
|
|
file: null as ExplorerFile | null,
|
|
error: t("workspace.terminal.hostDisconnected"),
|
|
};
|
|
}
|
|
try {
|
|
const file = await client.readFile(readTarget.cwd, readTarget.path);
|
|
const preview = await createFilePanePreview(file);
|
|
return {
|
|
file: preview.file,
|
|
imageAttachment: preview.imageAttachment,
|
|
error: null,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
file: null,
|
|
imageAttachment: null,
|
|
error: error instanceof Error ? error.message : t("panels.file.failedToLoad"),
|
|
};
|
|
}
|
|
},
|
|
staleTime: 5_000,
|
|
refetchOnMount: true,
|
|
});
|
|
const imagePreviewUri = useAttachmentPreviewUrl(query.data?.imageAttachment ?? null);
|
|
|
|
return (
|
|
<View style={styles.container} testID="workspace-file-pane">
|
|
{query.data?.error ? (
|
|
<View style={styles.centerState}>
|
|
<Text style={styles.errorText}>{query.data.error}</Text>
|
|
</View>
|
|
) : null}
|
|
|
|
<FilePreviewBody
|
|
preview={query.data?.file ?? null}
|
|
isLoading={query.isFetching}
|
|
isMobile={isMobile}
|
|
location={location}
|
|
imagePreviewUri={imagePreviewUri}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
container: {
|
|
flex: 1,
|
|
minHeight: 0,
|
|
backgroundColor: theme.colors.surface0,
|
|
},
|
|
centerState: {
|
|
flex: 1,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
padding: theme.spacing[4],
|
|
},
|
|
loadingText: {
|
|
marginTop: theme.spacing[2],
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
errorText: {
|
|
color: theme.colors.destructive,
|
|
fontSize: theme.fontSize.sm,
|
|
textAlign: "center",
|
|
},
|
|
emptyText: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
textAlign: "center",
|
|
},
|
|
binaryMetaText: {
|
|
marginTop: theme.spacing[2],
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
previewScrollContainer: {
|
|
flex: 1,
|
|
minHeight: 0,
|
|
},
|
|
previewContent: {
|
|
flex: 1,
|
|
minHeight: 0,
|
|
},
|
|
previewCodeScrollContent: {
|
|
padding: theme.spacing[4],
|
|
},
|
|
previewMarkdownScrollContent: {
|
|
padding: theme.spacing[4],
|
|
},
|
|
previewImageScrollContent: {
|
|
flexGrow: 1,
|
|
padding: theme.spacing[4],
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
previewImage: {
|
|
width: "100%",
|
|
height: 420,
|
|
},
|
|
}));
|