refactor(ui): extract diff viewer and tool call parsers into reusable components
This commit is contained in:
@@ -4,6 +4,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import type { Agent } from "@/contexts/session-context";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { getAgentStatusColor, getAgentStatusLabel } from "@/utils/agent-status";
|
||||
import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
|
||||
interface AgentListProps {
|
||||
agents: Map<string, Agent>;
|
||||
@@ -27,6 +28,7 @@ export function AgentList({ agents }: AgentListProps) {
|
||||
const statusColor = getAgentStatusColor(agent.status);
|
||||
const statusLabel = getAgentStatusLabel(agent.status);
|
||||
const timeAgo = formatTimeAgo(agent.lastActivityAt);
|
||||
const providerLabel = getAgentProviderDefinition(agent.provider).label;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -53,13 +55,26 @@ export function AgentList({ agents }: AgentListProps) {
|
||||
</Text>
|
||||
|
||||
<View style={styles.statusRow}>
|
||||
<View style={styles.statusBadge}>
|
||||
<View style={styles.statusGroup}>
|
||||
<View
|
||||
style={[styles.statusDot, { backgroundColor: statusColor }]}
|
||||
/>
|
||||
<Text style={[styles.statusText, { color: statusColor }]}>
|
||||
{statusLabel}
|
||||
</Text>
|
||||
style={[styles.providerBadge, { backgroundColor: theme.colors.muted }]}
|
||||
>
|
||||
<Text
|
||||
style={[styles.providerText, { color: theme.colors.mutedForeground }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{providerLabel}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.statusBadge}>
|
||||
<View
|
||||
style={[styles.statusDot, { backgroundColor: statusColor }]}
|
||||
/>
|
||||
<Text style={[styles.statusText, { color: statusColor }]}>
|
||||
{statusLabel}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.timeAgo}>
|
||||
@@ -111,6 +126,19 @@ const styles = StyleSheet.create((theme) => ({
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
statusGroup: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
providerBadge: {
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
providerText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
textTransform: "uppercase",
|
||||
},
|
||||
statusBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import React from "react";
|
||||
import { View, Text } from "react-native";
|
||||
import { ScrollView } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { DiffLine } from "@/utils/tool-call-parsers";
|
||||
|
||||
interface DiffViewerProps {
|
||||
diffLines: DiffLine[];
|
||||
maxHeight?: number;
|
||||
emptyLabel?: string;
|
||||
}
|
||||
|
||||
export function DiffViewer({ diffLines, maxHeight = 280, emptyLabel = "No changes to display" }: DiffViewerProps) {
|
||||
if (!diffLines.length) {
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyText}>{emptyLabel}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={[styles.verticalScroll, { maxHeight }]}
|
||||
contentContainerStyle={styles.verticalContent}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
contentContainerStyle={styles.horizontalContent}
|
||||
>
|
||||
<View style={styles.linesContainer}>
|
||||
{diffLines.map((line, index) => (
|
||||
<View
|
||||
key={`${line.type}-${index}`}
|
||||
style={[
|
||||
styles.line,
|
||||
line.type === "header" && styles.headerLine,
|
||||
line.type === "add" && styles.addLine,
|
||||
line.type === "remove" && styles.removeLine,
|
||||
line.type === "context" && styles.contextLine,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.lineText,
|
||||
line.type === "header" && styles.headerText,
|
||||
line.type === "add" && styles.addText,
|
||||
line.type === "remove" && styles.removeText,
|
||||
line.type === "context" && styles.contextText,
|
||||
]}
|
||||
>
|
||||
{line.content}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
verticalScroll: {},
|
||||
verticalContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
horizontalContent: {
|
||||
flexDirection: "column" as const,
|
||||
},
|
||||
linesContainer: {
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
line: {
|
||||
minWidth: "100%",
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
lineText: {
|
||||
fontFamily: "monospace",
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
headerLine: {
|
||||
backgroundColor: theme.colors.muted,
|
||||
},
|
||||
headerText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
},
|
||||
addLine: {
|
||||
backgroundColor: theme.colors.palette.green[900],
|
||||
},
|
||||
addText: {
|
||||
color: theme.colors.palette.green[200],
|
||||
},
|
||||
removeLine: {
|
||||
backgroundColor: theme.colors.palette.red[900],
|
||||
},
|
||||
removeText: {
|
||||
color: theme.colors.palette.red[200],
|
||||
},
|
||||
contextLine: {
|
||||
backgroundColor: theme.colors.card,
|
||||
},
|
||||
contextText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
},
|
||||
emptyState: {
|
||||
padding: theme.spacing[4],
|
||||
alignItems: "center" as const,
|
||||
justifyContent: "center" as const,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.mutedForeground,
|
||||
},
|
||||
}));
|
||||
@@ -5,700 +5,20 @@ import { ScrollView } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import {
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
BottomSheetScrollView,
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetBackdropProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import type { SelectedToolCall } from "@/types/shared";
|
||||
import { z } from "zod";
|
||||
|
||||
type DiffLine = {
|
||||
type: "add" | "remove" | "context" | "header";
|
||||
content: string;
|
||||
};
|
||||
|
||||
type EditEntry = {
|
||||
filePath?: string;
|
||||
diffLines: DiffLine[];
|
||||
};
|
||||
|
||||
type ReadEntry = {
|
||||
filePath?: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
type CommandDetails = {
|
||||
command?: string;
|
||||
cwd?: string;
|
||||
output?: string;
|
||||
exitCode?: number | null;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function getString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function splitIntoLines(text: string): string[] {
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return text.replace(/\r\n/g, "\n").split("\n");
|
||||
}
|
||||
|
||||
function buildLineDiff(originalText: string, updatedText: string): DiffLine[] {
|
||||
const originalLines = splitIntoLines(originalText);
|
||||
const updatedLines = splitIntoLines(updatedText);
|
||||
|
||||
const hasAnyContent = originalLines.length > 0 || updatedLines.length > 0;
|
||||
if (!hasAnyContent) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const m = originalLines.length;
|
||||
const n = updatedLines.length;
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, () =>
|
||||
Array(n + 1).fill(0)
|
||||
);
|
||||
|
||||
for (let i = m - 1; i >= 0; i -= 1) {
|
||||
for (let j = n - 1; j >= 0; j -= 1) {
|
||||
if (originalLines[i] === updatedLines[j]) {
|
||||
dp[i][j] = dp[i + 1][j + 1] + 1;
|
||||
} else {
|
||||
dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const diff: DiffLine[] = [];
|
||||
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < m && j < n) {
|
||||
if (originalLines[i] === updatedLines[j]) {
|
||||
diff.push({ type: "context", content: ` ${originalLines[i]}` });
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||
diff.push({ type: "remove", content: `-${originalLines[i]}` });
|
||||
i += 1;
|
||||
} else {
|
||||
diff.push({ type: "add", content: `+${updatedLines[j]}` });
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
|
||||
while (i < m) {
|
||||
diff.push({ type: "remove", content: `-${originalLines[i]}` });
|
||||
i += 1;
|
||||
}
|
||||
|
||||
while (j < n) {
|
||||
diff.push({ type: "add", content: `+${updatedLines[j]}` });
|
||||
j += 1;
|
||||
}
|
||||
|
||||
return diff;
|
||||
}
|
||||
|
||||
function parseUnifiedDiff(diffText?: string): DiffLine[] {
|
||||
if (!diffText) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lines = splitIntoLines(diffText);
|
||||
const diff: DiffLine[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.length) {
|
||||
diff.push({ type: "context", content: line });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("@@")) {
|
||||
diff.push({ type: "header", content: line });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("+")) {
|
||||
if (!line.startsWith("+++")) {
|
||||
diff.push({ type: "add", content: line });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("-")) {
|
||||
if (!line.startsWith("---")) {
|
||||
diff.push({ type: "remove", content: line });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
line.startsWith("diff --git") ||
|
||||
line.startsWith("index ") ||
|
||||
line.startsWith("---") ||
|
||||
line.startsWith("+++")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("\\ No newline")) {
|
||||
diff.push({ type: "header", content: line });
|
||||
continue;
|
||||
}
|
||||
|
||||
diff.push({ type: "context", content: line });
|
||||
}
|
||||
|
||||
return diff;
|
||||
}
|
||||
|
||||
function deriveDiffLines({
|
||||
unifiedDiff,
|
||||
original,
|
||||
updated,
|
||||
}: {
|
||||
unifiedDiff?: string;
|
||||
original?: string;
|
||||
updated?: string;
|
||||
}): DiffLine[] {
|
||||
if (unifiedDiff) {
|
||||
const parsed = parseUnifiedDiff(unifiedDiff);
|
||||
if (parsed.length > 0) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (original !== undefined || updated !== undefined) {
|
||||
return buildLineDiff(original ?? "", updated ?? "");
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function looksLikePatch(text: string): boolean {
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
return /(\*\*\* Begin Patch|@@|diff --git|\+\+\+|--- )/.test(text);
|
||||
}
|
||||
|
||||
function parsePatchText(text: string): DiffLine[] {
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
return parseUnifiedDiff(text);
|
||||
}
|
||||
|
||||
function getFilePathFromRecord(record: Record<string, unknown>): string | undefined {
|
||||
return (
|
||||
getString(record["file_path"]) ??
|
||||
getString(record["filePath"]) ??
|
||||
getString(record["path"]) ??
|
||||
getString(record["target_path"]) ??
|
||||
getString(record["targetPath"]) ??
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
const ChangeBlockSchema = z
|
||||
.object({
|
||||
unified_diff: z.string().optional(),
|
||||
unifiedDiff: z.string().optional(),
|
||||
diff: z.string().optional(),
|
||||
patch: z.string().optional(),
|
||||
old_content: z.string().optional(),
|
||||
oldContent: z.string().optional(),
|
||||
previous_content: z.string().optional(),
|
||||
previousContent: z.string().optional(),
|
||||
base_content: z.string().optional(),
|
||||
baseContent: z.string().optional(),
|
||||
old_string: z.string().optional(),
|
||||
new_string: z.string().optional(),
|
||||
new_content: z.string().optional(),
|
||||
newContent: z.string().optional(),
|
||||
replace_with: z.string().optional(),
|
||||
replaceWith: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
function buildEditEntryFromBlock(
|
||||
filePath: string | undefined,
|
||||
blockValue: Record<string, unknown>
|
||||
): EditEntry | null {
|
||||
const parsed = ChangeBlockSchema.safeParse(blockValue);
|
||||
if (!parsed.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
const diffLines = deriveDiffLines({
|
||||
unifiedDiff:
|
||||
getString(
|
||||
data.unified_diff ??
|
||||
data.unifiedDiff ??
|
||||
data.patch ??
|
||||
data.diff
|
||||
) ?? undefined,
|
||||
original:
|
||||
getString(
|
||||
data.old_string ??
|
||||
data.old_content ??
|
||||
data.oldContent ??
|
||||
data.previous_content ??
|
||||
data.previousContent ??
|
||||
data.base_content ??
|
||||
data.baseContent
|
||||
) ?? undefined,
|
||||
updated:
|
||||
getString(
|
||||
data.new_string ??
|
||||
data.new_content ??
|
||||
data.newContent ??
|
||||
data.replace_with ??
|
||||
data.replaceWith ??
|
||||
data.content
|
||||
) ?? undefined,
|
||||
});
|
||||
|
||||
if (diffLines.length > 0) {
|
||||
return {
|
||||
filePath: filePath ?? getFilePathFromRecord(blockValue),
|
||||
diffLines,
|
||||
};
|
||||
}
|
||||
|
||||
const patchCandidate =
|
||||
getString(data.unified_diff ?? data.unifiedDiff ?? data.patch ?? data.diff) ??
|
||||
undefined;
|
||||
if (patchCandidate && looksLikePatch(patchCandidate)) {
|
||||
const parsedLines = parsePatchText(patchCandidate);
|
||||
if (parsedLines.length > 0) {
|
||||
return {
|
||||
filePath: filePath ?? getFilePathFromRecord(blockValue),
|
||||
diffLines: parsedLines,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function mergeEditEntries(entries: EditEntry[]): EditEntry[] {
|
||||
if (entries.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const seen = new Map<string, EditEntry>();
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.diffLines.length) {
|
||||
return;
|
||||
}
|
||||
const hash = `${entry.filePath ?? "unknown"}::${entry.diffLines
|
||||
.map((line) => `${line.type}:${line.content}`)
|
||||
.join("|")}`;
|
||||
if (!seen.has(hash)) {
|
||||
seen.set(hash, entry);
|
||||
}
|
||||
});
|
||||
return Array.from(seen.values());
|
||||
}
|
||||
|
||||
function parseEditArguments(value: unknown, depth = 0): EditEntry[] {
|
||||
if (!value || depth > 5) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
if (looksLikePatch(value)) {
|
||||
const diffLines = parsePatchText(value);
|
||||
return diffLines.length ? [{ diffLines }] : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return mergeEditEntries(
|
||||
value.flatMap((entry) => parseEditArguments(entry, depth + 1))
|
||||
);
|
||||
}
|
||||
|
||||
if (!isRecord(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries: EditEntry[] = [];
|
||||
const filePathHint = getFilePathFromRecord(value);
|
||||
|
||||
const changesParse = z.record(z.unknown()).safeParse(value["changes"]);
|
||||
if (changesParse.success) {
|
||||
for (const [filePath, changeValue] of Object.entries(changesParse.data)) {
|
||||
const nested = parseEditArguments(changeValue, depth + 1);
|
||||
entries.push(
|
||||
...nested.map((entry) => ({
|
||||
...entry,
|
||||
filePath: entry.filePath ?? filePath ?? filePathHint,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(value["files"])) {
|
||||
for (const fileEntry of value["files"] as unknown[]) {
|
||||
const nested = parseEditArguments(fileEntry, depth + 1);
|
||||
const derivedFilePath = isRecord(fileEntry)
|
||||
? getFilePathFromRecord(fileEntry)
|
||||
: undefined;
|
||||
const resolvedFilePath = derivedFilePath ?? filePathHint;
|
||||
entries.push(
|
||||
...nested.map((entry) => ({
|
||||
...entry,
|
||||
filePath: entry.filePath ?? resolvedFilePath,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const direct = buildEditEntryFromBlock(filePathHint, value);
|
||||
if (direct) {
|
||||
entries.push(direct);
|
||||
} else {
|
||||
const patchCandidates = [
|
||||
getString(value["patch"]),
|
||||
getString(value["diff"]),
|
||||
getString(value["unified_diff"]),
|
||||
getString(value["unifiedDiff"]),
|
||||
].filter(Boolean) as string[];
|
||||
for (const patch of patchCandidates) {
|
||||
if (patch && looksLikePatch(patch)) {
|
||||
const diffLines = parsePatchText(patch);
|
||||
if (diffLines.length) {
|
||||
entries.push({ filePath: filePathHint, diffLines });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nestedKeys = [
|
||||
"input",
|
||||
"update",
|
||||
"create",
|
||||
"delete",
|
||||
"raw",
|
||||
"data",
|
||||
"payload",
|
||||
"arguments",
|
||||
"result",
|
||||
] as const;
|
||||
for (const key of nestedKeys) {
|
||||
if (value[key] !== undefined) {
|
||||
const nestedEntries = parseEditArguments(value[key], depth + 1);
|
||||
entries.push(
|
||||
...nestedEntries.map((entry) => ({
|
||||
...entry,
|
||||
filePath: entry.filePath ?? filePathHint,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return mergeEditEntries(entries);
|
||||
}
|
||||
|
||||
const ReadContainerSchema = z
|
||||
.object({
|
||||
filePath: z.string().optional(),
|
||||
file_path: z.string().optional(),
|
||||
path: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
blob: z.string().optional(),
|
||||
data: z
|
||||
.object({
|
||||
content: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
structuredContent: z
|
||||
.object({
|
||||
content: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
data: z
|
||||
.object({
|
||||
content: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
structured_content: z
|
||||
.object({
|
||||
content: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
output: z
|
||||
.object({
|
||||
content: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
function parseReadEntries(value: unknown, depth = 0): ReadEntry[] {
|
||||
if (!value || depth > 4) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length ? [{ content: value }] : [];
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((entry) => parseReadEntries(entry, depth + 1));
|
||||
}
|
||||
|
||||
if (!isRecord(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsed = ReadContainerSchema.safeParse(value);
|
||||
if (parsed.success) {
|
||||
const data = parsed.data;
|
||||
const content =
|
||||
getString(data.content) ??
|
||||
getString(data.text) ??
|
||||
getString(data.blob) ??
|
||||
getString(data.data?.content) ??
|
||||
getString(data.data?.text) ??
|
||||
getString(data.structuredContent?.content) ??
|
||||
getString(data.structuredContent?.text) ??
|
||||
getString(data.structuredContent?.data?.content) ??
|
||||
getString(data.structuredContent?.data?.text) ??
|
||||
getString(data.structured_content?.content) ??
|
||||
getString(data.structured_content?.text) ??
|
||||
getString(data.output?.content) ??
|
||||
getString(data.output?.text);
|
||||
if (content) {
|
||||
return [
|
||||
{
|
||||
filePath: data.filePath ?? data.file_path ?? data.path,
|
||||
content,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
const nestedKeys = [
|
||||
"output",
|
||||
"result",
|
||||
"structuredContent",
|
||||
"structured_content",
|
||||
"data",
|
||||
"raw",
|
||||
"value",
|
||||
"content",
|
||||
] as const;
|
||||
const entries: ReadEntry[] = [];
|
||||
for (const key of nestedKeys) {
|
||||
if (value[key] !== undefined) {
|
||||
entries.push(...parseReadEntries(value[key], depth + 1));
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function mergeReadEntries(entries: ReadEntry[]): ReadEntry[] {
|
||||
if (!entries.length) {
|
||||
return [];
|
||||
}
|
||||
const seen = new Map<string, ReadEntry>();
|
||||
entries.forEach((entry) => {
|
||||
const hash = `${entry.filePath ?? "content"}::${entry.content}`;
|
||||
if (!seen.has(hash)) {
|
||||
seen.set(hash, entry);
|
||||
}
|
||||
});
|
||||
return Array.from(seen.values());
|
||||
}
|
||||
|
||||
const CommandRawSchema = z
|
||||
.object({
|
||||
type: z.string().optional(),
|
||||
command: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
aggregated_output: z.string().optional(),
|
||||
exit_code: z.number().optional(),
|
||||
cwd: z.string().optional(),
|
||||
directory: z.string().optional(),
|
||||
metadata: z
|
||||
.object({
|
||||
exit_code: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
input: z.unknown().optional(),
|
||||
output: z.unknown().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const CommandResultSchema = z
|
||||
.object({
|
||||
output: z.string().optional(),
|
||||
exitCode: z.number().nullable().optional(),
|
||||
structuredContent: z
|
||||
.object({
|
||||
output: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
structured_content: z
|
||||
.object({
|
||||
output: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
metadata: z
|
||||
.object({
|
||||
exit_code: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
result: z.unknown().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
function coerceCommandValue(value: unknown): string | undefined {
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const tokens = value.filter((entry): entry is string => typeof entry === "string");
|
||||
if (tokens.length) {
|
||||
return tokens.join(" ");
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function collectCommandDetails(
|
||||
target: CommandDetails,
|
||||
value: unknown,
|
||||
depth = 0
|
||||
): void {
|
||||
if (!value || depth > 4) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
if (!target.output) {
|
||||
target.output = value;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isRecord(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawParsed = CommandRawSchema.safeParse(value);
|
||||
if (rawParsed.success) {
|
||||
const data = rawParsed.data;
|
||||
const commandCandidate =
|
||||
coerceCommandValue(data.command) ??
|
||||
(isRecord(data.input) ? coerceCommandValue(data.input["command"]) : undefined);
|
||||
if (!target.command && commandCandidate) {
|
||||
target.command = commandCandidate;
|
||||
}
|
||||
const cwdCandidate =
|
||||
getString(data.cwd ?? data.directory) ??
|
||||
(isRecord(data.input)
|
||||
? getString(data.input["cwd"] ?? data.input["directory"])
|
||||
: undefined);
|
||||
if (!target.cwd && cwdCandidate) {
|
||||
target.cwd = cwdCandidate;
|
||||
}
|
||||
const aggregatedOutput =
|
||||
getString(data.aggregated_output) ??
|
||||
(isRecord(data.output)
|
||||
? getString(
|
||||
(data.output as Record<string, unknown>)["aggregated_output"] ??
|
||||
(data.output as Record<string, unknown>)["output"] ??
|
||||
(data.output as Record<string, unknown>)["text"]
|
||||
)
|
||||
: undefined);
|
||||
if (!target.output && aggregatedOutput) {
|
||||
target.output = aggregatedOutput;
|
||||
}
|
||||
const exitCandidate =
|
||||
data.exit_code ??
|
||||
(data.metadata ? data.metadata.exit_code : undefined) ??
|
||||
(isRecord(data.output)
|
||||
? ((data.output as Record<string, unknown>)["exit_code"] as number | undefined) ??
|
||||
((data.output as Record<string, unknown>)["exitCode"] as number | undefined)
|
||||
: undefined);
|
||||
if ((typeof exitCandidate === "number" || exitCandidate === null) && target.exitCode === undefined) {
|
||||
target.exitCode = exitCandidate ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
const resultParsed = CommandResultSchema.safeParse(value);
|
||||
if (resultParsed.success) {
|
||||
const data = resultParsed.data;
|
||||
const outputCandidate =
|
||||
getString(data.output) ??
|
||||
getString(data.structuredContent?.output) ??
|
||||
getString(data.structuredContent?.text) ??
|
||||
getString(data.structured_content?.output) ??
|
||||
getString(data.structured_content?.text) ??
|
||||
undefined;
|
||||
if (!target.output && outputCandidate) {
|
||||
target.output = outputCandidate;
|
||||
}
|
||||
const exitCandidate = data.exitCode ?? data.metadata?.exit_code;
|
||||
if ((typeof exitCandidate === "number" || exitCandidate === null) && target.exitCode === undefined) {
|
||||
target.exitCode = exitCandidate ?? null;
|
||||
}
|
||||
if (data.result !== undefined) {
|
||||
collectCommandDetails(target, data.result, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const nestedKeys = [
|
||||
"input",
|
||||
"output",
|
||||
"result",
|
||||
"raw",
|
||||
"data",
|
||||
"payload",
|
||||
"structuredContent",
|
||||
"structured_content",
|
||||
] as const;
|
||||
for (const key of nestedKeys) {
|
||||
if (value[key] !== undefined) {
|
||||
collectCommandDetails(target, value[key], depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseCommandDetails(args: unknown, result: unknown): CommandDetails | null {
|
||||
const details: CommandDetails = {};
|
||||
collectCommandDetails(details, args);
|
||||
collectCommandDetails(details, result);
|
||||
if (details.command || details.output) {
|
||||
return details;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
import {
|
||||
extractCommandDetails,
|
||||
extractEditEntries,
|
||||
extractReadEntries,
|
||||
type CommandDetails,
|
||||
type EditEntry,
|
||||
type ReadEntry,
|
||||
} from "@/utils/tool-call-parsers";
|
||||
import { DiffViewer } from "./diff-viewer";
|
||||
|
||||
interface ToolCallBottomSheetProps {
|
||||
bottomSheetRef: React.RefObject<BottomSheetModal | null>;
|
||||
@@ -727,13 +47,16 @@ export function ToolCallBottomSheet({
|
||||
);
|
||||
|
||||
// Extract data based on source
|
||||
const { toolName, args, result, error } = useMemo(() => {
|
||||
const { toolName, args, result, error, parsedEdits, parsedReads, parsedCommand } = useMemo(() => {
|
||||
if (!selectedToolCall) {
|
||||
return {
|
||||
toolName: "Tool Call",
|
||||
args: undefined,
|
||||
result: undefined,
|
||||
error: undefined,
|
||||
parsedEdits: undefined,
|
||||
parsedReads: undefined,
|
||||
parsedCommand: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -746,6 +69,9 @@ export function ToolCallBottomSheet({
|
||||
args: data.raw,
|
||||
result: data.result,
|
||||
error: data.error,
|
||||
parsedEdits: data.parsedEdits,
|
||||
parsedReads: data.parsedReads,
|
||||
parsedCommand: data.parsedCommand,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -755,29 +81,30 @@ export function ToolCallBottomSheet({
|
||||
args: data.arguments,
|
||||
result: data.result,
|
||||
error: data.error,
|
||||
parsedEdits: undefined,
|
||||
parsedReads: undefined,
|
||||
parsedCommand: undefined,
|
||||
};
|
||||
}, [selectedToolCall]);
|
||||
|
||||
const editEntries = useMemo(() => {
|
||||
const entries = [
|
||||
...parseEditArguments(args),
|
||||
...parseEditArguments(result),
|
||||
];
|
||||
return mergeEditEntries(entries);
|
||||
}, [args, result]);
|
||||
|
||||
const readEntries = useMemo(() => {
|
||||
const entries = [
|
||||
...parseReadEntries(result),
|
||||
...parseReadEntries(args),
|
||||
];
|
||||
return mergeReadEntries(entries);
|
||||
}, [args, result]);
|
||||
|
||||
const commandDetails = useMemo(() => parseCommandDetails(args, result), [
|
||||
const fallbackEditEntries = useMemo(() => extractEditEntries(args, result), [
|
||||
args,
|
||||
result,
|
||||
]);
|
||||
const editEntries: EditEntry[] = parsedEdits ?? fallbackEditEntries;
|
||||
|
||||
const fallbackReadEntries = useMemo(() => extractReadEntries(result, args), [
|
||||
args,
|
||||
result,
|
||||
]);
|
||||
const readEntries: ReadEntry[] = parsedReads ?? fallbackReadEntries;
|
||||
|
||||
const fallbackCommandDetails = useMemo(
|
||||
() => extractCommandDetails(args, result),
|
||||
[args, result]
|
||||
);
|
||||
const commandDetails: CommandDetails | null =
|
||||
parsedCommand ?? fallbackCommandDetails;
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
@@ -812,52 +139,7 @@ export function ToolCallBottomSheet({
|
||||
|
||||
<Text style={styles.sectionTitle}>Diff</Text>
|
||||
<View style={styles.diffContainer}>
|
||||
{entry.diffLines.length === 0 ? (
|
||||
<View style={styles.diffEmptyState}>
|
||||
<Text style={styles.diffEmptyText}>No changes to display</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView
|
||||
style={styles.diffScrollVertical}
|
||||
contentContainerStyle={styles.diffVerticalContent}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
contentContainerStyle={styles.diffScrollContent}
|
||||
>
|
||||
<View style={styles.diffLinesContainer}>
|
||||
{entry.diffLines.map((line, lineIndex) => (
|
||||
<View
|
||||
key={`${line.type}-${lineIndex}`}
|
||||
style={[
|
||||
styles.diffLine,
|
||||
line.type === "header" && styles.diffHeaderLine,
|
||||
line.type === "add" && styles.diffAddLine,
|
||||
line.type === "remove" && styles.diffRemoveLine,
|
||||
line.type === "context" && styles.diffContextLine,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.diffLineText,
|
||||
line.type === "header" && styles.diffHeaderText,
|
||||
line.type === "add" && styles.diffAddText,
|
||||
line.type === "remove" && styles.diffRemoveText,
|
||||
line.type === "context" && styles.diffContextText,
|
||||
]}
|
||||
>
|
||||
{line.content}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
)}
|
||||
<DiffViewer diffLines={entry.diffLines} />
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
@@ -1039,61 +321,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.card,
|
||||
overflow: "hidden",
|
||||
},
|
||||
diffScrollVertical: {
|
||||
maxHeight: 280,
|
||||
},
|
||||
diffVerticalContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
diffScrollContent: {
|
||||
flexDirection: "column" as const,
|
||||
},
|
||||
diffLinesContainer: {
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
diffLine: {
|
||||
minWidth: "100%",
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
diffLineText: {
|
||||
fontFamily: "monospace",
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
diffHeaderLine: {
|
||||
backgroundColor: theme.colors.muted,
|
||||
},
|
||||
diffHeaderText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
},
|
||||
diffAddLine: {
|
||||
backgroundColor: theme.colors.palette.green[900],
|
||||
},
|
||||
diffAddText: {
|
||||
color: theme.colors.palette.green[200],
|
||||
},
|
||||
diffRemoveLine: {
|
||||
backgroundColor: theme.colors.palette.red[900],
|
||||
},
|
||||
diffRemoveText: {
|
||||
color: theme.colors.palette.red[200],
|
||||
},
|
||||
diffContextLine: {
|
||||
backgroundColor: theme.colors.card,
|
||||
},
|
||||
diffContextText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
},
|
||||
diffEmptyState: {
|
||||
padding: theme.spacing[4],
|
||||
alignItems: "center" as const,
|
||||
justifyContent: "center" as const,
|
||||
},
|
||||
diffEmptyText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.mutedForeground,
|
||||
},
|
||||
jsonContainer: {
|
||||
backgroundColor: theme.colors.background,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
|
||||
@@ -1064,6 +1064,60 @@ function testToolCallDeduplicationHydrated() {
|
||||
});
|
||||
}
|
||||
|
||||
function buildOutOfOrderToolCallSequence(provider: ToolCallProvider) {
|
||||
const timestamps = [
|
||||
new Date('2025-01-01T13:00:00Z'),
|
||||
new Date('2025-01-01T13:00:05Z'),
|
||||
];
|
||||
const callId = `${provider}-out-of-order`;
|
||||
return [
|
||||
{
|
||||
event: toolTimeline(
|
||||
'shell',
|
||||
'completed',
|
||||
{ type: 'tool_result', provider, tool_call_id: callId },
|
||||
{ provider, server: 'command', tool: 'shell', callId }
|
||||
),
|
||||
timestamp: timestamps[0],
|
||||
},
|
||||
{
|
||||
event: toolTimeline(
|
||||
'shell',
|
||||
'executing',
|
||||
{ type: 'tool_use', provider },
|
||||
{ provider, server: 'command', tool: 'shell', callId: null }
|
||||
),
|
||||
timestamp: timestamps[1],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function testOutOfOrderToolCallMergingLive() {
|
||||
(['claude', 'codex'] as const).forEach((provider) => {
|
||||
const updates = buildOutOfOrderToolCallSequence(provider);
|
||||
const finalState = updates.reduce<StreamItem[]>((state, { event, timestamp }) => {
|
||||
return reduceStreamUpdate(state, event, timestamp);
|
||||
}, []);
|
||||
const toolCalls = finalState.filter(
|
||||
(item): item is ToolCallItem => item.kind === 'tool_call' && item.payload.source === 'agent'
|
||||
);
|
||||
assert.strictEqual(toolCalls.length, 1, `${provider} live stream should not duplicate out-of-order calls`);
|
||||
assert.strictEqual(toolCalls[0]?.payload.data.status, 'completed', `${provider} live stream should keep completed status`);
|
||||
});
|
||||
}
|
||||
|
||||
function testOutOfOrderToolCallMergingHydrated() {
|
||||
(['claude', 'codex'] as const).forEach((provider) => {
|
||||
const updates = buildOutOfOrderToolCallSequence(provider);
|
||||
const hydrated = hydrateStreamState(updates);
|
||||
const toolCalls = hydrated.filter(
|
||||
(item): item is ToolCallItem => item.kind === 'tool_call' && item.payload.source === 'agent'
|
||||
);
|
||||
assert.strictEqual(toolCalls.length, 1, `${provider} hydration should not duplicate out-of-order calls`);
|
||||
assert.strictEqual(toolCalls[0]?.payload.data.status, 'completed', `${provider} hydration should keep completed status`);
|
||||
});
|
||||
}
|
||||
|
||||
describe('stream timeline reducers', () => {
|
||||
it('produces deterministic hydration results', testIdempotentReduction);
|
||||
it('deduplicates pending/completed tool entries in place', testUserMessageDeduplication);
|
||||
@@ -1083,4 +1137,6 @@ describe('stream timeline reducers', () => {
|
||||
it('keeps timeline ids stable after list shrinkage', testTimelineIdStabilityAfterRemovals);
|
||||
it('deduplicates live tool call entries', testToolCallDeduplicationLive);
|
||||
it('deduplicates hydrated tool call entries', testToolCallDeduplicationHydrated);
|
||||
it('merges out-of-order tool call updates without duplicating entries (live)', testOutOfOrderToolCallMergingLive);
|
||||
it('merges out-of-order tool call updates without duplicating entries (hydrated)', testOutOfOrderToolCallMergingHydrated);
|
||||
});
|
||||
|
||||
@@ -35,13 +35,30 @@ function createTimelineId(prefix: string, text: string, timestamp: Date): string
|
||||
|
||||
function createUniqueTimelineId(
|
||||
state: StreamItem[],
|
||||
prefix: "assistant" | "thought",
|
||||
prefix: "assistant" | "thought" | "user",
|
||||
text: string,
|
||||
timestamp: Date
|
||||
): string {
|
||||
const base = createTimelineId(prefix, text, timestamp);
|
||||
const suffixSeed = state.length.toString(36);
|
||||
return `${base}_${suffixSeed}`;
|
||||
let suffixSeed = state.length;
|
||||
let candidate = `${base}_${suffixSeed.toString(36)}`;
|
||||
|
||||
// Fast path when the generated id hasn't been used yet
|
||||
const hasCollision = state.some((entry) => entry.id === candidate);
|
||||
if (!hasCollision) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
// If the id already exists (e.g. prior items were pruned/replaced),
|
||||
// spin until we find an unused suffix. Building the lookup Set lazily keeps
|
||||
// the common case cheap while still guaranteeing uniqueness.
|
||||
const usedIds = new Set(state.map((entry) => entry.id));
|
||||
while (usedIds.has(candidate)) {
|
||||
suffixSeed += 1;
|
||||
candidate = `${base}_${suffixSeed.toString(36)}`;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export type StreamItem =
|
||||
@@ -157,7 +174,9 @@ function appendUserMessage(
|
||||
return state;
|
||||
}
|
||||
|
||||
const entryId = messageId ?? createTimelineId("user", chunk.trim() || chunk, timestamp);
|
||||
const chunkSeed = chunk.trim() || chunk;
|
||||
const entryId =
|
||||
messageId ?? createUniqueTimelineId(state, "user", chunkSeed, timestamp);
|
||||
const existingIndex = state.findIndex(
|
||||
(entry) => entry.kind === "user_message" && entry.id === entryId
|
||||
);
|
||||
@@ -297,23 +316,27 @@ function findExistingAgentToolCallIndex(
|
||||
}
|
||||
|
||||
const fallbackCandidates: Array<{ index: number; item: AgentToolCallItem }> = [];
|
||||
const metadataMatches: Array<{ index: number; item: AgentToolCallItem }> = [];
|
||||
for (let i = 0; i < state.length; i += 1) {
|
||||
const entry = state[i];
|
||||
if (entry.kind !== "tool_call" || entry.payload.source !== "agent") {
|
||||
continue;
|
||||
}
|
||||
const payload = entry.payload.data;
|
||||
const providerMatches =
|
||||
payload.provider === data.provider &&
|
||||
payload.server === data.server &&
|
||||
payload.tool === data.tool;
|
||||
if (providerMatches) {
|
||||
metadataMatches.push({ index: i, item: entry as AgentToolCallItem });
|
||||
}
|
||||
if (payload.callId) {
|
||||
continue;
|
||||
}
|
||||
if (payload.status !== "executing") {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
payload.provider === data.provider &&
|
||||
payload.server === data.server &&
|
||||
payload.tool === data.tool
|
||||
) {
|
||||
if (providerMatches) {
|
||||
fallbackCandidates.push({ index: i, item: entry as AgentToolCallItem });
|
||||
}
|
||||
}
|
||||
@@ -346,14 +369,34 @@ function findExistingAgentToolCallIndex(
|
||||
normalizedDisplayName
|
||||
);
|
||||
|
||||
const byKind = filterByComparableField(
|
||||
byDisplayName,
|
||||
(entry) => normalizeComparableString(entry.payload.data.kind),
|
||||
normalizedKind
|
||||
);
|
||||
const selectCandidate = (
|
||||
candidates: Array<{ index: number; item: AgentToolCallItem }>
|
||||
): number => {
|
||||
const byDisplayName = filterByComparableField(
|
||||
candidates,
|
||||
(entry) => normalizeComparableString(entry.payload.data.displayName),
|
||||
normalizedDisplayName
|
||||
);
|
||||
|
||||
// Fall back to the oldest candidate (FIFO) to avoid duplicating the initial "executing" pill.
|
||||
return byKind[0]?.index ?? -1;
|
||||
const byKind = filterByComparableField(
|
||||
byDisplayName,
|
||||
(entry) => normalizeComparableString(entry.payload.data.kind),
|
||||
normalizedKind
|
||||
);
|
||||
|
||||
return byKind[0]?.index ?? -1;
|
||||
};
|
||||
|
||||
if (fallbackCandidates.length) {
|
||||
return selectCandidate(fallbackCandidates);
|
||||
}
|
||||
|
||||
// If this update still lacks a call id, fall back to metadata matches (e.g. replayed hydration events)
|
||||
if (!normalizedCallId && metadataMatches.length) {
|
||||
return selectCandidate(metadataMatches);
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
function appendAgentToolCall(
|
||||
@@ -389,6 +432,10 @@ function appendAgentToolCall(
|
||||
payloadData.error !== undefined
|
||||
? payloadData.error
|
||||
: existing.payload.data.error;
|
||||
const mergedStatus = mergeToolCallStatus(
|
||||
existing.payload.data.status,
|
||||
payloadData.status ?? existing.payload.data.status ?? "executing"
|
||||
);
|
||||
const parsed = computeParsedToolPayload(mergedRaw, mergedResult);
|
||||
next[existingIndex] = {
|
||||
...existing,
|
||||
@@ -398,6 +445,7 @@ function appendAgentToolCall(
|
||||
data: {
|
||||
...existing.payload.data,
|
||||
...payloadData,
|
||||
status: mergedStatus,
|
||||
raw: mergedRaw,
|
||||
result: mergedResult,
|
||||
error: mergedError,
|
||||
@@ -579,6 +627,19 @@ function normalizeToolCallStatus(
|
||||
return normalizedFromStatus ?? "executing";
|
||||
}
|
||||
|
||||
function mergeToolCallStatus(
|
||||
existing: "executing" | "completed" | "failed" | undefined,
|
||||
incoming: "executing" | "completed" | "failed"
|
||||
): "executing" | "completed" | "failed" {
|
||||
if (existing === "failed" || incoming === "failed") {
|
||||
return "failed";
|
||||
}
|
||||
if (existing === "completed" || incoming === "completed") {
|
||||
return "completed";
|
||||
}
|
||||
return incoming ?? existing ?? "executing";
|
||||
}
|
||||
|
||||
const TOOL_CALL_ID_KEYS = [
|
||||
"toolCallId",
|
||||
"tool_call_id",
|
||||
|
||||
@@ -0,0 +1,706 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export type DiffLine = {
|
||||
type: "add" | "remove" | "context" | "header";
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type EditEntry = {
|
||||
filePath?: string;
|
||||
diffLines: DiffLine[];
|
||||
};
|
||||
|
||||
export type ReadEntry = {
|
||||
filePath?: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type CommandDetails = {
|
||||
command?: string;
|
||||
cwd?: string;
|
||||
output?: string;
|
||||
exitCode?: number | null;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function getString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function splitIntoLines(text: string): string[] {
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return text.replace(/\r\n/g, "\n").split("\n");
|
||||
}
|
||||
|
||||
function buildLineDiff(originalText: string, updatedText: string): DiffLine[] {
|
||||
const originalLines = splitIntoLines(originalText);
|
||||
const updatedLines = splitIntoLines(updatedText);
|
||||
|
||||
const hasAnyContent = originalLines.length > 0 || updatedLines.length > 0;
|
||||
if (!hasAnyContent) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const m = originalLines.length;
|
||||
const n = updatedLines.length;
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, () =>
|
||||
Array(n + 1).fill(0)
|
||||
);
|
||||
|
||||
for (let i = m - 1; i >= 0; i -= 1) {
|
||||
for (let j = n - 1; j >= 0; j -= 1) {
|
||||
if (originalLines[i] === updatedLines[j]) {
|
||||
dp[i][j] = dp[i + 1][j + 1] + 1;
|
||||
} else {
|
||||
dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const diff: DiffLine[] = [];
|
||||
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < m && j < n) {
|
||||
if (originalLines[i] === updatedLines[j]) {
|
||||
diff.push({ type: "context", content: ` ${originalLines[i]}` });
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||
diff.push({ type: "remove", content: `-${originalLines[i]}` });
|
||||
i += 1;
|
||||
} else {
|
||||
diff.push({ type: "add", content: `+${updatedLines[j]}` });
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
|
||||
while (i < m) {
|
||||
diff.push({ type: "remove", content: `-${originalLines[i]}` });
|
||||
i += 1;
|
||||
}
|
||||
|
||||
while (j < n) {
|
||||
diff.push({ type: "add", content: `+${updatedLines[j]}` });
|
||||
j += 1;
|
||||
}
|
||||
|
||||
return diff;
|
||||
}
|
||||
|
||||
function parseUnifiedDiff(diffText?: string): DiffLine[] {
|
||||
if (!diffText) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lines = splitIntoLines(diffText);
|
||||
const diff: DiffLine[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.length) {
|
||||
diff.push({ type: "context", content: line });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("@@")) {
|
||||
diff.push({ type: "header", content: line });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("+")) {
|
||||
if (!line.startsWith("+++")) {
|
||||
diff.push({ type: "add", content: line });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("-")) {
|
||||
if (!line.startsWith("---")) {
|
||||
diff.push({ type: "remove", content: line });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
line.startsWith("diff --git") ||
|
||||
line.startsWith("index ") ||
|
||||
line.startsWith("---") ||
|
||||
line.startsWith("+++")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("\\ No newline")) {
|
||||
diff.push({ type: "header", content: line });
|
||||
continue;
|
||||
}
|
||||
|
||||
diff.push({ type: "context", content: line });
|
||||
}
|
||||
|
||||
return diff;
|
||||
}
|
||||
|
||||
function deriveDiffLines({
|
||||
unifiedDiff,
|
||||
original,
|
||||
updated,
|
||||
}: {
|
||||
unifiedDiff?: string;
|
||||
original?: string;
|
||||
updated?: string;
|
||||
}): DiffLine[] {
|
||||
if (unifiedDiff) {
|
||||
const parsed = parseUnifiedDiff(unifiedDiff);
|
||||
if (parsed.length > 0) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (original !== undefined || updated !== undefined) {
|
||||
return buildLineDiff(original ?? "", updated ?? "");
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function looksLikePatch(text: string): boolean {
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
return /(\*\*\* Begin Patch|@@|diff --git|\+\+\+|--- )/.test(text);
|
||||
}
|
||||
|
||||
function parsePatchText(text: string): DiffLine[] {
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
return parseUnifiedDiff(text);
|
||||
}
|
||||
|
||||
function getFilePathFromRecord(record: Record<string, unknown>): string | undefined {
|
||||
return (
|
||||
getString(record["file_path"]) ??
|
||||
getString(record["filePath"]) ??
|
||||
getString(record["path"]) ??
|
||||
getString(record["target_path"]) ??
|
||||
getString(record["targetPath"]) ??
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
const ChangeBlockSchema = z
|
||||
.object({
|
||||
unified_diff: z.string().optional(),
|
||||
unifiedDiff: z.string().optional(),
|
||||
diff: z.string().optional(),
|
||||
patch: z.string().optional(),
|
||||
old_content: z.string().optional(),
|
||||
oldContent: z.string().optional(),
|
||||
previous_content: z.string().optional(),
|
||||
previousContent: z.string().optional(),
|
||||
base_content: z.string().optional(),
|
||||
baseContent: z.string().optional(),
|
||||
old_string: z.string().optional(),
|
||||
new_string: z.string().optional(),
|
||||
new_content: z.string().optional(),
|
||||
newContent: z.string().optional(),
|
||||
replace_with: z.string().optional(),
|
||||
replaceWith: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
function buildEditEntryFromBlock(
|
||||
filePath: string | undefined,
|
||||
blockValue: Record<string, unknown>
|
||||
): EditEntry | null {
|
||||
const parsed = ChangeBlockSchema.safeParse(blockValue);
|
||||
if (!parsed.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
const diffLines = deriveDiffLines({
|
||||
unifiedDiff:
|
||||
getString(
|
||||
data.unified_diff ??
|
||||
data.unifiedDiff ??
|
||||
data.patch ??
|
||||
data.diff
|
||||
) ?? undefined,
|
||||
original:
|
||||
getString(
|
||||
data.old_string ??
|
||||
data.old_content ??
|
||||
data.oldContent ??
|
||||
data.previous_content ??
|
||||
data.previousContent ??
|
||||
data.base_content ??
|
||||
data.baseContent
|
||||
) ?? undefined,
|
||||
updated:
|
||||
getString(
|
||||
data.new_string ??
|
||||
data.new_content ??
|
||||
data.newContent ??
|
||||
data.replace_with ??
|
||||
data.replaceWith ??
|
||||
data.content
|
||||
) ?? undefined,
|
||||
});
|
||||
|
||||
if (diffLines.length > 0) {
|
||||
return {
|
||||
filePath: filePath ?? getFilePathFromRecord(blockValue),
|
||||
diffLines,
|
||||
};
|
||||
}
|
||||
|
||||
const patchCandidate =
|
||||
getString(data.unified_diff ?? data.unifiedDiff ?? data.patch ?? data.diff) ??
|
||||
undefined;
|
||||
if (patchCandidate && looksLikePatch(patchCandidate)) {
|
||||
const parsedLines = parsePatchText(patchCandidate);
|
||||
if (parsedLines.length > 0) {
|
||||
return {
|
||||
filePath: filePath ?? getFilePathFromRecord(blockValue),
|
||||
diffLines: parsedLines,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function mergeEditEntries(entries: EditEntry[]): EditEntry[] {
|
||||
if (entries.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const seen = new Map<string, EditEntry>();
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.diffLines.length) {
|
||||
return;
|
||||
}
|
||||
const hash = `${entry.filePath ?? "unknown"}::${entry.diffLines
|
||||
.map((line) => `${line.type}:${line.content}`)
|
||||
.join("|")}`;
|
||||
if (!seen.has(hash)) {
|
||||
seen.set(hash, entry);
|
||||
}
|
||||
});
|
||||
return Array.from(seen.values());
|
||||
}
|
||||
|
||||
function parseEditArguments(value: unknown, depth = 0): EditEntry[] {
|
||||
if (!value || depth > 5) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
if (looksLikePatch(value)) {
|
||||
const diffLines = parsePatchText(value);
|
||||
return diffLines.length ? [{ diffLines }] : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((entry) => parseEditArguments(entry, depth + 1));
|
||||
}
|
||||
|
||||
if (!isRecord(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filePathHint = getFilePathFromRecord(value) ?? getString(value["name"]);
|
||||
|
||||
if (value["patch"] || value["diff"] || value["unified_diff"] || value["unifiedDiff"]) {
|
||||
const entry = buildEditEntryFromBlock(filePathHint, value);
|
||||
return entry ? [entry] : [];
|
||||
}
|
||||
|
||||
const entries: EditEntry[] = [];
|
||||
const changeKeys = [
|
||||
"changes",
|
||||
"files",
|
||||
"fileChanges",
|
||||
"file_changes",
|
||||
"edits",
|
||||
"diffs",
|
||||
"patches",
|
||||
"fileDiffs",
|
||||
"file_diffs",
|
||||
] as const;
|
||||
|
||||
for (const key of changeKeys) {
|
||||
const block = value[key];
|
||||
if (!block) {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(block)) {
|
||||
for (const item of block) {
|
||||
if (isRecord(item)) {
|
||||
const entry = buildEditEntryFromBlock(filePathHint, item);
|
||||
if (entry) {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isRecord(block)) {
|
||||
if (block["patch"] || block["diff"]) {
|
||||
const entry = buildEditEntryFromBlock(filePathHint, block);
|
||||
if (entry) {
|
||||
entries.push(entry);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (const [path, nested] of Object.entries(block)) {
|
||||
if (isRecord(nested)) {
|
||||
const entry = buildEditEntryFromBlock(path, nested);
|
||||
if (entry) {
|
||||
entries.push(entry);
|
||||
}
|
||||
} else if (typeof nested === "string" && looksLikePatch(nested)) {
|
||||
const diffLines = parsePatchText(nested);
|
||||
if (diffLines.length) {
|
||||
entries.push({ filePath: path, diffLines });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const changeEntry = buildEditEntryFromBlock(filePathHint, value);
|
||||
if (changeEntry) {
|
||||
entries.push(changeEntry);
|
||||
}
|
||||
|
||||
const nestedKeys = [
|
||||
"create",
|
||||
"delete",
|
||||
"raw",
|
||||
"data",
|
||||
"payload",
|
||||
"arguments",
|
||||
"result",
|
||||
] as const;
|
||||
for (const key of nestedKeys) {
|
||||
if (value[key] !== undefined) {
|
||||
const nestedEntries = parseEditArguments(value[key], depth + 1);
|
||||
entries.push(
|
||||
...nestedEntries.map((entry) => ({
|
||||
...entry,
|
||||
filePath: entry.filePath ?? filePathHint,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
const ReadContainerSchema = z
|
||||
.object({
|
||||
filePath: z.string().optional(),
|
||||
file_path: z.string().optional(),
|
||||
path: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
blob: z.string().optional(),
|
||||
data: z
|
||||
.object({
|
||||
content: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
structuredContent: z
|
||||
.object({
|
||||
content: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
data: z
|
||||
.object({
|
||||
content: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
structured_content: z
|
||||
.object({
|
||||
content: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
output: z
|
||||
.object({
|
||||
content: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
function parseReadEntriesInternal(value: unknown, depth = 0): ReadEntry[] {
|
||||
if (!value || depth > 4) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length ? [{ content: value }] : [];
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((entry) => parseReadEntriesInternal(entry, depth + 1));
|
||||
}
|
||||
|
||||
if (!isRecord(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsed = ReadContainerSchema.safeParse(value);
|
||||
if (parsed.success) {
|
||||
const data = parsed.data;
|
||||
const content =
|
||||
getString(data.content) ??
|
||||
getString(data.text) ??
|
||||
getString(data.blob) ??
|
||||
getString(data.data?.content) ??
|
||||
getString(data.data?.text) ??
|
||||
getString(data.structuredContent?.content) ??
|
||||
getString(data.structuredContent?.text) ??
|
||||
getString(data.structuredContent?.data?.content) ??
|
||||
getString(data.structuredContent?.data?.text) ??
|
||||
getString(data.structured_content?.content) ??
|
||||
getString(data.structured_content?.text) ??
|
||||
getString(data.output?.content) ??
|
||||
getString(data.output?.text);
|
||||
if (content) {
|
||||
return [
|
||||
{
|
||||
filePath: data.filePath ?? data.file_path ?? data.path,
|
||||
content,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
const nestedKeys = [
|
||||
"output",
|
||||
"result",
|
||||
"structuredContent",
|
||||
"structured_content",
|
||||
"data",
|
||||
"raw",
|
||||
"value",
|
||||
"content",
|
||||
] as const;
|
||||
const entries: ReadEntry[] = [];
|
||||
for (const key of nestedKeys) {
|
||||
if (value[key] !== undefined) {
|
||||
entries.push(...parseReadEntriesInternal(value[key], depth + 1));
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function mergeReadEntries(entries: ReadEntry[]): ReadEntry[] {
|
||||
if (!entries.length) {
|
||||
return [];
|
||||
}
|
||||
const seen = new Map<string, ReadEntry>();
|
||||
entries.forEach((entry) => {
|
||||
const hash = `${entry.filePath ?? "content"}::${entry.content}`;
|
||||
if (!seen.has(hash)) {
|
||||
seen.set(hash, entry);
|
||||
}
|
||||
});
|
||||
return Array.from(seen.values());
|
||||
}
|
||||
|
||||
const CommandRawSchema = z
|
||||
.object({
|
||||
type: z.string().optional(),
|
||||
command: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
aggregated_output: z.string().optional(),
|
||||
exit_code: z.number().optional(),
|
||||
cwd: z.string().optional(),
|
||||
directory: z.string().optional(),
|
||||
metadata: z
|
||||
.object({
|
||||
exit_code: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
input: z.unknown().optional(),
|
||||
output: z.unknown().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const CommandResultSchema = z
|
||||
.object({
|
||||
output: z.string().optional(),
|
||||
exitCode: z.number().nullable().optional(),
|
||||
structuredContent: z
|
||||
.object({
|
||||
output: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
structured_content: z
|
||||
.object({
|
||||
output: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
metadata: z
|
||||
.object({
|
||||
exit_code: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
result: z.unknown().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
function coerceCommandValue(value: unknown): string | undefined {
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const tokens = value.filter((entry): entry is string => typeof entry === "string");
|
||||
if (tokens.length) {
|
||||
return tokens.join(" ");
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function collectCommandDetails(
|
||||
target: CommandDetails,
|
||||
value: unknown,
|
||||
depth = 0
|
||||
): void {
|
||||
if (!value || depth > 4) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
if (!target.output) {
|
||||
target.output = value;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isRecord(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawParsed = CommandRawSchema.safeParse(value);
|
||||
if (rawParsed.success) {
|
||||
const data = rawParsed.data;
|
||||
const commandCandidate =
|
||||
coerceCommandValue(data.command) ??
|
||||
(isRecord(data.input) ? coerceCommandValue(data.input["command"]) : undefined);
|
||||
if (!target.command && commandCandidate) {
|
||||
target.command = commandCandidate;
|
||||
}
|
||||
const cwdCandidate =
|
||||
getString(data.cwd ?? data.directory) ??
|
||||
(isRecord(data.input)
|
||||
? getString(data.input["cwd"] ?? data.input["directory"])
|
||||
: undefined);
|
||||
if (!target.cwd && cwdCandidate) {
|
||||
target.cwd = cwdCandidate;
|
||||
}
|
||||
const aggregatedOutput =
|
||||
getString(data.aggregated_output) ??
|
||||
(isRecord(data.output)
|
||||
? getString(
|
||||
(data.output as Record<string, unknown>)["aggregated_output"] ??
|
||||
(data.output as Record<string, unknown>)["output"] ??
|
||||
(data.output as Record<string, unknown>)["text"]
|
||||
)
|
||||
: undefined);
|
||||
if (!target.output && aggregatedOutput) {
|
||||
target.output = aggregatedOutput;
|
||||
}
|
||||
const exitCandidate =
|
||||
data.exit_code ??
|
||||
(data.metadata ? data.metadata.exit_code : undefined) ??
|
||||
(isRecord(data.output)
|
||||
? ((data.output as Record<string, unknown>)["exit_code"] as number | undefined) ??
|
||||
((data.output as Record<string, unknown>)["exitCode"] as number | undefined)
|
||||
: undefined);
|
||||
if (target.exitCode === undefined && exitCandidate !== undefined) {
|
||||
target.exitCode = exitCandidate;
|
||||
}
|
||||
}
|
||||
|
||||
const resultParsed = CommandResultSchema.safeParse(value);
|
||||
if (resultParsed.success) {
|
||||
const data = resultParsed.data;
|
||||
if (!target.output) {
|
||||
target.output =
|
||||
getString(data.output) ??
|
||||
getString(data.structuredContent?.output) ??
|
||||
getString(data.structuredContent?.text) ??
|
||||
getString(data.structured_content?.output) ??
|
||||
getString(data.structured_content?.text) ??
|
||||
(typeof data.result === "string" ? data.result : undefined);
|
||||
}
|
||||
if (target.exitCode === undefined) {
|
||||
target.exitCode = data.exitCode ?? data.metadata?.exit_code;
|
||||
}
|
||||
if (!target.command && isRecord(data.result)) {
|
||||
const nestedCommand =
|
||||
coerceCommandValue(data.result["command"]) ??
|
||||
coerceCommandValue((data.result as Record<string, unknown>)["args"]);
|
||||
if (nestedCommand) {
|
||||
target.command = nestedCommand;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nestedKeys = [
|
||||
"input",
|
||||
"output",
|
||||
"result",
|
||||
"response",
|
||||
"data",
|
||||
"raw",
|
||||
"payload",
|
||||
] as const;
|
||||
for (const key of nestedKeys) {
|
||||
if (value[key] !== undefined) {
|
||||
collectCommandDetails(target, value[key], depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function extractEditEntries(...sources: unknown[]): EditEntry[] {
|
||||
const entries = sources.flatMap((value) => parseEditArguments(value));
|
||||
return mergeEditEntries(entries);
|
||||
}
|
||||
|
||||
export function extractReadEntries(...sources: unknown[]): ReadEntry[] {
|
||||
return mergeReadEntries(sources.flatMap((value) => parseReadEntriesInternal(value)));
|
||||
}
|
||||
|
||||
export function extractCommandDetails(...sources: unknown[]): CommandDetails | null {
|
||||
const details: CommandDetails = {};
|
||||
sources.forEach((value) => collectCommandDetails(details, value));
|
||||
if (details.command || details.output || details.cwd) {
|
||||
return details;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user