* fix(app): stabilize streamed chat rendering Use deterministic Markdown AST keys and preserve unique row identities when an assistant message resumes after a tool. Keep native gesture and plan-card children correctly keyed to avoid unsupported Fabric reconciliation paths. * fix(app): stabilize retained native panels Keep retained workspace roots in a stable native order and make active panels present in the selection commit. Hidden panels now stop expensive subscriptions and animations without losing mounted state. * fix(app): harden retained panel activity
256 lines
6.8 KiB
TypeScript
256 lines
6.8 KiB
TypeScript
import { useMemo, type ReactNode } from "react";
|
|
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
|
import Markdown, { type ASTNode } from "react-native-markdown-display";
|
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
import { useTranslation } from "react-i18next";
|
|
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
|
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
|
|
|
type MarkdownRuleStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
|
|
|
function MarkdownInlineText({
|
|
inheritedStyle,
|
|
ruleStyle,
|
|
children,
|
|
}: {
|
|
inheritedStyle: StyleProp<TextStyle>;
|
|
ruleStyle: StyleProp<TextStyle>;
|
|
children: ReactNode;
|
|
}) {
|
|
const style = useMemo(() => [inheritedStyle, ruleStyle], [inheritedStyle, ruleStyle]);
|
|
return <Text style={style}>{children}</Text>;
|
|
}
|
|
|
|
function MarkdownListItemContent({
|
|
contentStyle,
|
|
children,
|
|
}: {
|
|
contentStyle: StyleProp<ViewStyle>;
|
|
children: ReactNode;
|
|
}) {
|
|
const style = useMemo(() => [contentStyle, LIST_ITEM_CONTENT_INNER], [contentStyle]);
|
|
return <View style={style}>{children}</View>;
|
|
}
|
|
|
|
function MarkdownParagraph({
|
|
paragraphStyle,
|
|
isLastChild,
|
|
children,
|
|
}: {
|
|
paragraphStyle: StyleProp<ViewStyle>;
|
|
isLastChild: boolean;
|
|
children: ReactNode;
|
|
}) {
|
|
const style = useMemo<StyleProp<ViewStyle>>(
|
|
() => [paragraphStyle, isLastChild ? PARAGRAPH_LAST_CHILD : null],
|
|
[paragraphStyle, isLastChild],
|
|
);
|
|
return <View style={style}>{children}</View>;
|
|
}
|
|
|
|
function createPlanMarkdownRules() {
|
|
return {
|
|
text: (
|
|
node: ASTNode,
|
|
_children: ReactNode[],
|
|
_parent: ASTNode[],
|
|
styles: MarkdownRuleStyles,
|
|
inheritedStyles: TextStyle = {},
|
|
) => (
|
|
<MarkdownInlineText key={node.key} inheritedStyle={inheritedStyles} ruleStyle={styles.text}>
|
|
{node.content}
|
|
</MarkdownInlineText>
|
|
),
|
|
textgroup: (
|
|
node: ASTNode,
|
|
children: ReactNode[],
|
|
_parent: ASTNode[],
|
|
styles: MarkdownRuleStyles,
|
|
inheritedStyles: TextStyle = {},
|
|
) => (
|
|
<MarkdownInlineText
|
|
key={node.key}
|
|
inheritedStyle={inheritedStyles}
|
|
ruleStyle={styles.textgroup}
|
|
>
|
|
{children}
|
|
</MarkdownInlineText>
|
|
),
|
|
code_block: (
|
|
node: ASTNode,
|
|
_children: ReactNode[],
|
|
_parent: ASTNode[],
|
|
styles: MarkdownRuleStyles,
|
|
inheritedStyles: TextStyle = {},
|
|
) => (
|
|
<MarkdownInlineText
|
|
key={node.key}
|
|
inheritedStyle={inheritedStyles}
|
|
ruleStyle={styles.code_block}
|
|
>
|
|
{node.content}
|
|
</MarkdownInlineText>
|
|
),
|
|
fence: (
|
|
node: ASTNode,
|
|
_children: ReactNode[],
|
|
_parent: ASTNode[],
|
|
styles: MarkdownRuleStyles,
|
|
inheritedStyles: TextStyle = {},
|
|
) => (
|
|
<MarkdownInlineText key={node.key} inheritedStyle={inheritedStyles} ruleStyle={styles.fence}>
|
|
{node.content}
|
|
</MarkdownInlineText>
|
|
),
|
|
code_inline: (
|
|
node: ASTNode,
|
|
_children: ReactNode[],
|
|
_parent: ASTNode[],
|
|
styles: MarkdownRuleStyles,
|
|
inheritedStyles: TextStyle = {},
|
|
) => (
|
|
<MarkdownInlineText
|
|
key={node.key}
|
|
inheritedStyle={inheritedStyles}
|
|
ruleStyle={styles.code_inline}
|
|
>
|
|
{node.content}
|
|
</MarkdownInlineText>
|
|
),
|
|
bullet_list: (
|
|
node: ASTNode,
|
|
children: ReactNode[],
|
|
_parent: ASTNode[],
|
|
styles: MarkdownRuleStyles,
|
|
) => (
|
|
<View key={node.key} style={styles.bullet_list}>
|
|
{children}
|
|
</View>
|
|
),
|
|
ordered_list: (
|
|
node: ASTNode,
|
|
children: ReactNode[],
|
|
_parent: ASTNode[],
|
|
styles: MarkdownRuleStyles,
|
|
) => (
|
|
<View key={node.key} style={styles.ordered_list}>
|
|
{children}
|
|
</View>
|
|
),
|
|
list_item: (
|
|
node: ASTNode,
|
|
children: ReactNode[],
|
|
parent: ASTNode[],
|
|
styles: MarkdownRuleStyles,
|
|
) => {
|
|
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
|
|
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
|
|
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
|
|
|
|
return (
|
|
<View key={node.key} style={styles.list_item}>
|
|
<Text style={iconStyle}>{marker}</Text>
|
|
<MarkdownListItemContent contentStyle={contentStyle}>{children}</MarkdownListItemContent>
|
|
</View>
|
|
);
|
|
},
|
|
paragraph: (
|
|
node: ASTNode,
|
|
children: ReactNode[],
|
|
parent: ASTNode[],
|
|
styles: MarkdownRuleStyles,
|
|
) => {
|
|
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
|
|
return (
|
|
<MarkdownParagraph
|
|
key={node.key}
|
|
paragraphStyle={styles.paragraph}
|
|
isLastChild={isLastChild}
|
|
>
|
|
{children}
|
|
</MarkdownParagraph>
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
export function PlanCard({
|
|
title,
|
|
description,
|
|
text,
|
|
footer,
|
|
disableOuterSpacing = false,
|
|
testID,
|
|
}: {
|
|
title?: string;
|
|
description?: string;
|
|
text: string;
|
|
footer?: ReactNode;
|
|
disableOuterSpacing?: boolean;
|
|
testID?: string;
|
|
}) {
|
|
const { theme } = useUnistyles();
|
|
const { t } = useTranslation();
|
|
const markdownStyles = createMarkdownStyles(theme);
|
|
const markdownRules = createPlanMarkdownRules();
|
|
const resolvedTitle = title ?? t("agentStream.permission.plan");
|
|
|
|
const containerStyle = useMemo(
|
|
() => [
|
|
styles.container,
|
|
disableOuterSpacing && styles.containerCompact,
|
|
{
|
|
backgroundColor: theme.colors.surface1,
|
|
borderColor: theme.colors.border,
|
|
},
|
|
],
|
|
[disableOuterSpacing, theme.colors.surface1, theme.colors.border],
|
|
);
|
|
const titleStyle = useMemo(
|
|
() => [styles.title, { color: theme.colors.foreground }],
|
|
[theme.colors.foreground],
|
|
);
|
|
const descriptionStyle = useMemo(
|
|
() => [styles.description, { color: theme.colors.foregroundMuted }],
|
|
[theme.colors.foregroundMuted],
|
|
);
|
|
|
|
return (
|
|
<View testID={testID} style={containerStyle}>
|
|
<Text style={titleStyle}>{resolvedTitle}</Text>
|
|
{description ? <Text style={descriptionStyle}>{description}</Text> : null}
|
|
<Markdown style={markdownStyles} rules={markdownRules}>
|
|
{text}
|
|
</Markdown>
|
|
{footer ? <View style={styles.footer}>{footer}</View> : null}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
container: {
|
|
marginVertical: theme.spacing[3],
|
|
padding: theme.spacing[3],
|
|
borderRadius: theme.spacing[2],
|
|
borderWidth: 1,
|
|
gap: theme.spacing[2],
|
|
},
|
|
containerCompact: {
|
|
marginVertical: 0,
|
|
},
|
|
title: {
|
|
fontSize: theme.fontSize.base,
|
|
lineHeight: 22,
|
|
},
|
|
description: {
|
|
fontSize: theme.fontSize.sm,
|
|
lineHeight: 20,
|
|
},
|
|
footer: {
|
|
gap: theme.spacing[2],
|
|
},
|
|
}));
|
|
|
|
const LIST_ITEM_CONTENT_INNER = { flex: 1, flexShrink: 1, minWidth: 0 };
|
|
const PARAGRAPH_LAST_CHILD = { marginBottom: 0 };
|