Skip to content

Commit 9d2539a

Browse files
feat(mobile): port chat summaries and transitions to orchestration v2
Adapt grouped tool summaries and the floating working timer to V2 run, attempt, and queue state. Bring over the composer, keyboard, and disclosure transitions while retaining the V2 activity inspector and queue controls. Keep OV2 web composer and grouping behavior intact; share only the existing command label parser with mobile.
1 parent 9cb6092 commit 9d2539a

19 files changed

Lines changed: 3115 additions & 930 deletions

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

Lines changed: 178 additions & 120 deletions
Large diffs are not rendered by default.

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

Lines changed: 167 additions & 147 deletions
Large diffs are not rendered by default.

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

Lines changed: 128 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import {
1111
type RunId,
1212
} from "@t3tools/contracts";
1313
import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
14-
import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
1514
import { SymbolView } from "../../components/AppSymbol";
1615
import { HeaderHeightContext } from "@react-navigation/elements";
1716
import { useNavigation } from "@react-navigation/native";
@@ -53,7 +52,13 @@ import {
5352
import { TouchableOpacity } from "react-native-gesture-handler";
5453
import ImageViewing from "react-native-image-viewing";
5554
import { useSafeAreaInsets } from "react-native-safe-area-context";
56-
import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reanimated";
55+
import Animated, {
56+
FadeIn,
57+
FadeInUp,
58+
FadeOut,
59+
LinearTransition,
60+
type SharedValue,
61+
} from "react-native-reanimated";
5762
import { useUniwindTheme } from "../../lib/useUniwindTheme";
5863
import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
5964
import { useFontFamily } from "../../lib/useFontFamily";
@@ -104,7 +109,11 @@ import {
104109
resolveThreadFeedLiveFollow,
105110
type ThreadFeedLiveFollowEvent,
106111
} from "./thread-feed-live-follow";
107-
import { ThreadWorkGroupToggle, ThreadWorkLog } from "./thread-work-log";
112+
import {
113+
THREAD_DISCLOSURE_TRANSITION_MS,
114+
ThreadWorkGroupToggle,
115+
ThreadWorkLog,
116+
} from "./thread-work-log";
108117
import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size";
109118
import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
110119
import { useAssetUrl, useAssetUrlState } from "../../state/assets";
@@ -133,6 +142,11 @@ function formatMessageTime(input: string): string {
133142
return MESSAGE_TIME_FORMATTER.format(timestamp);
134143
}
135144

145+
const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
146+
const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.duration(140);
147+
const THREAD_FEED_DISCLOSURE_EXIT_TRANSITION = FadeOut.duration(120);
148+
const EMPTY_DISCLOSURE_ENTRY_IDS: ReadonlySet<string> = new Set();
149+
136150
// Entering animations must only play for rows born just now — LegendList
137151
// remounts rows when they scroll back into view, and replaying an entrance for
138152
// old content would be its own kind of jank.
@@ -1035,12 +1049,11 @@ function renderFeedEntry(
10351049
info: { item: ThreadFeedEntry; index: number },
10361050
props: Pick<ThreadFeedProps, "environmentId" | "skills" | "threadId" | "workspaceRoot"> & {
10371051
readonly copiedRowId: string | null;
1038-
readonly expandedWorkGroups: Record<string, boolean>;
10391052
readonly expandedWorkRows: Record<string, boolean>;
10401053
readonly terminalAssistantMessageIds: ReadonlySet<string>;
10411054
readonly unsettledTurnId: RunId | null;
10421055
readonly onCopyWorkRow: (rowId: string, value: string) => void;
1043-
readonly onToggleWorkGroup: (groupId: string) => void;
1056+
readonly onToggleWorkGroup: (groupId: string, anchorKey?: string) => void;
10441057
readonly onToggleWorkRow: (rowId: string, anchorKey?: string) => void;
10451058
readonly onToggleTurnFold: (runId: RunId) => void;
10461059
readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;
@@ -1057,10 +1070,6 @@ function renderFeedEntry(
10571070
const entry = info.item;
10581071
const { markdownStyles, iconSubtleColor, userBubbleColor } = props;
10591072

1060-
if (entry.type === "working") {
1061-
return <WorkingTimelineRow startedAt={entry.createdAt} />;
1062-
}
1063-
10641073
if (entry.type === "run-fold") {
10651074
return (
10661075
<Pressable
@@ -1089,8 +1098,11 @@ function renderFeedEntry(
10891098
expanded={entry.expanded}
10901099
hiddenCount={entry.hiddenCount}
10911100
iconSubtleColor={iconSubtleColor}
1092-
onlyToolActivities={entry.onlyToolActivities}
1093-
onToggle={() => props.onToggleWorkGroup(entry.groupId)}
1101+
summary={entry.summary}
1102+
summaryKind={entry.summaryKind}
1103+
hasFailure={entry.hasFailure}
1104+
shimmer={entry.shimmer}
1105+
onToggle={() => props.onToggleWorkGroup(entry.groupId, entry.id)}
10941106
/>
10951107
);
10961108
}
@@ -1282,43 +1294,15 @@ function renderFeedEntry(
12821294
copiedRowId={props.copiedRowId}
12831295
currentThreadId={props.threadId}
12841296
environmentId={props.environmentId}
1285-
expanded={props.expandedWorkGroups[entry.id] ?? false}
12861297
expandedRows={props.expandedWorkRows}
12871298
iconSubtleColor={iconSubtleColor}
12881299
onCopyRow={props.onCopyWorkRow}
1289-
onToggleGroup={() => props.onToggleWorkGroup(entry.id)}
12901300
onToggleRow={(rowId) => props.onToggleWorkRow(rowId, entry.id)}
12911301
workspaceRoot={props.workspaceRoot}
12921302
/>
12931303
);
12941304
}
12951305

1296-
const WorkingTimelineRow = memo(function WorkingTimelineRow(props: { readonly startedAt: string }) {
1297-
const [nowMs, setNowMs] = useState(() => Date.now());
1298-
1299-
useEffect(() => {
1300-
const intervalId = setInterval(() => {
1301-
setNowMs(Date.now());
1302-
}, 1_000);
1303-
return () => clearInterval(intervalId);
1304-
}, [props.startedAt]);
1305-
1306-
const durationLabel = formatElapsed(props.startedAt, new Date(nowMs).toISOString()) ?? "0s";
1307-
1308-
return (
1309-
<View className="mb-4 flex-row items-center gap-2 px-1.5 py-1">
1310-
<View className="flex-row items-center gap-1">
1311-
<View className="h-1 w-1 rounded-full bg-adaptive-neutral-400-500" />
1312-
<View className="h-1 w-1 rounded-full bg-adaptive-neutral-400-a80-500-a80" />
1313-
<View className="h-1 w-1 rounded-full bg-adaptive-neutral-400-a60-500-a60" />
1314-
</View>
1315-
<Text className="font-t3-medium text-xs tabular-nums text-adaptive-neutral-600-400">
1316-
Working for {durationLabel}
1317-
</Text>
1318-
</View>
1319-
);
1320-
});
1321-
13221306
function UserMessageContent(props: {
13231307
readonly text: string;
13241308
readonly markdownStyles: MarkdownStyleSet;
@@ -1625,14 +1609,14 @@ function ThreadFeedPlaceholder(props: {
16251609
export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
16261610
const navigation = useNavigation();
16271611
const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1628-
const foldSettleFrameRef = useRef<number | null>(null);
1629-
const foldSettleSecondFrameRef = useRef<number | null>(null);
1612+
const disclosureSettleFrameRef = useRef<number | null>(null);
1613+
const disclosureSettleSecondFrameRef = useRef<number | null>(null);
1614+
const disclosureAnchorKeyRef = useRef<string | null>(null);
1615+
const previousPresentedFeedRef = useRef<ReadonlyArray<ThreadFeedEntry> | null>(null);
16301616
const previousLatestTurnRef = useRef(props.latestRun);
16311617
const userScrollSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1632-
const disclosureAnchorKeyRef = useRef<string | null>(null);
16331618
const headerMaterialVisibleRef = useRef(false);
16341619
const { width: windowWidth } = useWindowDimensions();
1635-
const { appearance } = useAppearancePreferences();
16361620
const [viewportWidth, setViewportWidth] = useState(() =>
16371621
props.layoutVariant === "split" ? 0 : windowWidth,
16381622
);
@@ -1893,6 +1877,32 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
18931877
),
18941878
[expandedTurnIds, expandedWorkGroups, props.activeWorkStartedAt, props.feed, props.latestRun],
18951879
);
1880+
const disclosureEnteringEntryIds = useMemo(() => {
1881+
const anchorKey = disclosureAnchorKeyRef.current;
1882+
const previousPresentedFeed = previousPresentedFeedRef.current;
1883+
if (!disclosureToggleSettling || anchorKey === null || previousPresentedFeed === null) {
1884+
return EMPTY_DISCLOSURE_ENTRY_IDS;
1885+
}
1886+
1887+
const previousIds = new Set(previousPresentedFeed.map((entry) => entry.id));
1888+
const anchorIndex = presentedFeed.findIndex((entry) => entry.id === anchorKey);
1889+
const enteringIds = new Set<string>();
1890+
if (anchorIndex < 0) {
1891+
return enteringIds;
1892+
}
1893+
for (let index = anchorIndex + 1; index < presentedFeed.length; index += 1) {
1894+
const entryId = presentedFeed[index]!.id;
1895+
if (previousIds.has(entryId)) {
1896+
break;
1897+
}
1898+
enteringIds.add(entryId);
1899+
}
1900+
return enteringIds;
1901+
}, [disclosureToggleSettling, presentedFeed]);
1902+
1903+
useLayoutEffect(() => {
1904+
previousPresentedFeedRef.current = presentedFeed;
1905+
}, [presentedFeed]);
18961906

18971907
// The empty↔filled key below remounts the list, which resets its imperative
18981908
// content-inset override — and useKeyboardChatComposerInset (mounted above
@@ -1961,34 +1971,52 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
19611971
if (copyFeedbackTimeoutRef.current) {
19621972
clearTimeout(copyFeedbackTimeoutRef.current);
19631973
}
1964-
if (foldSettleFrameRef.current !== null) {
1965-
cancelAnimationFrame(foldSettleFrameRef.current);
1974+
if (disclosureSettleFrameRef.current !== null) {
1975+
cancelAnimationFrame(disclosureSettleFrameRef.current);
19661976
}
1967-
if (foldSettleSecondFrameRef.current !== null) {
1968-
cancelAnimationFrame(foldSettleSecondFrameRef.current);
1977+
if (disclosureSettleSecondFrameRef.current !== null) {
1978+
cancelAnimationFrame(disclosureSettleSecondFrameRef.current);
19691979
}
19701980
};
19711981
}, []);
19721982

1973-
const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => {
1974-
disclosureAnchorKeyRef.current = anchorKey;
1975-
setDisclosureToggleSettling(true);
1976-
if (foldSettleFrameRef.current !== null) {
1977-
cancelAnimationFrame(foldSettleFrameRef.current);
1983+
const settleDisclosureAfterLayout = useCallback(() => {
1984+
if (disclosureSettleFrameRef.current !== null) {
1985+
cancelAnimationFrame(disclosureSettleFrameRef.current);
19781986
}
1979-
if (foldSettleSecondFrameRef.current !== null) {
1980-
cancelAnimationFrame(foldSettleSecondFrameRef.current);
1987+
if (disclosureSettleSecondFrameRef.current !== null) {
1988+
cancelAnimationFrame(disclosureSettleSecondFrameRef.current);
19811989
}
1982-
foldSettleFrameRef.current = requestAnimationFrame(() => {
1983-
foldSettleSecondFrameRef.current = requestAnimationFrame(() => {
1990+
disclosureSettleFrameRef.current = requestAnimationFrame(() => {
1991+
disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => {
19841992
disclosureAnchorKeyRef.current = null;
19851993
setDisclosureToggleSettling(false);
1986-
foldSettleFrameRef.current = null;
1987-
foldSettleSecondFrameRef.current = null;
1994+
disclosureSettleFrameRef.current = null;
1995+
disclosureSettleSecondFrameRef.current = null;
19881996
});
19891997
});
19901998
}, []);
19911999

2000+
const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => {
2001+
disclosureAnchorKeyRef.current = anchorKey;
2002+
setDisclosureToggleSettling(true);
2003+
}, []);
2004+
2005+
// Start the quiet-frame countdown after React has committed the disclosure.
2006+
// Every measured item-size change restarts it, so end maintenance cannot
2007+
// wake between the data mutation and LegendList's final layout correction.
2008+
useLayoutEffect(() => {
2009+
if (disclosureAnchorKeyRef.current !== null) {
2010+
settleDisclosureAfterLayout();
2011+
}
2012+
}, [expandedTurnIds, expandedWorkGroups, expandedWorkRows, settleDisclosureAfterLayout]);
2013+
2014+
const handleItemSizeChanged = useCallback(() => {
2015+
if (disclosureAnchorKeyRef.current !== null) {
2016+
settleDisclosureAfterLayout();
2017+
}
2018+
}, [settleDisclosureAfterLayout]);
2019+
19922020
const shouldRestoreVisibleContentPosition = useCallback((entry: ThreadFeedEntry) => {
19932021
const disclosureAnchorKey = disclosureAnchorKeyRef.current;
19942022
return disclosureAnchorKey === null || entry.id === disclosureAnchorKey;
@@ -2021,8 +2049,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
20212049
}, []);
20222050

20232051
const onToggleWorkGroup = useCallback(
2024-
(groupId: string) => {
2025-
suspendEndScrollMaintenanceForDisclosure(`work-toggle:${groupId}`);
2052+
(groupId: string, anchorKey?: string) => {
2053+
suspendEndScrollMaintenanceForDisclosure(anchorKey ?? `work-toggle:${groupId}`);
20262054
setInteractionState((current) => ({
20272055
...current,
20282056
expandedWorkGroups: {
@@ -2078,39 +2106,49 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
20782106
// link, so marking them fixed would make LegendList skip their measurement
20792107
// and position the next message inside the card.
20802108
const getFixedItemSize = useCallback(
2081-
(entry: ThreadFeedEntry) => resolveThreadFeedFixedItemSize(entry.type, appearance.baseFontSize),
2082-
[appearance.baseFontSize],
2109+
(entry: ThreadFeedEntry) => resolveThreadFeedFixedItemSize(entry.type),
2110+
[],
20832111
);
20842112

20852113
const renderItem = useCallback(
2086-
(info: { item: ThreadFeedEntry; index: number }) =>
2087-
renderFeedEntry(info, {
2088-
environmentId: props.environmentId,
2089-
threadId: props.threadId,
2090-
copiedRowId,
2091-
expandedWorkGroups,
2092-
expandedWorkRows,
2093-
terminalAssistantMessageIds,
2094-
unsettledTurnId,
2095-
onCopyWorkRow,
2096-
onToggleWorkGroup,
2097-
onToggleWorkRow,
2098-
onToggleTurnFold,
2099-
onPressImage,
2100-
onMarkdownLinkPress,
2101-
iconSubtleColor,
2102-
userBubbleColor,
2103-
markdownStyles,
2104-
reviewCommentColors,
2105-
reviewCommentBubbleWidth,
2106-
userBubbleMaxWidth,
2107-
threadTitle: props.threadTitle,
2108-
skills: props.skills,
2109-
workspaceRoot: props.workspaceRoot,
2110-
}),
2114+
(info: { item: ThreadFeedEntry; index: number }) => (
2115+
<Animated.View
2116+
key={info.item.id}
2117+
entering={
2118+
disclosureEnteringEntryIds.has(info.item.id)
2119+
? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION
2120+
: undefined
2121+
}
2122+
exiting={THREAD_FEED_DISCLOSURE_EXIT_TRANSITION}
2123+
>
2124+
{renderFeedEntry(info, {
2125+
environmentId: props.environmentId,
2126+
threadId: props.threadId,
2127+
copiedRowId,
2128+
expandedWorkRows,
2129+
terminalAssistantMessageIds,
2130+
unsettledTurnId,
2131+
onCopyWorkRow,
2132+
onToggleWorkGroup,
2133+
onToggleWorkRow,
2134+
onToggleTurnFold,
2135+
onPressImage,
2136+
onMarkdownLinkPress,
2137+
iconSubtleColor,
2138+
userBubbleColor,
2139+
markdownStyles,
2140+
reviewCommentColors,
2141+
reviewCommentBubbleWidth,
2142+
userBubbleMaxWidth,
2143+
threadTitle: props.threadTitle,
2144+
skills: props.skills,
2145+
workspaceRoot: props.workspaceRoot,
2146+
})}
2147+
</Animated.View>
2148+
),
21112149
[
21122150
copiedRowId,
2113-
expandedWorkGroups,
2151+
disclosureEnteringEntryIds,
21142152
expandedWorkRows,
21152153
terminalAssistantMessageIds,
21162154
unsettledTurnId,
@@ -2223,6 +2261,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
22232261
entry.type === "message" ? `message:${entry.message.role}` : entry.type
22242262
}
22252263
getFixedItemSize={getFixedItemSize}
2264+
itemLayoutAnimation={THREAD_FEED_LAYOUT_TRANSITION}
2265+
onItemSizeChanged={handleItemSizeChanged}
22262266
// Measure rows well before they scroll into view so estimate→actual
22272267
// corrections land offscreen instead of under the user's finger.
22282268
drawDistance={500}

0 commit comments

Comments
 (0)