authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-26 14:26:36-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-26 14:26:36-07:00
log24abab789ec68c6aba865a34e0901f8be8743595
tree71866a51d1b2f51ee32649b03948882971bedc4e
parent52b04b947e3604e426386be53e6d20c6a4366fef
signature Signed by PGP key B5690EEEBB952194

Stabilize chat scroll anchoring after send (#3564)


9 files changed, 693 insertions(+), 250 deletions(-)

apps/mobile/src/features/threads/ThreadFeed.tsx+107-55
...@@ -74,7 +74,7 @@ import {...@@ -74,7 +74,7 @@ import {
74 type ThreadFeedLatestTurn,74 type ThreadFeedLatestTurn,
75} from "../../lib/threadActivity";75} from "../../lib/threadActivity";
76import type { ThreadContentPresentation } from "./threadContentPresentation";76import type { ThreadContentPresentation } from "./threadContentPresentation";
77import { ThreadWorkLog } from "./thread-work-log";77import { ThreadWorkGroupToggle, ThreadWorkLog } from "./thread-work-log";
78import { useAssetUrl } from "../../state/assets";78import { useAssetUrl } from "../../state/assets";
79import { resolveWorkspaceRelativeFilePath } from "../files/filePath";79import { resolveWorkspaceRelativeFilePath } from "../files/filePath";
8080
...@@ -655,7 +655,6 @@ function renderFeedEntry(...@@ -655,7 +655,6 @@ function renderFeedEntry(
655 info: { item: ThreadFeedEntry; index: number },655 info: { item: ThreadFeedEntry; index: number },
656 props: Pick<ThreadFeedProps, "environmentId" | "skills"> & {656 props: Pick<ThreadFeedProps, "environmentId" | "skills"> & {
657 readonly copiedRowId: string | null;657 readonly copiedRowId: string | null;
658 readonly expandedWorkGroups: Record<string, boolean>;
659 readonly expandedWorkRows: Record<string, boolean>;658 readonly expandedWorkRows: Record<string, boolean>;
660 readonly terminalAssistantMessageIds: ReadonlySet<string>;659 readonly terminalAssistantMessageIds: ReadonlySet<string>;
661 readonly unsettledTurnId: TurnId | null;660 readonly unsettledTurnId: TurnId | null;
...@@ -698,6 +697,18 @@ function renderFeedEntry(...@@ -698,6 +697,18 @@ function renderFeedEntry(
698 );697 );
699 }698 }
700699
700 if (entry.type === "work-toggle") {
701 return (
702 <ThreadWorkGroupToggle
703 expanded={entry.expanded}
704 hiddenCount={entry.hiddenCount}
705 iconSubtleColor={iconSubtleColor}
706 onlyToolActivities={entry.onlyToolActivities}
707 onToggle={() => props.onToggleWorkGroup(entry.groupId)}
708 />
709 );
710 }
711
701 if (entry.type === "message") {712 if (entry.type === "message") {
702 const { message } = entry;713 const { message } = entry;
703 const isUser = message.role === "user";714 const isUser = message.role === "user";
...@@ -825,11 +836,9 @@ function renderFeedEntry(...@@ -825,11 +836,9 @@ function renderFeedEntry(
825 <ThreadWorkLog836 <ThreadWorkLog
826 activities={entry.activities}837 activities={entry.activities}
827 copiedRowId={props.copiedRowId}838 copiedRowId={props.copiedRowId}
828 expanded={props.expandedWorkGroups[entry.id] ?? false}
829 expandedRows={props.expandedWorkRows}839 expandedRows={props.expandedWorkRows}
830 iconSubtleColor={iconSubtleColor}840 iconSubtleColor={iconSubtleColor}
831 onCopyRow={props.onCopyWorkRow}841 onCopyRow={props.onCopyWorkRow}
832 onToggleGroup={() => props.onToggleWorkGroup(entry.id)}
833 onToggleRow={props.onToggleWorkRow}842 onToggleRow={props.onToggleWorkRow}
834 />843 />
835 );844 );
...@@ -1110,9 +1119,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1110,9 +1119,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1110 const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);1119 const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1111 const foldSettleFrameRef = useRef<number | null>(null);1120 const foldSettleFrameRef = useRef<number | null>(null);
1112 const foldSettleSecondFrameRef = useRef<number | null>(null);1121 const foldSettleSecondFrameRef = useRef<number | null>(null);
1122 const disclosureAnchorKeyRef = useRef<string | null>(null);
1113 const previousLatestTurnRef = useRef(props.latestTurn);1123 const previousLatestTurnRef = useRef(props.latestTurn);
1114 const { width: viewportWidth } = useWindowDimensions();1124 const { width: viewportWidth } = useWindowDimensions();
1115 const [foldToggleSettling, setFoldToggleSettling] = useState(false);1125 const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);
1116 const [interactionState, setInteractionState] = useState<{1126 const [interactionState, setInteractionState] = useState<{
1117 readonly copiedRowId: string | null;1127 readonly copiedRowId: string | null;
1118 readonly expandedWorkGroups: Record<string, boolean>;1128 readonly expandedWorkGroups: Record<string, boolean>;
...@@ -1173,7 +1183,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1173,7 +1183,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1173 const listAppearanceData = useMemo(1183 const listAppearanceData = useMemo(
1174 () => ({1184 () => ({
1175 copiedRowId,1185 copiedRowId,
1176 expandedWorkGroups,
1177 expandedWorkRows,1186 expandedWorkRows,
1178 iconSubtleColor,1187 iconSubtleColor,
1179 markdownStyles,1188 markdownStyles,
...@@ -1182,7 +1191,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1182,7 +1191,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1182 }),1191 }),
1183 [1192 [
1184 copiedRowId,1193 copiedRowId,
1185 expandedWorkGroups,
1186 expandedWorkRows,1194 expandedWorkRows,
1187 iconSubtleColor,1195 iconSubtleColor,
1188 markdownStyles,1196 markdownStyles,
...@@ -1190,9 +1198,24 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1190,9 +1198,24 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1190 userBubbleColor,1198 userBubbleColor,
1191 ],1199 ],
1192 );1200 );
1201 const expandedWorkGroupIds = useMemo(() => {
1202 const ids = new Set<string>();
1203 for (const [groupId, expanded] of Object.entries(expandedWorkGroups)) {
1204 if (expanded) {
1205 ids.add(groupId);
1206 }
1207 }
1208 return ids;
1209 }, [expandedWorkGroups]);
1193 const presentedFeed = useMemo(1210 const presentedFeed = useMemo(
1194 () => deriveThreadFeedPresentation(props.feed, props.latestTurn, expandedTurnIds),1211 () =>
1195 [expandedTurnIds, props.feed, props.latestTurn],1212 deriveThreadFeedPresentation(
1213 props.feed,
1214 props.latestTurn,
1215 expandedTurnIds,
1216 expandedWorkGroupIds,
1217 ),
1218 [expandedTurnIds, expandedWorkGroupIds, props.feed, props.latestTurn],
1196 );1219 );
1197 const anchoredEndSpace = useMemo(1220 const anchoredEndSpace = useMemo(
1198 () =>1221 () =>
...@@ -1259,6 +1282,39 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1259,6 +1282,39 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1259 };1282 };
1260 }, []);1283 }, []);
12611284
1285 const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => {
1286 disclosureAnchorKeyRef.current = anchorKey;
1287 setDisclosureToggleSettling(true);
1288 if (foldSettleFrameRef.current !== null) {
1289 cancelAnimationFrame(foldSettleFrameRef.current);
1290 }
1291 if (foldSettleSecondFrameRef.current !== null) {
1292 cancelAnimationFrame(foldSettleSecondFrameRef.current);
1293 }
1294 foldSettleFrameRef.current = requestAnimationFrame(() => {
1295 foldSettleSecondFrameRef.current = requestAnimationFrame(() => {
1296 disclosureAnchorKeyRef.current = null;
1297 setDisclosureToggleSettling(false);
1298 foldSettleFrameRef.current = null;
1299 foldSettleSecondFrameRef.current = null;
1300 });
1301 });
1302 }, []);
1303
1304 const shouldRestoreVisibleContentPosition = useCallback((entry: ThreadFeedEntry) => {
1305 const disclosureAnchorKey = disclosureAnchorKeyRef.current;
1306 return disclosureAnchorKey === null || entry.id === disclosureAnchorKey;
1307 }, []);
1308
1309 const maintainVisibleContentPosition = useMemo(
1310 () => ({
1311 data: true,
1312 size: true,
1313 shouldRestorePosition: shouldRestoreVisibleContentPosition,
1314 }),
1315 [shouldRestoreVisibleContentPosition],
1316 );
1317
1262 const onCopyWorkRow = useCallback((rowId: string, value: string) => {1318 const onCopyWorkRow = useCallback((rowId: string, value: string) => {
1263 copyTextWithHaptic(value, {1319 copyTextWithHaptic(value, {
1264 target: "thread-work-row",1320 target: "thread-work-row",
...@@ -1276,51 +1332,49 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1276,51 +1332,49 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1276 }, 1200);1332 }, 1200);
1277 }, []);1333 }, []);
12781334
1279 const onToggleWorkGroup = useCallback((groupId: string) => {1335 const onToggleWorkGroup = useCallback(
1280 setInteractionState((current) => ({1336 (groupId: string) => {
1281 ...current,1337 suspendEndScrollMaintenanceForDisclosure(`work-toggle:${groupId}`);
1282 expandedWorkGroups: {1338 setInteractionState((current) => ({
1283 ...current.expandedWorkGroups,1339 ...current,
1284 [groupId]: !(current.expandedWorkGroups[groupId] ?? false),1340 expandedWorkGroups: {
1285 },1341 ...current.expandedWorkGroups,
1286 }));1342 [groupId]: !(current.expandedWorkGroups[groupId] ?? false),
1287 }, []);1343 },
1344 }));
1345 },
1346 [suspendEndScrollMaintenanceForDisclosure],
1347 );
12881348
1289 const onToggleWorkRow = useCallback((rowId: string) => {1349 const onToggleWorkRow = useCallback(
1290 setInteractionState((current) => ({1350 (rowId: string) => {
1291 ...current,1351 suspendEndScrollMaintenanceForDisclosure(rowId);
1292 expandedWorkRows: {1352 setInteractionState((current) => ({
1293 ...current.expandedWorkRows,1353 ...current,
1294 [rowId]: !(current.expandedWorkRows[rowId] ?? false),1354 expandedWorkRows: {
1295 },1355 ...current.expandedWorkRows,
1296 }));1356 [rowId]: !(current.expandedWorkRows[rowId] ?? false),
1297 }, []);1357 },
1358 }));
1359 },
1360 [suspendEndScrollMaintenanceForDisclosure],
1361 );
12981362
1299 const onToggleTurnFold = useCallback((turnId: TurnId) => {1363 const onToggleTurnFold = useCallback(
1300 setFoldToggleSettling(true);1364 (turnId: TurnId) => {
1301 if (foldSettleFrameRef.current !== null) {1365 suspendEndScrollMaintenanceForDisclosure(`turn-fold:${turnId}`);
1302 cancelAnimationFrame(foldSettleFrameRef.current);1366 setInteractionState((current) => {
1303 }1367 const next = new Set(current.expandedTurnIds);
1304 if (foldSettleSecondFrameRef.current !== null) {1368 if (next.has(turnId)) {
1305 cancelAnimationFrame(foldSettleSecondFrameRef.current);1369 next.delete(turnId);
1306 }1370 } else {
1307 setInteractionState((current) => {1371 next.add(turnId);
1308 const next = new Set(current.expandedTurnIds);1372 }
1309 if (next.has(turnId)) {1373 return { ...current, expandedTurnIds: next };
1310 next.delete(turnId);
1311 } else {
1312 next.add(turnId);
1313 }
1314 return { ...current, expandedTurnIds: next };
1315 });
1316 foldSettleFrameRef.current = requestAnimationFrame(() => {
1317 foldSettleSecondFrameRef.current = requestAnimationFrame(() => {
1318 setFoldToggleSettling(false);
1319 foldSettleFrameRef.current = null;
1320 foldSettleSecondFrameRef.current = null;
1321 });1374 });
1322 });1375 },
1323 }, []);1376 [suspendEndScrollMaintenanceForDisclosure],
1377 );
13241378
1325 const onPressImage = useCallback((uri: string, headers?: Record<string, string>) => {1379 const onPressImage = useCallback((uri: string, headers?: Record<string, string>) => {
1326 setExpandedImage({ uri, headers });1380 setExpandedImage({ uri, headers });
...@@ -1331,7 +1385,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1331,7 +1385,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1331 renderFeedEntry(info, {1385 renderFeedEntry(info, {
1332 environmentId: props.environmentId,1386 environmentId: props.environmentId,
1333 copiedRowId,1387 copiedRowId,
1334 expandedWorkGroups,
1335 expandedWorkRows,1388 expandedWorkRows,
1336 terminalAssistantMessageIds,1389 terminalAssistantMessageIds,
1337 unsettledTurnId,1390 unsettledTurnId,
...@@ -1351,7 +1404,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1351,7 +1404,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1351 }),1404 }),
1352 [1405 [
1353 copiedRowId,1406 copiedRowId,
1354 expandedWorkGroups,
1355 expandedWorkRows,1407 expandedWorkRows,
1356 terminalAssistantMessageIds,1408 terminalAssistantMessageIds,
1357 unsettledTurnId,1409 unsettledTurnId,
...@@ -1410,7 +1462,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1410,7 +1462,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1410 contentInsetEndAdjustment={props.contentInsetEndAdjustment}1462 contentInsetEndAdjustment={props.contentInsetEndAdjustment}
1411 freeze={props.freeze}1463 freeze={props.freeze}
1412 maintainScrollAtEnd={1464 maintainScrollAtEnd={
1413 foldToggleSettling1465 disclosureToggleSettling
1414 ? false1466 ? false
1415 : {1467 : {
1416 animated: false,1468 animated: false,
...@@ -1421,7 +1473,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1421,7 +1473,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1421 },1473 },
1422 }1474 }
1423 }1475 }
1424 maintainVisibleContentPosition1476 maintainVisibleContentPosition={maintainVisibleContentPosition}
1425 data={presentedFeed}1477 data={presentedFeed}
1426 extraData={listAppearanceData}1478 extraData={listAppearanceData}
1427 renderItem={renderItem}1479 renderItem={renderItem}
apps/mobile/src/features/threads/thread-work-log.tsx+60-49
...@@ -6,7 +6,6 @@ import { AppText as Text } from "../../components/AppText";...@@ -6,7 +6,6 @@ import { AppText as Text } from "../../components/AppText";
6import { cn } from "../../lib/cn";6import { cn } from "../../lib/cn";
7import type { ThreadFeedActivity } from "../../lib/threadActivity";7import type { ThreadFeedActivity } from "../../lib/threadActivity";
88
9const MAX_VISIBLE_WORK_LOG_ENTRIES = 1;
10const WORK_LOG_LAYOUT_ANIMATION = {9const WORK_LOG_LAYOUT_ANIMATION = {
11 duration: 180,10 duration: 180,
12 create: {11 create: {
...@@ -72,11 +71,9 @@ function workRowSymbolName(icon: ThreadFeedActivity["icon"]): SFSymbol {...@@ -72,11 +71,9 @@ function workRowSymbolName(icon: ThreadFeedActivity["icon"]): SFSymbol {
72export function ThreadWorkLog(props: {71export function ThreadWorkLog(props: {
73 readonly activities: ReadonlyArray<ThreadFeedActivity>;72 readonly activities: ReadonlyArray<ThreadFeedActivity>;
74 readonly copiedRowId: string | null;73 readonly copiedRowId: string | null;
75 readonly expanded: boolean;
76 readonly expandedRows: Readonly<Record<string, boolean>>;74 readonly expandedRows: Readonly<Record<string, boolean>>;
77 readonly iconSubtleColor: import("react-native").ColorValue;75 readonly iconSubtleColor: import("react-native").ColorValue;
78 readonly onCopyRow: (rowId: string, value: string) => void;76 readonly onCopyRow: (rowId: string, value: string) => void;
79 readonly onToggleGroup: () => void;
80 readonly onToggleRow: (rowId: string) => void;77 readonly onToggleRow: (rowId: string) => void;
81}) {78}) {
82 const colorScheme = useColorScheme();79 const colorScheme = useColorScheme();
...@@ -89,14 +86,10 @@ export function ThreadWorkLog(props: {...@@ -89,14 +86,10 @@ export function ThreadWorkLog(props: {
89 return null;86 return null;
90 }87 }
9188
92 const hasOverflow = rows.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
93 const visibleRows =
94 hasOverflow && !props.expanded ? rows.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) : rows;
95 const hiddenCount = rows.length - visibleRows.length;
96 const onlyToolRows = rows.every((row) => row.toolLike);89 const onlyToolRows = rows.every((row) => row.toolLike);
9790
98 return (91 return (
99 <View className="-mx-1 mb-3 px-1 py-0.5">92 <View className="-mx-1 mb-1 px-1 py-0">
100 {!onlyToolRows ? (93 {!onlyToolRows ? (
101 <Text className="px-0.5 pb-0.5 font-t3-medium text-2xs text-foreground-muted opacity-60">94 <Text className="px-0.5 pb-0.5 font-t3-medium text-2xs text-foreground-muted opacity-60">
102 work log95 work log
...@@ -104,7 +97,7 @@ export function ThreadWorkLog(props: {...@@ -104,7 +97,7 @@ export function ThreadWorkLog(props: {
104 ) : null}97 ) : null}
10598
106 <View className="gap-px">99 <View className="gap-px">
107 {visibleRows.map((row) => {100 {rows.map((row) => {
108 const expanded = props.expandedRows[row.id] ?? false;101 const expanded = props.expandedRows[row.id] ?? false;
109 const canExpand = row.fullDetail !== null;102 const canExpand = row.fullDetail !== null;
110 const displayText = row.detail ? `${row.summary} ${row.detail}` : row.summary;103 const displayText = row.detail ? `${row.summary} ${row.detail}` : row.summary;
...@@ -132,13 +125,13 @@ export function ThreadWorkLog(props: {...@@ -132,13 +125,13 @@ export function ThreadWorkLog(props: {
132 style={({ pressed }) => ({125 style={({ pressed }) => ({
133 backgroundColor: pressed ? pressedBackground : "transparent",126 backgroundColor: pressed ? pressedBackground : "transparent",
134 })}127 })}
135 className="rounded-md px-0.5 py-0.5"128 className="rounded-md px-0.5 py-0"
136 >129 >
137 <View className="min-h-9 flex-row items-center gap-1.5">130 <View className="min-h-8 flex-row items-center gap-1.5">
138 <View className="h-5 w-5 shrink-0 items-center justify-center">131 <View className="h-[18px] w-5 shrink-0 items-center justify-center">
139 <SymbolView132 <SymbolView
140 name={workRowSymbolName(row.icon)}133 name={workRowSymbolName(row.icon)}
141 size={14}134 size={13}
142 weight="medium"135 weight="medium"
143 tintColor={iconIsDestructive ? "#e11d48" : props.iconSubtleColor}136 tintColor={iconIsDestructive ? "#e11d48" : props.iconSubtleColor}
144 type="monochrome"137 type="monochrome"
...@@ -146,7 +139,7 @@ export function ThreadWorkLog(props: {...@@ -146,7 +139,7 @@ export function ThreadWorkLog(props: {
146 </View>139 </View>
147140
148 <Text141 <Text
149 className="min-w-0 flex-1 text-xs leading-5 text-foreground"142 className="min-w-0 flex-1 text-xs leading-4 text-foreground"
150 numberOfLines={1}143 numberOfLines={1}
151 >144 >
152 <Text145 <Text
...@@ -199,7 +192,7 @@ export function ThreadWorkLog(props: {...@@ -199,7 +192,7 @@ export function ThreadWorkLog(props: {
199 </Pressable>192 </Pressable>
200193
201 {expanded && row.fullDetail ? (194 {expanded && row.fullDetail ? (
202 <View className="ml-7 border-l border-neutral-300/60 pb-1.5 pl-3 pt-0.5 dark:border-white/[0.12]">195 <View className="ml-7 border-l border-neutral-300/60 pb-1 pl-3 pt-0.5 dark:border-white/[0.12]">
203 <ScrollView196 <ScrollView
204 nestedScrollEnabled197 nestedScrollEnabled
205 directionalLockEnabled198 directionalLockEnabled
...@@ -221,41 +214,59 @@ export function ThreadWorkLog(props: {...@@ -221,41 +214,59 @@ export function ThreadWorkLog(props: {
221 );214 );
222 })}215 })}
223 </View>216 </View>
217 </View>
218 );
219}
224220
225 {hasOverflow ? (221export function ThreadWorkGroupToggle(props: {
226 <Pressable222 readonly expanded: boolean;
227 accessibilityRole="button"223 readonly hiddenCount: number;
228 accessibilityState={{ expanded: props.expanded }}224 readonly iconSubtleColor: import("react-native").ColorValue;
229 accessibilityLabel={225 readonly onlyToolActivities: boolean;
230 props.expanded226 readonly onToggle: () => void;
231 ? "Show fewer tool calls"227}) {
232 : `Show ${hiddenCount} previous tool ${hiddenCount === 1 ? "call" : "calls"}`228 const colorScheme = useColorScheme();
233 }229 const pressedBackground = colorScheme === "dark" ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.035)";
234 hitSlop={4}230 const noun = props.onlyToolActivities
235 onPress={() => {231 ? props.hiddenCount === 1
236 triggerDisclosureFeedback();232 ? "tool call"
237 props.onToggleGroup();233 : "tool calls"
238 }}234 : props.hiddenCount === 1
239 style={({ pressed }) => ({235 ? "log entry"
240 backgroundColor: pressed ? pressedBackground : "transparent",236 : "log entries";
241 })}237 const collapsedLabel = `Show ${props.hiddenCount} previous ${noun}`;
242 className="min-h-9 flex-row items-center gap-1.5 rounded-md px-0.5 py-0.5"238 const expandedLabel = props.onlyToolActivities
243 >239 ? "Show fewer tool calls"
244 <View className="h-5 w-5 items-center justify-center">240 : "Show fewer log entries";
245 <SymbolView241
246 name={props.expanded ? "chevron.up" : "chevron.down"}242 return (
247 size={13}243 <View className="-mx-1 mb-1 px-1 py-0">
248 tintColor={props.iconSubtleColor}244 <Pressable
249 type="monochrome"245 accessibilityRole="button"
250 />246 accessibilityState={{ expanded: props.expanded }}
251 </View>247 accessibilityLabel={props.expanded ? expandedLabel : collapsedLabel}
252 <Text className="font-t3-medium text-xs text-foreground opacity-80">248 hitSlop={4}
253 {props.expanded249 onPress={() => {
254 ? "Show fewer tool calls"250 void Haptics.selectionAsync();
255 : `+${hiddenCount} previous tool ${hiddenCount === 1 ? "call" : "calls"}`}251 props.onToggle();
256 </Text>252 }}
257 </Pressable>253 style={({ pressed }) => ({
258 ) : null}254 backgroundColor: pressed ? pressedBackground : "transparent",
255 })}
256 className="min-h-8 flex-row items-center gap-1.5 rounded-md px-0.5 py-0"
257 >
258 <View className="h-[18px] w-5 items-center justify-center">
259 <SymbolView
260 name={props.expanded ? "chevron.up" : "chevron.down"}
261 size={12}
262 tintColor={props.iconSubtleColor}
263 type="monochrome"
264 />
265 </View>
266 <Text className="font-t3-medium text-xs text-foreground opacity-80">
267 {props.expanded ? expandedLabel : `+${props.hiddenCount} previous ${noun}`}
268 </Text>
269 </Pressable>
259 </View>270 </View>
260 );271 );
261}272}
apps/mobile/src/lib/threadActivity.test.ts+60-1
...@@ -11,7 +11,12 @@ import {...@@ -11,7 +11,12 @@ import {
11 type OrchestrationThreadActivity,11 type OrchestrationThreadActivity,
12} from "@t3tools/contracts";12} from "@t3tools/contracts";
1313
14import { buildThreadFeed, deriveThreadFeedPresentation } from "./threadActivity";14import {
15 buildThreadFeed,
16 deriveThreadFeedPresentation,
17 type ThreadFeedActivity,
18 type ThreadFeedEntry,
19} from "./threadActivity";
1520
16function makeActivity(21function makeActivity(
17 input: Partial<OrchestrationThreadActivity> &22 input: Partial<OrchestrationThreadActivity> &
...@@ -406,4 +411,58 @@ describe("buildThreadFeed", () => {...@@ -406,4 +411,58 @@ describe("buildThreadFeed", () => {
406 activities: [{ status: "failure" }],411 activities: [{ status: "failure" }],
407 });412 });
408 });413 });
414
415 it("models work-log overflow as list rows", () => {
416 const activity = (
417 id: string,
418 createdAt: string,
419 status: ThreadFeedActivity["status"] = "success",
420 ): ThreadFeedActivity => ({
421 id,
422 createdAt,
423 turnId: null,
424 summary: `Tool ${id}`,
425 detail: null,
426 fullDetail: null,
427 copyText: id,
428 icon: "command",
429 toolLike: true,
430 status,
431 });
432 const feed: ThreadFeedEntry[] = [
433 {
434 type: "activity-group",
435 id: "work-group-1",
436 createdAt: "2026-04-01T00:00:01.000Z",
437 turnId: null,
438 activities: [
439 activity("activity-1", "2026-04-01T00:00:01.000Z"),
440 activity("activity-neutral", "2026-04-01T00:00:02.000Z", "neutral"),
441 activity("activity-2", "2026-04-01T00:00:03.000Z"),
442 activity("activity-3", "2026-04-01T00:00:04.000Z"),
443 ],
444 },
445 ];
446
447 const collapsed = deriveThreadFeedPresentation(feed, null, new Set());
448 expect(collapsed.map((entry) => entry.id)).toEqual(["activity-3", "work-toggle:work-group-1"]);
449 expect(collapsed[1]).toMatchObject({
450 type: "work-toggle",
451 groupId: "work-group-1",
452 hiddenCount: 2,
453 expanded: false,
454 });
455
456 const expanded = deriveThreadFeedPresentation(feed, null, new Set(), new Set(["work-group-1"]));
457 expect(expanded.map((entry) => entry.id)).toEqual([
458 "activity-1",
459 "activity-2",
460 "activity-3",
461 "work-toggle:work-group-1",
462 ]);
463 expect(expanded.at(-1)).toMatchObject({
464 type: "work-toggle",
465 expanded: true,
466 });
467 });
409});468});
apps/mobile/src/lib/threadActivity.ts+67-2
...@@ -55,6 +55,8 @@ export interface ThreadFeedActivity {...@@ -55,6 +55,8 @@ export interface ThreadFeedActivity {
55 readonly status: "success" | "failure" | "neutral" | null;55 readonly status: "success" | "failure" | "neutral" | null;
56}56}
5757
58const MAX_VISIBLE_WORK_LOG_ENTRIES = 1;
59
58type WorkLogToolLifecycleStatus = "inProgress" | "completed" | "failed" | "declined" | "stopped";60type WorkLogToolLifecycleStatus = "inProgress" | "completed" | "failed" | "declined" | "stopped";
5961
60interface WorkLogEntry {62interface WorkLogEntry {
...@@ -103,6 +105,16 @@ export type ThreadFeedEntry =...@@ -103,6 +105,16 @@ export type ThreadFeedEntry =
103 readonly turnId: TurnId | null;105 readonly turnId: TurnId | null;
104 readonly activities: ReadonlyArray<ThreadFeedActivity>;106 readonly activities: ReadonlyArray<ThreadFeedActivity>;
105 }107 }
108 | {
109 readonly type: "work-toggle";
110 readonly id: string;
111 readonly createdAt: string;
112 readonly turnId: TurnId | null;
113 readonly groupId: string;
114 readonly hiddenCount: number;
115 readonly expanded: boolean;
116 readonly onlyToolActivities: boolean;
117 }
106 | {118 | {
107 readonly type: "turn-fold";119 readonly type: "turn-fold";
108 readonly id: string;120 readonly id: string;
...@@ -1092,8 +1104,11 @@ export function deriveThreadFeedPresentation(...@@ -1092,8 +1104,11 @@ export function deriveThreadFeedPresentation(
1092 feed: ReadonlyArray<ThreadFeedEntry>,1104 feed: ReadonlyArray<ThreadFeedEntry>,
1093 latestTurn: ThreadFeedLatestTurn | null,1105 latestTurn: ThreadFeedLatestTurn | null,
1094 expandedTurnIds: ReadonlySet<TurnId>,1106 expandedTurnIds: ReadonlySet<TurnId>,
1107 expandedWorkGroupIds: ReadonlySet<string> = new Set(),
1095): ThreadFeedEntry[] {1108): ThreadFeedEntry[] {
1096 const sourceFeed = feed.filter((entry) => entry.type !== "turn-fold");1109 const sourceFeed = feed.filter(
1110 (entry) => entry.type !== "turn-fold" && entry.type !== "work-toggle",
1111 );
1097 const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn);1112 const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn);
1098 const collapsedEntryIds = new Set<string>();1113 const collapsedEntryIds = new Set<string>();
1099 for (const fold of foldsByAnchorId.values()) {1114 for (const fold of foldsByAnchorId.values()) {
...@@ -1118,12 +1133,62 @@ export function deriveThreadFeedPresentation(...@@ -1118,12 +1133,62 @@ export function deriveThreadFeedPresentation(
1118 });1133 });
1119 }1134 }
1120 if (!collapsedEntryIds.has(entry.id)) {1135 if (!collapsedEntryIds.has(entry.id)) {
1121 result.push(entry);1136 appendPresentedFeedEntry(result, entry, expandedWorkGroupIds);
1122 }1137 }
1123 }1138 }
1124 return result;1139 return result;
1125}1140}
11261141
1142function appendPresentedFeedEntry(
1143 result: ThreadFeedEntry[],
1144 entry: Exclude<ThreadFeedEntry, { readonly type: "turn-fold" | "work-toggle" }>,
1145 expandedWorkGroupIds: ReadonlySet<string>,
1146): void {
1147 if (entry.type !== "activity-group") {
1148 result.push(entry);
1149 return;
1150 }
1151
1152 const activities = entry.activities.filter(
1153 (activity) => !(activity.toolLike && activity.status === "neutral"),
1154 );
1155 if (activities.length === 0) {
1156 return;
1157 }
1158 if (activities.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) {
1159 result.push({
1160 ...entry,
1161 activities,
1162 });
1163 return;
1164 }
1165
1166 const groupId = entry.id;
1167 const expanded = expandedWorkGroupIds.has(groupId);
1168 const hiddenCount = activities.length - MAX_VISIBLE_WORK_LOG_ENTRIES;
1169 const visibleActivities = expanded ? activities : activities.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES);
1170
1171 for (const activity of visibleActivities) {
1172 result.push({
1173 type: "activity-group",
1174 id: activity.id,
1175 createdAt: activity.createdAt,
1176 turnId: activity.turnId,
1177 activities: [activity],
1178 });
1179 }
1180 result.push({
1181 type: "work-toggle",
1182 id: `work-toggle:${groupId}`,
1183 createdAt: entry.createdAt,
1184 turnId: entry.turnId,
1185 groupId,
1186 hiddenCount,
1187 expanded,
1188 onlyToolActivities: activities.every((activity) => activity.toolLike),
1189 });
1190}
1191
1127export function derivePendingApprovals(1192export function derivePendingApprovals(
1128 activities: ReadonlyArray<OrchestrationThreadActivity>,1193 activities: ReadonlyArray<OrchestrationThreadActivity>,
1129): PendingApproval[] {1194): PendingApproval[] {
apps/web/src/components/ChatView.tsx+82-5
...@@ -36,6 +36,7 @@ import {...@@ -36,6 +36,7 @@ import {
36 createModelSelection,36 createModelSelection,
37 resolvePromptInjectedEffort,37 resolvePromptInjectedEffort,
38} from "@t3tools/shared/model";38} from "@t3tools/shared/model";
39import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList";
39import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";40import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
40import { truncate } from "@t3tools/shared/String";41import { truncate } from "@t3tools/shared/String";
41import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";42import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";
...@@ -3147,10 +3148,83 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3147,10 +3148,83 @@ function ChatViewContent(props: ChatViewProps) {
3147 ],3148 ],
3148 );3149 );
31493150
3150 // Scroll helpers — LegendList handles auto-scroll via maintainScrollAtEnd.3151 // Scrolling is explicit so streamed timeline updates never take control away
3152 // from the user after the newly sent row has been positioned once.
3151 const scrollToEnd = useCallback((animated = false) => {3153 const scrollToEnd = useCallback((animated = false) => {
3152 void legendListRef.current?.scrollToEnd?.({ animated });3154 void legendListRef.current?.scrollToEnd?.({ animated });
3153 }, []);3155 }, []);
3156 const positionedTimelineAnchorRef = useRef<MessageId | null>(null);
3157 const settledTimelineAnchorRef = useRef<MessageId | null>(null);
3158 const pendingAnchorScrollRestoreRef = useRef<{
3159 readonly messageId: MessageId;
3160 readonly offset: number;
3161 } | null>(null);
3162 const anchorScrollRestoreFrameRef = useRef<number | null>(null);
3163 const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => {
3164 if (positionedTimelineAnchorRef.current === messageId) {
3165 return;
3166 }
3167 positionedTimelineAnchorRef.current = messageId;
3168 settledTimelineAnchorRef.current = null;
3169 requestAnimationFrame(() => {
3170 requestAnimationFrame(() => {
3171 if (positionedTimelineAnchorRef.current !== messageId) {
3172 return;
3173 }
3174 const list = legendListRef.current;
3175 if (!list) {
3176 return;
3177 }
3178 const scrollNode = list.getScrollableNode();
3179 let finished = false;
3180 const finishAnimatedPositioning = () => {
3181 if (finished) {
3182 return;
3183 }
3184 finished = true;
3185 window.clearTimeout(fallbackTimer);
3186 scrollNode.removeEventListener("scrollend", finishAnimatedPositioning);
3187 if (positionedTimelineAnchorRef.current !== messageId) {
3188 return;
3189 }
3190 const scrollOffset = list.getState().scroll;
3191 void list.scrollToOffset({ offset: scrollOffset, animated: false });
3192 settledTimelineAnchorRef.current = messageId;
3193 };
3194 const fallbackTimer = window.setTimeout(finishAnimatedPositioning, 750);
3195 scrollNode.addEventListener("scrollend", finishAnimatedPositioning, { once: true });
3196 void list.scrollToIndex({
3197 index: anchorIndex,
3198 animated: true,
3199 viewPosition: 0,
3200 viewOffset: CHAT_LIST_ANCHOR_OFFSET,
3201 });
3202 });
3203 });
3204 }, []);
3205 const onTimelineAnchorSizeChanged = useCallback((messageId: MessageId) => {
3206 if (settledTimelineAnchorRef.current !== messageId) {
3207 return;
3208 }
3209 const scrollOffset = legendListRef.current?.getState().scroll;
3210 if (scrollOffset === undefined) {
3211 return;
3212 }
3213 if (pendingAnchorScrollRestoreRef.current === null) {
3214 pendingAnchorScrollRestoreRef.current = { messageId, offset: scrollOffset };
3215 }
3216 if (anchorScrollRestoreFrameRef.current !== null) {
3217 return;
3218 }
3219 anchorScrollRestoreFrameRef.current = requestAnimationFrame(() => {
3220 anchorScrollRestoreFrameRef.current = null;
3221 const pending = pendingAnchorScrollRestoreRef.current;
3222 pendingAnchorScrollRestoreRef.current = null;
3223 if (pending && settledTimelineAnchorRef.current === pending.messageId) {
3224 void legendListRef.current?.scrollToOffset({ offset: pending.offset, animated: false });
3225 }
3226 });
3227 }, []);
31543228
3155 // Debounce *showing* the scroll-to-bottom pill so it doesn't flash during3229 // Debounce *showing* the scroll-to-bottom pill so it doesn't flash during
3156 // thread switches. LegendList fires scroll events with isAtEnd=false while3230 // thread switches. LegendList fires scroll events with isAtEnd=false while
...@@ -3751,8 +3825,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3751,8 +3825,6 @@ function ChatViewContent(props: ChatViewProps) {
3751 streaming: false,3825 streaming: false,
3752 },3826 },
3753 ]);3827 ]);
3754 void legendListRef.current?.scrollToEnd?.({ animated: false });
3755
3756 setThreadError(threadIdForSend, null);3828 setThreadError(threadIdForSend, null);
3757 if (expiredTerminalContextCount > 0) {3829 if (expiredTerminalContextCount > 0) {
3758 const toastCopy = buildExpiredTerminalContextToastCopy(3830 const toastCopy = buildExpiredTerminalContextToastCopy(
...@@ -4165,11 +4237,14 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4165,11 +4237,14 @@ function ChatViewContent(props: ChatViewProps) {
4165 beginLocalDispatch({ preparingWorktree: false });4237 beginLocalDispatch({ preparingWorktree: false });
4166 setThreadError(threadIdForSend, null);4238 setThreadError(threadIdForSend, null);
41674239
4168 // Scroll to the current end *before* adding the optimistic message.4240 // Position this sent row once LegendList has measured the anchored tail.
4169 isAtEndRef.current = true;4241 isAtEndRef.current = true;
4170 showScrollDebouncer.current.cancel();4242 showScrollDebouncer.current.cancel();
4171 setShowScrollToBottom(false);4243 setShowScrollToBottom(false);
4172 await legendListRef.current?.scrollToEnd?.({ animated: false });4244 setTimelineAnchor({
4245 threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)),
4246 messageId: messageIdForSend,
4247 });
41734248
4174 setOptimisticUserMessages((existing) => [4249 setOptimisticUserMessages((existing) => [
4175 ...existing,4250 ...existing,
...@@ -4789,6 +4864,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4789,6 +4864,8 @@ function ChatViewContent(props: ChatViewProps) {
4789 workspaceRoot={activeWorkspaceRoot}4864 workspaceRoot={activeWorkspaceRoot}
4790 skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS}4865 skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS}
4791 anchorMessageId={timelineAnchorMessageId}4866 anchorMessageId={timelineAnchorMessageId}
4867 onAnchorReady={onTimelineAnchorReady}
4868 onAnchorSizeChanged={onTimelineAnchorSizeChanged}
4792 contentInsetEndAdjustment={composerOverlayHeight}4869 contentInsetEndAdjustment={composerOverlayHeight}
4793 onIsAtEndChange={onIsAtEndChange}4870 onIsAtEndChange={onIsAtEndChange}
4794 />4871 />
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+71
...@@ -940,6 +940,77 @@ describe("deriveMessagesTimelineRows", () => {...@@ -940,6 +940,77 @@ describe("deriveMessagesTimelineRows", () => {
940 expect(assistantRow?.showAssistantMeta).toBe(false);940 expect(assistantRow?.showAssistantMeta).toBe(false);
941 expect(assistantRow?.showAssistantCopyButton).toBe(false);941 expect(assistantRow?.showAssistantCopyButton).toBe(false);
942 });942 });
943
944 it("models work log overflow expansion as inserted list rows", () => {
945 const timelineEntries = [
946 {
947 id: "work-entry-1",
948 kind: "work" as const,
949 createdAt: "2026-01-01T00:00:01Z",
950 entry: {
951 id: "work-1",
952 createdAt: "2026-01-01T00:00:01Z",
953 label: "read",
954 detail: "Reading package.json",
955 tone: "tool" as const,
956 },
957 },
958 {
959 id: "work-entry-2",
960 kind: "work" as const,
961 createdAt: "2026-01-01T00:00:02Z",
962 entry: {
963 id: "work-2",
964 createdAt: "2026-01-01T00:00:02Z",
965 label: "edit",
966 detail: "Editing MessagesTimeline.tsx",
967 tone: "tool" as const,
968 },
969 },
970 {
971 id: "work-entry-3",
972 kind: "work" as const,
973 createdAt: "2026-01-01T00:00:03Z",
974 entry: {
975 id: "work-3",
976 createdAt: "2026-01-01T00:00:03Z",
977 label: "test",
978 detail: "Running tests",
979 tone: "tool" as const,
980 },
981 },
982 ];
983
984 const baseInput = {
985 timelineEntries,
986 isWorking: false,
987 activeTurnStartedAt: null,
988 turnDiffSummaryByAssistantMessageId: new Map(),
989 revertTurnCountByUserMessageId: new Map(),
990 };
991 const collapsedRows = deriveMessagesTimelineRows(baseInput);
992 const expandedRows = deriveMessagesTimelineRows({
993 ...baseInput,
994 expandedWorkGroupIds: new Set(["work-group:work-entry-1"]),
995 });
996
997 expect(collapsedRows.map((row) => row.id)).toEqual(["work-3", "work-toggle:work-entry-1"]);
998 expect(collapsedRows.find((row) => row.kind === "work-toggle")).toMatchObject({
999 groupId: "work-group:work-entry-1",
1000 hiddenCount: 2,
1001 expanded: false,
1002 onlyToolEntries: true,
1003 });
1004 expect(expandedRows.map((row) => row.id)).toEqual([
1005 "work-1",
1006 "work-2",
1007 "work-3",
1008 "work-toggle:work-entry-1",
1009 ]);
1010 expect(expandedRows.find((row) => row.kind === "work-toggle")).toMatchObject({
1011 expanded: true,
1012 });
1013 });
943});1014});
9441015
945describe("computeStableMessagesTimelineRows", () => {1016describe("computeStableMessagesTimelineRows", () => {
apps/web/src/components/chat/MessagesTimeline.logic.ts+66-7
...@@ -1,5 +1,11 @@...@@ -1,5 +1,11 @@
1import * as Equal from "effect/Equal";1import * as Equal from "effect/Equal";
2import { formatDuration, type TimelineEntry, type WorkLogEntry } from "../../session-logic";2import {
3 formatDuration,
4 workEntryIndicatesToolNeutralStatus,
5 workLogEntryIsToolLike,
6 type TimelineEntry,
7 type WorkLogEntry,
8} from "../../session-logic";
3import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";9import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";
4import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts";10import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts";
511
...@@ -42,6 +48,15 @@ export type MessagesTimelineRow =...@@ -42,6 +48,15 @@ export type MessagesTimelineRow =
42 createdAt: string;48 createdAt: string;
43 groupedEntries: WorkLogEntry[];49 groupedEntries: WorkLogEntry[];
44 }50 }
51 | {
52 kind: "work-toggle";
53 id: string;
54 createdAt: string;
55 groupId: string;
56 hiddenCount: number;
57 expanded: boolean;
58 onlyToolEntries: boolean;
59 }
45 | {60 | {
46 kind: "turn-fold";61 kind: "turn-fold";
47 id: string;62 id: string;
...@@ -300,6 +315,7 @@ export function deriveMessagesTimelineRows(input: {...@@ -300,6 +315,7 @@ export function deriveMessagesTimelineRows(input: {
300 latestTurn?: TimelineLatestTurn | null;315 latestTurn?: TimelineLatestTurn | null;
301 runningTurnId?: TurnId | null;316 runningTurnId?: TurnId | null;
302 expandedTurnIds?: ReadonlySet<TurnId>;317 expandedTurnIds?: ReadonlySet<TurnId>;
318 expandedWorkGroupIds?: ReadonlySet<string>;
303 isWorking: boolean;319 isWorking: boolean;
304 activeTurnStartedAt: string | null;320 activeTurnStartedAt: string | null;
305 turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>;321 turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>;
...@@ -367,12 +383,44 @@ export function deriveMessagesTimelineRows(input: {...@@ -367,12 +383,44 @@ export function deriveMessagesTimelineRows(input: {
367 groupedEntries.push(nextEntry.entry);383 groupedEntries.push(nextEntry.entry);
368 cursor += 1;384 cursor += 1;
369 }385 }
370 nextRows.push({386 const visibleGroupedEntries = groupedEntries.filter(
371 kind: "work",387 (entry) => !workEntryIndicatesToolNeutralStatus(entry),
372 id: timelineEntry.id,388 );
373 createdAt: timelineEntry.createdAt,389 if (visibleGroupedEntries.length > 0) {
374 groupedEntries,390 if (visibleGroupedEntries.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) {
375 });391 nextRows.push({
392 kind: "work",
393 id: timelineEntry.id,
394 createdAt: timelineEntry.createdAt,
395 groupedEntries: visibleGroupedEntries,
396 });
397 } else {
398 const groupId = `work-group:${timelineEntry.id}`;
399 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
400 const hiddenEntries = visibleGroupedEntries.slice(0, -MAX_VISIBLE_WORK_LOG_ENTRIES);
401 const visibleEntries = visibleGroupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES);
402 const renderedEntries = expanded ? [...hiddenEntries, ...visibleEntries] : visibleEntries;
403
404 for (const workEntry of renderedEntries) {
405 nextRows.push({
406 kind: "work",
407 id: workEntry.id,
408 createdAt: workEntry.createdAt,
409 groupedEntries: [workEntry],
410 });
411 }
412
413 nextRows.push({
414 kind: "work-toggle",
415 id: `work-toggle:${timelineEntry.id}`,
416 createdAt: timelineEntry.createdAt,
417 groupId,
418 hiddenCount: hiddenEntries.length,
419 expanded,
420 onlyToolEntries: visibleGroupedEntries.every((entry) => workLogEntryIsToolLike(entry)),
421 });
422 }
423 }
376 index = cursor - 1;424 index = cursor - 1;
377 continue;425 continue;
378 }426 }
...@@ -473,6 +521,17 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean...@@ -473,6 +521,17 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
473 case "work":521 case "work":
474 return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries);522 return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries);
475523
524 case "work-toggle": {
525 const bw = b as typeof a;
526 return (
527 a.createdAt === bw.createdAt &&
528 a.groupId === bw.groupId &&
529 a.hiddenCount === bw.hiddenCount &&
530 a.expanded === bw.expanded &&
531 a.onlyToolEntries === bw.onlyToolEntries
532 );
533 }
534
476 case "message": {535 case "message": {
477 const bm = b as typeof a;536 const bm = b as typeof a;
478 return (537 return (
apps/web/src/components/chat/MessagesTimeline.test.tsx+65-15
...@@ -17,24 +17,59 @@ vi.mock("@legendapp/list/react", async () => {...@@ -17,24 +17,59 @@ vi.mock("@legendapp/list/react", async () => {
17 anchorIndex: number;17 anchorIndex: number;
18 anchorMaxSize?: number;18 anchorMaxSize?: number;
19 anchorOffset?: number;19 anchorOffset?: number;
20 onReady?: (info: { anchorIndex: number }) => void;
21 onSizeChanged?: (size: number) => void;
20 };22 };
21 contentInsetEndAdjustment?: number;23 contentInsetEndAdjustment?: number;
24 className?: string;
25 maintainScrollAtEnd?: boolean;
26 maintainVisibleContentPosition?:
27 | boolean
28 | {
29 data?: boolean;
30 size?: boolean;
31 shouldRestorePosition?: (item: { id: string }) => boolean;
32 };
22 ref?: Ref<LegendListRef>;33 ref?: Ref<LegendListRef>;
23 }) => (34 }) => {
24 <div35 if (props.anchoredEndSpace) {
25 data-testid={legendListTestId}36 props.anchoredEndSpace.onSizeChanged?.(240);
26 data-anchor-index={props.anchoredEndSpace?.anchorIndex}37 props.anchoredEndSpace.onReady?.({ anchorIndex: props.anchoredEndSpace.anchorIndex });
27 data-anchor-max-size={props.anchoredEndSpace?.anchorMaxSize}38 }
28 data-anchor-offset={props.anchoredEndSpace?.anchorOffset}39 return (
29 data-content-inset-end={props.contentInsetEndAdjustment}40 <div
30 >41 data-testid={legendListTestId}
31 {props.ListHeaderComponent}42 data-anchor-index={props.anchoredEndSpace?.anchorIndex}
32 {props.data.map((item) => (43 data-anchor-max-size={props.anchoredEndSpace?.anchorMaxSize}
33 <div key={props.keyExtractor(item)}>{props.renderItem({ item })}</div>44 data-anchor-offset={props.anchoredEndSpace?.anchorOffset}
34 ))}45 data-anchor-on-ready={Boolean(props.anchoredEndSpace?.onReady)}
35 {props.ListFooterComponent}46 data-content-inset-end={props.contentInsetEndAdjustment}
36 </div>47 data-class-name={props.className}
37 );48 data-maintain-scroll-at-end={props.maintainScrollAtEnd}
49 data-maintain-visible-content-position={
50 typeof props.maintainVisibleContentPosition === "object"
51 ? "object"
52 : props.maintainVisibleContentPosition
53 }
54 data-maintain-visible-content-position-data={
55 typeof props.maintainVisibleContentPosition === "object"
56 ? props.maintainVisibleContentPosition.data
57 : undefined
58 }
59 data-maintain-visible-content-position-size={
60 typeof props.maintainVisibleContentPosition === "object"
61 ? props.maintainVisibleContentPosition.size
62 : undefined
63 }
64 >
65 {props.ListHeaderComponent}
66 {props.data.map((item) => (
67 <div key={props.keyExtractor(item)}>{props.renderItem({ item })}</div>
68 ))}
69 {props.ListFooterComponent}
70 </div>
71 );
72 };
3873
39 return { LegendList };74 return { LegendList };
40});75});
...@@ -123,6 +158,8 @@ function buildProps() {...@@ -123,6 +158,8 @@ function buildProps() {
123 timestampFormat: "locale" as const,158 timestampFormat: "locale" as const,
124 workspaceRoot: undefined,159 workspaceRoot: undefined,
125 anchorMessageId: null,160 anchorMessageId: null,
161 onAnchorReady: () => {},
162 onAnchorSizeChanged: () => {},
126 contentInsetEndAdjustment: 0,163 contentInsetEndAdjustment: 0,
127 onIsAtEndChange: () => {},164 onIsAtEndChange: () => {},
128 };165 };
...@@ -154,6 +191,8 @@ function buildUserTimelineEntry(text: string) {...@@ -154,6 +191,8 @@ function buildUserTimelineEntry(text: string) {
154describe("MessagesTimeline", () => {191describe("MessagesTimeline", () => {
155 it("anchors a sent attachment message using its measured height", async () => {192 it("anchors a sent attachment message using its measured height", async () => {
156 const { MessagesTimeline } = await import("./MessagesTimeline");193 const { MessagesTimeline } = await import("./MessagesTimeline");
194 const onAnchorReady = vi.fn();
195 const onAnchorSizeChanged = vi.fn();
157 const firstEntry = buildUserTimelineEntry("First prompt.");196 const firstEntry = buildUserTimelineEntry("First prompt.");
158 const secondEntry = {197 const secondEntry = {
159 ...buildUserTimelineEntry("Newest prompt."),198 ...buildUserTimelineEntry("Newest prompt."),
...@@ -177,6 +216,8 @@ describe("MessagesTimeline", () => {...@@ -177,6 +216,8 @@ describe("MessagesTimeline", () => {
177 <MessagesTimeline216 <MessagesTimeline
178 {...buildProps()}217 {...buildProps()}
179 anchorMessageId={secondEntry.message.id}218 anchorMessageId={secondEntry.message.id}
219 onAnchorReady={onAnchorReady}
220 onAnchorSizeChanged={onAnchorSizeChanged}
180 contentInsetEndAdjustment={144}221 contentInsetEndAdjustment={144}
181 timelineEntries={[firstEntry, secondEntry]}222 timelineEntries={[firstEntry, secondEntry]}
182 />,223 />,
...@@ -184,8 +225,17 @@ describe("MessagesTimeline", () => {...@@ -184,8 +225,17 @@ describe("MessagesTimeline", () => {
184225
185 expect(markup).toContain('data-anchor-index="1"');226 expect(markup).toContain('data-anchor-index="1"');
186 expect(markup).toContain('data-anchor-offset="16"');227 expect(markup).toContain('data-anchor-offset="16"');
228 expect(markup).toContain('data-anchor-on-ready="true"');
187 expect(markup).not.toContain("data-anchor-max-size=");229 expect(markup).not.toContain("data-anchor-max-size=");
188 expect(markup).toContain('data-content-inset-end="144"');230 expect(markup).toContain('data-content-inset-end="144"');
231 expect(markup).toContain("[overflow-anchor:none]");
232 expect(markup).not.toContain("data-maintain-scroll-at-end=");
233 expect(markup).toContain('data-maintain-visible-content-position="object"');
234 expect(markup).toContain('data-maintain-visible-content-position-data="true"');
235 expect(markup).toContain('data-maintain-visible-content-position-size="false"');
236 expect(onAnchorReady).toHaveBeenCalledOnce();
237 expect(onAnchorReady).toHaveBeenCalledWith(secondEntry.message.id, 1);
238 expect(onAnchorSizeChanged).toHaveBeenCalledWith(secondEntry.message.id, 240);
189 });239 });
190240
191 it("renders collapse controls for long user messages", async () => {241 it("renders collapse controls for long user messages", async () => {
apps/web/src/components/chat/MessagesTimeline.tsx+115-116
...@@ -14,13 +14,13 @@ import {...@@ -14,13 +14,13 @@ import {
14 use,14 use,
15 useCallback,15 useCallback,
16 useEffect,16 useEffect,
17 useLayoutEffect,
18 useMemo,17 useMemo,
19 useRef,18 useRef,
20 useState,19 useState,
21 type KeyboardEvent,20 type KeyboardEvent,
22 type ReactNode,21 type ReactNode,
23} from "react";22} from "react";
23import { flushSync } from "react-dom";
24import { LegendList, type LegendListRef } from "@legendapp/list/react";24import { LegendList, type LegendListRef } from "@legendapp/list/react";
25import { FileDiff } from "@pierre/diffs/react";25import { FileDiff } from "@pierre/diffs/react";
26import {26import {
...@@ -43,7 +43,6 @@ import {...@@ -43,7 +43,6 @@ import {
43 CheckIcon,43 CheckIcon,
44 ChevronDownIcon,44 ChevronDownIcon,
45 ChevronRightIcon,45 ChevronRightIcon,
46 ChevronUpIcon,
47 CircleAlertIcon,46 CircleAlertIcon,
48 EyeIcon,47 EyeIcon,
49 GlobeIcon,48 GlobeIcon,
...@@ -67,7 +66,6 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";...@@ -67,7 +66,6 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
67import { MessageCopyButton } from "./MessageCopyButton";66import { MessageCopyButton } from "./MessageCopyButton";
68import {67import {
69 computeStableMessagesTimelineRows,68 computeStableMessagesTimelineRows,
70 MAX_VISIBLE_WORK_LOG_ENTRIES,
71 deriveMessagesTimelineRows,69 deriveMessagesTimelineRows,
72 normalizeCompactToolLabel,70 normalizeCompactToolLabel,
73 resolveAssistantMessageCopyState,71 resolveAssistantMessageCopyState,
...@@ -128,6 +126,7 @@ interface TimelineRowSharedState {...@@ -128,6 +126,7 @@ interface TimelineRowSharedState {
128 onImageExpand: (preview: ExpandedImagePreview) => void;126 onImageExpand: (preview: ExpandedImagePreview) => void;
129 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;127 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
130 onToggleTurnFold: (turnId: TurnId) => void;128 onToggleTurnFold: (turnId: TurnId) => void;
129 onToggleWorkGroup: (groupId: string, anchorElement?: HTMLElement) => void;
131}130}
132131
133interface TimelineRowActivityState {132interface TimelineRowActivityState {
...@@ -168,6 +167,8 @@ interface MessagesTimelineProps {...@@ -168,6 +167,8 @@ interface MessagesTimelineProps {
168 workspaceRoot: string | undefined;167 workspaceRoot: string | undefined;
169 skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;168 skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
170 anchorMessageId: MessageId | null;169 anchorMessageId: MessageId | null;
170 onAnchorReady: (messageId: MessageId, anchorIndex: number) => void;
171 onAnchorSizeChanged: (messageId: MessageId, size: number) => void;
171 contentInsetEndAdjustment: number;172 contentInsetEndAdjustment: number;
172 onIsAtEndChange: (isAtEnd: boolean) => void;173 onIsAtEndChange: (isAtEnd: boolean) => void;
173}174}
...@@ -198,20 +199,15 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -198,20 +199,15 @@ export const MessagesTimeline = memo(function MessagesTimeline({
198 workspaceRoot,199 workspaceRoot,
199 skills = EMPTY_TIMELINE_SKILLS,200 skills = EMPTY_TIMELINE_SKILLS,
200 anchorMessageId,201 anchorMessageId,
202 onAnchorReady,
203 onAnchorSizeChanged,
201 contentInsetEndAdjustment,204 contentInsetEndAdjustment,
202 onIsAtEndChange,205 onIsAtEndChange,
203}: MessagesTimelineProps) {206}: MessagesTimelineProps) {
204 const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set());207 const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set());
208 const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set());
205209
206 // Toggling a fold inserts/removes rows between the fold row and the final
207 // message — everything above the trigger is unchanged, so the trigger stays
208 // put as long as the list doesn't re-anchor. maintainScrollAtEnd would do
209 // exactly that (pin the bottom content when row data changes while scrolled
210 // to the end), yanking the trigger out of view. Suppress it for the frames
211 // in which the toggle's data change and item measurements settle.
212 const [foldToggleSettling, setFoldToggleSettling] = useState(false);
213 const onToggleTurnFold = useCallback((turnId: TurnId) => {210 const onToggleTurnFold = useCallback((turnId: TurnId) => {
214 setFoldToggleSettling(true);
215 setExpandedTurnIds((existing) => {211 setExpandedTurnIds((existing) => {
216 const next = new Set(existing);212 const next = new Set(existing);
217 if (next.has(turnId)) {213 if (next.has(turnId)) {
...@@ -222,23 +218,39 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -222,23 +218,39 @@ export const MessagesTimeline = memo(function MessagesTimeline({
222 return next;218 return next;
223 });219 });
224 }, []);220 }, []);
225 useEffect(() => {221 const onToggleWorkGroup = useCallback(
226 if (!foldToggleSettling) {222 (groupId: string, anchorElement?: HTMLElement) => {
227 return;223 const anchorBottomBeforeToggle = anchorElement?.getBoundingClientRect().bottom ?? null;
228 }224
229 let secondFrameId: number | null = null;225 flushSync(() => {
230 const firstFrameId = window.requestAnimationFrame(() => {226 setExpandedWorkGroupIds((existing) => {
231 secondFrameId = window.requestAnimationFrame(() => {227 const next = new Set(existing);
232 setFoldToggleSettling(false);228 if (next.has(groupId)) {
229 next.delete(groupId);
230 } else {
231 next.add(groupId);
232 }
233 return next;
234 });
233 });235 });
234 });236
235 return () => {237 if (anchorBottomBeforeToggle === null || !anchorElement) {
236 window.cancelAnimationFrame(firstFrameId);238 return;
237 if (secondFrameId !== null) {
238 window.cancelAnimationFrame(secondFrameId);
239 }239 }
240 };240
241 }, [foldToggleSettling]);241 const delta = anchorElement.getBoundingClientRect().bottom - anchorBottomBeforeToggle;
242 if (Math.abs(delta) < 0.5) {
243 return;
244 }
245
246 const list = listRef.current;
247 const currentScroll = list?.getState?.().scroll;
248 if (list && typeof currentScroll === "number") {
249 list.scrollToOffset({ offset: currentScroll + delta, animated: false });
250 }
251 },
252 [listRef],
253 );
242254
243 // An in-session interrupt leaves its turn expanded so the user keeps their255 // An in-session interrupt leaves its turn expanded so the user keeps their
244 // place; the next turn (or a reload, since this is local state) folds it.256 // place; the next turn (or a reload, since this is local state) folds it.
...@@ -276,6 +288,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -276,6 +288,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
276 latestTurn,288 latestTurn,
277 runningTurnId,289 runningTurnId,
278 expandedTurnIds,290 expandedTurnIds,
291 expandedWorkGroupIds,
279 isWorking,292 isWorking,
280 activeTurnStartedAt,293 activeTurnStartedAt,
281 turnDiffSummaryByAssistantMessageId,294 turnDiffSummaryByAssistantMessageId,
...@@ -286,6 +299,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -286,6 +299,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
286 latestTurn,299 latestTurn,
287 runningTurnId,300 runningTurnId,
288 expandedTurnIds,301 expandedTurnIds,
302 expandedWorkGroupIds,
289 isWorking,303 isWorking,
290 activeTurnStartedAt,304 activeTurnStartedAt,
291 turnDiffSummaryByAssistantMessageId,305 turnDiffSummaryByAssistantMessageId,
...@@ -293,13 +307,30 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -293,13 +307,30 @@ export const MessagesTimeline = memo(function MessagesTimeline({
293 ],307 ],
294 );308 );
295 const rows = useStableRows(rawRows);309 const rows = useStableRows(rawRows);
296 const anchoredEndSpace = useMemo(310 const handleAnchorReady = useCallback(
297 () =>311 (info: { anchorIndex: number | undefined }) => {
298 resolveChatListAnchoredEndSpace(rows, anchorMessageId, (row) =>312 if (anchorMessageId !== null && info.anchorIndex !== undefined) {
299 row.kind === "message" ? row.message.id : null,313 onAnchorReady(anchorMessageId, info.anchorIndex);
300 ),314 }
301 [anchorMessageId, rows],315 },
316 [anchorMessageId, onAnchorReady],
317 );
318 const handleAnchorSizeChanged = useCallback(
319 (size: number) => {
320 if (anchorMessageId !== null) {
321 onAnchorSizeChanged(anchorMessageId, size);
322 }
323 },
324 [anchorMessageId, onAnchorSizeChanged],
302 );325 );
326 const anchoredEndSpace = useMemo(() => {
327 const config = resolveChatListAnchoredEndSpace(rows, anchorMessageId, (row) =>
328 row.kind === "message" ? row.message.id : null,
329 );
330 return config
331 ? { ...config, onReady: handleAnchorReady, onSizeChanged: handleAnchorSizeChanged }
332 : undefined;
333 }, [anchorMessageId, handleAnchorReady, handleAnchorSizeChanged, rows]);
303334
304 const handleScroll = useCallback(() => {335 const handleScroll = useCallback(() => {
305 const state = listRef.current?.getState?.();336 const state = listRef.current?.getState?.();
...@@ -322,6 +353,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -322,6 +353,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
322 onImageExpand,353 onImageExpand,
323 onOpenTurnDiff,354 onOpenTurnDiff,
324 onToggleTurnFold,355 onToggleTurnFold,
356 onToggleWorkGroup,
325 }),357 }),
326 [358 [
327 timestampFormat,359 timestampFormat,
...@@ -335,6 +367,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -335,6 +367,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
335 onImageExpand,367 onImageExpand,
336 onOpenTurnDiff,368 onOpenTurnDiff,
337 onToggleTurnFold,369 onToggleTurnFold,
370 onToggleWorkGroup,
338 ],371 ],
339 );372 );
340 const activityState = useMemo<TimelineRowActivityState>(373 const activityState = useMemo<TimelineRowActivityState>(
...@@ -380,11 +413,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -380,11 +413,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({
380 initialScrollAtEnd413 initialScrollAtEnd
381 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}414 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
382 contentInsetEndAdjustment={contentInsetEndAdjustment}415 contentInsetEndAdjustment={contentInsetEndAdjustment}
383 maintainScrollAtEnd={!foldToggleSettling}416 maintainVisibleContentPosition={{
384 maintainScrollAtEndThreshold={0.1}417 data: true,
385 maintainVisibleContentPosition418 size: false,
419 }}
386 onScroll={handleScroll}420 onScroll={handleScroll}
387 className="scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 sm:px-5"421 className="scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5"
388 ListHeaderComponent={TIMELINE_LIST_HEADER}422 ListHeaderComponent={TIMELINE_LIST_HEADER}
389 ListFooterComponent={TIMELINE_LIST_FOOTER}423 ListFooterComponent={TIMELINE_LIST_FOOTER}
390 />424 />
...@@ -417,7 +451,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time...@@ -417,7 +451,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
417 // Commentary (non-terminal assistant) rows carry no metadata row, so451 // Commentary (non-terminal assistant) rows carry no metadata row, so
418 // they sit closer to the work that follows them.452 // they sit closer to the work that follows them.
419 (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) ||453 (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) ||
420 row.kind === "work"454 row.kind === "work" ||
455 row.kind === "work-toggle"
421 ? "pb-2"456 ? "pb-2"
422 : "pb-4",457 : "pb-4",
423 row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null,458 row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null,
...@@ -428,6 +463,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time...@@ -428,6 +463,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
428 data-message-role={row.kind === "message" ? row.message.role : undefined}463 data-message-role={row.kind === "message" ? row.message.role : undefined}
429 >464 >
430 {row.kind === "work" ? <WorkGroupSection groupedEntries={row.groupedEntries} /> : null}465 {row.kind === "work" ? <WorkGroupSection groupedEntries={row.groupedEntries} /> : null}
466 {row.kind === "work-toggle" ? <WorkGroupToggleTimelineRow row={row} /> : null}
431 {row.kind === "turn-fold" ? <TurnFoldTimelineRow row={row} /> : null}467 {row.kind === "turn-fold" ? <TurnFoldTimelineRow row={row} /> : null}
432 {row.kind === "message" && row.message.role === "user" ? <UserTimelineRow row={row} /> : null}468 {row.kind === "message" && row.message.role === "user" ? <UserTimelineRow row={row} /> : null}
433 {row.kind === "message" && row.message.role === "assistant" ? (469 {row.kind === "message" && row.message.role === "assistant" ? (
...@@ -720,26 +756,17 @@ function WorkingTimer({ createdAt }: { createdAt: string }) {...@@ -720,26 +756,17 @@ function WorkingTimer({ createdAt }: { createdAt: string }) {
720// re-render only the affected row, not the entire list.756// re-render only the affected row, not the entire list.
721// ---------------------------------------------------------------------------757// ---------------------------------------------------------------------------
722758
723/** Collapsed state shows the earliest chunk so "Show more" only appends rows downward. */759/** Renders one or more already-derived work log rows. Overflow expansion is modeled as LegendList data. */
724const WorkGroupSection = memo(function WorkGroupSection({760const WorkGroupSection = memo(function WorkGroupSection({
725 groupedEntries,761 groupedEntries,
726}: {762}: {
727 groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"];763 groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"];
728}) {764}) {
729 const { workspaceRoot } = use(TimelineRowCtx);765 const { workspaceRoot } = use(TimelineRowCtx);
730 const [isExpanded, setIsExpanded] = useState(false);
731 const sectionRef = useRef<HTMLElement>(null);
732 const anchorBottomBeforeToggleRef = useRef<number | null>(null);
733 const nonEmptyEntries = useMemo(766 const nonEmptyEntries = useMemo(
734 () => groupedEntries.filter((entry) => !workEntryIndicatesToolNeutralStatus(entry)),767 () => groupedEntries.filter((entry) => !workEntryIndicatesToolNeutralStatus(entry)),
735 [groupedEntries],768 [groupedEntries],
736 );769 );
737 const hasOverflow = nonEmptyEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
738 const visibleEntries =
739 hasOverflow && !isExpanded
740 ? nonEmptyEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES)
741 : nonEmptyEntries;
742 const hiddenCount = nonEmptyEntries.length - visibleEntries.length;
743 const onlyToolEntries = nonEmptyEntries.every((entry) => workLogEntryIsToolLike(entry));770 const onlyToolEntries = nonEmptyEntries.every((entry) => workLogEntryIsToolLike(entry));
744 const groupLabel = onlyToolEntries771 const groupLabel = onlyToolEntries
745 ? nonEmptyEntries.length === 1772 ? nonEmptyEntries.length === 1
...@@ -747,49 +774,17 @@ const WorkGroupSection = memo(function WorkGroupSection({...@@ -747,49 +774,17 @@ const WorkGroupSection = memo(function WorkGroupSection({
747 : `${nonEmptyEntries.length} tool calls`774 : `${nonEmptyEntries.length} tool calls`
748 : "Work Log";775 : "Work Log";
749776
750 useLayoutEffect(() => {
751 const anchorBottomBeforeToggle = anchorBottomBeforeToggleRef.current;
752 anchorBottomBeforeToggleRef.current = null;
753
754 if (anchorBottomBeforeToggle === null) {
755 return;
756 }
757
758 const section = sectionRef.current;
759 if (!section) {
760 return;
761 }
762
763 const delta = section.getBoundingClientRect().bottom - anchorBottomBeforeToggle;
764 if (Math.abs(delta) < 0.5) {
765 return;
766 }
767
768 const scroller = findNearestVerticalScroller(section);
769 if (scroller) {
770 scroller.scrollTop += delta;
771 } else {
772 window.scrollBy(0, delta);
773 }
774 }, [isExpanded]);
775
776 const toggleExpanded = () => {
777 anchorBottomBeforeToggleRef.current =
778 sectionRef.current?.getBoundingClientRect().bottom ?? null;
779 setIsExpanded((v) => !v);
780 };
781
782 if (nonEmptyEntries.length === 0) return null;777 if (nonEmptyEntries.length === 0) return null;
783778
784 return (779 return (
785 <section ref={sectionRef} className="-mx-1 space-y-0.5 px-1 py-0.5" aria-label={groupLabel}>780 <section className="-mx-1 space-y-0.5 px-1 py-0.5" aria-label={groupLabel}>
786 {!onlyToolEntries && (781 {!onlyToolEntries && (
787 <p className="px-0.5 pb-0.5 font-medium text-[11px] text-muted-foreground/65">782 <p className="px-0.5 pb-0.5 font-medium text-[11px] text-muted-foreground/65">
788 {groupLabel}783 {groupLabel}
789 </p>784 </p>
790 )}785 )}
791 <div className="space-y-px">786 <div className="space-y-px">
792 {visibleEntries.map((workEntry) => (787 {nonEmptyEntries.map((workEntry) => (
793 <SimpleWorkEntryRow788 <SimpleWorkEntryRow
794 key={workEntry.id}789 key={workEntry.id}
795 workEntry={workEntry}790 workEntry={workEntry}
...@@ -797,45 +792,49 @@ const WorkGroupSection = memo(function WorkGroupSection({...@@ -797,45 +792,49 @@ const WorkGroupSection = memo(function WorkGroupSection({
797 />792 />
798 ))}793 ))}
799 </div>794 </div>
800 {hasOverflow && (
801 <button
802 type="button"
803 className="flex w-full cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-[12px] leading-5 transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
804 onClick={toggleExpanded}
805 >
806 <span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground/65">
807 {isExpanded ? (
808 <ChevronUpIcon className="size-3.5 shrink-0 opacity-70" />
809 ) : (
810 <ChevronDownIcon className="size-3.5 shrink-0 opacity-70" />
811 )}
812 </span>
813 {isExpanded ? (
814 <span className="font-medium text-foreground/82">Show fewer tool calls</span>
815 ) : (
816 <span className="font-medium text-foreground/82">
817 +{hiddenCount} previous tool {hiddenCount === 1 ? "call" : "calls"}
818 </span>
819 )}
820 </button>
821 )}
822 </section>795 </section>
823 );796 );
824});797});
825798
826function findNearestVerticalScroller(element: HTMLElement): HTMLElement | null {799function WorkGroupToggleTimelineRow({
827 let parent = element.parentElement;800 row,
828 while (parent) {801}: {
829 const { overflowY } = window.getComputedStyle(parent);802 row: Extract<TimelineRow, { kind: "work-toggle" }>;
830 if (803}) {
831 (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") &&804 const ctx = use(TimelineRowCtx);
832 parent.scrollHeight > parent.clientHeight805 const labelNoun = row.onlyToolEntries ? "tool call" : "log entry";
833 ) {806
834 return parent;807 return (
835 }808 <button
836 parent = parent.parentElement;809 type="button"
837 }810 className="flex w-full cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-[12px] leading-5 transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
838 return null;811 aria-expanded={row.expanded}
812 onClick={(event) => {
813 const anchorElement =
814 event.currentTarget.closest<HTMLElement>("[data-timeline-row-id]") ?? event.currentTarget;
815 ctx.onToggleWorkGroup(row.groupId, anchorElement);
816 }}
817 >
818 <span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground/65">
819 <ChevronDownIcon
820 className={cn(
821 "size-3.5 shrink-0 opacity-70 transition-transform duration-200",
822 row.expanded && "rotate-180",
823 )}
824 />
825 </span>
826 {row.expanded ? (
827 <span className="font-medium text-foreground/82">
828 Show fewer {row.onlyToolEntries ? "tool calls" : "log entries"}
829 </span>
830 ) : (
831 <span className="font-medium text-foreground/82">
832 +{row.hiddenCount} previous {labelNoun}
833 {row.hiddenCount === 1 ? "" : "s"}
834 </span>
835 )}
836 </button>
837 );
839}838}
840839
841/** Subscribes directly to the UI state store for expand/collapse state,840/** Subscribes directly to the UI state store for expand/collapse state,