Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
bfd9ca3
fix(mobile): Backport iOS runtime and chat stability fixes
mwolson Jul 11, 2026
3e7ed41
fix(mobile): Account for header in thread fit checks
mwolson Jul 12, 2026
c62877c
fix(mobile): Stabilize screenshot attachment layout
mwolson Jul 12, 2026
ca998a4
fix(mobile): Harden iOS crash capture and stack option updates
mwolson Jul 12, 2026
f347d3e
fix(mobile): Satisfy SwiftLint opening_brace in T3CrashLog
mwolson Jul 12, 2026
de8b9f6
fix(client-runtime): Heal shell archive membership on reconnect
mwolson Jul 13, 2026
666bf0a
fix(client-runtime): Apply socket shell heal authoritatively
mwolson Jul 13, 2026
292609f
fix(client-runtime): Clear socket-authoritative flag after HTTP heal
mwolson Jul 13, 2026
07c6c32
test(server): Harden CursorAdapter log wait for CI load
mwolson Jul 13, 2026
7b2a0a3
test(server): Use wall-clock waits in CursorAdapter log polling
mwolson Jul 13, 2026
ed1a5f2
fix(mobile): Keep threadScreenOptions useMemo above early returns
mwolson Jul 13, 2026
4311beb
Confirm resumed thread subscriptions (#2)
twistedgrim Jul 13, 2026
73c2c48
fix(orchestration): Gate thread resume sync marker after catch-up
mwolson Jul 13, 2026
936f67f
fix(client-runtime): Hold shell list until server heal on reconnect
mwolson Jul 13, 2026
6aabd97
fix(client-runtime): Delay shell disk paint and re-arm sync marker
mwolson Jul 13, 2026
924ad10
fix(mobile): Keep chat content above composer when keyboard opens on …
mwolson Jul 14, 2026
8346525
fix(mobile): Preserve Android scroll position on keyboard close
mwolson Jul 14, 2026
2ec85a8
fix(deps): Update keyboard controller patch lockfile
mwolson Jul 14, 2026
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
4 changes: 4 additions & 0 deletions apps/mobile/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
// Installed via a side-effect import listed first so the fatal-error handler
// is in place before the rest of the app module graph evaluates.
import "./src/lib/installCrashLog";

import { registerRootComponent } from "expo";
import "react-native-gesture-handler";
import { featureFlags } from "react-native-screens";
Expand Down
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
25 changes: 21 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,7 @@ 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);
// 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 +262,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));
},
[onComposerLayout],
);
const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef });
const showContent = props.showContent ?? true;
Expand Down Expand Up @@ -408,6 +424,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
freeze={freeze}
anchorMessageId={anchorMessageId}
contentInsetEndAdjustment={contentInsetEndAdjustment}
contentInsetEndEstimate={composerOverlayHeight + THREAD_FEED_COMPOSER_GAP}
Comment thread
cursor[bot] marked this conversation as resolved.
contentTopInset={0}
contentBottomInset={estimatedOverlayHeight}
contentMaxWidth={contentMaxWidth}
Expand All @@ -430,7 +447,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