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