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 {
7474 type ThreadFeedLatestTurn,
7575} from "../../lib/threadActivity";
7676import type { ThreadContentPresentation } from "./threadContentPresentation";
77import { ThreadWorkLog } from "./thread-work-log";
77import { ThreadWorkGroupToggle, ThreadWorkLog } from "./thread-work-log";
7878import { useAssetUrl } from "../../state/assets";
7979import { resolveWorkspaceRelativeFilePath } from "../files/filePath";
8080
......@@ -655,7 +655,6 @@ function renderFeedEntry(
655655 info: { item: ThreadFeedEntry; index: number },
656656 props: Pick<ThreadFeedProps, "environmentId" | "skills"> & {
657657 readonly copiedRowId: string | null;
658 readonly expandedWorkGroups: Record<string, boolean>;
659658 readonly expandedWorkRows: Record<string, boolean>;
660659 readonly terminalAssistantMessageIds: ReadonlySet<string>;
661660 readonly unsettledTurnId: TurnId | null;
......@@ -698,6 +697,18 @@ function renderFeedEntry(
698697 );
699698 }
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
701712 if (entry.type === "message") {
702713 const { message } = entry;
703714 const isUser = message.role === "user";
......@@ -825,11 +836,9 @@ function renderFeedEntry(
825836 <ThreadWorkLog
826837 activities={entry.activities}
827838 copiedRowId={props.copiedRowId}
828 expanded={props.expandedWorkGroups[entry.id] ?? false}
829839 expandedRows={props.expandedWorkRows}
830840 iconSubtleColor={iconSubtleColor}
831841 onCopyRow={props.onCopyWorkRow}
832 onToggleGroup={() => props.onToggleWorkGroup(entry.id)}
833842 onToggleRow={props.onToggleWorkRow}
834843 />
835844 );
......@@ -1110,9 +1119,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
11101119 const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
11111120 const foldSettleFrameRef = useRef<number | null>(null);
11121121 const foldSettleSecondFrameRef = useRef<number | null>(null);
1122 const disclosureAnchorKeyRef = useRef<string | null>(null);
11131123 const previousLatestTurnRef = useRef(props.latestTurn);
11141124 const { width: viewportWidth } = useWindowDimensions();
1115 const [foldToggleSettling, setFoldToggleSettling] = useState(false);
1125 const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);
11161126 const [interactionState, setInteractionState] = useState<{
11171127 readonly copiedRowId: string | null;
11181128 readonly expandedWorkGroups: Record<string, boolean>;
......@@ -1173,7 +1183,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
11731183 const listAppearanceData = useMemo(
11741184 () => ({
11751185 copiedRowId,
1176 expandedWorkGroups,
11771186 expandedWorkRows,
11781187 iconSubtleColor,
11791188 markdownStyles,
......@@ -1182,7 +1191,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
11821191 }),
11831192 [
11841193 copiedRowId,
1185 expandedWorkGroups,
11861194 expandedWorkRows,
11871195 iconSubtleColor,
11881196 markdownStyles,
......@@ -1190,9 +1198,24 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
11901198 userBubbleColor,
11911199 ],
11921200 );
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]);
11931210 const presentedFeed = useMemo(
1194 () => deriveThreadFeedPresentation(props.feed, props.latestTurn, expandedTurnIds),
1195 [expandedTurnIds, props.feed, props.latestTurn],
1211 () =>
1212 deriveThreadFeedPresentation(
1213 props.feed,
1214 props.latestTurn,
1215 expandedTurnIds,
1216 expandedWorkGroupIds,
1217 ),
1218 [expandedTurnIds, expandedWorkGroupIds, props.feed, props.latestTurn],
11961219 );
11971220 const anchoredEndSpace = useMemo(
11981221 () =>
......@@ -1259,6 +1282,39 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
12591282 };
12601283 }, []);
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
12621318 const onCopyWorkRow = useCallback((rowId: string, value: string) => {
12631319 copyTextWithHaptic(value, {
12641320 target: "thread-work-row",
......@@ -1276,51 +1332,49 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
12761332 }, 1200);
12771333 }, []);
12781334
1279 const onToggleWorkGroup = useCallback((groupId: string) => {
1280 setInteractionState((current) => ({
1281 ...current,
1282 expandedWorkGroups: {
1283 ...current.expandedWorkGroups,
1284 [groupId]: !(current.expandedWorkGroups[groupId] ?? false),
1285 },
1286 }));
1287 }, []);
1335 const onToggleWorkGroup = useCallback(
1336 (groupId: string) => {
1337 suspendEndScrollMaintenanceForDisclosure(`work-toggle:${groupId}`);
1338 setInteractionState((current) => ({
1339 ...current,
1340 expandedWorkGroups: {
1341 ...current.expandedWorkGroups,
1342 [groupId]: !(current.expandedWorkGroups[groupId] ?? false),
1343 },
1344 }));
1345 },
1346 [suspendEndScrollMaintenanceForDisclosure],
1347 );
12881348
1289 const onToggleWorkRow = useCallback((rowId: string) => {
1290 setInteractionState((current) => ({
1291 ...current,
1292 expandedWorkRows: {
1293 ...current.expandedWorkRows,
1294 [rowId]: !(current.expandedWorkRows[rowId] ?? false),
1295 },
1296 }));
1297 }, []);
1349 const onToggleWorkRow = useCallback(
1350 (rowId: string) => {
1351 suspendEndScrollMaintenanceForDisclosure(rowId);
1352 setInteractionState((current) => ({
1353 ...current,
1354 expandedWorkRows: {
1355 ...current.expandedWorkRows,
1356 [rowId]: !(current.expandedWorkRows[rowId] ?? false),
1357 },
1358 }));
1359 },
1360 [suspendEndScrollMaintenanceForDisclosure],
1361 );
12981362
1299 const onToggleTurnFold = useCallback((turnId: TurnId) => {
1300 setFoldToggleSettling(true);
1301 if (foldSettleFrameRef.current !== null) {
1302 cancelAnimationFrame(foldSettleFrameRef.current);
1303 }
1304 if (foldSettleSecondFrameRef.current !== null) {
1305 cancelAnimationFrame(foldSettleSecondFrameRef.current);
1306 }
1307 setInteractionState((current) => {
1308 const next = new Set(current.expandedTurnIds);
1309 if (next.has(turnId)) {
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;
1363 const onToggleTurnFold = useCallback(
1364 (turnId: TurnId) => {
1365 suspendEndScrollMaintenanceForDisclosure(`turn-fold:${turnId}`);
1366 setInteractionState((current) => {
1367 const next = new Set(current.expandedTurnIds);
1368 if (next.has(turnId)) {
1369 next.delete(turnId);
1370 } else {
1371 next.add(turnId);
1372 }
1373 return { ...current, expandedTurnIds: next };
13211374 });
1322 });
1323 }, []);
1375 },
1376 [suspendEndScrollMaintenanceForDisclosure],
1377 );
13241378
13251379 const onPressImage = useCallback((uri: string, headers?: Record<string, string>) => {
13261380 setExpandedImage({ uri, headers });
......@@ -1331,7 +1385,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
13311385 renderFeedEntry(info, {
13321386 environmentId: props.environmentId,
13331387 copiedRowId,
1334 expandedWorkGroups,
13351388 expandedWorkRows,
13361389 terminalAssistantMessageIds,
13371390 unsettledTurnId,
......@@ -1351,7 +1404,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
13511404 }),
13521405 [
13531406 copiedRowId,
1354 expandedWorkGroups,
13551407 expandedWorkRows,
13561408 terminalAssistantMessageIds,
13571409 unsettledTurnId,
......@@ -1410,7 +1462,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
14101462 contentInsetEndAdjustment={props.contentInsetEndAdjustment}
14111463 freeze={props.freeze}
14121464 maintainScrollAtEnd={
1413 foldToggleSettling
1465 disclosureToggleSettling
14141466 ? false
14151467 : {
14161468 animated: false,
......@@ -1421,7 +1473,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
14211473 },
14221474 }
14231475 }
1424 maintainVisibleContentPosition
1476 maintainVisibleContentPosition={maintainVisibleContentPosition}
14251477 data={presentedFeed}
14261478 extraData={listAppearanceData}
14271479 renderItem={renderItem}
apps/mobile/src/features/threads/thread-work-log.tsx+60-49
......@@ -6,7 +6,6 @@ import { AppText as Text } from "../../components/AppText";
66import { cn } from "../../lib/cn";
77import type { ThreadFeedActivity } from "../../lib/threadActivity";
88
9const MAX_VISIBLE_WORK_LOG_ENTRIES = 1;
109const WORK_LOG_LAYOUT_ANIMATION = {
1110 duration: 180,
1211 create: {
......@@ -72,11 +71,9 @@ function workRowSymbolName(icon: ThreadFeedActivity["icon"]): SFSymbol {
7271export function ThreadWorkLog(props: {
7372 readonly activities: ReadonlyArray<ThreadFeedActivity>;
7473 readonly copiedRowId: string | null;
75 readonly expanded: boolean;
7674 readonly expandedRows: Readonly<Record<string, boolean>>;
7775 readonly iconSubtleColor: import("react-native").ColorValue;
7876 readonly onCopyRow: (rowId: string, value: string) => void;
79 readonly onToggleGroup: () => void;
8077 readonly onToggleRow: (rowId: string) => void;
8178}) {
8279 const colorScheme = useColorScheme();
......@@ -89,14 +86,10 @@ export function ThreadWorkLog(props: {
8986 return null;
9087 }
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;
9689 const onlyToolRows = rows.every((row) => row.toolLike);
9790
9891 return (
99 <View className="-mx-1 mb-3 px-1 py-0.5">
92 <View className="-mx-1 mb-1 px-1 py-0">
10093 {!onlyToolRows ? (
10194 <Text className="px-0.5 pb-0.5 font-t3-medium text-2xs text-foreground-muted opacity-60">
10295 work log
......@@ -104,7 +97,7 @@ export function ThreadWorkLog(props: {
10497 ) : null}
10598
10699 <View className="gap-px">
107 {visibleRows.map((row) => {
100 {rows.map((row) => {
108101 const expanded = props.expandedRows[row.id] ?? false;
109102 const canExpand = row.fullDetail !== null;
110103 const displayText = row.detail ? `${row.summary} ${row.detail}` : row.summary;
......@@ -132,13 +125,13 @@ export function ThreadWorkLog(props: {
132125 style={({ pressed }) => ({
133126 backgroundColor: pressed ? pressedBackground : "transparent",
134127 })}
135 className="rounded-md px-0.5 py-0.5"
128 className="rounded-md px-0.5 py-0"
136129 >
137 <View className="min-h-9 flex-row items-center gap-1.5">
138 <View className="h-5 w-5 shrink-0 items-center justify-center">
130 <View className="min-h-8 flex-row items-center gap-1.5">
131 <View className="h-[18px] w-5 shrink-0 items-center justify-center">
139132 <SymbolView
140133 name={workRowSymbolName(row.icon)}
141 size={14}
134 size={13}
142135 weight="medium"
143136 tintColor={iconIsDestructive ? "#e11d48" : props.iconSubtleColor}
144137 type="monochrome"
......@@ -146,7 +139,7 @@ export function ThreadWorkLog(props: {
146139 </View>
147140
148141 <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"
150143 numberOfLines={1}
151144 >
152145 <Text
......@@ -199,7 +192,7 @@ export function ThreadWorkLog(props: {
199192 </Pressable>
200193
201194 {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]">
203196 <ScrollView
204197 nestedScrollEnabled
205198 directionalLockEnabled
......@@ -221,41 +214,59 @@ export function ThreadWorkLog(props: {
221214 );
222215 })}
223216 </View>
217 </View>
218 );
219}
224220
225 {hasOverflow ? (
226 <Pressable
227 accessibilityRole="button"
228 accessibilityState={{ expanded: props.expanded }}
229 accessibilityLabel={
230 props.expanded
231 ? "Show fewer tool calls"
232 : `Show ${hiddenCount} previous tool ${hiddenCount === 1 ? "call" : "calls"}`
233 }
234 hitSlop={4}
235 onPress={() => {
236 triggerDisclosureFeedback();
237 props.onToggleGroup();
238 }}
239 style={({ pressed }) => ({
240 backgroundColor: pressed ? pressedBackground : "transparent",
241 })}
242 className="min-h-9 flex-row items-center gap-1.5 rounded-md px-0.5 py-0.5"
243 >
244 <View className="h-5 w-5 items-center justify-center">
245 <SymbolView
246 name={props.expanded ? "chevron.up" : "chevron.down"}
247 size={13}
248 tintColor={props.iconSubtleColor}
249 type="monochrome"
250 />
251 </View>
252 <Text className="font-t3-medium text-xs text-foreground opacity-80">
253 {props.expanded
254 ? "Show fewer tool calls"
255 : `+${hiddenCount} previous tool ${hiddenCount === 1 ? "call" : "calls"}`}
256 </Text>
257 </Pressable>
258 ) : null}
221export function ThreadWorkGroupToggle(props: {
222 readonly expanded: boolean;
223 readonly hiddenCount: number;
224 readonly iconSubtleColor: import("react-native").ColorValue;
225 readonly onlyToolActivities: boolean;
226 readonly onToggle: () => void;
227}) {
228 const colorScheme = useColorScheme();
229 const pressedBackground = colorScheme === "dark" ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.035)";
230 const noun = props.onlyToolActivities
231 ? props.hiddenCount === 1
232 ? "tool call"
233 : "tool calls"
234 : props.hiddenCount === 1
235 ? "log entry"
236 : "log entries";
237 const collapsedLabel = `Show ${props.hiddenCount} previous ${noun}`;
238 const expandedLabel = props.onlyToolActivities
239 ? "Show fewer tool calls"
240 : "Show fewer log entries";
241
242 return (
243 <View className="-mx-1 mb-1 px-1 py-0">
244 <Pressable
245 accessibilityRole="button"
246 accessibilityState={{ expanded: props.expanded }}
247 accessibilityLabel={props.expanded ? expandedLabel : collapsedLabel}
248 hitSlop={4}
249 onPress={() => {
250 void Haptics.selectionAsync();
251 props.onToggle();
252 }}
253 style={({ pressed }) => ({
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>
259270 </View>
260271 );
261272}
apps/mobile/src/lib/threadActivity.test.ts+60-1
......@@ -11,7 +11,12 @@ import {
1111 type OrchestrationThreadActivity,
1212} from "@t3tools/contracts";
1313
14import { buildThreadFeed, deriveThreadFeedPresentation } from "./threadActivity";
14import {
15 buildThreadFeed,
16 deriveThreadFeedPresentation,
17 type ThreadFeedActivity,
18 type ThreadFeedEntry,
19} from "./threadActivity";
1520
1621function makeActivity(
1722 input: Partial<OrchestrationThreadActivity> &
......@@ -406,4 +411,58 @@ describe("buildThreadFeed", () => {
406411 activities: [{ status: "failure" }],
407412 });
408413 });
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 });
409468});
apps/mobile/src/lib/threadActivity.ts+67-2
......@@ -55,6 +55,8 @@ export interface ThreadFeedActivity {
5555 readonly status: "success" | "failure" | "neutral" | null;
5656}
5757
58const MAX_VISIBLE_WORK_LOG_ENTRIES = 1;
59
5860type WorkLogToolLifecycleStatus = "inProgress" | "completed" | "failed" | "declined" | "stopped";
5961
6062interface WorkLogEntry {
......@@ -103,6 +105,16 @@ export type ThreadFeedEntry =
103105 readonly turnId: TurnId | null;
104106 readonly activities: ReadonlyArray<ThreadFeedActivity>;
105107 }
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 }
106118 | {
107119 readonly type: "turn-fold";
108120 readonly id: string;
......@@ -1092,8 +1104,11 @@ export function deriveThreadFeedPresentation(
10921104 feed: ReadonlyArray<ThreadFeedEntry>,
10931105 latestTurn: ThreadFeedLatestTurn | null,
10941106 expandedTurnIds: ReadonlySet<TurnId>,
1107 expandedWorkGroupIds: ReadonlySet<string> = new Set(),
10951108): 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 );
10971112 const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn);
10981113 const collapsedEntryIds = new Set<string>();
10991114 for (const fold of foldsByAnchorId.values()) {
......@@ -1118,12 +1133,62 @@ export function deriveThreadFeedPresentation(
11181133 });
11191134 }
11201135 if (!collapsedEntryIds.has(entry.id)) {
1121 result.push(entry);
1136 appendPresentedFeedEntry(result, entry, expandedWorkGroupIds);
11221137 }
11231138 }
11241139 return result;
11251140}
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
11271192export function derivePendingApprovals(
11281193 activities: ReadonlyArray<OrchestrationThreadActivity>,
11291194): PendingApproval[] {
apps/web/src/components/ChatView.tsx+82-5
......@@ -36,6 +36,7 @@ import {
3636 createModelSelection,
3737 resolvePromptInjectedEffort,
3838} from "@t3tools/shared/model";
39import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList";
3940import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
4041import { truncate } from "@t3tools/shared/String";
4142import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";
......@@ -3147,10 +3148,83 @@ function ChatViewContent(props: ChatViewProps) {
31473148 ],
31483149 );
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.
31513153 const scrollToEnd = useCallback((animated = false) => {
31523154 void legendListRef.current?.scrollToEnd?.({ animated });
31533155 }, []);
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
31553229 // Debounce *showing* the scroll-to-bottom pill so it doesn't flash during
31563230 // thread switches. LegendList fires scroll events with isAtEnd=false while
......@@ -3751,8 +3825,6 @@ function ChatViewContent(props: ChatViewProps) {
37513825 streaming: false,
37523826 },
37533827 ]);
3754 void legendListRef.current?.scrollToEnd?.({ animated: false });
3755
37563828 setThreadError(threadIdForSend, null);
37573829 if (expiredTerminalContextCount > 0) {
37583830 const toastCopy = buildExpiredTerminalContextToastCopy(
......@@ -4165,11 +4237,14 @@ function ChatViewContent(props: ChatViewProps) {
41654237 beginLocalDispatch({ preparingWorktree: false });
41664238 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.
41694241 isAtEndRef.current = true;
41704242 showScrollDebouncer.current.cancel();
41714243 setShowScrollToBottom(false);
4172 await legendListRef.current?.scrollToEnd?.({ animated: false });
4244 setTimelineAnchor({
4245 threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)),
4246 messageId: messageIdForSend,
4247 });
41734248
41744249 setOptimisticUserMessages((existing) => [
41754250 ...existing,
......@@ -4789,6 +4864,8 @@ function ChatViewContent(props: ChatViewProps) {
47894864 workspaceRoot={activeWorkspaceRoot}
47904865 skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS}
47914866 anchorMessageId={timelineAnchorMessageId}
4867 onAnchorReady={onTimelineAnchorReady}
4868 onAnchorSizeChanged={onTimelineAnchorSizeChanged}
47924869 contentInsetEndAdjustment={composerOverlayHeight}
47934870 onIsAtEndChange={onIsAtEndChange}
47944871 />
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+71
......@@ -940,6 +940,77 @@ describe("deriveMessagesTimelineRows", () => {
940940 expect(assistantRow?.showAssistantMeta).toBe(false);
941941 expect(assistantRow?.showAssistantCopyButton).toBe(false);
942942 });
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 });
9431014});
9441015
9451016describe("computeStableMessagesTimelineRows", () => {
apps/web/src/components/chat/MessagesTimeline.logic.ts+66-7
......@@ -1,5 +1,11 @@
11import * 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";
39import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";
410import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts";
511
......@@ -42,6 +48,15 @@ export type MessagesTimelineRow =
4248 createdAt: string;
4349 groupedEntries: WorkLogEntry[];
4450 }
51 | {
52 kind: "work-toggle";
53 id: string;
54 createdAt: string;
55 groupId: string;
56 hiddenCount: number;
57 expanded: boolean;
58 onlyToolEntries: boolean;
59 }
4560 | {
4661 kind: "turn-fold";
4762 id: string;
......@@ -300,6 +315,7 @@ export function deriveMessagesTimelineRows(input: {
300315 latestTurn?: TimelineLatestTurn | null;
301316 runningTurnId?: TurnId | null;
302317 expandedTurnIds?: ReadonlySet<TurnId>;
318 expandedWorkGroupIds?: ReadonlySet<string>;
303319 isWorking: boolean;
304320 activeTurnStartedAt: string | null;
305321 turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>;
......@@ -367,12 +383,44 @@ export function deriveMessagesTimelineRows(input: {
367383 groupedEntries.push(nextEntry.entry);
368384 cursor += 1;
369385 }
370 nextRows.push({
371 kind: "work",
372 id: timelineEntry.id,
373 createdAt: timelineEntry.createdAt,
374 groupedEntries,
375 });
386 const visibleGroupedEntries = groupedEntries.filter(
387 (entry) => !workEntryIndicatesToolNeutralStatus(entry),
388 );
389 if (visibleGroupedEntries.length > 0) {
390 if (visibleGroupedEntries.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) {
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 }
376424 index = cursor - 1;
377425 continue;
378426 }
......@@ -473,6 +521,17 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
473521 case "work":
474522 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
476535 case "message": {
477536 const bm = b as typeof a;
478537 return (
apps/web/src/components/chat/MessagesTimeline.test.tsx+65-15
......@@ -17,24 +17,59 @@ vi.mock("@legendapp/list/react", async () => {
1717 anchorIndex: number;
1818 anchorMaxSize?: number;
1919 anchorOffset?: number;
20 onReady?: (info: { anchorIndex: number }) => void;
21 onSizeChanged?: (size: number) => void;
2022 };
2123 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 };
2233 ref?: Ref<LegendListRef>;
23 }) => (
24 <div
25 data-testid={legendListTestId}
26 data-anchor-index={props.anchoredEndSpace?.anchorIndex}
27 data-anchor-max-size={props.anchoredEndSpace?.anchorMaxSize}
28 data-anchor-offset={props.anchoredEndSpace?.anchorOffset}
29 data-content-inset-end={props.contentInsetEndAdjustment}
30 >
31 {props.ListHeaderComponent}
32 {props.data.map((item) => (
33 <div key={props.keyExtractor(item)}>{props.renderItem({ item })}</div>
34 ))}
35 {props.ListFooterComponent}
36 </div>
37 );
34 }) => {
35 if (props.anchoredEndSpace) {
36 props.anchoredEndSpace.onSizeChanged?.(240);
37 props.anchoredEndSpace.onReady?.({ anchorIndex: props.anchoredEndSpace.anchorIndex });
38 }
39 return (
40 <div
41 data-testid={legendListTestId}
42 data-anchor-index={props.anchoredEndSpace?.anchorIndex}
43 data-anchor-max-size={props.anchoredEndSpace?.anchorMaxSize}
44 data-anchor-offset={props.anchoredEndSpace?.anchorOffset}
45 data-anchor-on-ready={Boolean(props.anchoredEndSpace?.onReady)}
46 data-content-inset-end={props.contentInsetEndAdjustment}
47 data-class-name={props.className}
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
3974 return { LegendList };
4075});
......@@ -123,6 +158,8 @@ function buildProps() {
123158 timestampFormat: "locale" as const,
124159 workspaceRoot: undefined,
125160 anchorMessageId: null,
161 onAnchorReady: () => {},
162 onAnchorSizeChanged: () => {},
126163 contentInsetEndAdjustment: 0,
127164 onIsAtEndChange: () => {},
128165 };
......@@ -154,6 +191,8 @@ function buildUserTimelineEntry(text: string) {
154191describe("MessagesTimeline", () => {
155192 it("anchors a sent attachment message using its measured height", async () => {
156193 const { MessagesTimeline } = await import("./MessagesTimeline");
194 const onAnchorReady = vi.fn();
195 const onAnchorSizeChanged = vi.fn();
157196 const firstEntry = buildUserTimelineEntry("First prompt.");
158197 const secondEntry = {
159198 ...buildUserTimelineEntry("Newest prompt."),
......@@ -177,6 +216,8 @@ describe("MessagesTimeline", () => {
177216 <MessagesTimeline
178217 {...buildProps()}
179218 anchorMessageId={secondEntry.message.id}
219 onAnchorReady={onAnchorReady}
220 onAnchorSizeChanged={onAnchorSizeChanged}
180221 contentInsetEndAdjustment={144}
181222 timelineEntries={[firstEntry, secondEntry]}
182223 />,
......@@ -184,8 +225,17 @@ describe("MessagesTimeline", () => {
184225
185226 expect(markup).toContain('data-anchor-index="1"');
186227 expect(markup).toContain('data-anchor-offset="16"');
228 expect(markup).toContain('data-anchor-on-ready="true"');
187229 expect(markup).not.toContain("data-anchor-max-size=");
188230 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);
189239 });
190240
191241 it("renders collapse controls for long user messages", async () => {
apps/web/src/components/chat/MessagesTimeline.tsx+115-116
......@@ -14,13 +14,13 @@ import {
1414 use,
1515 useCallback,
1616 useEffect,
17 useLayoutEffect,
1817 useMemo,
1918 useRef,
2019 useState,
2120 type KeyboardEvent,
2221 type ReactNode,
2322} from "react";
23import { flushSync } from "react-dom";
2424import { LegendList, type LegendListRef } from "@legendapp/list/react";
2525import { FileDiff } from "@pierre/diffs/react";
2626import {
......@@ -43,7 +43,6 @@ import {
4343 CheckIcon,
4444 ChevronDownIcon,
4545 ChevronRightIcon,
46 ChevronUpIcon,
4746 CircleAlertIcon,
4847 EyeIcon,
4948 GlobeIcon,
......@@ -67,7 +66,6 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
6766import { MessageCopyButton } from "./MessageCopyButton";
6867import {
6968 computeStableMessagesTimelineRows,
70 MAX_VISIBLE_WORK_LOG_ENTRIES,
7169 deriveMessagesTimelineRows,
7270 normalizeCompactToolLabel,
7371 resolveAssistantMessageCopyState,
......@@ -128,6 +126,7 @@ interface TimelineRowSharedState {
128126 onImageExpand: (preview: ExpandedImagePreview) => void;
129127 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
130128 onToggleTurnFold: (turnId: TurnId) => void;
129 onToggleWorkGroup: (groupId: string, anchorElement?: HTMLElement) => void;
131130}
132131
133132interface TimelineRowActivityState {
......@@ -168,6 +167,8 @@ interface MessagesTimelineProps {
168167 workspaceRoot: string | undefined;
169168 skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
170169 anchorMessageId: MessageId | null;
170 onAnchorReady: (messageId: MessageId, anchorIndex: number) => void;
171 onAnchorSizeChanged: (messageId: MessageId, size: number) => void;
171172 contentInsetEndAdjustment: number;
172173 onIsAtEndChange: (isAtEnd: boolean) => void;
173174}
......@@ -198,20 +199,15 @@ export const MessagesTimeline = memo(function MessagesTimeline({
198199 workspaceRoot,
199200 skills = EMPTY_TIMELINE_SKILLS,
200201 anchorMessageId,
202 onAnchorReady,
203 onAnchorSizeChanged,
201204 contentInsetEndAdjustment,
202205 onIsAtEndChange,
203206}: MessagesTimelineProps) {
204207 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);
213210 const onToggleTurnFold = useCallback((turnId: TurnId) => {
214 setFoldToggleSettling(true);
215211 setExpandedTurnIds((existing) => {
216212 const next = new Set(existing);
217213 if (next.has(turnId)) {
......@@ -222,23 +218,39 @@ export const MessagesTimeline = memo(function MessagesTimeline({
222218 return next;
223219 });
224220 }, []);
225 useEffect(() => {
226 if (!foldToggleSettling) {
227 return;
228 }
229 let secondFrameId: number | null = null;
230 const firstFrameId = window.requestAnimationFrame(() => {
231 secondFrameId = window.requestAnimationFrame(() => {
232 setFoldToggleSettling(false);
221 const onToggleWorkGroup = useCallback(
222 (groupId: string, anchorElement?: HTMLElement) => {
223 const anchorBottomBeforeToggle = anchorElement?.getBoundingClientRect().bottom ?? null;
224
225 flushSync(() => {
226 setExpandedWorkGroupIds((existing) => {
227 const next = new Set(existing);
228 if (next.has(groupId)) {
229 next.delete(groupId);
230 } else {
231 next.add(groupId);
232 }
233 return next;
234 });
233235 });
234 });
235 return () => {
236 window.cancelAnimationFrame(firstFrameId);
237 if (secondFrameId !== null) {
238 window.cancelAnimationFrame(secondFrameId);
236
237 if (anchorBottomBeforeToggle === null || !anchorElement) {
238 return;
239239 }
240 };
241 }, [foldToggleSettling]);
240
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
243255 // An in-session interrupt leaves its turn expanded so the user keeps their
244256 // place; the next turn (or a reload, since this is local state) folds it.
......@@ -276,6 +288,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
276288 latestTurn,
277289 runningTurnId,
278290 expandedTurnIds,
291 expandedWorkGroupIds,
279292 isWorking,
280293 activeTurnStartedAt,
281294 turnDiffSummaryByAssistantMessageId,
......@@ -286,6 +299,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
286299 latestTurn,
287300 runningTurnId,
288301 expandedTurnIds,
302 expandedWorkGroupIds,
289303 isWorking,
290304 activeTurnStartedAt,
291305 turnDiffSummaryByAssistantMessageId,
......@@ -293,13 +307,30 @@ export const MessagesTimeline = memo(function MessagesTimeline({
293307 ],
294308 );
295309 const rows = useStableRows(rawRows);
296 const anchoredEndSpace = useMemo(
297 () =>
298 resolveChatListAnchoredEndSpace(rows, anchorMessageId, (row) =>
299 row.kind === "message" ? row.message.id : null,
300 ),
301 [anchorMessageId, rows],
310 const handleAnchorReady = useCallback(
311 (info: { anchorIndex: number | undefined }) => {
312 if (anchorMessageId !== null && info.anchorIndex !== undefined) {
313 onAnchorReady(anchorMessageId, info.anchorIndex);
314 }
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],
302325 );
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
304335 const handleScroll = useCallback(() => {
305336 const state = listRef.current?.getState?.();
......@@ -322,6 +353,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
322353 onImageExpand,
323354 onOpenTurnDiff,
324355 onToggleTurnFold,
356 onToggleWorkGroup,
325357 }),
326358 [
327359 timestampFormat,
......@@ -335,6 +367,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
335367 onImageExpand,
336368 onOpenTurnDiff,
337369 onToggleTurnFold,
370 onToggleWorkGroup,
338371 ],
339372 );
340373 const activityState = useMemo<TimelineRowActivityState>(
......@@ -380,11 +413,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({
380413 initialScrollAtEnd
381414 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
382415 contentInsetEndAdjustment={contentInsetEndAdjustment}
383 maintainScrollAtEnd={!foldToggleSettling}
384 maintainScrollAtEndThreshold={0.1}
385 maintainVisibleContentPosition
416 maintainVisibleContentPosition={{
417 data: true,
418 size: false,
419 }}
386420 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"
388422 ListHeaderComponent={TIMELINE_LIST_HEADER}
389423 ListFooterComponent={TIMELINE_LIST_FOOTER}
390424 />
......@@ -417,7 +451,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
417451 // Commentary (non-terminal assistant) rows carry no metadata row, so
418452 // they sit closer to the work that follows them.
419453 (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) ||
420 row.kind === "work"
454 row.kind === "work" ||
455 row.kind === "work-toggle"
421456 ? "pb-2"
422457 : "pb-4",
423458 row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null,
......@@ -428,6 +463,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
428463 data-message-role={row.kind === "message" ? row.message.role : undefined}
429464 >
430465 {row.kind === "work" ? <WorkGroupSection groupedEntries={row.groupedEntries} /> : null}
466 {row.kind === "work-toggle" ? <WorkGroupToggleTimelineRow row={row} /> : null}
431467 {row.kind === "turn-fold" ? <TurnFoldTimelineRow row={row} /> : null}
432468 {row.kind === "message" && row.message.role === "user" ? <UserTimelineRow row={row} /> : null}
433469 {row.kind === "message" && row.message.role === "assistant" ? (
......@@ -720,26 +756,17 @@ function WorkingTimer({ createdAt }: { createdAt: string }) {
720756// re-render only the affected row, not the entire list.
721757// ---------------------------------------------------------------------------
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. */
724760const WorkGroupSection = memo(function WorkGroupSection({
725761 groupedEntries,
726762}: {
727763 groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"];
728764}) {
729765 const { workspaceRoot } = use(TimelineRowCtx);
730 const [isExpanded, setIsExpanded] = useState(false);
731 const sectionRef = useRef<HTMLElement>(null);
732 const anchorBottomBeforeToggleRef = useRef<number | null>(null);
733766 const nonEmptyEntries = useMemo(
734767 () => groupedEntries.filter((entry) => !workEntryIndicatesToolNeutralStatus(entry)),
735768 [groupedEntries],
736769 );
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;
743770 const onlyToolEntries = nonEmptyEntries.every((entry) => workLogEntryIsToolLike(entry));
744771 const groupLabel = onlyToolEntries
745772 ? nonEmptyEntries.length === 1
......@@ -747,49 +774,17 @@ const WorkGroupSection = memo(function WorkGroupSection({
747774 : `${nonEmptyEntries.length} tool calls`
748775 : "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
782777 if (nonEmptyEntries.length === 0) return null;
783778
784779 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}>
786781 {!onlyToolEntries && (
787782 <p className="px-0.5 pb-0.5 font-medium text-[11px] text-muted-foreground/65">
788783 {groupLabel}
789784 </p>
790785 )}
791786 <div className="space-y-px">
792 {visibleEntries.map((workEntry) => (
787 {nonEmptyEntries.map((workEntry) => (
793788 <SimpleWorkEntryRow
794789 key={workEntry.id}
795790 workEntry={workEntry}
......@@ -797,45 +792,49 @@ const WorkGroupSection = memo(function WorkGroupSection({
797792 />
798793 ))}
799794 </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 )}
822795 </section>
823796 );
824797});
825798
826function findNearestVerticalScroller(element: HTMLElement): HTMLElement | null {
827 let parent = element.parentElement;
828 while (parent) {
829 const { overflowY } = window.getComputedStyle(parent);
830 if (
831 (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") &&
832 parent.scrollHeight > parent.clientHeight
833 ) {
834 return parent;
835 }
836 parent = parent.parentElement;
837 }
838 return null;
799function WorkGroupToggleTimelineRow({
800 row,
801}: {
802 row: Extract<TimelineRow, { kind: "work-toggle" }>;
803}) {
804 const ctx = use(TimelineRowCtx);
805 const labelNoun = row.onlyToolEntries ? "tool call" : "log entry";
806
807 return (
808 <button
809 type="button"
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"
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 );
839838}
840839
841840/** Subscribes directly to the UI state store for expand/collapse state,