| author | |
| committer | |
| log | 24abab789ec68c6aba865a34e0901f8be8743595 |
| tree | 71866a51d1b2f51ee32649b03948882971bedc4e |
| parent | 52b04b947e3604e426386be53e6d20c6a4366fef |
| signature | Signed by PGP key B5690EEEBB952194 |
9 files changed, 693 insertions(+), 250 deletions(-)
apps/mobile/src/features/threads/ThreadFeed.tsx+107-55| ... | ... | @@ -74,7 +74,7 @@ import { |
| 74 | 74 | type ThreadFeedLatestTurn, |
| 75 | 75 | } from "../../lib/threadActivity"; |
| 76 | 76 | import type { ThreadContentPresentation } from "./threadContentPresentation"; |
| 77 | import { ThreadWorkLog } from "./thread-work-log"; | |
| 77 | import { ThreadWorkGroupToggle, ThreadWorkLog } from "./thread-work-log"; | |
| 78 | 78 | import { useAssetUrl } from "../../state/assets"; |
| 79 | 79 | import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; |
| 80 | 80 | |
| ... | ... | @@ -655,7 +655,6 @@ function renderFeedEntry( |
| 655 | 655 | info: { item: ThreadFeedEntry; index: number }, |
| 656 | 656 | props: Pick<ThreadFeedProps, "environmentId" | "skills"> & { |
| 657 | 657 | readonly copiedRowId: string | null; |
| 658 | readonly expandedWorkGroups: Record<string, boolean>; | |
| 659 | 658 | readonly expandedWorkRows: Record<string, boolean>; |
| 660 | 659 | readonly terminalAssistantMessageIds: ReadonlySet<string>; |
| 661 | 660 | readonly unsettledTurnId: TurnId | null; |
| ... | ... | @@ -698,6 +697,18 @@ function renderFeedEntry( |
| 698 | 697 | ); |
| 699 | 698 | } |
| 700 | 699 | |
| 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 | 712 | if (entry.type === "message") { |
| 702 | 713 | const { message } = entry; |
| 703 | 714 | const isUser = message.role === "user"; |
| ... | ... | @@ -825,11 +836,9 @@ function renderFeedEntry( |
| 825 | 836 | <ThreadWorkLog |
| 826 | 837 | activities={entry.activities} |
| 827 | 838 | copiedRowId={props.copiedRowId} |
| 828 | expanded={props.expandedWorkGroups[entry.id] ?? false} | |
| 829 | 839 | expandedRows={props.expandedWorkRows} |
| 830 | 840 | iconSubtleColor={iconSubtleColor} |
| 831 | 841 | onCopyRow={props.onCopyWorkRow} |
| 832 | onToggleGroup={() => props.onToggleWorkGroup(entry.id)} | |
| 833 | 842 | onToggleRow={props.onToggleWorkRow} |
| 834 | 843 | /> |
| 835 | 844 | ); |
| ... | ... | @@ -1110,9 +1119,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1110 | 1119 | const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 1111 | 1120 | const foldSettleFrameRef = useRef<number | null>(null); |
| 1112 | 1121 | const foldSettleSecondFrameRef = useRef<number | null>(null); |
| 1122 | const disclosureAnchorKeyRef = useRef<string | null>(null); | |
| 1113 | 1123 | const previousLatestTurnRef = useRef(props.latestTurn); |
| 1114 | 1124 | const { width: viewportWidth } = useWindowDimensions(); |
| 1115 | const [foldToggleSettling, setFoldToggleSettling] = useState(false); | |
| 1125 | const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); | |
| 1116 | 1126 | const [interactionState, setInteractionState] = useState<{ |
| 1117 | 1127 | readonly copiedRowId: string | null; |
| 1118 | 1128 | readonly expandedWorkGroups: Record<string, boolean>; |
| ... | ... | @@ -1173,7 +1183,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1173 | 1183 | const listAppearanceData = useMemo( |
| 1174 | 1184 | () => ({ |
| 1175 | 1185 | copiedRowId, |
| 1176 | expandedWorkGroups, | |
| 1177 | 1186 | expandedWorkRows, |
| 1178 | 1187 | iconSubtleColor, |
| 1179 | 1188 | markdownStyles, |
| ... | ... | @@ -1182,7 +1191,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1182 | 1191 | }), |
| 1183 | 1192 | [ |
| 1184 | 1193 | copiedRowId, |
| 1185 | expandedWorkGroups, | |
| 1186 | 1194 | expandedWorkRows, |
| 1187 | 1195 | iconSubtleColor, |
| 1188 | 1196 | markdownStyles, |
| ... | ... | @@ -1190,9 +1198,24 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1190 | 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 | 1210 | 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], | |
| 1196 | 1219 | ); |
| 1197 | 1220 | const anchoredEndSpace = useMemo( |
| 1198 | 1221 | () => |
| ... | ... | @@ -1259,6 +1282,39 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1259 | 1282 | }; |
| 1260 | 1283 | }, []); |
| 1261 | 1284 | |
| 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 | 1318 | const onCopyWorkRow = useCallback((rowId: string, value: string) => { |
| 1263 | 1319 | copyTextWithHaptic(value, { |
| 1264 | 1320 | target: "thread-work-row", |
| ... | ... | @@ -1276,51 +1332,49 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1276 | 1332 | }, 1200); |
| 1277 | 1333 | }, []); |
| 1278 | 1334 | |
| 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 | ); | |
| 1288 | 1348 | |
| 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 | ); | |
| 1298 | 1362 | |
| 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 }; | |
| 1321 | 1374 | }); |
| 1322 | }); | |
| 1323 | }, []); | |
| 1375 | }, | |
| 1376 | [suspendEndScrollMaintenanceForDisclosure], | |
| 1377 | ); | |
| 1324 | 1378 | |
| 1325 | 1379 | const onPressImage = useCallback((uri: string, headers?: Record<string, string>) => { |
| 1326 | 1380 | setExpandedImage({ uri, headers }); |
| ... | ... | @@ -1331,7 +1385,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1331 | 1385 | renderFeedEntry(info, { |
| 1332 | 1386 | environmentId: props.environmentId, |
| 1333 | 1387 | copiedRowId, |
| 1334 | expandedWorkGroups, | |
| 1335 | 1388 | expandedWorkRows, |
| 1336 | 1389 | terminalAssistantMessageIds, |
| 1337 | 1390 | unsettledTurnId, |
| ... | ... | @@ -1351,7 +1404,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1351 | 1404 | }), |
| 1352 | 1405 | [ |
| 1353 | 1406 | copiedRowId, |
| 1354 | expandedWorkGroups, | |
| 1355 | 1407 | expandedWorkRows, |
| 1356 | 1408 | terminalAssistantMessageIds, |
| 1357 | 1409 | unsettledTurnId, |
| ... | ... | @@ -1410,7 +1462,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1410 | 1462 | contentInsetEndAdjustment={props.contentInsetEndAdjustment} |
| 1411 | 1463 | freeze={props.freeze} |
| 1412 | 1464 | maintainScrollAtEnd={ |
| 1413 | foldToggleSettling | |
| 1465 | disclosureToggleSettling | |
| 1414 | 1466 | ? false |
| 1415 | 1467 | : { |
| 1416 | 1468 | animated: false, |
| ... | ... | @@ -1421,7 +1473,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1421 | 1473 | }, |
| 1422 | 1474 | } |
| 1423 | 1475 | } |
| 1424 | maintainVisibleContentPosition | |
| 1476 | maintainVisibleContentPosition={maintainVisibleContentPosition} | |
| 1425 | 1477 | data={presentedFeed} |
| 1426 | 1478 | extraData={listAppearanceData} |
| 1427 | 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 | 6 | import { cn } from "../../lib/cn"; |
| 7 | 7 | import type { ThreadFeedActivity } from "../../lib/threadActivity"; |
| 8 | 8 | |
| 9 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 1; | |
| 10 | 9 | const WORK_LOG_LAYOUT_ANIMATION = { |
| 11 | 10 | duration: 180, |
| 12 | 11 | create: { |
| ... | ... | @@ -72,11 +71,9 @@ function workRowSymbolName(icon: ThreadFeedActivity["icon"]): SFSymbol { |
| 72 | 71 | export function ThreadWorkLog(props: { |
| 73 | 72 | readonly activities: ReadonlyArray<ThreadFeedActivity>; |
| 74 | 73 | readonly copiedRowId: string | null; |
| 75 | readonly expanded: boolean; | |
| 76 | 74 | readonly expandedRows: Readonly<Record<string, boolean>>; |
| 77 | 75 | readonly iconSubtleColor: import("react-native").ColorValue; |
| 78 | 76 | readonly onCopyRow: (rowId: string, value: string) => void; |
| 79 | readonly onToggleGroup: () => void; | |
| 80 | 77 | readonly onToggleRow: (rowId: string) => void; |
| 81 | 78 | }) { |
| 82 | 79 | const colorScheme = useColorScheme(); |
| ... | ... | @@ -89,14 +86,10 @@ export function ThreadWorkLog(props: { |
| 89 | 86 | return null; |
| 90 | 87 | } |
| 91 | 88 | |
| 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 | 89 | const onlyToolRows = rows.every((row) => row.toolLike); |
| 97 | 90 | |
| 98 | 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 | 93 | {!onlyToolRows ? ( |
| 101 | 94 | <Text className="px-0.5 pb-0.5 font-t3-medium text-2xs text-foreground-muted opacity-60"> |
| 102 | 95 | work log |
| ... | ... | @@ -104,7 +97,7 @@ export function ThreadWorkLog(props: { |
| 104 | 97 | ) : null} |
| 105 | 98 | |
| 106 | 99 | <View className="gap-px"> |
| 107 | {visibleRows.map((row) => { | |
| 100 | {rows.map((row) => { | |
| 108 | 101 | const expanded = props.expandedRows[row.id] ?? false; |
| 109 | 102 | const canExpand = row.fullDetail !== null; |
| 110 | 103 | const displayText = row.detail ? `${row.summary} ${row.detail}` : row.summary; |
| ... | ... | @@ -132,13 +125,13 @@ export function ThreadWorkLog(props: { |
| 132 | 125 | style={({ pressed }) => ({ |
| 133 | 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"> | |
| 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"> | |
| 139 | 132 | <SymbolView |
| 140 | 133 | name={workRowSymbolName(row.icon)} |
| 141 | size={14} | |
| 134 | size={13} | |
| 142 | 135 | weight="medium" |
| 143 | 136 | tintColor={iconIsDestructive ? "#e11d48" : props.iconSubtleColor} |
| 144 | 137 | type="monochrome" |
| ... | ... | @@ -146,7 +139,7 @@ export function ThreadWorkLog(props: { |
| 146 | 139 | </View> |
| 147 | 140 | |
| 148 | 141 | <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 | 143 | numberOfLines={1} |
| 151 | 144 | > |
| 152 | 145 | <Text |
| ... | ... | @@ -199,7 +192,7 @@ export function ThreadWorkLog(props: { |
| 199 | 192 | </Pressable> |
| 200 | 193 | |
| 201 | 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 | 196 | <ScrollView |
| 204 | 197 | nestedScrollEnabled |
| 205 | 198 | directionalLockEnabled |
| ... | ... | @@ -221,41 +214,59 @@ export function ThreadWorkLog(props: { |
| 221 | 214 | ); |
| 222 | 215 | })} |
| 223 | 216 | </View> |
| 217 | </View> | |
| 218 | ); | |
| 219 | } | |
| 224 | 220 | |
| 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} | |
| 221 | export 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> | |
| 259 | 270 | </View> |
| 260 | 271 | ); |
| 261 | 272 | } |
apps/mobile/src/lib/threadActivity.test.ts+60-1| ... | ... | @@ -11,7 +11,12 @@ import { |
| 11 | 11 | type OrchestrationThreadActivity, |
| 12 | 12 | } from "@t3tools/contracts"; |
| 13 | 13 | |
| 14 | import { buildThreadFeed, deriveThreadFeedPresentation } from "./threadActivity"; | |
| 14 | import { | |
| 15 | buildThreadFeed, | |
| 16 | deriveThreadFeedPresentation, | |
| 17 | type ThreadFeedActivity, | |
| 18 | type ThreadFeedEntry, | |
| 19 | } from "./threadActivity"; | |
| 15 | 20 | |
| 16 | 21 | function makeActivity( |
| 17 | 22 | input: Partial<OrchestrationThreadActivity> & |
| ... | ... | @@ -406,4 +411,58 @@ describe("buildThreadFeed", () => { |
| 406 | 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 | 55 | readonly status: "success" | "failure" | "neutral" | null; |
| 56 | 56 | } |
| 57 | 57 | |
| 58 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 1; | |
| 59 | ||
| 58 | 60 | type WorkLogToolLifecycleStatus = "inProgress" | "completed" | "failed" | "declined" | "stopped"; |
| 59 | 61 | |
| 60 | 62 | interface WorkLogEntry { |
| ... | ... | @@ -103,6 +105,16 @@ export type ThreadFeedEntry = |
| 103 | 105 | readonly turnId: TurnId | null; |
| 104 | 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 | 119 | readonly type: "turn-fold"; |
| 108 | 120 | readonly id: string; |
| ... | ... | @@ -1092,8 +1104,11 @@ export function deriveThreadFeedPresentation( |
| 1092 | 1104 | feed: ReadonlyArray<ThreadFeedEntry>, |
| 1093 | 1105 | latestTurn: ThreadFeedLatestTurn | null, |
| 1094 | 1106 | expandedTurnIds: ReadonlySet<TurnId>, |
| 1107 | expandedWorkGroupIds: ReadonlySet<string> = new Set(), | |
| 1095 | 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 | 1112 | const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn); |
| 1098 | 1113 | const collapsedEntryIds = new Set<string>(); |
| 1099 | 1114 | for (const fold of foldsByAnchorId.values()) { |
| ... | ... | @@ -1118,12 +1133,62 @@ export function deriveThreadFeedPresentation( |
| 1118 | 1133 | }); |
| 1119 | 1134 | } |
| 1120 | 1135 | if (!collapsedEntryIds.has(entry.id)) { |
| 1121 | result.push(entry); | |
| 1136 | appendPresentedFeedEntry(result, entry, expandedWorkGroupIds); | |
| 1122 | 1137 | } |
| 1123 | 1138 | } |
| 1124 | 1139 | return result; |
| 1125 | 1140 | } |
| 1126 | 1141 | |
| 1142 | function 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 | ||
| 1127 | 1192 | export function derivePendingApprovals( |
| 1128 | 1193 | activities: ReadonlyArray<OrchestrationThreadActivity>, |
| 1129 | 1194 | ): PendingApproval[] { |
apps/web/src/components/ChatView.tsx+82-5| ... | ... | @@ -36,6 +36,7 @@ import { |
| 36 | 36 | createModelSelection, |
| 37 | 37 | resolvePromptInjectedEffort, |
| 38 | 38 | } from "@t3tools/shared/model"; |
| 39 | import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; | |
| 39 | 40 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; |
| 40 | 41 | import { truncate } from "@t3tools/shared/String"; |
| 41 | 42 | import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; |
| ... | ... | @@ -3147,10 +3148,83 @@ function ChatViewContent(props: ChatViewProps) { |
| 3147 | 3148 | ], |
| 3148 | 3149 | ); |
| 3149 | 3150 | |
| 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 | 3153 | const scrollToEnd = useCallback((animated = false) => { |
| 3152 | 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 | }, []); | |
| 3154 | 3228 | |
| 3155 | 3229 | // Debounce *showing* the scroll-to-bottom pill so it doesn't flash during |
| 3156 | 3230 | // thread switches. LegendList fires scroll events with isAtEnd=false while |
| ... | ... | @@ -3751,8 +3825,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 3751 | 3825 | streaming: false, |
| 3752 | 3826 | }, |
| 3753 | 3827 | ]); |
| 3754 | void legendListRef.current?.scrollToEnd?.({ animated: false }); | |
| 3755 | ||
| 3756 | 3828 | setThreadError(threadIdForSend, null); |
| 3757 | 3829 | if (expiredTerminalContextCount > 0) { |
| 3758 | 3830 | const toastCopy = buildExpiredTerminalContextToastCopy( |
| ... | ... | @@ -4165,11 +4237,14 @@ function ChatViewContent(props: ChatViewProps) { |
| 4165 | 4237 | beginLocalDispatch({ preparingWorktree: false }); |
| 4166 | 4238 | setThreadError(threadIdForSend, null); |
| 4167 | 4239 | |
| 4168 | // Scroll to the current end *before* adding the optimistic message. | |
| 4240 | // Position this sent row once LegendList has measured the anchored tail. | |
| 4169 | 4241 | isAtEndRef.current = true; |
| 4170 | 4242 | showScrollDebouncer.current.cancel(); |
| 4171 | 4243 | setShowScrollToBottom(false); |
| 4172 | await legendListRef.current?.scrollToEnd?.({ animated: false }); | |
| 4244 | setTimelineAnchor({ | |
| 4245 | threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), | |
| 4246 | messageId: messageIdForSend, | |
| 4247 | }); | |
| 4173 | 4248 | |
| 4174 | 4249 | setOptimisticUserMessages((existing) => [ |
| 4175 | 4250 | ...existing, |
| ... | ... | @@ -4789,6 +4864,8 @@ function ChatViewContent(props: ChatViewProps) { |
| 4789 | 4864 | workspaceRoot={activeWorkspaceRoot} |
| 4790 | 4865 | skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} |
| 4791 | 4866 | anchorMessageId={timelineAnchorMessageId} |
| 4867 | onAnchorReady={onTimelineAnchorReady} | |
| 4868 | onAnchorSizeChanged={onTimelineAnchorSizeChanged} | |
| 4792 | 4869 | contentInsetEndAdjustment={composerOverlayHeight} |
| 4793 | 4870 | onIsAtEndChange={onIsAtEndChange} |
| 4794 | 4871 | /> |
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+71| ... | ... | @@ -940,6 +940,77 @@ describe("deriveMessagesTimelineRows", () => { |
| 940 | 940 | expect(assistantRow?.showAssistantMeta).toBe(false); |
| 941 | 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 | }); |
| 944 | 1015 | |
| 945 | 1016 | describe("computeStableMessagesTimelineRows", () => { |
apps/web/src/components/chat/MessagesTimeline.logic.ts+66-7| ... | ... | @@ -1,5 +1,11 @@ |
| 1 | 1 | import * as Equal from "effect/Equal"; |
| 2 | import { formatDuration, type TimelineEntry, type WorkLogEntry } from "../../session-logic"; | |
| 2 | import { | |
| 3 | formatDuration, | |
| 4 | workEntryIndicatesToolNeutralStatus, | |
| 5 | workLogEntryIsToolLike, | |
| 6 | type TimelineEntry, | |
| 7 | type WorkLogEntry, | |
| 8 | } from "../../session-logic"; | |
| 3 | 9 | import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; |
| 4 | 10 | import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts"; |
| 5 | 11 | |
| ... | ... | @@ -42,6 +48,15 @@ export type MessagesTimelineRow = |
| 42 | 48 | createdAt: string; |
| 43 | 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 | 61 | kind: "turn-fold"; |
| 47 | 62 | id: string; |
| ... | ... | @@ -300,6 +315,7 @@ export function deriveMessagesTimelineRows(input: { |
| 300 | 315 | latestTurn?: TimelineLatestTurn | null; |
| 301 | 316 | runningTurnId?: TurnId | null; |
| 302 | 317 | expandedTurnIds?: ReadonlySet<TurnId>; |
| 318 | expandedWorkGroupIds?: ReadonlySet<string>; | |
| 303 | 319 | isWorking: boolean; |
| 304 | 320 | activeTurnStartedAt: string | null; |
| 305 | 321 | turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>; |
| ... | ... | @@ -367,12 +383,44 @@ export function deriveMessagesTimelineRows(input: { |
| 367 | 383 | groupedEntries.push(nextEntry.entry); |
| 368 | 384 | cursor += 1; |
| 369 | 385 | } |
| 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 | } | |
| 376 | 424 | index = cursor - 1; |
| 377 | 425 | continue; |
| 378 | 426 | } |
| ... | ... | @@ -473,6 +521,17 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean |
| 473 | 521 | case "work": |
| 474 | 522 | return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries); |
| 475 | 523 | |
| 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 | 535 | case "message": { |
| 477 | 536 | const bm = b as typeof a; |
| 478 | 537 | return ( |
apps/web/src/components/chat/MessagesTimeline.test.tsx+65-15| ... | ... | @@ -17,24 +17,59 @@ vi.mock("@legendapp/list/react", async () => { |
| 17 | 17 | anchorIndex: number; |
| 18 | 18 | anchorMaxSize?: number; |
| 19 | 19 | anchorOffset?: number; |
| 20 | onReady?: (info: { anchorIndex: number }) => void; | |
| 21 | onSizeChanged?: (size: number) => void; | |
| 20 | 22 | }; |
| 21 | 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 | 33 | 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 | }; | |
| 38 | 73 | |
| 39 | 74 | return { LegendList }; |
| 40 | 75 | }); |
| ... | ... | @@ -123,6 +158,8 @@ function buildProps() { |
| 123 | 158 | timestampFormat: "locale" as const, |
| 124 | 159 | workspaceRoot: undefined, |
| 125 | 160 | anchorMessageId: null, |
| 161 | onAnchorReady: () => {}, | |
| 162 | onAnchorSizeChanged: () => {}, | |
| 126 | 163 | contentInsetEndAdjustment: 0, |
| 127 | 164 | onIsAtEndChange: () => {}, |
| 128 | 165 | }; |
| ... | ... | @@ -154,6 +191,8 @@ function buildUserTimelineEntry(text: string) { |
| 154 | 191 | describe("MessagesTimeline", () => { |
| 155 | 192 | it("anchors a sent attachment message using its measured height", async () => { |
| 156 | 193 | const { MessagesTimeline } = await import("./MessagesTimeline"); |
| 194 | const onAnchorReady = vi.fn(); | |
| 195 | const onAnchorSizeChanged = vi.fn(); | |
| 157 | 196 | const firstEntry = buildUserTimelineEntry("First prompt."); |
| 158 | 197 | const secondEntry = { |
| 159 | 198 | ...buildUserTimelineEntry("Newest prompt."), |
| ... | ... | @@ -177,6 +216,8 @@ describe("MessagesTimeline", () => { |
| 177 | 216 | <MessagesTimeline |
| 178 | 217 | {...buildProps()} |
| 179 | 218 | anchorMessageId={secondEntry.message.id} |
| 219 | onAnchorReady={onAnchorReady} | |
| 220 | onAnchorSizeChanged={onAnchorSizeChanged} | |
| 180 | 221 | contentInsetEndAdjustment={144} |
| 181 | 222 | timelineEntries={[firstEntry, secondEntry]} |
| 182 | 223 | />, |
| ... | ... | @@ -184,8 +225,17 @@ describe("MessagesTimeline", () => { |
| 184 | 225 | |
| 185 | 226 | expect(markup).toContain('data-anchor-index="1"'); |
| 186 | 227 | expect(markup).toContain('data-anchor-offset="16"'); |
| 228 | expect(markup).toContain('data-anchor-on-ready="true"'); | |
| 187 | 229 | expect(markup).not.toContain("data-anchor-max-size="); |
| 188 | 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 | }); |
| 190 | 240 | |
| 191 | 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 | 14 | use, |
| 15 | 15 | useCallback, |
| 16 | 16 | useEffect, |
| 17 | useLayoutEffect, | |
| 18 | 17 | useMemo, |
| 19 | 18 | useRef, |
| 20 | 19 | useState, |
| 21 | 20 | type KeyboardEvent, |
| 22 | 21 | type ReactNode, |
| 23 | 22 | } from "react"; |
| 23 | import { flushSync } from "react-dom"; | |
| 24 | 24 | import { LegendList, type LegendListRef } from "@legendapp/list/react"; |
| 25 | 25 | import { FileDiff } from "@pierre/diffs/react"; |
| 26 | 26 | import { |
| ... | ... | @@ -43,7 +43,6 @@ import { |
| 43 | 43 | CheckIcon, |
| 44 | 44 | ChevronDownIcon, |
| 45 | 45 | ChevronRightIcon, |
| 46 | ChevronUpIcon, | |
| 47 | 46 | CircleAlertIcon, |
| 48 | 47 | EyeIcon, |
| 49 | 48 | GlobeIcon, |
| ... | ... | @@ -67,7 +66,6 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; |
| 67 | 66 | import { MessageCopyButton } from "./MessageCopyButton"; |
| 68 | 67 | import { |
| 69 | 68 | computeStableMessagesTimelineRows, |
| 70 | MAX_VISIBLE_WORK_LOG_ENTRIES, | |
| 71 | 69 | deriveMessagesTimelineRows, |
| 72 | 70 | normalizeCompactToolLabel, |
| 73 | 71 | resolveAssistantMessageCopyState, |
| ... | ... | @@ -128,6 +126,7 @@ interface TimelineRowSharedState { |
| 128 | 126 | onImageExpand: (preview: ExpandedImagePreview) => void; |
| 129 | 127 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; |
| 130 | 128 | onToggleTurnFold: (turnId: TurnId) => void; |
| 129 | onToggleWorkGroup: (groupId: string, anchorElement?: HTMLElement) => void; | |
| 131 | 130 | } |
| 132 | 131 | |
| 133 | 132 | interface TimelineRowActivityState { |
| ... | ... | @@ -168,6 +167,8 @@ interface MessagesTimelineProps { |
| 168 | 167 | workspaceRoot: string | undefined; |
| 169 | 168 | skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; |
| 170 | 169 | anchorMessageId: MessageId | null; |
| 170 | onAnchorReady: (messageId: MessageId, anchorIndex: number) => void; | |
| 171 | onAnchorSizeChanged: (messageId: MessageId, size: number) => void; | |
| 171 | 172 | contentInsetEndAdjustment: number; |
| 172 | 173 | onIsAtEndChange: (isAtEnd: boolean) => void; |
| 173 | 174 | } |
| ... | ... | @@ -198,20 +199,15 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 198 | 199 | workspaceRoot, |
| 199 | 200 | skills = EMPTY_TIMELINE_SKILLS, |
| 200 | 201 | anchorMessageId, |
| 202 | onAnchorReady, | |
| 203 | onAnchorSizeChanged, | |
| 201 | 204 | contentInsetEndAdjustment, |
| 202 | 205 | onIsAtEndChange, |
| 203 | 206 | }: MessagesTimelineProps) { |
| 204 | 207 | const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set()); |
| 208 | const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set()); | |
| 205 | 209 | |
| 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 | 210 | const onToggleTurnFold = useCallback((turnId: TurnId) => { |
| 214 | setFoldToggleSettling(true); | |
| 215 | 211 | setExpandedTurnIds((existing) => { |
| 216 | 212 | const next = new Set(existing); |
| 217 | 213 | if (next.has(turnId)) { |
| ... | ... | @@ -222,23 +218,39 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 222 | 218 | return next; |
| 223 | 219 | }); |
| 224 | 220 | }, []); |
| 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 | }); | |
| 233 | 235 | }); |
| 234 | }); | |
| 235 | return () => { | |
| 236 | window.cancelAnimationFrame(firstFrameId); | |
| 237 | if (secondFrameId !== null) { | |
| 238 | window.cancelAnimationFrame(secondFrameId); | |
| 236 | ||
| 237 | if (anchorBottomBeforeToggle === null || !anchorElement) { | |
| 238 | return; | |
| 239 | 239 | } |
| 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 | ); | |
| 242 | 254 | |
| 243 | 255 | // An in-session interrupt leaves its turn expanded so the user keeps their |
| 244 | 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 | 288 | latestTurn, |
| 277 | 289 | runningTurnId, |
| 278 | 290 | expandedTurnIds, |
| 291 | expandedWorkGroupIds, | |
| 279 | 292 | isWorking, |
| 280 | 293 | activeTurnStartedAt, |
| 281 | 294 | turnDiffSummaryByAssistantMessageId, |
| ... | ... | @@ -286,6 +299,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 286 | 299 | latestTurn, |
| 287 | 300 | runningTurnId, |
| 288 | 301 | expandedTurnIds, |
| 302 | expandedWorkGroupIds, | |
| 289 | 303 | isWorking, |
| 290 | 304 | activeTurnStartedAt, |
| 291 | 305 | turnDiffSummaryByAssistantMessageId, |
| ... | ... | @@ -293,13 +307,30 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 293 | 307 | ], |
| 294 | 308 | ); |
| 295 | 309 | 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], | |
| 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]); | |
| 303 | 334 | |
| 304 | 335 | const handleScroll = useCallback(() => { |
| 305 | 336 | const state = listRef.current?.getState?.(); |
| ... | ... | @@ -322,6 +353,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 322 | 353 | onImageExpand, |
| 323 | 354 | onOpenTurnDiff, |
| 324 | 355 | onToggleTurnFold, |
| 356 | onToggleWorkGroup, | |
| 325 | 357 | }), |
| 326 | 358 | [ |
| 327 | 359 | timestampFormat, |
| ... | ... | @@ -335,6 +367,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 335 | 367 | onImageExpand, |
| 336 | 368 | onOpenTurnDiff, |
| 337 | 369 | onToggleTurnFold, |
| 370 | onToggleWorkGroup, | |
| 338 | 371 | ], |
| 339 | 372 | ); |
| 340 | 373 | const activityState = useMemo<TimelineRowActivityState>( |
| ... | ... | @@ -380,11 +413,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 380 | 413 | initialScrollAtEnd |
| 381 | 414 | {...(anchoredEndSpace ? { anchoredEndSpace } : {})} |
| 382 | 415 | contentInsetEndAdjustment={contentInsetEndAdjustment} |
| 383 | maintainScrollAtEnd={!foldToggleSettling} | |
| 384 | maintainScrollAtEndThreshold={0.1} | |
| 385 | maintainVisibleContentPosition | |
| 416 | maintainVisibleContentPosition={{ | |
| 417 | data: true, | |
| 418 | size: false, | |
| 419 | }} | |
| 386 | 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 | 422 | ListHeaderComponent={TIMELINE_LIST_HEADER} |
| 389 | 423 | ListFooterComponent={TIMELINE_LIST_FOOTER} |
| 390 | 424 | /> |
| ... | ... | @@ -417,7 +451,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time |
| 417 | 451 | // Commentary (non-terminal assistant) rows carry no metadata row, so |
| 418 | 452 | // they sit closer to the work that follows them. |
| 419 | 453 | (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) || |
| 420 | row.kind === "work" | |
| 454 | row.kind === "work" || | |
| 455 | row.kind === "work-toggle" | |
| 421 | 456 | ? "pb-2" |
| 422 | 457 | : "pb-4", |
| 423 | 458 | row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null, |
| ... | ... | @@ -428,6 +463,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time |
| 428 | 463 | data-message-role={row.kind === "message" ? row.message.role : undefined} |
| 429 | 464 | > |
| 430 | 465 | {row.kind === "work" ? <WorkGroupSection groupedEntries={row.groupedEntries} /> : null} |
| 466 | {row.kind === "work-toggle" ? <WorkGroupToggleTimelineRow row={row} /> : null} | |
| 431 | 467 | {row.kind === "turn-fold" ? <TurnFoldTimelineRow row={row} /> : null} |
| 432 | 468 | {row.kind === "message" && row.message.role === "user" ? <UserTimelineRow row={row} /> : null} |
| 433 | 469 | {row.kind === "message" && row.message.role === "assistant" ? ( |
| ... | ... | @@ -720,26 +756,17 @@ function WorkingTimer({ createdAt }: { createdAt: string }) { |
| 720 | 756 | // re-render only the affected row, not the entire list. |
| 721 | 757 | // --------------------------------------------------------------------------- |
| 722 | 758 | |
| 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. */ | |
| 724 | 760 | const WorkGroupSection = memo(function WorkGroupSection({ |
| 725 | 761 | groupedEntries, |
| 726 | 762 | }: { |
| 727 | 763 | groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"]; |
| 728 | 764 | }) { |
| 729 | 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 | 766 | const nonEmptyEntries = useMemo( |
| 734 | 767 | () => groupedEntries.filter((entry) => !workEntryIndicatesToolNeutralStatus(entry)), |
| 735 | 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 | 770 | const onlyToolEntries = nonEmptyEntries.every((entry) => workLogEntryIsToolLike(entry)); |
| 744 | 771 | const groupLabel = onlyToolEntries |
| 745 | 772 | ? nonEmptyEntries.length === 1 |
| ... | ... | @@ -747,49 +774,17 @@ const WorkGroupSection = memo(function WorkGroupSection({ |
| 747 | 774 | : `${nonEmptyEntries.length} tool calls` |
| 748 | 775 | : "Work Log"; |
| 749 | 776 | |
| 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 | 777 | if (nonEmptyEntries.length === 0) return null; |
| 783 | 778 | |
| 784 | 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 | 781 | {!onlyToolEntries && ( |
| 787 | 782 | <p className="px-0.5 pb-0.5 font-medium text-[11px] text-muted-foreground/65"> |
| 788 | 783 | {groupLabel} |
| 789 | 784 | </p> |
| 790 | 785 | )} |
| 791 | 786 | <div className="space-y-px"> |
| 792 | {visibleEntries.map((workEntry) => ( | |
| 787 | {nonEmptyEntries.map((workEntry) => ( | |
| 793 | 788 | <SimpleWorkEntryRow |
| 794 | 789 | key={workEntry.id} |
| 795 | 790 | workEntry={workEntry} |
| ... | ... | @@ -797,45 +792,49 @@ const WorkGroupSection = memo(function WorkGroupSection({ |
| 797 | 792 | /> |
| 798 | 793 | ))} |
| 799 | 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 | 795 | </section> |
| 823 | 796 | ); |
| 824 | 797 | }); |
| 825 | 798 | |
| 826 | function 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; | |
| 799 | function 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 | ); | |
| 839 | 838 | } |
| 840 | 839 | |
| 841 | 840 | /** Subscribes directly to the UI state store for expand/collapse state, |