Skip to content

Commit 8e84fb8

Browse files
juliusmarmingerynfar
authored andcommitted
feat(mobile): update tool summaries and chat transitions (#8793)
Adapted for Pylon. Adopted: the work-log presentation and command-label logic move into client-runtime, which is genuine convergence - web's MessagesTimeline drops its local commandProgramName and the shell tokenizer behind it in favour of the shared module. Also adopted are the thread feed's disclosure enter/exit transitions and the FloatingWorkingControl extraction, which C-21 builds on. Not adopted: upstream's composer toolbar restructure. Pylon's mobile composer is a deliberate fork carrying QuickQuestionTrigger, ContextWindowIndicator, the follow-up queue pill and ComposerToolbarScroller, and upstream's new View nesting cannot hold them without losing behaviour. Pylon's ThreadComposer is kept whole and now exports its layout transition constants for the detail screen. The two upstream-only couplings that came with it - a fifth argument to useKeyboardChatComposerInset and a queueCount composer prop - are dropped with it, since both exist only to drive upstream's composer animation. Restored after the cherry-pick dropped them through non-conflicting hunks: the session-interaction surfaces on ThreadDetailScreen. Taking upstream's version silently removed the above- and below-editor SessionPresentationSurface blocks, PendingSessionInteractionCard, the third pending-interaction condition, sessionInputBlocked, the below-widget bottom-inset gate, and 23 composer props. thread-work-log keeps Pylon's amber-warning / rose-error split rather than upstream's collapse of both into destructive, and does not adopt its xmark failure glyph - that would move mobile opposite to the circle-alert decision web already made. Upstream's live shimmer and grouped-detail icon gating are taken.
1 parent a8e59e4 commit 8e84fb8

14 files changed

Lines changed: 2879 additions & 855 deletions

apps/mobile/src/features/threads/ThreadComposer.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,8 @@ export interface ThreadComposerProps {
240240
// KeyboardStickyView (frame-synced to the IME), and a time-based morph
241241
// running alongside that translate reads as jitter. Snapping the layout and
242242
// letting the keyboard-synced slide be the only motion looks native there.
243-
const COMPOSER_LAYOUT_TRANSITION =
243+
export const COMPOSER_TRANSITION_DURATION_MS = 220;
244+
export const COMPOSER_LAYOUT_TRANSITION =
244245
Platform.OS === "android" ? undefined : LinearTransition.duration(220);
245246

246247
export function ComposerSurface(props: {

apps/mobile/src/features/threads/ThreadDetailScreen.tsx

Lines changed: 214 additions & 196 deletions
Large diffs are not rendered by default.

apps/mobile/src/features/threads/ThreadFeed.tsx

Lines changed: 123 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import {
1313
splitCodexArtifactTemplateMarkdown,
1414
} from "@t3tools/client-runtime/codex-markdown-directives";
1515
import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
16-
import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
1716
import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";
1817
import { HeaderHeightContext } from "@react-navigation/elements";
1918
import { useNavigation } from "@react-navigation/native";
@@ -54,7 +53,13 @@ import {
5453
import { TouchableOpacity } from "react-native-gesture-handler";
5554
import ImageViewing from "react-native-image-viewing";
5655
import { useSafeAreaInsets } from "react-native-safe-area-context";
57-
import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reanimated";
56+
import Animated, {
57+
FadeIn,
58+
FadeInUp,
59+
FadeOut,
60+
LinearTransition,
61+
type SharedValue,
62+
} from "react-native-reanimated";
5863
import { useUniwindTheme } from "../../lib/useUniwindTheme";
5964
import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
6065
import { useFontFamily } from "../../lib/useFontFamily";
@@ -93,9 +98,7 @@ import {
9398
import {
9499
resolveMarkdownFontSizes,
95100
resolveNativeMarkdownTypography,
96-
scaledTypographyLineHeight,
97101
} from "../../lib/appearancePreferences";
98-
import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
99102
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
100103
import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
101104
import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";
@@ -114,6 +117,7 @@ import {
114117
collapsedWorkLogHeight,
115118
ThreadWorkGroupToggle,
116119
ThreadWorkLog,
120+
THREAD_DISCLOSURE_TRANSITION_MS,
117121
WORK_GROUP_TOGGLE_HEIGHT,
118122
} from "./thread-work-log";
119123
import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
@@ -143,9 +147,10 @@ function formatMessageTime(input: string): string {
143147
// so its height is a constant; a drifted value costs one correction on
144148
// measure, not a persistent offset.
145149
const TURN_FOLD_HEIGHT = 56; // min-h-11 (44) + mb-3 (12)
146-
// The working row has no min-height clamp — its height follows the scaled
147-
// text-xs line height (see workingRowHeight in ThreadFeed).
148-
const WORKING_ROW_VERTICAL_EXTRAS = 24; // py-1 (8) + mb-4 (16)
150+
const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
151+
const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.duration(140);
152+
const THREAD_FEED_DISCLOSURE_EXIT_TRANSITION = FadeOut.duration(120);
153+
const EMPTY_DISCLOSURE_ENTRY_IDS: ReadonlySet<string> = new Set();
149154

150155
// Entering animations must only play for rows born just now — LegendList
151156
// remounts rows when they scroll back into view, and replaying an entrance for
@@ -1104,10 +1109,6 @@ function renderFeedEntry(
11041109
const entry = info.item;
11051110
const { markdownStyles, iconSubtleColor, userBubbleColor } = props;
11061111

1107-
if (entry.type === "working") {
1108-
return <WorkingTimelineRow startedAt={entry.createdAt} />;
1109-
}
1110-
11111112
if (entry.type === "turn-fold") {
11121113
return (
11131114
<Pressable
@@ -1136,7 +1137,10 @@ function renderFeedEntry(
11361137
expanded={entry.expanded}
11371138
hiddenCount={entry.hiddenCount}
11381139
iconSubtleColor={iconSubtleColor}
1139-
onlyToolActivities={entry.onlyToolActivities}
1140+
summary={entry.summary}
1141+
summaryKind={entry.summaryKind}
1142+
hasFailure={entry.hasFailure}
1143+
shimmer={entry.shimmer}
11401144
onToggle={() => props.onToggleWorkGroup(entry.groupId)}
11411145
/>
11421146
);
@@ -1299,32 +1303,6 @@ function renderFeedEntry(
12991303
);
13001304
}
13011305

1302-
const WorkingTimelineRow = memo(function WorkingTimelineRow(props: { readonly startedAt: string }) {
1303-
const [nowMs, setNowMs] = useState(() => Date.now());
1304-
1305-
useEffect(() => {
1306-
const intervalId = setInterval(() => {
1307-
setNowMs(Date.now());
1308-
}, 1_000);
1309-
return () => clearInterval(intervalId);
1310-
}, [props.startedAt]);
1311-
1312-
const durationLabel = formatElapsed(props.startedAt, new Date(nowMs).toISOString()) ?? "0s";
1313-
1314-
return (
1315-
<View className="mb-4 flex-row items-center gap-2 px-1.5 py-1">
1316-
<View className="flex-row items-center gap-1">
1317-
<View className="h-1 w-1 rounded-full bg-adaptive-neutral-400-500" />
1318-
<View className="h-1 w-1 rounded-full bg-adaptive-neutral-400-a80-500-a80" />
1319-
<View className="h-1 w-1 rounded-full bg-adaptive-neutral-400-a60-500-a60" />
1320-
</View>
1321-
<Text className="font-t3-medium text-xs tabular-nums text-adaptive-neutral-600-400">
1322-
Working for {durationLabel}
1323-
</Text>
1324-
</View>
1325-
);
1326-
});
1327-
13281306
function UserMessageContent(props: {
13291307
readonly text: string;
13301308
readonly markdownStyles: MarkdownStyleSet;
@@ -1598,14 +1576,14 @@ function ThreadFeedPlaceholder(props: {
15981576
export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
15991577
const navigation = useNavigation();
16001578
const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1601-
const foldSettleFrameRef = useRef<number | null>(null);
1602-
const foldSettleSecondFrameRef = useRef<number | null>(null);
1579+
const disclosureSettleFrameRef = useRef<number | null>(null);
1580+
const disclosureSettleSecondFrameRef = useRef<number | null>(null);
16031581
const disclosureAnchorKeyRef = useRef<string | null>(null);
1582+
const previousPresentedFeedRef = useRef<ReadonlyArray<ThreadFeedEntry> | null>(null);
16041583
const headerMaterialVisibleRef = useRef(false);
16051584
const previousLatestTurnRef = useRef(props.latestTurn);
16061585
const userScrollSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
16071586
const { width: windowWidth } = useWindowDimensions();
1608-
const { appearance } = useAppearancePreferences();
16091587
const [viewportWidth, setViewportWidth] = useState(() =>
16101588
props.layoutVariant === "split" ? 0 : windowWidth,
16111589
);
@@ -1910,14 +1888,37 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
19101888
props.latestTurn,
19111889
],
19121890
);
1891+
const disclosureEnteringEntryIds = useMemo(() => {
1892+
const anchorKey = disclosureAnchorKeyRef.current;
1893+
const previousPresentedFeed = previousPresentedFeedRef.current;
1894+
if (!disclosureToggleSettling || anchorKey === null || previousPresentedFeed === null) {
1895+
return EMPTY_DISCLOSURE_ENTRY_IDS;
1896+
}
1897+
1898+
const previousIds = new Set(previousPresentedFeed.map((entry) => entry.id));
1899+
const anchorIndex = presentedFeed.findIndex((entry) => entry.id === anchorKey);
1900+
const enteringIds = new Set<string>();
1901+
if (anchorIndex < 0) {
1902+
return enteringIds;
1903+
}
1904+
for (let index = anchorIndex + 1; index < presentedFeed.length; index += 1) {
1905+
const entryId = presentedFeed[index]!.id;
1906+
if (previousIds.has(entryId)) {
1907+
break;
1908+
}
1909+
enteringIds.add(entryId);
1910+
}
1911+
return enteringIds;
1912+
}, [disclosureToggleSettling, presentedFeed]);
1913+
1914+
useLayoutEffect(() => {
1915+
previousPresentedFeedRef.current = presentedFeed;
1916+
}, [presentedFeed]);
19131917

1914-
// The empty↔filled key below remounts the list, which resets its imperative
1915-
// content-inset override — and useKeyboardChatComposerInset (mounted above
1916-
// the remount boundary) deduplicates by height, so it never re-reports the
1917-
// composer inset to the fresh instance. Re-report the measured overlay height
1918-
// (composer plus any pending approval / user-input card) so the remounted
1919-
// list's scroll math gets the true value; on Android the declarative
1920-
// contentInset floor below covers the window before this effect lands.
1918+
// The empty↔filled key below remounts the list and resets its imperative
1919+
// content-inset override. Seed the fresh instance synchronously with the
1920+
// current overlay height before the scroll integration's next reaction;
1921+
// on Android the declarative contentInset floor covers this same window.
19211922
const listMountKey = `${feedThreadKey}:${props.feed.length === 0 ? "empty" : "filled"}`;
19221923
useLayoutEffect(() => {
19231924
const bottom = props.contentInsetEndAdjustment.value;
@@ -1982,34 +1983,52 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
19821983
if (copyFeedbackTimeoutRef.current) {
19831984
clearTimeout(copyFeedbackTimeoutRef.current);
19841985
}
1985-
if (foldSettleFrameRef.current !== null) {
1986-
cancelAnimationFrame(foldSettleFrameRef.current);
1986+
if (disclosureSettleFrameRef.current !== null) {
1987+
cancelAnimationFrame(disclosureSettleFrameRef.current);
19871988
}
1988-
if (foldSettleSecondFrameRef.current !== null) {
1989-
cancelAnimationFrame(foldSettleSecondFrameRef.current);
1989+
if (disclosureSettleSecondFrameRef.current !== null) {
1990+
cancelAnimationFrame(disclosureSettleSecondFrameRef.current);
19901991
}
19911992
};
19921993
}, []);
19931994

1994-
const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => {
1995-
disclosureAnchorKeyRef.current = anchorKey;
1996-
setDisclosureToggleSettling(true);
1997-
if (foldSettleFrameRef.current !== null) {
1998-
cancelAnimationFrame(foldSettleFrameRef.current);
1995+
const settleDisclosureAfterLayout = useCallback(() => {
1996+
if (disclosureSettleFrameRef.current !== null) {
1997+
cancelAnimationFrame(disclosureSettleFrameRef.current);
19991998
}
2000-
if (foldSettleSecondFrameRef.current !== null) {
2001-
cancelAnimationFrame(foldSettleSecondFrameRef.current);
1999+
if (disclosureSettleSecondFrameRef.current !== null) {
2000+
cancelAnimationFrame(disclosureSettleSecondFrameRef.current);
20022001
}
2003-
foldSettleFrameRef.current = requestAnimationFrame(() => {
2004-
foldSettleSecondFrameRef.current = requestAnimationFrame(() => {
2002+
disclosureSettleFrameRef.current = requestAnimationFrame(() => {
2003+
disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => {
20052004
disclosureAnchorKeyRef.current = null;
20062005
setDisclosureToggleSettling(false);
2007-
foldSettleFrameRef.current = null;
2008-
foldSettleSecondFrameRef.current = null;
2006+
disclosureSettleFrameRef.current = null;
2007+
disclosureSettleSecondFrameRef.current = null;
20092008
});
20102009
});
20112010
}, []);
20122011

2012+
const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => {
2013+
disclosureAnchorKeyRef.current = anchorKey;
2014+
setDisclosureToggleSettling(true);
2015+
}, []);
2016+
2017+
// Start the quiet-frame countdown after React has committed the disclosure.
2018+
// Every measured item-size change restarts it, so end maintenance cannot
2019+
// wake between the data mutation and LegendList's final layout correction.
2020+
useLayoutEffect(() => {
2021+
if (disclosureAnchorKeyRef.current !== null) {
2022+
settleDisclosureAfterLayout();
2023+
}
2024+
}, [expandedTurnIds, expandedWorkGroups, expandedWorkRows, settleDisclosureAfterLayout]);
2025+
2026+
const handleItemSizeChanged = useCallback(() => {
2027+
if (disclosureAnchorKeyRef.current !== null) {
2028+
settleDisclosureAfterLayout();
2029+
}
2030+
}, [settleDisclosureAfterLayout]);
2031+
20132032
const shouldRestoreVisibleContentPosition = useCallback((entry: ThreadFeedEntry) => {
20142033
const disclosureAnchorKey = disclosureAnchorKeyRef.current;
20152034
return disclosureAnchorKey === null || entry.id === disclosureAnchorKey;
@@ -2094,59 +2113,65 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
20942113
// scrolling up through unmeasured content corrects each row's height as it
20952114
// mounts — the feed visibly jumps. Fixed sizes make the small chrome rows
20962115
// exact; message rows stay undefined and use LegendList's per-type running
2097-
// average once one of their type has been measured. Text-driven heights
2098-
// follow the configurable base font size via scaledTypographyLineHeight.
2099-
const workingRowHeight =
2100-
WORKING_ROW_VERTICAL_EXTRAS +
2101-
scaledTypographyLineHeight(MOBILE_TYPOGRAPHY.label, appearance.baseFontSize);
2116+
// average once one of their type has been measured.
21022117
const getFixedItemSize = useCallback(
21032118
(entry: ThreadFeedEntry) => {
21042119
switch (entry.type) {
21052120
case "turn-fold":
21062121
return TURN_FOLD_HEIGHT;
21072122
case "work-toggle":
21082123
return WORK_GROUP_TOGGLE_HEIGHT;
2109-
case "working":
2110-
return workingRowHeight;
21112124
case "activity-group":
21122125
// Expanded rows append a variable detail block — fall back to
21132126
// measurement for those groups.
21142127
return entry.activities.some((activity) => expandedWorkRows[activity.id])
21152128
? undefined
2116-
: collapsedWorkLogHeight(entry.activities, appearance.baseFontSize);
2129+
: collapsedWorkLogHeight(entry.activities);
21172130
default:
21182131
return undefined;
21192132
}
21202133
},
2121-
[expandedWorkRows, workingRowHeight, appearance.baseFontSize],
2134+
[expandedWorkRows],
21222135
);
21232136

21242137
const renderItem = useCallback(
2125-
(info: { item: ThreadFeedEntry; index: number }) =>
2126-
renderFeedEntry(info, {
2127-
environmentId: props.environmentId,
2128-
copiedRowId,
2129-
expandedWorkRows,
2130-
terminalAssistantMessageIds,
2131-
unsettledTurnId,
2132-
onCopyWorkRow,
2133-
onToggleWorkGroup,
2134-
onToggleWorkRow,
2135-
onToggleTurnFold,
2136-
onPressImage,
2137-
onMarkdownLinkPress,
2138-
renderMarkdownImage,
2139-
iconSubtleColor,
2140-
userBubbleColor,
2141-
markdownStyles,
2142-
reviewCommentColors,
2143-
reviewCommentBubbleWidth,
2144-
userBubbleMaxWidth,
2145-
skills: props.skills,
2146-
onUseArtifactTemplate: props.onUseArtifactTemplate,
2147-
}),
2138+
(info: { item: ThreadFeedEntry; index: number }) => (
2139+
<Animated.View
2140+
key={info.item.id}
2141+
entering={
2142+
disclosureEnteringEntryIds.has(info.item.id)
2143+
? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION
2144+
: undefined
2145+
}
2146+
exiting={THREAD_FEED_DISCLOSURE_EXIT_TRANSITION}
2147+
>
2148+
{renderFeedEntry(info, {
2149+
environmentId: props.environmentId,
2150+
copiedRowId,
2151+
expandedWorkRows,
2152+
terminalAssistantMessageIds,
2153+
unsettledTurnId,
2154+
onCopyWorkRow,
2155+
onToggleWorkGroup,
2156+
onToggleWorkRow,
2157+
onToggleTurnFold,
2158+
onPressImage,
2159+
onMarkdownLinkPress,
2160+
renderMarkdownImage,
2161+
iconSubtleColor,
2162+
userBubbleColor,
2163+
markdownStyles,
2164+
reviewCommentColors,
2165+
reviewCommentBubbleWidth,
2166+
userBubbleMaxWidth,
2167+
skills: props.skills,
2168+
onUseArtifactTemplate: props.onUseArtifactTemplate,
2169+
})}
2170+
</Animated.View>
2171+
),
21482172
[
21492173
copiedRowId,
2174+
disclosureEnteringEntryIds,
21502175
expandedWorkRows,
21512176
terminalAssistantMessageIds,
21522177
unsettledTurnId,
@@ -2214,7 +2239,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
22142239
}
22152240
: { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })}
22162241
{...(anchoredEndSpace ? { anchoredEndSpace } : {})}
2217-
// Patched LegendList prop (patches/@legendapp__list@3.2.0.patch):
2242+
// Patched LegendList prop (patches/@legendapp__list@3.3.5.patch):
22182243
// lets its scroll math clamp programmatic scrolls to -headerInset
22192244
// instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short
22202245
// content rest below the transparent header rather than at frame top.
@@ -2269,6 +2294,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
22692294
entry.type === "message" ? `message:${entry.message.role}` : entry.type
22702295
}
22712296
getFixedItemSize={getFixedItemSize}
2297+
itemLayoutAnimation={THREAD_FEED_LAYOUT_TRANSITION}
2298+
onItemSizeChanged={handleItemSizeChanged}
22722299
// Measure rows well before they scroll into view so estimate→actual
22732300
// corrections land offscreen instead of under the user's finger.
22742301
drawDistance={500}

0 commit comments

Comments
 (0)