Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,16 @@ struct T3MarkdownTextParagraphStyleRange {
Float firstLineHeadIndent;
Float headIndent;
Float paragraphSpacing;

bool operator==(const T3MarkdownTextParagraphStyleRange&) const = default;
};

struct T3MarkdownTextAttachmentRange {
size_t location;
size_t length;
std::string imageUri;

bool operator==(const T3MarkdownTextAttachmentRange&) const = default;
};

inline Float T3MarkdownTextAttachmentSize(const T3MarkdownTextAttachmentRange &) {
Expand All @@ -52,11 +56,6 @@ T3MarkdownTextStateReal> {
public:
using ConcreteViewShadowNode::ConcreteViewShadowNode;

T3MarkdownTextShadowNode(
const ShadowNode& sourceShadowNode,
const ShadowNodeFragment& fragment
);

static ShadowNodeTraits BaseTraits() {
auto traits = ConcreteViewShadowNode::BaseTraits();
traits.set(ShadowNodeTraits::Trait::LeafYogaNode);
Expand All @@ -71,8 +70,18 @@ T3MarkdownTextStateReal> {
const LayoutConstraints& layoutConstraints) const override;

private:
mutable AttributedString _attributedString;
mutable std::vector<T3MarkdownTextParagraphStyleRange> _paragraphStyleRanges;
mutable std::vector<T3MarkdownTextAttachmentRange> _attachmentRanges;
// Content must be derived from the current children whenever it is needed.
// Yoga can invoke layout() on a fresh clone without ever calling
// measureContent() on it (for example when both dimensions are already
// exact), so caching measure-time content in mutable members and publishing
// it from layout() lets state fall behind the children and drop text.
struct Content {
AttributedString attributedString;
std::vector<T3MarkdownTextParagraphStyleRange> paragraphStyleRanges;
std::vector<T3MarkdownTextAttachmentRange> attachmentRanges;
};

Content buildContent(const LayoutContext& layoutContext) const;
void updateStateIfNeeded(Content&& content);
};
} // namespace facebook::React
Original file line number Diff line number Diff line change
Expand Up @@ -72,15 +72,8 @@ static void applyAttachments(
}
}

T3MarkdownTextShadowNode::T3MarkdownTextShadowNode(
const ShadowNode& sourceShadowNode,
const ShadowNodeFragment& fragment
) : ConcreteViewShadowNode(sourceShadowNode, fragment) {
};

Size T3MarkdownTextShadowNode::measureContent(
const LayoutContext& layoutContext,
const LayoutConstraints& layoutConstraints) const {
T3MarkdownTextShadowNode::Content T3MarkdownTextShadowNode::buildContent(
const LayoutContext& layoutContext) const {
const auto &baseProps = getConcreteProps();

auto baseTextAttributes = TextAttributes::defaultTextAttributes();
Expand Down Expand Up @@ -207,14 +200,23 @@ static void applyAttachments(
}
}

_attributedString = baseAttributedString;
_paragraphStyleRanges = paragraphStyleRanges;
_attachmentRanges = attachmentRanges;
return Content{
std::move(baseAttributedString),
std::move(paragraphStyleRanges),
std::move(attachmentRanges),
};
}

Size T3MarkdownTextShadowNode::measureContent(
const LayoutContext& layoutContext,
const LayoutConstraints& layoutConstraints) const {
const auto &baseProps = getConcreteProps();
const auto content = buildContent(layoutContext);

NSMutableAttributedString *convertedAttributedString =
[RCTNSAttributedStringFromAttributedString(baseAttributedString) mutableCopy];
applyParagraphStyles(convertedAttributedString, paragraphStyleRanges);
applyAttachments(convertedAttributedString, attachmentRanges);
[RCTNSAttributedStringFromAttributedString(content.attributedString) mutableCopy];
applyParagraphStyles(convertedAttributedString, content.paragraphStyleRanges);
applyAttachments(convertedAttributedString, content.attachmentRanges);

const CGFloat maximumWidth = std::isfinite(layoutConstraints.maximumSize.width)
? layoutConstraints.maximumSize.width
Expand Down Expand Up @@ -255,10 +257,21 @@ static void applyAttachments(

void T3MarkdownTextShadowNode::layout(LayoutContext layoutContext) {
ensureUnsealed();
updateStateIfNeeded(buildContent(layoutContext));
}

void T3MarkdownTextShadowNode::updateStateIfNeeded(Content&& content) {
const auto &stateData = getStateData();
if (stateData.attributedString == content.attributedString &&
stateData.paragraphStyleRanges == content.paragraphStyleRanges &&
stateData.attachmentRanges == content.attachmentRanges) {
return;
}

setStateData(T3MarkdownTextStateReal{
_attributedString,
_paragraphStyleRanges,
_attachmentRanges,
std::move(content.attributedString),
std::move(content.paragraphStyleRanges),
std::move(content.attachmentRanges),
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export function SelectableMarkdownText({
skills = EMPTY_SKILLS,
textStyle,
highlightCode,
fillWidth = false,
preserveSoftBreaks = false,
onLinkPress,
marginTop = 0,
Expand Down Expand Up @@ -63,7 +64,15 @@ export function SelectableMarkdownText({
// shrink-to-fit containers such as user-message bubbles. Yoga then gives
// the native text node an unbounded second pass and the parent only clips
// the resulting single-line width instead of reflowing it.
<View style={{ flexShrink: 1, minWidth: 0, marginTop, marginBottom }}>
<View
style={{
flexShrink: 1,
minWidth: 0,
...(fillWidth ? { alignSelf: "stretch" } : null),
marginTop,
marginBottom,
}}
>
{chunks.map((chunk, index) => {
const content =
chunk.kind === "rich" ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export interface SelectableMarkdownTextProps {
readonly markdown: string;
readonly textStyle: NativeMarkdownTextStyle;
readonly highlightCode: MarkdownCodeHighlighter;
readonly fillWidth?: boolean;
readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
readonly preserveSoftBreaks?: boolean;
readonly onLinkPress?: (href: string) => void;
Expand Down
42 changes: 38 additions & 4 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type {
import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
import * as Haptics from "expo-haptics";
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Platform, View, type GestureResponderEvent } from "react-native";
import { Platform, View, type GestureResponderEvent, type LayoutChangeEvent } from "react-native";
import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller";
import Animated, { FadeInDown, FadeOut, LinearTransition } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
Expand Down Expand Up @@ -176,6 +176,10 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray<ThreadFeedE
// overlay). Matches the rendered pill: pt-2 + pb-2 (16) wrapping a bordered
// px-3/py-2 row (~36), so ~52 — keep it in sync with WorkingDurationPill.
const WORKING_INDICATOR_HEIGHT = 52;
// Extra list end-inset so the last message rests above the floating composer
// instead of flush against (or under) it. Applied via heightAdjustment so both
// the pre-layout estimate and the measured overlay height pick it up.
const THREAD_FEED_COMPOSER_GAP = 24;

const WorkingDurationPill = memo(function WorkingDurationPill(props: {
readonly startedAt: string;
Expand Down Expand Up @@ -248,6 +252,24 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
const composerOverlapHeight = composerChrome + composerBottomInset;
const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0;
const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight;
const [composerOverlayHeight, setComposerOverlayHeight] = useState(estimatedOverlayHeight);
const estimatedOverlayHeightRef = useRef(estimatedOverlayHeight);
useEffect(() => {
estimatedOverlayHeightRef.current = estimatedOverlayHeight;
}, [estimatedOverlayHeight]);
Comment thread
cursor[bot] marked this conversation as resolved.
useEffect(() => {
// A measured overlay can include approval or input cards that are not part
// of this baseline estimate. Keep that larger measurement until layout
// reports a new height, while ensuring chrome changes never under-estimate
// the composer inset.
setComposerOverlayHeight((current) => Math.max(current, estimatedOverlayHeight));
Comment thread
juliusmarminge marked this conversation as resolved.
}, [estimatedOverlayHeight]);
useEffect(() => {
// Layout callbacks from the previous thread can arrive after navigation.
// Reset their larger measurement to this thread's known baseline; its own
// overlay layout will immediately replace it if it contains extra cards.
setComposerOverlayHeight(estimatedOverlayHeightRef.current);
}, [selectedThreadKey]);
// The overlay's measured height includes the home-indicator inset (the
// composer pads it), but contentInsetAdjustmentBehavior="automatic" makes
// UIKit add the safe-area bottom to the content inset AGAIN — leaving a
Expand All @@ -257,11 +279,22 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
// its end-scroll math matches the real resting position.
const nativeInsetOvercount =
props.usesAutomaticContentInsets === true && Platform.OS === "ios" ? insets.bottom : 0;
// heightAdjustment is added to measured composer height. Keep the safe-area
// under-report (UIKit automatic insets) and add breathing room above chrome.
const composerEndHeightAdjustment = THREAD_FEED_COMPOSER_GAP - nativeInsetOvercount;
const { contentInsetEndAdjustment, onComposerLayout } = useKeyboardChatComposerInset(
listRef,
composerOverlayRef,
Math.max(0, estimatedOverlayHeight - nativeInsetOvercount),
-nativeInsetOvercount,
Math.max(0, estimatedOverlayHeight + composerEndHeightAdjustment),
composerEndHeightAdjustment,
);
const handleComposerLayout = useCallback(
(event: LayoutChangeEvent) => {
onComposerLayout(event);
const height = event.nativeEvent.layout.height;
setComposerOverlayHeight((current) => (Math.abs(current - height) > 1 ? height : current));
Comment thread
juliusmarminge marked this conversation as resolved.
},
[onComposerLayout],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale composer layout after navigation

Medium Severity

The handleComposerLayout callback processes layout events without verifying they belong to the active thread. This can cause delayed layout events from a previous thread to incorrectly update the composer's height and content insets for the new thread, leading to incorrect list padding.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7f53a54. Configure here.

);
const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef });
const showContent = props.showContent ?? true;
Expand Down Expand Up @@ -408,6 +441,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
freeze={freeze}
anchorMessageId={anchorMessageId}
contentInsetEndAdjustment={contentInsetEndAdjustment}
contentInsetEndEstimate={composerOverlayHeight + THREAD_FEED_COMPOSER_GAP}
contentTopInset={0}
contentBottomInset={estimatedOverlayHeight}
contentMaxWidth={contentMaxWidth}
Expand All @@ -430,7 +464,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
{/* No paddingTop here: the overlay's measured height becomes the
list's bottom inset, so any padding above the pill/composer
pushes the resting content floor up by the same amount. */}
<View ref={composerOverlayRef} onLayout={onComposerLayout} className="w-full">
<View ref={composerOverlayRef} onLayout={handleComposerLayout} className="w-full">
<Animated.View
className="w-full self-center"
layout={LinearTransition.duration(220)}
Expand Down
Loading
Loading