| author | |
| committer | |
| log | b520120cf169ce63a5606447a292451e97622c9a |
| tree | 3365af6cc0511b94733e8043ff04602a369d7072 |
| parent | db4bf9497b524f35e665af9e941aa8faeb05ea9e |
| signature | Signed by PGP key B5690EEEBB952194 |
27 files changed, 2765 insertions(+), 471 deletions(-)
apps/mobile/src/features/threads/ThreadFeed.tsx+61-16| ... | ... | @@ -125,6 +125,7 @@ import { cn } from "../../lib/cn"; |
| 125 | 125 | import { |
| 126 | 126 | deriveCenteredContentHorizontalPadding, |
| 127 | 127 | deriveThreadFeedInitialContentInset, |
| 128 | deriveThreadWorkLogSizing, | |
| 128 | 129 | type LayoutVariant, |
| 129 | 130 | } from "../../lib/layout"; |
| 130 | 131 | import { |
| ... | ... | @@ -148,6 +149,7 @@ import type { ThreadContentPresentation } from "./threadContentPresentation"; |
| 148 | 149 | import { |
| 149 | 150 | resolveThreadFeedLiveFollow, |
| 150 | 151 | type ThreadFeedLiveFollowEvent, |
| 152 | type ThreadWorkGroupScrollPosition, | |
| 151 | 153 | } from "./thread-feed-live-follow"; |
| 152 | 154 | import { |
| 153 | 155 | collapsedWorkLogHeight, |
| ... | ... | @@ -191,12 +193,9 @@ function formatMessageTime(input: string): string { |
| 191 | 193 | return MESSAGE_TIME_FORMATTER.format(timestamp); |
| 192 | 194 | } |
| 193 | 195 | |
| 194 | // Pre-measurement heights for getFixedItemSize, mirroring renderFeedEntry's | |
| 195 | // classNames. The fold row's min-h-11 (44px) stays taller than its single | |
| 196 | // text-sm line at every supported base font size (26px at the 22pt maximum), | |
| 197 | // so its height is a constant; a drifted value costs one correction on | |
| 198 | // measure, not a persistent offset. | |
| 199 | const TURN_FOLD_HEIGHT = 48; // min-h-11 (44) + mb-1 (4) | |
| 196 | // Fixed heights mirror renderFeedEntry's classNames and are only used while | |
| 197 | // text fits at the current font settings. Larger accessibility text is measured. | |
| 198 | const TURN_FOLD_HEIGHT = 42; // min-h-11 (38.5) + mb-1 (3.5), with the mobile 14px rem | |
| 200 | 199 | const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS); |
| 201 | 200 | // Let neighboring rows move out of the new rows' space before showing their text. |
| 202 | 201 | const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay( |
| ... | ... | @@ -1452,11 +1451,13 @@ function renderFeedEntry( |
| 1452 | 1451 | props: Pick<ThreadFeedProps, "environmentId" | "onUseArtifactTemplate" | "skills"> & { |
| 1453 | 1452 | readonly copiedRowId: string | null; |
| 1454 | 1453 | readonly expandedWorkRows: Record<string, boolean>; |
| 1454 | readonly workRowSizing: ReturnType<typeof deriveThreadWorkLogSizing>; | |
| 1455 | readonly workGroupScrollPositions: Map<string, ThreadWorkGroupScrollPosition>; | |
| 1455 | 1456 | readonly terminalAssistantMessageIds: ReadonlySet<string>; |
| 1456 | 1457 | readonly unsettledTurnId: TurnId | null; |
| 1457 | 1458 | readonly onCopyWorkRow: (rowId: string, value: string) => void; |
| 1458 | readonly onToggleWorkGroup: (groupId: string) => void; | |
| 1459 | readonly onToggleWorkRow: (rowId: string) => void; | |
| 1459 | readonly onToggleWorkGroup: (groupId: string, anchorKey: string) => void; | |
| 1460 | readonly onToggleWorkRow: (rowId: string, anchorKey: string) => void; | |
| 1460 | 1461 | readonly onToggleTurnFold: (turnId: TurnId) => void; |
| 1461 | 1462 | readonly onPressPreview: (source: FilePreviewSource) => void; |
| 1462 | 1463 | readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; |
| ... | ... | @@ -1482,8 +1483,14 @@ function renderFeedEntry( |
| 1482 | 1483 | onPress={() => props.onToggleTurnFold(entry.turnId)} |
| 1483 | 1484 | hitSlop={4} |
| 1484 | 1485 | className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2" |
| 1486 | style={{ | |
| 1487 | minHeight: Math.max(TURN_FOLD_HEIGHT - 3.5, props.workRowSizing.estimatedRowHeight), | |
| 1488 | }} | |
| 1485 | 1489 | > |
| 1486 | <Text className="font-t3-medium text-sm tabular-nums text-foreground-muted"> | |
| 1490 | <Text | |
| 1491 | key={props.workRowSizing.textSizeKey} | |
| 1492 | className="font-t3-medium text-sm tabular-nums text-foreground-muted" | |
| 1493 | > | |
| 1487 | 1494 | {entry.label} |
| 1488 | 1495 | </Text> |
| 1489 | 1496 | <ThreadDisclosureChevron |
| ... | ... | @@ -1499,14 +1506,16 @@ function renderFeedEntry( |
| 1499 | 1506 | if (entry.type === "work-toggle") { |
| 1500 | 1507 | return ( |
| 1501 | 1508 | <ThreadWorkGroupToggle |
| 1509 | rowSizing={props.workRowSizing} | |
| 1502 | 1510 | expanded={entry.expanded} |
| 1503 | 1511 | hiddenCount={entry.hiddenCount} |
| 1504 | 1512 | iconSubtleColor={iconSubtleColor} |
| 1505 | 1513 | summary={entry.summary} |
| 1506 | 1514 | summaryKind={entry.summaryKind} |
| 1515 | summaryToolIcon={entry.summaryToolIcon} | |
| 1507 | 1516 | hasFailure={entry.hasFailure} |
| 1508 | 1517 | shimmer={entry.shimmer} |
| 1509 | onToggle={() => props.onToggleWorkGroup(entry.groupId)} | |
| 1518 | onToggle={() => props.onToggleWorkGroup(entry.groupId, entry.id)} | |
| 1510 | 1519 | /> |
| 1511 | 1520 | ); |
| 1512 | 1521 | } |
| ... | ... | @@ -1669,9 +1678,15 @@ function renderFeedEntry( |
| 1669 | 1678 | |
| 1670 | 1679 | return ( |
| 1671 | 1680 | <ThreadWorkLog |
| 1681 | // Fixed native rows need fresh measurement after a text-size change. | |
| 1682 | // Anchors/details live in ThreadFeed and survive this group-only remount. | |
| 1683 | key={`${entry.id}:${props.workRowSizing.textSizeKey}`} | |
| 1672 | 1684 | activities={entry.activities} |
| 1685 | anchorKey={entry.id} | |
| 1673 | 1686 | copiedRowId={props.copiedRowId} |
| 1674 | 1687 | expandedRows={props.expandedWorkRows} |
| 1688 | rowSizing={props.workRowSizing} | |
| 1689 | scrollPositions={props.workGroupScrollPositions} | |
| 1675 | 1690 | iconSubtleColor={iconSubtleColor} |
| 1676 | 1691 | onCopyRow={props.onCopyWorkRow} |
| 1677 | 1692 | onToggleRow={props.onToggleWorkRow} |
| ... | ... | @@ -1959,7 +1974,22 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1959 | 1974 | const headerMaterialVisibleRef = useRef(false); |
| 1960 | 1975 | const previousLatestTurnRef = useRef(props.latestTurn); |
| 1961 | 1976 | const userScrollSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 1962 | const { width: windowWidth } = useWindowDimensions(); | |
| 1977 | const { width: windowWidth, fontScale } = useWindowDimensions(); | |
| 1978 | const { appearance } = useAppearancePreferences(); | |
| 1979 | const workRowSizing = useMemo( | |
| 1980 | () => deriveThreadWorkLogSizing({ baseFontSize: appearance.baseFontSize, fontScale }), | |
| 1981 | [appearance.baseFontSize, fontScale], | |
| 1982 | ); | |
| 1983 | const previousTextSize = useRef(workRowSizing.textSizeKey); | |
| 1984 | useLayoutEffect(() => { | |
| 1985 | if (previousTextSize.current === workRowSizing.textSizeKey) { | |
| 1986 | return; | |
| 1987 | } | |
| 1988 | previousTextSize.current = workRowSizing.textSizeKey; | |
| 1989 | // Text-size changes invalidate the outer list's fixed-height cache too. | |
| 1990 | // This never runs for scrolling, streamed output, or disclosure toggles. | |
| 1991 | props.listRef.current?.clearCaches({ mode: "sizes" }); | |
| 1992 | }, [workRowSizing.textSizeKey, props.listRef]); | |
| 1963 | 1993 | const [viewportWidth, setViewportWidth] = useState(() => |
| 1964 | 1994 | props.layoutVariant === "split" ? 0 : windowWidth, |
| 1965 | 1995 | ); |
| ... | ... | @@ -2227,6 +2257,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2227 | 2257 | () => ({ |
| 2228 | 2258 | copiedRowId, |
| 2229 | 2259 | expandedWorkRows, |
| 2260 | workRowSizing, | |
| 2230 | 2261 | iconSubtleColor, |
| 2231 | 2262 | markdownStyles, |
| 2232 | 2263 | reviewCommentColors, |
| ... | ... | @@ -2236,6 +2267,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2236 | 2267 | [ |
| 2237 | 2268 | copiedRowId, |
| 2238 | 2269 | expandedWorkRows, |
| 2270 | workRowSizing, | |
| 2239 | 2271 | iconSubtleColor, |
| 2240 | 2272 | markdownStyles, |
| 2241 | 2273 | reviewCommentColors, |
| ... | ... | @@ -2338,6 +2370,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2338 | 2370 | // ThreadId, and keying resets (or the list mount) on the bare id would |
| 2339 | 2371 | // carry stale scroll/follow state across an environment switch. |
| 2340 | 2372 | const feedThreadKey = scopedThreadKey(props.environmentId, props.threadId); |
| 2373 | // Virtualized groups can unmount without losing the reader's place. This cache | |
| 2374 | // belongs to this thread view only and never causes per-scroll React updates. | |
| 2375 | const workGroupScrollPositions = useMemo( | |
| 2376 | () => new Map<string, ThreadWorkGroupScrollPosition>(), | |
| 2377 | [feedThreadKey], | |
| 2378 | ); | |
| 2341 | 2379 | |
| 2342 | 2380 | useEffect(() => { |
| 2343 | 2381 | reportHeaderMaterialVisibility(false); |
| ... | ... | @@ -2541,8 +2579,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2541 | 2579 | }, []); |
| 2542 | 2580 | |
| 2543 | 2581 | const onToggleWorkGroup = useCallback( |
| 2544 | (groupId: string) => { | |
| 2545 | suspendEndScrollMaintenanceForDisclosure(`work-toggle:${groupId}`); | |
| 2582 | (groupId: string, anchorKey: string) => { | |
| 2583 | suspendEndScrollMaintenanceForDisclosure(anchorKey); | |
| 2546 | 2584 | setInteractionState((current) => ({ |
| 2547 | 2585 | ...current, |
| 2548 | 2586 | expandedWorkGroups: { |
| ... | ... | @@ -2555,8 +2593,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2555 | 2593 | ); |
| 2556 | 2594 | |
| 2557 | 2595 | const onToggleWorkRow = useCallback( |
| 2558 | (rowId: string) => { | |
| 2559 | suspendEndScrollMaintenanceForDisclosure(rowId); | |
| 2596 | (rowId: string, anchorKey: string) => { | |
| 2597 | suspendEndScrollMaintenanceForDisclosure(anchorKey); | |
| 2560 | 2598 | setInteractionState((current) => ({ |
| 2561 | 2599 | ...current, |
| 2562 | 2600 | expandedWorkRows: { |
| ... | ... | @@ -2610,6 +2648,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2610 | 2648 | // average once one of their type has been measured. |
| 2611 | 2649 | const getFixedItemSize = useCallback( |
| 2612 | 2650 | (entry: ThreadFeedEntry) => { |
| 2651 | if (workRowSizing.fixedRowHeight === undefined) { | |
| 2652 | return undefined; | |
| 2653 | } | |
| 2613 | 2654 | switch (entry.type) { |
| 2614 | 2655 | case "turn-fold": |
| 2615 | 2656 | return TURN_FOLD_HEIGHT; |
| ... | ... | @@ -2625,7 +2666,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2625 | 2666 | return undefined; |
| 2626 | 2667 | } |
| 2627 | 2668 | }, |
| 2628 | [expandedWorkRows], | |
| 2669 | [expandedWorkRows, workRowSizing.fixedRowHeight], | |
| 2629 | 2670 | ); |
| 2630 | 2671 | |
| 2631 | 2672 | // Disclosures can mount existing offscreen rows as well as new work rows. |
| ... | ... | @@ -2641,6 +2682,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2641 | 2682 | environmentId: props.environmentId, |
| 2642 | 2683 | copiedRowId, |
| 2643 | 2684 | expandedWorkRows, |
| 2685 | workRowSizing, | |
| 2686 | workGroupScrollPositions, | |
| 2644 | 2687 | terminalAssistantMessageIds, |
| 2645 | 2688 | unsettledTurnId, |
| 2646 | 2689 | onCopyWorkRow, |
| ... | ... | @@ -2668,6 +2711,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2668 | 2711 | copiedRowId, |
| 2669 | 2712 | disclosureToggleSettling, |
| 2670 | 2713 | expandedWorkRows, |
| 2714 | workRowSizing, | |
| 2715 | workGroupScrollPositions, | |
| 2671 | 2716 | terminalAssistantMessageIds, |
| 2672 | 2717 | unsettledTurnId, |
| 2673 | 2718 | iconSubtleColor, |
apps/mobile/src/features/threads/thread-feed-live-follow.test.ts+77| ... | ... | @@ -3,8 +3,85 @@ import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | import { |
| 4 | 4 | resolveThreadFeedLiveFollow, |
| 5 | 5 | resolveThreadFeedSubmissionAnchor, |
| 6 | resolveThreadWorkGroupInitialScroll, | |
| 7 | shouldFollowThreadWorkGroupAppend, | |
| 6 | 8 | } from "./thread-feed-live-follow"; |
| 7 | 9 | |
| 10 | describe("tool-group scroll restoration", () => { | |
| 11 | const position = { | |
| 12 | rowId: "read-output", | |
| 13 | offsetWithinRow: 80, | |
| 14 | scrollOffset: 600, | |
| 15 | contentHeight: 1_000, | |
| 16 | }; | |
| 17 | ||
| 18 | it("restores the visible row and its detail offset rather than stale absolute pixels", () => { | |
| 19 | const before = [{ id: "first" }, { id: "read-output" }, { id: "last" }]; | |
| 20 | const after = [{ id: "older" }, ...before]; | |
| 21 | expect(resolveThreadWorkGroupInitialScroll(before, position)).toEqual({ | |
| 22 | index: 1, | |
| 23 | viewOffset: -80, | |
| 24 | }); | |
| 25 | expect(resolveThreadWorkGroupInitialScroll(after, position)).toEqual({ | |
| 26 | index: 2, | |
| 27 | viewOffset: -80, | |
| 28 | }); | |
| 29 | }); | |
| 30 | ||
| 31 | it("starts normally when the saved row no longer belongs to the group", () => { | |
| 32 | expect(resolveThreadWorkGroupInitialScroll([{ id: "other" }], position)).toBeUndefined(); | |
| 33 | expect(resolveThreadWorkGroupInitialScroll([{ id: "read-output" }], undefined)).toBeUndefined(); | |
| 34 | }); | |
| 35 | }); | |
| 36 | ||
| 37 | describe("tool-group append following", () => { | |
| 38 | const previousRows = Array.from({ length: 10 }, (_, index) => ({ id: `call-${index}` })); | |
| 39 | const appendedRows = [...previousRows, { id: "new-call" }]; | |
| 40 | const atEnd = { | |
| 41 | previousRows, | |
| 42 | rows: appendedRows, | |
| 43 | previousContentHeight: 289, | |
| 44 | contentHeight: 318, | |
| 45 | viewportHeight: 256, | |
| 46 | scrollOffset: 33, | |
| 47 | detailsChanged: false, | |
| 48 | userScrolling: false, | |
| 49 | }; | |
| 50 | ||
| 51 | it("follows a new call when the reader was at the end", () => { | |
| 52 | expect(shouldFollowThreadWorkGroupAppend(atEnd)).toBe(true); | |
| 53 | }); | |
| 54 | ||
| 55 | it("follows the first overflowing append as a short group reaches its height cap", () => { | |
| 56 | expect( | |
| 57 | shouldFollowThreadWorkGroupAppend({ | |
| 58 | ...atEnd, | |
| 59 | previousRows: previousRows.slice(0, 8), | |
| 60 | rows: previousRows.slice(0, 9), | |
| 61 | previousContentHeight: 231, | |
| 62 | contentHeight: 260, | |
| 63 | viewportHeight: 231, | |
| 64 | scrollOffset: 0, | |
| 65 | }), | |
| 66 | ).toBe(true); | |
| 67 | }); | |
| 68 | ||
| 69 | it.each([ | |
| 70 | { name: "reading earlier calls", changes: { scrollOffset: 20 } }, | |
| 71 | { name: "dragging before leaving the edge", changes: { userScrolling: true } }, | |
| 72 | { name: "opening detail during an append", changes: { detailsChanged: true } }, | |
| 73 | { name: "streaming a result", changes: { rows: previousRows, contentHeight: 600 } }, | |
| 74 | { name: "updating a lifecycle label", changes: { rows: previousRows, contentHeight: 289 } }, | |
| 75 | { name: "prepending old calls", changes: { rows: [{ id: "older" }, ...previousRows] } }, | |
| 76 | { | |
| 77 | name: "replacing a call while appending", | |
| 78 | changes: { rows: [{ id: "replacement" }, ...appendedRows.slice(1)] }, | |
| 79 | }, | |
| 80 | ])("does not steal the reader's position when $name", ({ changes }) => { | |
| 81 | expect(shouldFollowThreadWorkGroupAppend({ ...atEnd, ...changes })).toBe(false); | |
| 82 | }); | |
| 83 | }); | |
| 84 | ||
| 8 | 85 | describe("resolveThreadFeedSubmissionAnchor", () => { |
| 9 | 86 | it("anchors the first user message in a thread", () => { |
| 10 | 87 | expect( |
apps/mobile/src/features/threads/thread-feed-live-follow.ts+35| ... | ... | @@ -12,6 +12,41 @@ export type ThreadFeedLiveFollowEvent = |
| 12 | 12 | readonly userScrollSessionActive: boolean; |
| 13 | 13 | }; |
| 14 | 14 | |
| 15 | export interface ThreadWorkGroupScrollPosition { | |
| 16 | readonly rowId: string; | |
| 17 | readonly offsetWithinRow: number; | |
| 18 | readonly scrollOffset: number; | |
| 19 | readonly contentHeight: number; | |
| 20 | } | |
| 21 | ||
| 22 | export function resolveThreadWorkGroupInitialScroll( | |
| 23 | rows: ReadonlyArray<{ readonly id: string }>, | |
| 24 | position: ThreadWorkGroupScrollPosition | undefined, | |
| 25 | ) { | |
| 26 | const index = position ? rows.findIndex((row) => row.id === position.rowId) : -1; | |
| 27 | return index >= 0 && position ? { index, viewOffset: -position.offsetWithinRow } : undefined; | |
| 28 | } | |
| 29 | ||
| 30 | export function shouldFollowThreadWorkGroupAppend(input: { | |
| 31 | readonly previousRows: ReadonlyArray<{ readonly id: string }>; | |
| 32 | readonly rows: ReadonlyArray<{ readonly id: string }>; | |
| 33 | readonly previousContentHeight: number; | |
| 34 | readonly contentHeight: number; | |
| 35 | readonly viewportHeight: number; | |
| 36 | readonly scrollOffset: number; | |
| 37 | readonly detailsChanged: boolean; | |
| 38 | readonly userScrolling: boolean; | |
| 39 | }) { | |
| 40 | return ( | |
| 41 | !input.detailsChanged && | |
| 42 | !input.userScrolling && | |
| 43 | input.contentHeight > input.previousContentHeight && | |
| 44 | input.rows.length > input.previousRows.length && | |
| 45 | input.previousRows.every((row, index) => row.id === input.rows[index]?.id) && | |
| 46 | input.previousContentHeight - input.viewportHeight - input.scrollOffset <= 1 | |
| 47 | ); | |
| 48 | } | |
| 49 | ||
| 15 | 50 | export function resolveThreadFeedSubmissionAnchor<AnchorId>(input: { |
| 16 | 51 | readonly currentAnchorMessageId: AnchorId | null; |
| 17 | 52 | readonly submittedMessageId: AnchorId; |
apps/mobile/src/features/threads/thread-work-log.tsx+492-166| ... | ... | @@ -1,8 +1,21 @@ |
| 1 | 1 | import * as Haptics from "expo-haptics"; |
| 2 | 2 | import { type AppSymbolName, SymbolView } from "../../components/AppSymbol"; |
| 3 | 3 | import { MaskedView } from "@expo/ui/community/masked-view"; |
| 4 | import type { LegendListRef } from "@legendapp/list/react-native"; | |
| 5 | import { AnimatedLegendList } from "@legendapp/list/reanimated"; | |
| 4 | 6 | import { useIsFocused } from "@react-navigation/native"; |
| 5 | import { useEffect, useId, useLayoutEffect, useState, type ComponentProps } from "react"; | |
| 7 | import { | |
| 8 | memo, | |
| 9 | useCallback, | |
| 10 | useEffect, | |
| 11 | useId, | |
| 12 | useLayoutEffect, | |
| 13 | useMemo, | |
| 14 | useRef, | |
| 15 | useState, | |
| 16 | type ComponentProps, | |
| 17 | type ReactNode, | |
| 18 | } from "react"; | |
| 6 | 19 | import { |
| 7 | 20 | AccessibilityInfo, |
| 8 | 21 | AppState, |
| ... | ... | @@ -15,12 +28,21 @@ import { |
| 15 | 28 | import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; |
| 16 | 29 | |
| 17 | 30 | import { AppText as Text } from "../../components/AppText"; |
| 31 | import { T3Wordmark } from "../../components/T3Wordmark"; | |
| 18 | 32 | import { cn } from "../../lib/cn"; |
| 33 | import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout"; | |
| 19 | 34 | import type { ThreadFeedActivity } from "../../lib/threadActivity"; |
| 20 | 35 | import { |
| 36 | resolveThreadWorkGroupInitialScroll, | |
| 37 | shouldFollowThreadWorkGroupAppend, | |
| 38 | type ThreadWorkGroupScrollPosition, | |
| 39 | } from "./thread-feed-live-follow"; | |
| 40 | import { | |
| 41 | resolveWorkEntryToolPresentation, | |
| 21 | 42 | type ToolGroupSummaryKind, |
| 22 | 43 | workEntryViewedImagePath, |
| 23 | 44 | } from "@t3tools/client-runtime/work-log/presentation"; |
| 45 | import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; | |
| 24 | 46 | import type { MarkdownImageRenderer } from "../../native/SelectableMarkdownText"; |
| 25 | 47 | import Animated, { |
| 26 | 48 | cancelAnimation, |
| ... | ... | @@ -45,6 +67,33 @@ export const THREAD_DISCLOSURE_TRANSITION_MS = 180; |
| 45 | 67 | const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS); |
| 46 | 68 | const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140); |
| 47 | 69 | const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120); |
| 70 | type WorkContentIcon = AppSymbolName | "browser" | "t3-code"; | |
| 71 | ||
| 72 | function WorkLogIcon(props: { | |
| 73 | readonly icon: WorkContentIcon; | |
| 74 | readonly color: ColorValue; | |
| 75 | readonly highlighted?: boolean; | |
| 76 | }) { | |
| 77 | if (props.icon === "t3-code") { | |
| 78 | return ( | |
| 79 | <T3Wordmark | |
| 80 | height={10} | |
| 81 | {...(props.highlighted ? { colorClassName: "accent-foreground" } : { color: props.color })} | |
| 82 | /> | |
| 83 | ); | |
| 84 | } | |
| 85 | return ( | |
| 86 | <SymbolView | |
| 87 | name={props.icon === "browser" ? { ios: "globe", android: "public" } : props.icon} | |
| 88 | size={14} | |
| 89 | weight="medium" | |
| 90 | {...(props.highlighted | |
| 91 | ? { tintColorClassName: "accent-foreground" } | |
| 92 | : { tintColor: props.color })} | |
| 93 | type="monochrome" | |
| 94 | /> | |
| 95 | ); | |
| 96 | } | |
| 48 | 97 | |
| 49 | 98 | export function ThreadDisclosureChevron(props: { |
| 50 | 99 | readonly expanded: boolean; |
| ... | ... | @@ -86,7 +135,7 @@ export function ThreadDisclosureChevron(props: { |
| 86 | 135 | |
| 87 | 136 | function ShimmerWorkContent(props: { |
| 88 | 137 | readonly highlighted: boolean; |
| 89 | readonly icon: AppSymbolName; | |
| 138 | readonly icon: WorkContentIcon; | |
| 90 | 139 | readonly iconSubtleColor: ColorValue; |
| 91 | 140 | readonly label: string; |
| 92 | 141 | readonly onTextLayout?: ComponentProps<typeof Text>["onTextLayout"]; |
| ... | ... | @@ -96,14 +145,10 @@ function ShimmerWorkContent(props: { |
| 96 | 145 | <View className="flex-row items-center gap-1.5"> |
| 97 | 146 | <View className="h-6 w-6 shrink-0 items-center justify-center"> |
| 98 | 147 | {props.showIcon ? ( |
| 99 | <SymbolView | |
| 100 | name={props.icon} | |
| 101 | size={14} | |
| 102 | weight="medium" | |
| 103 | {...(props.highlighted | |
| 104 | ? { tintColorClassName: "accent-foreground" as const } | |
| 105 | : { tintColor: props.iconSubtleColor })} | |
| 106 | type="monochrome" | |
| 148 | <WorkLogIcon | |
| 149 | icon={props.icon} | |
| 150 | color={props.iconSubtleColor} | |
| 151 | highlighted={props.highlighted} | |
| 107 | 152 | /> |
| 108 | 153 | ) : null} |
| 109 | 154 | </View> |
| ... | ... | @@ -122,7 +167,7 @@ function ShimmerWorkContent(props: { |
| 122 | 167 | } |
| 123 | 168 | |
| 124 | 169 | export function ShimmeringWorkContent(props: { |
| 125 | readonly icon: AppSymbolName; | |
| 170 | readonly icon: WorkContentIcon; | |
| 126 | 171 | readonly iconSubtleColor: ColorValue; |
| 127 | 172 | readonly label: string; |
| 128 | 173 | readonly showIcon: boolean; |
| ... | ... | @@ -293,182 +338,461 @@ function isFreshRow(createdAt: string): boolean { |
| 293 | 338 | return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ROW_WINDOW_MS; |
| 294 | 339 | } |
| 295 | 340 | |
| 296 | // Pre-measurement heights for the feed's getFixedItemSize. Collapsed work-log | |
| 297 | // rows are single-line (numberOfLines={1}) inside a min-height that stays | |
| 298 | // taller than text-sm at every supported base font size, so row height is | |
| 299 | // deterministic. Values mirror the classNames below. A mismatch only costs a | |
| 300 | // one-time correction on measure. | |
| 301 | const WORK_ROW_HEIGHT = 32; // min-h-8 | |
| 341 | // The minimum matches min-h-8 below. Exact sizing is disabled when native | |
| 342 | // accessibility scaling can make the single-line text taller than that minimum. | |
| 343 | const WORK_ROW_HEIGHT = THREAD_WORK_ROW_MIN_HEIGHT; | |
| 302 | 344 | const WORK_ROW_GAP = 1; // gap-px |
| 303 | const WORK_LOG_BOTTOM_MARGIN = 4; // mb-1 | |
| 345 | const WORK_LOG_BOTTOM_MARGIN = 3.5; // mb-1 with the mobile 14px rem | |
| 346 | const WORK_GROUP_MAX_HEIGHT = 256; | |
| 347 | const WORK_GROUP_EDGE_FADE_HEIGHT = 12; | |
| 348 | ||
| 349 | export const WORK_GROUP_TOGGLE_HEIGHT = THREAD_WORK_ROW_MIN_HEIGHT; | |
| 304 | 350 | |
| 305 | export const WORK_GROUP_TOGGLE_HEIGHT = 32; // min-h-8 | |
| 351 | function workLogRowsHeight( | |
| 352 | activities: ReadonlyArray<ThreadFeedActivity>, | |
| 353 | rowHeight = WORK_ROW_HEIGHT, | |
| 354 | ): number { | |
| 355 | return activities.length * rowHeight + Math.max(0, activities.length - 1) * WORK_ROW_GAP; | |
| 356 | } | |
| 306 | 357 | |
| 307 | 358 | export function collapsedWorkLogHeight(activities: ReadonlyArray<ThreadFeedActivity>): number { |
| 308 | const rows = activities; | |
| 309 | if (rows.length === 0) { | |
| 359 | if (activities.length === 0) { | |
| 310 | 360 | return 0; |
| 311 | 361 | } |
| 312 | return WORK_LOG_BOTTOM_MARGIN + rows.length * WORK_ROW_HEIGHT + (rows.length - 1) * WORK_ROW_GAP; | |
| 362 | const height = workLogRowsHeight(activities); | |
| 363 | return ( | |
| 364 | WORK_LOG_BOTTOM_MARGIN + | |
| 365 | (activities[0]?.groupedToolDetail ? Math.min(height, WORK_GROUP_MAX_HEIGHT) : height) | |
| 366 | ); | |
| 313 | 367 | } |
| 314 | 368 | |
| 315 | export function ThreadWorkLog(props: { | |
| 369 | interface ThreadWorkLogProps { | |
| 316 | 370 | readonly activities: ReadonlyArray<ThreadFeedActivity>; |
| 371 | readonly anchorKey: string; | |
| 317 | 372 | readonly copiedRowId: string | null; |
| 318 | 373 | readonly expandedRows: Readonly<Record<string, boolean>>; |
| 319 | readonly iconSubtleColor: import("react-native").ColorValue; | |
| 374 | readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>; | |
| 375 | readonly scrollPositions: Map<string, ThreadWorkGroupScrollPosition>; | |
| 376 | readonly iconSubtleColor: ColorValue; | |
| 320 | 377 | readonly onCopyRow: (rowId: string, value: string) => void; |
| 321 | readonly onToggleRow: (rowId: string) => void; | |
| 378 | readonly onToggleRow: (rowId: string, anchorKey: string) => void; | |
| 322 | 379 | readonly renderImage: MarkdownImageRenderer; |
| 323 | }) { | |
| 324 | const rows = props.activities.map((activity) => ({ | |
| 325 | ...activity, | |
| 326 | detail: compactActivityDetail(activity.detail), | |
| 327 | })); | |
| 380 | } | |
| 381 | ||
| 382 | export function ThreadWorkLog(props: ThreadWorkLogProps) { | |
| 383 | const renderRow = useCallback( | |
| 384 | (row: ThreadFeedActivity) => ( | |
| 385 | <ThreadWorkLogRow | |
| 386 | key={row.id} | |
| 387 | row={row} | |
| 388 | anchorKey={props.anchorKey} | |
| 389 | copied={props.copiedRowId === row.id} | |
| 390 | expanded={props.expandedRows[row.id] ?? false} | |
| 391 | iconSubtleColor={props.iconSubtleColor} | |
| 392 | onCopyRow={props.onCopyRow} | |
| 393 | onToggleRow={props.onToggleRow} | |
| 394 | renderImage={props.renderImage} | |
| 395 | /> | |
| 396 | ), | |
| 397 | [ | |
| 398 | props.anchorKey, | |
| 399 | props.copiedRowId, | |
| 400 | props.expandedRows, | |
| 401 | props.iconSubtleColor, | |
| 402 | props.onCopyRow, | |
| 403 | props.onToggleRow, | |
| 404 | props.renderImage, | |
| 405 | ], | |
| 406 | ); | |
| 328 | 407 | |
| 329 | if (rows.length === 0) { | |
| 408 | if (props.activities.length === 0) { | |
| 330 | 409 | return null; |
| 331 | 410 | } |
| 332 | 411 | |
| 333 | 412 | return ( |
| 334 | 413 | <View className="-mx-1 mb-1 px-1 py-0"> |
| 335 | <View className="gap-px"> | |
| 336 | {rows.map((row) => { | |
| 337 | const expanded = props.expandedRows[row.id] ?? false; | |
| 338 | const canExpand = row.canExpand; | |
| 339 | const fullDetail = expanded ? row.getFullDetail() : null; | |
| 340 | const viewedImagePath = workEntryViewedImagePath(row.workEntry); | |
| 341 | const previewText = row.detail ?? row.summary; | |
| 342 | const displayText = expanded && row.workEntry.command?.trim() ? "Command" : previewText; | |
| 343 | const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; | |
| 344 | const failed = row.status === "failure"; | |
| 345 | const showIcon = !row.groupedToolDetail || iconIsDestructive || failed; | |
| 346 | ||
| 347 | return ( | |
| 348 | <Animated.View | |
| 349 | key={row.id} | |
| 350 | layout={WORK_LOG_LAYOUT_TRANSITION} | |
| 351 | className="overflow-hidden" | |
| 352 | {...(isFreshRow(row.createdAt) ? { entering: FadeIn.duration(200) } : {})} | |
| 353 | > | |
| 354 | <Pressable | |
| 355 | accessibilityRole={canExpand ? "button" : undefined} | |
| 356 | accessibilityLabel={failed ? `${previewText}, tool call failed` : previewText} | |
| 357 | accessibilityHint={ | |
| 358 | canExpand | |
| 359 | ? "Double tap to show full details. Long press to copy." | |
| 360 | : "Long press to copy." | |
| 361 | } | |
| 362 | accessibilityState={canExpand ? { expanded } : undefined} | |
| 363 | hitSlop={4} | |
| 364 | onPress={() => { | |
| 365 | if (canExpand) { | |
| 366 | void Haptics.selectionAsync(); | |
| 367 | props.onToggleRow(row.id); | |
| 368 | } | |
| 369 | }} | |
| 370 | onLongPress={() => props.onCopyRow(row.id, row.getCopyText())} | |
| 371 | className="rounded-md px-0.5 py-0 active:bg-subtle" | |
| 372 | > | |
| 373 | <View className="min-h-8 flex-row items-center gap-1.5"> | |
| 374 | {row.live ? ( | |
| 375 | <ShimmeringWorkContent | |
| 376 | icon={workRowSymbolName(row.icon)} | |
| 377 | iconSubtleColor={props.iconSubtleColor} | |
| 378 | label={displayText} | |
| 379 | showIcon={showIcon} | |
| 380 | /> | |
| 381 | ) : ( | |
| 382 | <> | |
| 383 | <View className="h-6 w-6 shrink-0 items-center justify-center"> | |
| 384 | {showIcon ? ( | |
| 385 | <SymbolView | |
| 386 | name={ | |
| 387 | failed | |
| 388 | ? { ios: "xmark", android: "close" } | |
| 389 | : workRowSymbolName(row.icon) | |
| 390 | } | |
| 391 | size={14} | |
| 392 | weight="medium" | |
| 393 | tintColor={iconIsDestructive ? "#e11d48" : props.iconSubtleColor} | |
| 394 | type="monochrome" | |
| 395 | /> | |
| 396 | ) : null} | |
| 397 | </View> | |
| 398 | <Text | |
| 399 | className={cn( | |
| 400 | "min-w-0 flex-1 text-sm text-foreground-muted", | |
| 401 | iconIsDestructive && "font-t3-medium text-adaptive-rose-600-400", | |
| 402 | )} | |
| 403 | numberOfLines={1} | |
| 404 | > | |
| 405 | {displayText} | |
| 406 | </Text> | |
| 407 | </> | |
| 408 | )} | |
| 409 | ||
| 410 | <View className="shrink-0 flex-row items-center gap-px"> | |
| 411 | {props.copiedRowId === row.id ? ( | |
| 412 | <Text className="pr-1 font-t3-medium text-3xs text-adaptive-emerald-600-400"> | |
| 413 | Copied | |
| 414 | </Text> | |
| 415 | ) : null} | |
| 416 | <View className="h-4 w-4 items-center justify-center"> | |
| 417 | {canExpand ? ( | |
| 418 | <ThreadDisclosureChevron | |
| 419 | expanded={expanded} | |
| 420 | collapsedDirection="down" | |
| 421 | size={11} | |
| 422 | tintColor={props.iconSubtleColor} | |
| 423 | /> | |
| 424 | ) : null} | |
| 425 | </View> | |
| 426 | </View> | |
| 427 | </View> | |
| 428 | </Pressable> | |
| 429 | ||
| 430 | {fullDetail ? ( | |
| 431 | <Animated.View | |
| 432 | entering={WORK_LOG_DETAIL_ENTER_TRANSITION} | |
| 433 | exiting={WORK_LOG_DETAIL_EXIT_TRANSITION} | |
| 434 | layout={WORK_LOG_LAYOUT_TRANSITION} | |
| 435 | className="ml-7 border-l border-adaptive-neutral-300-a60-white-a12 pb-1 pl-3 pt-0.5" | |
| 436 | > | |
| 437 | {viewedImagePath ? ( | |
| 438 | <View className="pb-1.5"> | |
| 439 | {props.renderImage({ href: viewedImagePath, alt: null, title: null })} | |
| 440 | </View> | |
| 441 | ) : null} | |
| 442 | <ScrollView | |
| 443 | nestedScrollEnabled | |
| 444 | directionalLockEnabled | |
| 445 | showsVerticalScrollIndicator | |
| 446 | className="max-h-60" | |
| 447 | contentContainerStyle={{ paddingRight: 8 }} | |
| 448 | > | |
| 449 | <Text | |
| 450 | selectable | |
| 451 | className="font-mono text-2xs leading-normal text-foreground-muted" | |
| 452 | > | |
| 453 | {fullDetail} | |
| 454 | </Text> | |
| 455 | </ScrollView> | |
| 456 | </Animated.View> | |
| 457 | ) : null} | |
| 458 | </Animated.View> | |
| 459 | ); | |
| 460 | })} | |
| 461 | </View> | |
| 414 | {props.activities[0]?.groupedToolDetail ? ( | |
| 415 | <ThreadWorkGroupList | |
| 416 | activities={props.activities} | |
| 417 | expandedRows={props.expandedRows} | |
| 418 | groupId={props.anchorKey} | |
| 419 | rowSizing={props.rowSizing} | |
| 420 | scrollPositions={props.scrollPositions} | |
| 421 | renderRow={renderRow} | |
| 422 | /> | |
| 423 | ) : ( | |
| 424 | <View className="gap-px">{props.activities.map(renderRow)}</View> | |
| 425 | )} | |
| 462 | 426 | </View> |
| 463 | 427 | ); |
| 464 | 428 | } |
| 465 | 429 | |
| 430 | function ThreadWorkGroupList(props: { | |
| 431 | readonly activities: ReadonlyArray<ThreadFeedActivity>; | |
| 432 | readonly expandedRows: Readonly<Record<string, boolean>>; | |
| 433 | readonly groupId: string; | |
| 434 | readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>; | |
| 435 | readonly scrollPositions: Map<string, ThreadWorkGroupScrollPosition>; | |
| 436 | readonly renderRow: (row: ThreadFeedActivity) => ReactNode; | |
| 437 | }) { | |
| 438 | const estimatedRowsHeight = workLogRowsHeight( | |
| 439 | props.activities, | |
| 440 | props.rowSizing.estimatedRowHeight, | |
| 441 | ); | |
| 442 | const [initialPosition] = useState(() => { | |
| 443 | const position = props.scrollPositions.get(props.groupId); | |
| 444 | return props.activities.some((row) => row.id === position?.rowId) ? position : undefined; | |
| 445 | }); | |
| 446 | const [initialScrollIndex] = useState(() => | |
| 447 | resolveThreadWorkGroupInitialScroll(props.activities, initialPosition), | |
| 448 | ); | |
| 449 | const [restoringPosition, setRestoringPosition] = useState(initialScrollIndex !== undefined); | |
| 450 | const listRef = useRef<LegendListRef>(null); | |
| 451 | const loadedRef = useRef(false); | |
| 452 | const userScrollingRef = useRef(false); | |
| 453 | const pendingAppendHeightRef = useRef<number | null>(null); | |
| 454 | const previousContent = useRef({ | |
| 455 | rows: props.activities, | |
| 456 | height: Math.max(estimatedRowsHeight, initialPosition?.contentHeight ?? 0), | |
| 457 | expandedRows: props.expandedRows, | |
| 458 | }); | |
| 459 | const [measuredContent, setMeasuredContent] = useState(() => ({ | |
| 460 | height: Math.max(estimatedRowsHeight, initialPosition?.contentHeight ?? 0), | |
| 461 | rowCount: props.activities.length, | |
| 462 | })); | |
| 463 | const contentHeight = Math.max( | |
| 464 | 1, | |
| 465 | measuredContent.height + | |
| 466 | Math.max(0, props.activities.length - measuredContent.rowCount) * | |
| 467 | (props.rowSizing.estimatedRowHeight + WORK_ROW_GAP), | |
| 468 | ); | |
| 469 | const height = Math.min(contentHeight, WORK_GROUP_MAX_HEIGHT); | |
| 470 | const scrollOffset = useSharedValue(initialPosition?.scrollOffset ?? 0); | |
| 471 | const sharedValues = useMemo(() => ({ scrollOffset }), [scrollOffset]); | |
| 472 | const gradientId = `work-group-fade-${useId().replaceAll(":", "")}`; | |
| 473 | const fadeFraction = WORK_GROUP_EDGE_FADE_HEIGHT / height; | |
| 474 | ||
| 475 | // Opaque covers remove each edge fade at the scroll boundary. Scroll offset | |
| 476 | // stays on the UI thread; only content-size changes update React state. | |
| 477 | const topCoverStyle = useAnimatedStyle(() => ({ | |
| 478 | opacity: 1 - Math.min(1, Math.max(0, scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT), | |
| 479 | })); | |
| 480 | const bottomCoverStyle = useAnimatedStyle(() => ({ | |
| 481 | opacity: | |
| 482 | 1 - | |
| 483 | Math.min( | |
| 484 | 1, | |
| 485 | Math.max(0, contentHeight - height - scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT, | |
| 486 | ), | |
| 487 | })); | |
| 488 | const rememberPosition = useCallback(() => { | |
| 489 | if (!loadedRef.current) return; | |
| 490 | const state = listRef.current?.getState(); | |
| 491 | const position = state && resolveWorkGroupScrollAnchor(state); | |
| 492 | if (!state || !position) return; | |
| 493 | props.scrollPositions.set(props.groupId, { | |
| 494 | ...position, | |
| 495 | contentHeight: state.contentLength, | |
| 496 | }); | |
| 497 | }, [props.groupId, props.scrollPositions]); | |
| 498 | const finishPendingAppend = useCallback(() => { | |
| 499 | const targetHeight = pendingAppendHeightRef.current; | |
| 500 | const state = listRef.current?.getState(); | |
| 501 | if ( | |
| 502 | targetHeight !== null && | |
| 503 | state && | |
| 504 | !userScrollingRef.current && | |
| 505 | Math.abs(state.scrollLength - targetHeight) <= 1 | |
| 506 | ) { | |
| 507 | pendingAppendHeightRef.current = null; | |
| 508 | void listRef.current?.scrollToEnd({ animated: false }); | |
| 509 | } | |
| 510 | }, []); | |
| 511 | const onContentSizeChange = useCallback( | |
| 512 | (_width: number, nextHeight: number) => { | |
| 513 | const previous = previousContent.current; | |
| 514 | const detailsChanged = previous.expandedRows !== props.expandedRows; | |
| 515 | const followAppend = | |
| 516 | loadedRef.current && | |
| 517 | shouldFollowThreadWorkGroupAppend({ | |
| 518 | previousRows: previous.rows, | |
| 519 | rows: props.activities, | |
| 520 | previousContentHeight: previous.height, | |
| 521 | contentHeight: nextHeight, | |
| 522 | viewportHeight: Math.min(previous.height, WORK_GROUP_MAX_HEIGHT), | |
| 523 | scrollOffset: scrollOffset.value, | |
| 524 | detailsChanged, | |
| 525 | userScrolling: userScrollingRef.current, | |
| 526 | }); | |
| 527 | previousContent.current = { | |
| 528 | rows: props.activities, | |
| 529 | height: nextHeight, | |
| 530 | expandedRows: props.expandedRows, | |
| 531 | }; | |
| 532 | setMeasuredContent((current) => | |
| 533 | current.height === nextHeight && current.rowCount === props.activities.length | |
| 534 | ? current | |
| 535 | : { height: nextHeight, rowCount: props.activities.length }, | |
| 536 | ); | |
| 537 | // Follow new calls only, never a detail toggle or a growing tool result. | |
| 538 | if (followAppend) { | |
| 539 | pendingAppendHeightRef.current = Math.min(nextHeight, WORK_GROUP_MAX_HEIGHT); | |
| 540 | } else if (detailsChanged || userScrollingRef.current || previous.rows !== props.activities) { | |
| 541 | pendingAppendHeightRef.current = null; | |
| 542 | } else if (pendingAppendHeightRef.current !== null) { | |
| 543 | pendingAppendHeightRef.current = Math.min(nextHeight, WORK_GROUP_MAX_HEIGHT); | |
| 544 | } | |
| 545 | // A short group can grow its viewport on this append. Wait for that | |
| 546 | // layout before calculating the end offset, rather than jumping twice. | |
| 547 | finishPendingAppend(); | |
| 548 | rememberPosition(); | |
| 549 | }, | |
| 550 | [props.activities, props.expandedRows, scrollOffset, finishPendingAppend, rememberPosition], | |
| 551 | ); | |
| 552 | const getFixedItemSize = useCallback( | |
| 553 | (row: ThreadFeedActivity, index: number) => | |
| 554 | props.expandedRows[row.id] || props.rowSizing.fixedRowHeight === undefined | |
| 555 | ? undefined | |
| 556 | : props.rowSizing.fixedRowHeight + (index < props.activities.length - 1 ? WORK_ROW_GAP : 0), | |
| 557 | [props.activities.length, props.expandedRows, props.rowSizing.fixedRowHeight], | |
| 558 | ); | |
| 559 | const renderItem = useCallback( | |
| 560 | ({ item, index }: { item: ThreadFeedActivity; index: number }) => ( | |
| 561 | <View className={index < props.activities.length - 1 ? "pb-px" : undefined}> | |
| 562 | {props.renderRow(item)} | |
| 563 | </View> | |
| 564 | ), | |
| 565 | [props.activities.length, props.renderRow], | |
| 566 | ); | |
| 567 | ||
| 568 | return ( | |
| 569 | <MaskedView | |
| 570 | style={{ height }} | |
| 571 | maskElement={ | |
| 572 | <View style={StyleSheet.absoluteFill} pointerEvents="none"> | |
| 573 | <Svg width="100%" height="100%"> | |
| 574 | <Defs> | |
| 575 | <LinearGradient id={gradientId} x1="0%" x2="0%" y1="0%" y2="100%"> | |
| 576 | <Stop offset={0} stopColor="white" stopOpacity={0} /> | |
| 577 | <Stop offset={fadeFraction} stopColor="white" stopOpacity={1} /> | |
| 578 | <Stop offset={1 - fadeFraction} stopColor="white" stopOpacity={1} /> | |
| 579 | <Stop offset={1} stopColor="white" stopOpacity={0} /> | |
| 580 | </LinearGradient> | |
| 581 | </Defs> | |
| 582 | <Rect width="100%" height="100%" fill={`url(#${gradientId})`} /> | |
| 583 | </Svg> | |
| 584 | <Animated.View | |
| 585 | className="absolute inset-x-0 top-0 bg-white" | |
| 586 | style={[{ height: WORK_GROUP_EDGE_FADE_HEIGHT }, topCoverStyle]} | |
| 587 | /> | |
| 588 | <Animated.View | |
| 589 | className="absolute inset-x-0 bottom-0 bg-white" | |
| 590 | style={[{ height: WORK_GROUP_EDGE_FADE_HEIGHT }, bottomCoverStyle]} | |
| 591 | /> | |
| 592 | </View> | |
| 593 | } | |
| 594 | > | |
| 595 | <AnimatedLegendList | |
| 596 | ref={listRef} | |
| 597 | data={props.activities} | |
| 598 | keyExtractor={workLogRowKey} | |
| 599 | estimatedItemSize={props.rowSizing.estimatedRowHeight + WORK_ROW_GAP} | |
| 600 | getFixedItemSize={getFixedItemSize} | |
| 601 | initialScrollIndex={initialScrollIndex} | |
| 602 | // Bootstrap overscan is only 50px. An offset inside expanded detail can | |
| 603 | // otherwise leave its own row unmeasured until after scroll restoration. | |
| 604 | alwaysRender={ | |
| 605 | restoringPosition && initialPosition ? { keys: [initialPosition.rowId] } : undefined | |
| 606 | } | |
| 607 | recycleItems={false} | |
| 608 | extraData={props.renderRow} | |
| 609 | renderItem={renderItem} | |
| 610 | sharedValues={sharedValues} | |
| 611 | onContentSizeChange={onContentSizeChange} | |
| 612 | onLayout={finishPendingAppend} | |
| 613 | onLoad={() => { | |
| 614 | loadedRef.current = true; | |
| 615 | setRestoringPosition(false); | |
| 616 | rememberPosition(); | |
| 617 | }} | |
| 618 | onScroll={rememberPosition} | |
| 619 | onScrollBeginDrag={() => { | |
| 620 | userScrollingRef.current = true; | |
| 621 | pendingAppendHeightRef.current = null; | |
| 622 | }} | |
| 623 | onScrollEndDrag={() => { | |
| 624 | userScrollingRef.current = false; | |
| 625 | }} | |
| 626 | onMomentumScrollBegin={() => { | |
| 627 | userScrollingRef.current = true; | |
| 628 | pendingAppendHeightRef.current = null; | |
| 629 | }} | |
| 630 | onMomentumScrollEnd={() => { | |
| 631 | userScrollingRef.current = false; | |
| 632 | rememberPosition(); | |
| 633 | }} | |
| 634 | maintainVisibleContentPosition | |
| 635 | nestedScrollEnabled | |
| 636 | directionalLockEnabled | |
| 637 | showsVerticalScrollIndicator | |
| 638 | scrollsToTop={false} | |
| 639 | bounces={false} | |
| 640 | keyboardShouldPersistTaps="handled" | |
| 641 | style={StyleSheet.absoluteFill} | |
| 642 | /> | |
| 643 | </MaskedView> | |
| 644 | ); | |
| 645 | } | |
| 646 | ||
| 647 | function workLogRowKey(row: ThreadFeedActivity): string { | |
| 648 | return row.id; | |
| 649 | } | |
| 650 | ||
| 651 | const ThreadWorkLogRow = memo(function ThreadWorkLogRow( | |
| 652 | props: Omit< | |
| 653 | ThreadWorkLogProps, | |
| 654 | "activities" | "copiedRowId" | "expandedRows" | "rowSizing" | "scrollPositions" | |
| 655 | > & { | |
| 656 | readonly row: ThreadFeedActivity; | |
| 657 | readonly copied: boolean; | |
| 658 | readonly expanded: boolean; | |
| 659 | }, | |
| 660 | ) { | |
| 661 | const { row, expanded } = props; | |
| 662 | const canExpand = row.canExpand; | |
| 663 | const fullDetail = expanded ? row.getFullDetail() : null; | |
| 664 | const viewedImagePath = workEntryViewedImagePath(row.workEntry); | |
| 665 | const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry); | |
| 666 | const previewText = | |
| 667 | toolPresentation?.displayName ?? compactActivityDetail(row.detail) ?? row.summary; | |
| 668 | const displayText = | |
| 669 | !toolPresentation && expanded && row.workEntry.command?.trim() ? "Command" : previewText; | |
| 670 | const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; | |
| 671 | const failed = row.status === "failure"; | |
| 672 | const icon = toolPresentation?.icon ?? (failed ? "xmark" : workRowSymbolName(row.icon)); | |
| 673 | ||
| 674 | return ( | |
| 675 | <Animated.View | |
| 676 | layout={WORK_LOG_LAYOUT_TRANSITION} | |
| 677 | className="overflow-hidden" | |
| 678 | {...(isFreshRow(row.createdAt) ? { entering: FadeIn.duration(200) } : {})} | |
| 679 | > | |
| 680 | <Pressable | |
| 681 | accessibilityRole={canExpand ? "button" : undefined} | |
| 682 | accessibilityLabel={failed ? `${previewText}, tool call failed` : previewText} | |
| 683 | accessibilityHint={ | |
| 684 | canExpand ? "Double tap to show full details. Long press to copy." : "Long press to copy." | |
| 685 | } | |
| 686 | accessibilityState={canExpand ? { expanded } : undefined} | |
| 687 | hitSlop={4} | |
| 688 | onPress={() => { | |
| 689 | if (canExpand) { | |
| 690 | void Haptics.selectionAsync(); | |
| 691 | props.onToggleRow(row.id, props.anchorKey); | |
| 692 | } | |
| 693 | }} | |
| 694 | onLongPress={() => props.onCopyRow(row.id, row.getCopyText())} | |
| 695 | className="rounded-md px-0.5 py-0 active:bg-subtle" | |
| 696 | > | |
| 697 | <View className="min-h-8 flex-row items-center gap-1.5"> | |
| 698 | {row.live ? ( | |
| 699 | <ShimmeringWorkContent | |
| 700 | icon={icon} | |
| 701 | iconSubtleColor={props.iconSubtleColor} | |
| 702 | label={displayText} | |
| 703 | showIcon | |
| 704 | /> | |
| 705 | ) : ( | |
| 706 | <> | |
| 707 | <View className="h-6 w-6 shrink-0 items-center justify-center"> | |
| 708 | <WorkLogIcon | |
| 709 | icon={icon} | |
| 710 | color={iconIsDestructive ? "#e11d48" : props.iconSubtleColor} | |
| 711 | /> | |
| 712 | </View> | |
| 713 | <Text | |
| 714 | className={cn( | |
| 715 | "min-w-0 flex-1 text-sm text-foreground-muted", | |
| 716 | iconIsDestructive && "font-t3-medium text-adaptive-rose-600-400", | |
| 717 | )} | |
| 718 | numberOfLines={1} | |
| 719 | > | |
| 720 | {displayText} | |
| 721 | </Text> | |
| 722 | </> | |
| 723 | )} | |
| 724 | ||
| 725 | <View className="shrink-0 flex-row items-center gap-px"> | |
| 726 | {props.copied ? ( | |
| 727 | <Text className="pr-1 font-t3-medium text-3xs text-adaptive-emerald-600-400"> | |
| 728 | Copied | |
| 729 | </Text> | |
| 730 | ) : null} | |
| 731 | {failed && toolPresentation ? ( | |
| 732 | <View | |
| 733 | className="h-4 w-4 items-center justify-center" | |
| 734 | accessibilityElementsHidden | |
| 735 | importantForAccessibility="no-hide-descendants" | |
| 736 | > | |
| 737 | <SymbolView | |
| 738 | name="xmark" | |
| 739 | size={11} | |
| 740 | tintColorClassName="accent-adaptive-rose-600-400" | |
| 741 | type="monochrome" | |
| 742 | /> | |
| 743 | </View> | |
| 744 | ) : null} | |
| 745 | <View className="h-4 w-4 items-center justify-center"> | |
| 746 | {canExpand ? ( | |
| 747 | <ThreadDisclosureChevron | |
| 748 | expanded={expanded} | |
| 749 | collapsedDirection="down" | |
| 750 | size={11} | |
| 751 | tintColor={props.iconSubtleColor} | |
| 752 | /> | |
| 753 | ) : null} | |
| 754 | </View> | |
| 755 | </View> | |
| 756 | </View> | |
| 757 | </Pressable> | |
| 758 | ||
| 759 | {fullDetail ? ( | |
| 760 | <Animated.View | |
| 761 | entering={WORK_LOG_DETAIL_ENTER_TRANSITION} | |
| 762 | exiting={WORK_LOG_DETAIL_EXIT_TRANSITION} | |
| 763 | layout={WORK_LOG_LAYOUT_TRANSITION} | |
| 764 | className="ml-7 border-l border-adaptive-neutral-300-a60-white-a12 pb-1 pl-3 pt-0.5" | |
| 765 | > | |
| 766 | {viewedImagePath ? ( | |
| 767 | <View className="pb-1.5"> | |
| 768 | {props.renderImage({ href: viewedImagePath, alt: null, title: null })} | |
| 769 | </View> | |
| 770 | ) : null} | |
| 771 | <ScrollView | |
| 772 | nestedScrollEnabled | |
| 773 | directionalLockEnabled | |
| 774 | showsVerticalScrollIndicator | |
| 775 | className="max-h-60" | |
| 776 | contentContainerStyle={{ paddingRight: 8 }} | |
| 777 | > | |
| 778 | <Text selectable className="font-mono text-2xs leading-normal text-foreground-muted"> | |
| 779 | {fullDetail} | |
| 780 | </Text> | |
| 781 | </ScrollView> | |
| 782 | </Animated.View> | |
| 783 | ) : null} | |
| 784 | </Animated.View> | |
| 785 | ); | |
| 786 | }); | |
| 787 | ||
| 466 | 788 | export function ThreadWorkGroupToggle(props: { |
| 789 | readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>; | |
| 467 | 790 | readonly expanded: boolean; |
| 468 | 791 | readonly hiddenCount: number; |
| 469 | 792 | readonly iconSubtleColor: import("react-native").ColorValue; |
| 470 | 793 | readonly summary: string; |
| 471 | 794 | readonly summaryKind: ToolGroupSummaryKind; |
| 795 | readonly summaryToolIcon?: "browser" | "t3-code"; | |
| 472 | 796 | readonly hasFailure: boolean; |
| 473 | 797 | readonly shimmer: boolean; |
| 474 | 798 | readonly onToggle: () => void; |
| ... | ... | @@ -476,7 +800,7 @@ export function ThreadWorkGroupToggle(props: { |
| 476 | 800 | const accessibilityLabel = props.hasFailure |
| 477 | 801 | ? `${props.summary}, tool call failed` |
| 478 | 802 | : props.summary; |
| 479 | const icon = toolGroupSummarySymbolName(props.summaryKind); | |
| 803 | const icon = props.summaryToolIcon ?? toolGroupSummarySymbolName(props.summaryKind); | |
| 480 | 804 | |
| 481 | 805 | return ( |
| 482 | 806 | <View className="-mx-1 px-1 py-0"> |
| ... | ... | @@ -491,9 +815,11 @@ export function ThreadWorkGroupToggle(props: { |
| 491 | 815 | props.onToggle(); |
| 492 | 816 | }} |
| 493 | 817 | className="min-h-8 flex-row items-center gap-1.5 rounded-md px-0.5 py-0 active:bg-subtle" |
| 818 | style={{ minHeight: props.rowSizing.estimatedRowHeight }} | |
| 494 | 819 | > |
| 495 | 820 | {props.shimmer ? ( |
| 496 | 821 | <ShimmeringWorkContent |
| 822 | key={props.rowSizing.textSizeKey} | |
| 497 | 823 | icon={icon} |
| 498 | 824 | iconSubtleColor={props.iconSubtleColor} |
| 499 | 825 | label={props.summary} |
| ... | ... | @@ -502,14 +828,13 @@ export function ThreadWorkGroupToggle(props: { |
| 502 | 828 | ) : ( |
| 503 | 829 | <> |
| 504 | 830 | <View className="h-6 w-6 items-center justify-center"> |
| 505 | <SymbolView | |
| 506 | name={icon} | |
| 507 | size={14} | |
| 508 | tintColor={props.iconSubtleColor} | |
| 509 | type="monochrome" | |
| 510 | /> | |
| 831 | <WorkLogIcon icon={icon} color={props.iconSubtleColor} /> | |
| 511 | 832 | </View> |
| 512 | <Text className="min-w-0 flex-1 text-sm text-foreground-muted" numberOfLines={1}> | |
| 833 | <Text | |
| 834 | key={props.rowSizing.textSizeKey} | |
| 835 | className="min-w-0 flex-1 text-sm text-foreground-muted" | |
| 836 | numberOfLines={1} | |
| 837 | > | |
| 513 | 838 | {props.summary} |
| 514 | 839 | </Text> |
| 515 | 840 | </> |
| ... | ... | @@ -533,6 +858,7 @@ function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName { |
| 533 | 858 | return { ios: "square.and.pencil", android: "edit" }; |
| 534 | 859 | case "command": |
| 535 | 860 | return { ios: "terminal", android: "terminal" }; |
| 861 | case "browser": | |
| 536 | 862 | case "search": |
| 537 | 863 | return { ios: "globe", android: "public" }; |
| 538 | 864 | case "code-search": |
apps/mobile/src/lib/layout.test.ts+39| ... | ... | @@ -8,11 +8,50 @@ import { |
| 8 | 8 | deriveLayout, |
| 9 | 9 | deriveStableFormSheetDetent, |
| 10 | 10 | deriveThreadFeedInitialContentInset, |
| 11 | deriveThreadWorkLogSizing, | |
| 11 | 12 | deriveWorkspacePaneLayout, |
| 12 | 13 | SPLIT_LAYOUT_MIN_HEIGHT, |
| 13 | 14 | SPLIT_LAYOUT_MIN_WIDTH, |
| 14 | 15 | } from "./layout"; |
| 15 | 16 | |
| 17 | describe("thread work-log text sizing", () => { | |
| 18 | it.each([11, 16, 22])( | |
| 19 | "keeps exact compact rows at base size %i without OS enlargement", | |
| 20 | (baseFontSize) => { | |
| 21 | expect(deriveThreadWorkLogSizing({ baseFontSize, fontScale: 1 })).toMatchObject({ | |
| 22 | estimatedRowHeight: 28, | |
| 23 | fixedRowHeight: 28, | |
| 24 | }); | |
| 25 | }, | |
| 26 | ); | |
| 27 | ||
| 28 | it.each([ | |
| 29 | { baseFontSize: 16, fontScale: 1.25, estimatedRowHeight: 28 }, | |
| 30 | { baseFontSize: 16, fontScale: 2, estimatedRowHeight: 38 }, | |
| 31 | { baseFontSize: 22, fontScale: 2, estimatedRowHeight: 52 }, | |
| 32 | ])( | |
| 33 | "measures accessibility text instead of locking it to the estimate: %j", | |
| 34 | ({ estimatedRowHeight, ...settings }) => { | |
| 35 | expect(deriveThreadWorkLogSizing(settings)).toMatchObject({ | |
| 36 | estimatedRowHeight, | |
| 37 | fixedRowHeight: undefined, | |
| 38 | }); | |
| 39 | }, | |
| 40 | ); | |
| 41 | ||
| 42 | it("invalidates native text measurements even when the minimum row height is unchanged", () => { | |
| 43 | const original = deriveThreadWorkLogSizing({ baseFontSize: 16, fontScale: 1 }); | |
| 44 | for (const settings of [ | |
| 45 | { baseFontSize: 16, fontScale: 1.1 }, | |
| 46 | { baseFontSize: 17, fontScale: 1 }, | |
| 47 | ]) { | |
| 48 | const resized = deriveThreadWorkLogSizing(settings); | |
| 49 | expect(resized.estimatedRowHeight).toBe(original.estimatedRowHeight); | |
| 50 | expect(resized.textSizeKey).not.toBe(original.textSizeKey); | |
| 51 | } | |
| 52 | }); | |
| 53 | }); | |
| 54 | ||
| 16 | 55 | describe("deriveThreadFeedInitialContentInset", () => { |
| 17 | 56 | it("seeds Android scroll math with the composer overlay estimate", () => { |
| 18 | 57 | expect( |
apps/mobile/src/lib/layout.ts+26| ... | ... | @@ -1,3 +1,6 @@ |
| 1 | import { scaledTypographyLineHeight } from "./appearancePreferences"; | |
| 2 | import { MOBILE_TYPOGRAPHY } from "./typography"; | |
| 3 | ||
| 1 | 4 | function clamp(value: number, min: number, max: number): number { |
| 2 | 5 | return Math.min(Math.max(value, min), max); |
| 3 | 6 | } |
| ... | ... | @@ -18,6 +21,29 @@ const SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH = 380; |
| 18 | 21 | |
| 19 | 22 | export const AUXILIARY_PANE_MIN_CONTENT_WIDTH = 960; |
| 20 | 23 | export const CHAT_CONTENT_MAX_WIDTH = 960; |
| 24 | // min-h-8 uses the 14px rem configured in metro.config.js. | |
| 25 | export const THREAD_WORK_ROW_MIN_HEIGHT = 28; | |
| 26 | ||
| 27 | export function deriveThreadWorkLogSizing(input: { | |
| 28 | readonly baseFontSize: number; | |
| 29 | readonly fontScale: number; | |
| 30 | }) { | |
| 31 | const lineHeight = scaledTypographyLineHeight(MOBILE_TYPOGRAPHY.footnote, input.baseFontSize); | |
| 32 | return { | |
| 33 | // Different text metrics can share the same minimum row height. | |
| 34 | textSizeKey: `${input.baseFontSize}:${input.fontScale}`, | |
| 35 | estimatedRowHeight: Math.max( | |
| 36 | THREAD_WORK_ROW_MIN_HEIGHT, | |
| 37 | Math.ceil(lineHeight * input.fontScale), | |
| 38 | ), | |
| 39 | // Native text can exceed its authored line height with accessibility scaling. | |
| 40 | // Leave those rows measured instead of promising LegendList an exact size. | |
| 41 | fixedRowHeight: | |
| 42 | input.fontScale <= 1 && lineHeight <= THREAD_WORK_ROW_MIN_HEIGHT | |
| 43 | ? THREAD_WORK_ROW_MIN_HEIGHT | |
| 44 | : undefined, | |
| 45 | }; | |
| 46 | } | |
| 21 | 47 | |
| 22 | 48 | export const AUXILIARY_PANE_MIN_WIDTH = 260; |
| 23 | 49 | export const AUXILIARY_PANE_MAX_WIDTH = 480; |
apps/mobile/src/lib/threadActivity.test.ts+636-97| ... | ... | @@ -825,6 +825,408 @@ describe("buildThreadFeed", () => { |
| 825 | 825 | expect(group.activities[0]?.getFullDetail()).toContain("repository.search"); |
| 826 | 826 | }); |
| 827 | 827 | |
| 828 | it.each([ | |
| 829 | { | |
| 830 | source: "raw MCP browser identity", | |
| 831 | label: "Call MCP tool", | |
| 832 | title: "Call MCP tool", | |
| 833 | item: { server: "t3-code", tool: "preview_navigate" }, | |
| 834 | status: "inProgress", | |
| 835 | displayName: "Navigating the preview browser", | |
| 836 | icon: "browser", | |
| 837 | }, | |
| 838 | { | |
| 839 | source: "raw MCP orchestration identity", | |
| 840 | label: "Call MCP tool", | |
| 841 | title: "Call MCP tool", | |
| 842 | item: { server: "t3-code", tool: "task_status" }, | |
| 843 | status: "inProgress", | |
| 844 | displayName: "Getting delegated task status", | |
| 845 | icon: "t3-code", | |
| 846 | }, | |
| 847 | { | |
| 848 | source: "provider-qualified title", | |
| 849 | label: "Call MCP tool", | |
| 850 | title: "mcp__t3-code__preview_snapshot", | |
| 851 | item: undefined, | |
| 852 | status: "inProgress", | |
| 853 | displayName: "Taking a snapshot of the preview page", | |
| 854 | icon: "browser", | |
| 855 | }, | |
| 856 | { | |
| 857 | source: "provider-qualified label", | |
| 858 | label: "mcp__t3-code__task_status", | |
| 859 | title: undefined, | |
| 860 | item: undefined, | |
| 861 | status: "inProgress", | |
| 862 | displayName: "Getting delegated task status", | |
| 863 | icon: "t3-code", | |
| 864 | }, | |
| 865 | { | |
| 866 | source: "browser identity without lifecycle status", | |
| 867 | label: "Call MCP tool", | |
| 868 | title: "Call MCP tool", | |
| 869 | item: { server: "t3-code", tool: "preview_click" }, | |
| 870 | status: undefined, | |
| 871 | displayName: "Click in the preview browser", | |
| 872 | liveDisplayName: "Clicking in the preview browser", | |
| 873 | icon: "browser", | |
| 874 | }, | |
| 875 | { | |
| 876 | source: "orchestration identity without lifecycle status", | |
| 877 | label: "Call MCP tool", | |
| 878 | title: "Call MCP tool", | |
| 879 | item: { server: "t3-code", tool: "task_status" }, | |
| 880 | status: undefined, | |
| 881 | displayName: "Get delegated task status", | |
| 882 | liveDisplayName: "Getting delegated task status", | |
| 883 | icon: "t3-code", | |
| 884 | }, | |
| 885 | ])( | |
| 886 | "uses friendly row and running labels from $source", | |
| 887 | ({ label, title, item, status, displayName, liveDisplayName, icon }) => { | |
| 888 | const turnId = TurnId.make("turn-friendly-mcp"); | |
| 889 | const rawCommand = "node mcp-call.js"; | |
| 890 | const rawDetail = '{"provider":"raw MCP output"}'; | |
| 891 | const thread = makeThread({ | |
| 892 | id: ThreadId.make("thread-friendly-mcp"), | |
| 893 | projectId: ProjectId.make("project-1"), | |
| 894 | title: "Friendly MCP labels", | |
| 895 | latestTurn: { | |
| 896 | turnId, | |
| 897 | state: "running", | |
| 898 | requestedAt: "2026-04-01T00:00:00.000Z", | |
| 899 | startedAt: "2026-04-01T00:00:01.000Z", | |
| 900 | completedAt: null, | |
| 901 | assistantMessageId: null, | |
| 902 | }, | |
| 903 | activities: [ | |
| 904 | makeActivity({ | |
| 905 | id: EventId.make("friendly-mcp"), | |
| 906 | kind: "tool.updated", | |
| 907 | tone: "tool", | |
| 908 | summary: label, | |
| 909 | createdAt: "2026-04-01T00:00:02.000Z", | |
| 910 | turnId, | |
| 911 | payload: { | |
| 912 | title, | |
| 913 | itemType: "mcp_tool_call", | |
| 914 | detail: rawDetail, | |
| 915 | status, | |
| 916 | data: { item, command: rawCommand }, | |
| 917 | }, | |
| 918 | }), | |
| 919 | ], | |
| 920 | }); | |
| 921 | ||
| 922 | const feed = buildThreadFeed(thread); | |
| 923 | const group = feed[0]; | |
| 924 | expect(group).toMatchObject({ | |
| 925 | type: "activity-group", | |
| 926 | activities: [{ summary: displayName, detail: rawCommand }], | |
| 927 | }); | |
| 928 | if (!group || group.type !== "activity-group") return; | |
| 929 | const activity = group.activities[0]!; | |
| 930 | expect(activity.getFullDetail()).toContain(rawCommand); | |
| 931 | expect(activity.getFullDetail()).toContain(rawDetail); | |
| 932 | expect(activity.getCopyText()).toContain(rawCommand); | |
| 933 | expect(activity.getCopyText()).toContain(rawDetail); | |
| 934 | expect(activity.getCopyText()).not.toContain(displayName); | |
| 935 | if (item) expect(activity.getFullDetail()).toContain(JSON.stringify(item, null, 2)); | |
| 936 | expect( | |
| 937 | deriveThreadFeedPresentation( | |
| 938 | feed, | |
| 939 | thread.latestTurn, | |
| 940 | new Set(), | |
| 941 | new Set(), | |
| 942 | thread.latestTurn!.startedAt, | |
| 943 | ), | |
| 944 | ).toMatchObject([ | |
| 945 | { | |
| 946 | type: "work-toggle", | |
| 947 | summary: liveDisplayName ?? displayName, | |
| 948 | summaryToolIcon: icon, | |
| 949 | live: true, | |
| 950 | }, | |
| 951 | ]); | |
| 952 | }, | |
| 953 | ); | |
| 954 | ||
| 955 | it("retains Claude MCP metadata behind friendly row and running labels", () => { | |
| 956 | const turnId = TurnId.make("turn-claude-mcp"); | |
| 957 | const toolData = { | |
| 958 | toolName: "mcp__t3-code__preview_click", | |
| 959 | input: { locator: { role: "button", name: "Continue" } }, | |
| 960 | result: { content: "Clicked Continue" }, | |
| 961 | }; | |
| 962 | const detail = "Click Continue"; | |
| 963 | const thread = makeThread({ | |
| 964 | id: ThreadId.make("thread-claude-mcp"), | |
| 965 | projectId: ProjectId.make("project-1"), | |
| 966 | title: "Claude MCP labels", | |
| 967 | latestTurn: { | |
| 968 | turnId, | |
| 969 | state: "running", | |
| 970 | requestedAt: "2026-04-01T00:00:00.000Z", | |
| 971 | startedAt: "2026-04-01T00:00:01.000Z", | |
| 972 | completedAt: null, | |
| 973 | assistantMessageId: null, | |
| 974 | }, | |
| 975 | activities: [ | |
| 976 | makeActivity({ | |
| 977 | id: EventId.make("claude-mcp-completed"), | |
| 978 | kind: "tool.completed", | |
| 979 | tone: "tool", | |
| 980 | summary: "MCP tool call completed", | |
| 981 | createdAt: "2026-04-01T00:00:02.000Z", | |
| 982 | turnId, | |
| 983 | payload: { | |
| 984 | title: "MCP tool call", | |
| 985 | itemType: "mcp_tool_call", | |
| 986 | status: "completed", | |
| 987 | detail, | |
| 988 | data: toolData, | |
| 989 | }, | |
| 990 | }), | |
| 991 | ], | |
| 992 | }); | |
| 993 | ||
| 994 | const feed = buildThreadFeed(thread); | |
| 995 | const group = feed[0]; | |
| 996 | expect(group).toMatchObject({ | |
| 997 | type: "activity-group", | |
| 998 | activities: [ | |
| 999 | { | |
| 1000 | summary: "Clicked in the preview browser", | |
| 1001 | detail, | |
| 1002 | workEntry: { label: "MCP tool call completed", toolTitle: "MCP tool call" }, | |
| 1003 | }, | |
| 1004 | ], | |
| 1005 | }); | |
| 1006 | if (!group || group.type !== "activity-group") return; | |
| 1007 | const activity = group.activities[0]!; | |
| 1008 | const fullDetail = `MCP call\n${JSON.stringify(toolData, null, 2)}\n\n${detail}`; | |
| 1009 | expect(activity.workEntry.toolData).toBe(toolData); | |
| 1010 | expect(activity.getFullDetail()).toBe(fullDetail); | |
| 1011 | expect(activity.getCopyText()).toBe(`MCP tool call\n${detail}\n${fullDetail}`); | |
| 1012 | expect( | |
| 1013 | deriveThreadFeedPresentation( | |
| 1014 | feed, | |
| 1015 | thread.latestTurn, | |
| 1016 | new Set(), | |
| 1017 | new Set(), | |
| 1018 | thread.latestTurn!.startedAt, | |
| 1019 | ), | |
| 1020 | ).toMatchObject([ | |
| 1021 | { | |
| 1022 | type: "work-toggle", | |
| 1023 | summary: "Clicked in the preview browser", | |
| 1024 | summaryToolIcon: "browser", | |
| 1025 | live: true, | |
| 1026 | }, | |
| 1027 | ]); | |
| 1028 | }); | |
| 1029 | ||
| 1030 | it.each([ | |
| 1031 | { | |
| 1032 | status: "completed", | |
| 1033 | displayName: "Clicked in the preview browser", | |
| 1034 | detail: "Clicked Continue", | |
| 1035 | hasFailure: false, | |
| 1036 | }, | |
| 1037 | { | |
| 1038 | status: "failed", | |
| 1039 | displayName: "Failed to click in the preview browser", | |
| 1040 | detail: "Timed out waiting for Continue", | |
| 1041 | hasFailure: true, | |
| 1042 | }, | |
| 1043 | ])( | |
| 1044 | "keeps a browser group expanded as its action changes from active to $status", | |
| 1045 | ({ status, displayName, detail, hasFailure }) => { | |
| 1046 | const turnId = TurnId.make("turn-preview-lifecycle"); | |
| 1047 | const toolCallId = "preview-click"; | |
| 1048 | const groupId = `work-group:tool:${turnId}:${toolCallId}`; | |
| 1049 | const toolData = { | |
| 1050 | server: "t3-code", | |
| 1051 | tool: "preview_click", | |
| 1052 | arguments: { locator: { role: "button", name: "Continue" } }, | |
| 1053 | }; | |
| 1054 | const thread = makeThread({ | |
| 1055 | id: ThreadId.make("thread-preview-lifecycle"), | |
| 1056 | projectId: ProjectId.make("project-1"), | |
| 1057 | title: "Browser tool lifecycle", | |
| 1058 | latestTurn: { | |
| 1059 | turnId, | |
| 1060 | state: "running", | |
| 1061 | requestedAt: "2026-04-01T00:00:00.000Z", | |
| 1062 | startedAt: "2026-04-01T00:00:01.000Z", | |
| 1063 | completedAt: null, | |
| 1064 | assistantMessageId: null, | |
| 1065 | }, | |
| 1066 | activities: [ | |
| 1067 | makeActivity({ | |
| 1068 | id: EventId.make("preview-click-started"), | |
| 1069 | kind: "tool.updated", | |
| 1070 | tone: "tool", | |
| 1071 | summary: "MCP tool call", | |
| 1072 | createdAt: "2026-04-01T00:00:02.000Z", | |
| 1073 | turnId, | |
| 1074 | payload: { | |
| 1075 | title: "MCP tool call", | |
| 1076 | itemType: "mcp_tool_call", | |
| 1077 | status: "inProgress", | |
| 1078 | toolCallId, | |
| 1079 | data: { item: toolData }, | |
| 1080 | }, | |
| 1081 | }), | |
| 1082 | ], | |
| 1083 | }); | |
| 1084 | const present = (currentThread: OrchestrationThread) => | |
| 1085 | deriveThreadFeedPresentation( | |
| 1086 | buildThreadFeed(currentThread), | |
| 1087 | currentThread.latestTurn, | |
| 1088 | new Set([turnId]), | |
| 1089 | new Set([groupId]), | |
| 1090 | currentThread.latestTurn?.state === "running" ? currentThread.latestTurn.startedAt : null, | |
| 1091 | ); | |
| 1092 | ||
| 1093 | expect(present(thread)).toMatchObject([ | |
| 1094 | { | |
| 1095 | type: "work-toggle", | |
| 1096 | groupId, | |
| 1097 | hiddenCount: 1, | |
| 1098 | expanded: true, | |
| 1099 | summary: "Clicking in the preview browser", | |
| 1100 | summaryToolIcon: "browser", | |
| 1101 | live: true, | |
| 1102 | shimmer: true, | |
| 1103 | }, | |
| 1104 | { | |
| 1105 | type: "activity-group", | |
| 1106 | id: `work-details:${groupId}`, | |
| 1107 | activities: [ | |
| 1108 | { | |
| 1109 | id: "preview-click-started", | |
| 1110 | summary: "Clicking in the preview browser", | |
| 1111 | lifecycleStatus: "inProgress", | |
| 1112 | live: true, | |
| 1113 | }, | |
| 1114 | ], | |
| 1115 | }, | |
| 1116 | ]); | |
| 1117 | ||
| 1118 | const terminalThread = { | |
| 1119 | ...thread, | |
| 1120 | activities: [ | |
| 1121 | ...thread.activities, | |
| 1122 | makeActivity({ | |
| 1123 | id: EventId.make("preview-click-completed"), | |
| 1124 | kind: "tool.completed", | |
| 1125 | tone: "tool", | |
| 1126 | summary: "MCP tool call completed", | |
| 1127 | createdAt: "2026-04-01T00:00:03.000Z", | |
| 1128 | turnId, | |
| 1129 | payload: { itemType: "mcp_tool_call", toolCallId, status, detail }, | |
| 1130 | }), | |
| 1131 | ], | |
| 1132 | }; | |
| 1133 | const terminalRows = present(terminalThread); | |
| 1134 | expect(terminalRows).toMatchObject([ | |
| 1135 | { | |
| 1136 | type: "work-toggle", | |
| 1137 | groupId, | |
| 1138 | hiddenCount: 1, | |
| 1139 | expanded: true, | |
| 1140 | summary: displayName, | |
| 1141 | summaryToolIcon: "browser", | |
| 1142 | hasFailure, | |
| 1143 | live: true, | |
| 1144 | shimmer: false, | |
| 1145 | }, | |
| 1146 | { | |
| 1147 | type: "activity-group", | |
| 1148 | id: `work-details:${groupId}`, | |
| 1149 | activities: [ | |
| 1150 | { | |
| 1151 | id: "preview-click-started", | |
| 1152 | summary: displayName, | |
| 1153 | lifecycleStatus: status, | |
| 1154 | live: false, | |
| 1155 | }, | |
| 1156 | ], | |
| 1157 | }, | |
| 1158 | ]); | |
| 1159 | const terminalGroup = terminalRows[1]; | |
| 1160 | if (terminalGroup?.type !== "activity-group") return; | |
| 1161 | const activity = terminalGroup.activities[0]!; | |
| 1162 | const fullDetail = `MCP call\n${JSON.stringify(toolData, null, 2)}\n\n${detail}`; | |
| 1163 | expect(activity.workEntry.toolData).toBe(toolData); | |
| 1164 | expect(activity.getFullDetail()).toBe(fullDetail); | |
| 1165 | expect(activity.getCopyText()).toBe(`MCP tool call\n${detail}\n${fullDetail}`); | |
| 1166 | ||
| 1167 | const settledRows = present({ | |
| 1168 | ...terminalThread, | |
| 1169 | latestTurn: { | |
| 1170 | ...thread.latestTurn!, | |
| 1171 | state: "completed", | |
| 1172 | completedAt: "2026-04-01T00:00:04.000Z", | |
| 1173 | }, | |
| 1174 | }); | |
| 1175 | expect(settledRows.find((entry) => entry.type === "work-toggle")).toMatchObject({ | |
| 1176 | groupId, | |
| 1177 | hiddenCount: 1, | |
| 1178 | expanded: true, | |
| 1179 | summary: "Used browser 1 time", | |
| 1180 | summaryKind: "browser", | |
| 1181 | hasFailure, | |
| 1182 | live: false, | |
| 1183 | }); | |
| 1184 | expect(settledRows.find((entry) => entry.type === "activity-group")).toMatchObject({ | |
| 1185 | id: `work-details:${groupId}`, | |
| 1186 | activities: [{ id: "preview-click-started", summary: displayName, live: false }], | |
| 1187 | }); | |
| 1188 | }, | |
| 1189 | ); | |
| 1190 | ||
| 1191 | it.each([ | |
| 1192 | [0, "Used browser 3 times", "browser"], | |
| 1193 | [2, "Ran 2 commands and used browser 3 times", "mixed"], | |
| 1194 | ] as const)( | |
| 1195 | "separates browser counts from %s completed commands", | |
| 1196 | (commandCount, summary, summaryKind) => { | |
| 1197 | const thread = makeThread({ | |
| 1198 | id: ThreadId.make("thread-browser-counts"), | |
| 1199 | projectId: ProjectId.make("project-1"), | |
| 1200 | title: "Browser group counts", | |
| 1201 | activities: Array.from({ length: commandCount + 3 }, (_, index) => | |
| 1202 | makeActivity({ | |
| 1203 | id: EventId.make(`browser-count-${index}`), | |
| 1204 | createdAt: new Date(Date.UTC(2026, 3, 1, 0, 0, index)).toISOString(), | |
| 1205 | kind: "tool.completed", | |
| 1206 | tone: "tool", | |
| 1207 | summary: index < commandCount ? "Ran command" : "MCP tool call", | |
| 1208 | payload: { | |
| 1209 | toolCallId: `browser-count-${index}`, | |
| 1210 | status: "completed", | |
| 1211 | ...(index < commandCount | |
| 1212 | ? { | |
| 1213 | itemType: "command_execution", | |
| 1214 | data: { item: { command: "/bin/bash -lc 'vp test run'" } }, | |
| 1215 | } | |
| 1216 | : { | |
| 1217 | itemType: "mcp_tool_call", | |
| 1218 | data: { item: { server: "t3-code", tool: "preview_click" } }, | |
| 1219 | }), | |
| 1220 | }, | |
| 1221 | }), | |
| 1222 | ), | |
| 1223 | }); | |
| 1224 | expect( | |
| 1225 | deriveThreadFeedPresentation(buildThreadFeed(thread), null, new Set(), new Set()), | |
| 1226 | ).toMatchObject([{ type: "work-toggle", summary, summaryKind, live: false }]); | |
| 1227 | }, | |
| 1228 | ); | |
| 1229 | ||
| 828 | 1230 | it("defers large tool output expansion until a work row is opened or copied", () => { |
| 829 | 1231 | let serializedToolOutputs = 0; |
| 830 | 1232 | const activities = Array.from({ length: 5_000 }, (_, index) => |
| ... | ... | @@ -866,6 +1268,22 @@ describe("buildThreadFeed", () => { |
| 866 | 1268 | } |
| 867 | 1269 | |
| 868 | 1270 | expect(group.activities).toHaveLength(5_000); |
| 1271 | const expanded = deriveThreadFeedPresentation( | |
| 1272 | feed, | |
| 1273 | null, | |
| 1274 | new Set(), | |
| 1275 | new Set(["work-group:large-tool-0"]), | |
| 1276 | ); | |
| 1277 | expect(expanded).toHaveLength(2); | |
| 1278 | expect(expanded[1]).toMatchObject({ | |
| 1279 | type: "activity-group", | |
| 1280 | id: "work-details:work-group:large-tool-0", | |
| 1281 | }); | |
| 1282 | if (expanded[1]?.type === "activity-group") { | |
| 1283 | expect(expanded[1].activities).toHaveLength(5_000); | |
| 1284 | expect(expanded[1].activities[0]?.getFullDetail).toBe(group.activities[0]?.getFullDetail); | |
| 1285 | } | |
| 1286 | expect(serializedToolOutputs).toBe(0); | |
| 869 | 1287 | expect(group.activities[0]?.getFullDetail()).toContain('"output"'); |
| 870 | 1288 | expect(serializedToolOutputs).toBe(1); |
| 871 | 1289 | expect(group.activities[0]?.getCopyText()).toContain('"output"'); |
| ... | ... | @@ -1137,17 +1555,26 @@ describe("buildThreadFeed", () => { |
| 1137 | 1555 | type: "activity-group", |
| 1138 | 1556 | activities: [{ status: "success" }, { status: "failure" }], |
| 1139 | 1557 | }); |
| 1140 | expect( | |
| 1141 | deriveThreadFeedPresentation( | |
| 1142 | feed, | |
| 1143 | thread.latestTurn, | |
| 1144 | new Set(), | |
| 1145 | new Set(["work-group:tool-succeeded"]), | |
| 1146 | ).map((entry) => entry.id), | |
| 1147 | ).toEqual(["work-toggle:work-group:tool-succeeded", "tool-succeeded", "tool-failed"]); | |
| 1558 | const expanded = deriveThreadFeedPresentation( | |
| 1559 | feed, | |
| 1560 | thread.latestTurn, | |
| 1561 | new Set(), | |
| 1562 | new Set(["work-group:tool-succeeded"]), | |
| 1563 | ); | |
| 1564 | expect(expanded.map((entry) => entry.id)).toEqual([ | |
| 1565 | "work-toggle:work-group:tool-succeeded", | |
| 1566 | "work-details:work-group:tool-succeeded", | |
| 1567 | ]); | |
| 1568 | expect(expanded[1]).toMatchObject({ | |
| 1569 | type: "activity-group", | |
| 1570 | activities: [ | |
| 1571 | { id: "tool-succeeded", status: "success", groupedToolDetail: true }, | |
| 1572 | { id: "tool-failed", status: "failure", groupedToolDetail: true }, | |
| 1573 | ], | |
| 1574 | }); | |
| 1148 | 1575 | }); |
| 1149 | 1576 | |
| 1150 | it("models work-log overflow as list rows", () => { | |
| 1577 | it("keeps expanded work in one group with stable row identities", () => { | |
| 1151 | 1578 | const activity = ( |
| 1152 | 1579 | id: string, |
| 1153 | 1580 | createdAt: string, |
| ... | ... | @@ -1206,111 +1633,192 @@ describe("buildThreadFeed", () => { |
| 1206 | 1633 | ); |
| 1207 | 1634 | expect(expanded.map((entry) => entry.id)).toEqual([ |
| 1208 | 1635 | "work-toggle:work-group:activity-1", |
| 1209 | "activity-1", | |
| 1210 | "activity-2", | |
| 1211 | "activity-3", | |
| 1636 | "work-details:work-group:activity-1", | |
| 1212 | 1637 | ]); |
| 1213 | 1638 | expect(expanded[0]).toMatchObject({ |
| 1214 | 1639 | type: "work-toggle", |
| 1215 | 1640 | expanded: true, |
| 1216 | 1641 | }); |
| 1642 | expect(expanded[1]).toMatchObject({ | |
| 1643 | type: "activity-group", | |
| 1644 | activities: [ | |
| 1645 | { id: "activity-1", groupedToolDetail: true, live: false }, | |
| 1646 | { id: "activity-2", groupedToolDetail: true, live: false }, | |
| 1647 | { id: "activity-3", groupedToolDetail: true, live: false }, | |
| 1648 | ], | |
| 1649 | }); | |
| 1217 | 1650 | }); |
| 1218 | 1651 | |
| 1219 | it("keeps live state on the active uninterrupted tool run", () => { | |
| 1220 | const turnId = TurnId.make("turn-live-tools"); | |
| 1221 | const activity = ( | |
| 1222 | id: string, | |
| 1223 | status: ThreadFeedActivity["status"], | |
| 1224 | lifecycleStatus: ThreadFeedActivity["lifecycleStatus"], | |
| 1225 | tone: "tool" | "error" = "tool", | |
| 1226 | command?: string, | |
| 1227 | ): ThreadFeedActivity => ({ | |
| 1228 | id, | |
| 1229 | createdAt: `2026-04-01T00:00:0${id.at(-1)}.000Z`, | |
| 1230 | turnId, | |
| 1231 | summary: `Tool ${id}`, | |
| 1232 | detail: null, | |
| 1233 | canExpand: false, | |
| 1234 | getFullDetail: () => null, | |
| 1235 | getCopyText: () => id, | |
| 1236 | icon: "command", | |
| 1237 | toolLike: true, | |
| 1238 | status, | |
| 1239 | lifecycleStatus, | |
| 1240 | workEntry: { | |
| 1652 | it.each( | |
| 1653 | [ | |
| 1654 | "sudo -u root pnpm test", | |
| 1655 | "/bin/zsh -lc 'sudo -u root pnpm test'", | |
| 1656 | "/bin/bash -lc 'sudo -u root pnpm test'", | |
| 1657 | ].flatMap((command) => | |
| 1658 | ( | |
| 1659 | [ | |
| 1660 | { lifecycleStatus: "inProgress", summary: "Running pnpm", shimmer: true }, | |
| 1661 | { lifecycleStatus: "completed", summary: "Ran pnpm", shimmer: false }, | |
| 1662 | { lifecycleStatus: "failed", summary: "Failed pnpm", shimmer: false }, | |
| 1663 | { lifecycleStatus: "declined", summary: "Declined pnpm", shimmer: false }, | |
| 1664 | { lifecycleStatus: "stopped", summary: "Stopped pnpm", shimmer: false }, | |
| 1665 | ] as const | |
| 1666 | ).map((state) => ({ command, ...state })), | |
| 1667 | ), | |
| 1668 | )( | |
| 1669 | "keeps the command summary in sync with $lifecycleStatus: $command", | |
| 1670 | ({ command, lifecycleStatus, summary, shimmer }) => { | |
| 1671 | const turnId = TurnId.make("turn-live-tools"); | |
| 1672 | const activity = ( | |
| 1673 | id: string, | |
| 1674 | status: ThreadFeedActivity["status"], | |
| 1675 | lifecycleStatus: ThreadFeedActivity["lifecycleStatus"], | |
| 1676 | tone: "tool" | "error" = "tool", | |
| 1677 | command?: string, | |
| 1678 | ): ThreadFeedActivity => ({ | |
| 1241 | 1679 | id, |
| 1242 | 1680 | createdAt: `2026-04-01T00:00:0${id.at(-1)}.000Z`, |
| 1243 | 1681 | turnId, |
| 1244 | label: `Tool ${id}`, | |
| 1245 | tone, | |
| 1246 | toolLifecycleStatus: lifecycleStatus, | |
| 1247 | ...(command ? { command, itemType: "command_execution" as const } : {}), | |
| 1248 | }, | |
| 1249 | }); | |
| 1250 | const feed: ThreadFeedEntry[] = [ | |
| 1251 | { | |
| 1252 | type: "activity-group", | |
| 1253 | id: "activity-1", | |
| 1254 | createdAt: "2026-04-01T00:00:01.000Z", | |
| 1682 | summary: `Tool ${id}`, | |
| 1683 | detail: lifecycleStatus === "stopped" ? "Exit code 130" : null, | |
| 1684 | canExpand: false, | |
| 1685 | getFullDetail: () => null, | |
| 1686 | getCopyText: () => id, | |
| 1687 | icon: "command", | |
| 1688 | toolLike: true, | |
| 1689 | status, | |
| 1690 | lifecycleStatus, | |
| 1691 | workEntry: { | |
| 1692 | id, | |
| 1693 | createdAt: `2026-04-01T00:00:0${id.at(-1)}.000Z`, | |
| 1694 | turnId, | |
| 1695 | label: `Tool ${id}`, | |
| 1696 | tone, | |
| 1697 | toolLifecycleStatus: lifecycleStatus, | |
| 1698 | ...(lifecycleStatus === "stopped" ? { detail: "Exit code 130" } : {}), | |
| 1699 | ...(command ? { command, itemType: "command_execution" as const } : {}), | |
| 1700 | }, | |
| 1701 | }); | |
| 1702 | const feed: ThreadFeedEntry[] = [ | |
| 1703 | { | |
| 1704 | type: "activity-group", | |
| 1705 | id: "activity-1", | |
| 1706 | createdAt: "2026-04-01T00:00:01.000Z", | |
| 1707 | turnId, | |
| 1708 | activities: [ | |
| 1709 | activity("activity-1", "success", "completed"), | |
| 1710 | activity("activity-2", "failure", "failed", "error"), | |
| 1711 | activity( | |
| 1712 | "activity-3", | |
| 1713 | lifecycleStatus === "inProgress" | |
| 1714 | ? "neutral" | |
| 1715 | : lifecycleStatus === "completed" | |
| 1716 | ? "success" | |
| 1717 | : "failure", | |
| 1718 | lifecycleStatus, | |
| 1719 | "tool", | |
| 1720 | command, | |
| 1721 | ), | |
| 1722 | ...(lifecycleStatus === "inProgress" | |
| 1723 | ? [activity("activity-4", "success", "completed", "tool", "printf done")] | |
| 1724 | : []), | |
| 1725 | ], | |
| 1726 | }, | |
| 1727 | ]; | |
| 1728 | const latestTurn = { | |
| 1255 | 1729 | turnId, |
| 1256 | activities: [ | |
| 1257 | activity("activity-1", "success", "completed"), | |
| 1258 | activity("activity-2", "failure", "failed", "error"), | |
| 1259 | activity("activity-3", "success", "completed", "tool", "sudo -u root pnpm test"), | |
| 1260 | ], | |
| 1730 | state: "running" as const, | |
| 1731 | requestedAt: "2026-04-01T00:00:00.000Z", | |
| 1732 | startedAt: "2026-04-01T00:00:00.000Z", | |
| 1733 | completedAt: null, | |
| 1734 | assistantMessageId: null, | |
| 1735 | }; | |
| 1736 | ||
| 1737 | const rows = deriveThreadFeedPresentation( | |
| 1738 | feed, | |
| 1739 | latestTurn, | |
| 1740 | new Set(), | |
| 1741 | new Set(), | |
| 1742 | latestTurn.startedAt, | |
| 1743 | ); | |
| 1744 | expect(rows.slice(0, 3).map((entry) => [entry.id, entry.type])).toEqual([ | |
| 1745 | ["work-toggle:work-group:activity-1", "work-toggle"], | |
| 1746 | ["activity-2", "activity-group"], | |
| 1747 | ["work-live:work-group:activity-3", "work-toggle"], | |
| 1748 | ]); | |
| 1749 | expect(rows.slice(0, 3).map((entry) => entry.type === "work-toggle" && entry.live)).toEqual([ | |
| 1750 | false, | |
| 1751 | false, | |
| 1752 | true, | |
| 1753 | ]); | |
| 1754 | expect(rows[2]).toMatchObject({ | |
| 1755 | summary, | |
| 1756 | summaryKind: "command", | |
| 1757 | live: true, | |
| 1758 | shimmer, | |
| 1759 | }); | |
| 1760 | expect(rows[0]).toMatchObject({ live: false, shimmer: false }); | |
| 1761 | ||
| 1762 | const stoppedRows = deriveThreadFeedPresentation(feed, latestTurn, new Set()); | |
| 1763 | expect(stoppedRows.filter((entry) => entry.type === "work-toggle")).toMatchObject([ | |
| 1764 | { live: false, shimmer: false }, | |
| 1765 | { live: false, shimmer: false }, | |
| 1766 | ]); | |
| 1767 | ||
| 1768 | const completedRows = deriveThreadFeedPresentation( | |
| 1769 | feed, | |
| 1770 | { ...latestTurn, state: "completed", completedAt: "2026-04-01T00:00:04.000Z" }, | |
| 1771 | new Set([turnId]), | |
| 1772 | new Set(), | |
| 1773 | latestTurn.startedAt, | |
| 1774 | ); | |
| 1775 | expect(completedRows.filter((entry) => entry.type === "work-toggle")).toMatchObject([ | |
| 1776 | { live: false, shimmer: false }, | |
| 1777 | { live: false, shimmer: false }, | |
| 1778 | ]); | |
| 1779 | }, | |
| 1780 | ); | |
| 1781 | ||
| 1782 | it.each([ | |
| 1783 | ["inProgress", true], | |
| 1784 | ["completed", false], | |
| 1785 | ["failed", false], | |
| 1786 | ["declined", false], | |
| 1787 | ["stopped", false], | |
| 1788 | ] as const)("respects the %s lifecycle of trailing task progress", (status, shimmer) => { | |
| 1789 | const turnId = TurnId.make("turn-task-progress"); | |
| 1790 | const thread = makeThread({ | |
| 1791 | id: ThreadId.make("thread-task-progress"), | |
| 1792 | projectId: ProjectId.make("project-1"), | |
| 1793 | title: "Task lifecycle", | |
| 1794 | latestTurn: { | |
| 1795 | turnId, | |
| 1796 | state: "running", | |
| 1797 | requestedAt: "2026-04-01T00:00:00.000Z", | |
| 1798 | startedAt: "2026-04-01T00:00:01.000Z", | |
| 1799 | completedAt: null, | |
| 1800 | assistantMessageId: null, | |
| 1261 | 1801 | }, |
| 1262 | ]; | |
| 1263 | const latestTurn = { | |
| 1264 | turnId, | |
| 1265 | state: "running" as const, | |
| 1266 | requestedAt: "2026-04-01T00:00:00.000Z", | |
| 1267 | startedAt: "2026-04-01T00:00:00.000Z", | |
| 1268 | completedAt: null, | |
| 1269 | assistantMessageId: null, | |
| 1270 | }; | |
| 1802 | activities: [ | |
| 1803 | makeActivity({ | |
| 1804 | id: EventId.make("task-progress"), | |
| 1805 | kind: "task.progress", | |
| 1806 | summary: "Task progress", | |
| 1807 | createdAt: "2026-04-01T00:00:02.000Z", | |
| 1808 | turnId, | |
| 1809 | payload: { taskId: "task-1", status }, | |
| 1810 | }), | |
| 1811 | ], | |
| 1812 | }); | |
| 1271 | 1813 | |
| 1272 | 1814 | const rows = deriveThreadFeedPresentation( |
| 1273 | feed, | |
| 1274 | latestTurn, | |
| 1815 | buildThreadFeed(thread), | |
| 1816 | thread.latestTurn, | |
| 1275 | 1817 | new Set(), |
| 1276 | 1818 | new Set(), |
| 1277 | latestTurn.startedAt, | |
| 1278 | ); | |
| 1279 | expect(rows.slice(0, 3).map((entry) => [entry.id, entry.type])).toEqual([ | |
| 1280 | ["work-toggle:work-group:activity-1", "work-toggle"], | |
| 1281 | ["activity-2", "activity-group"], | |
| 1282 | ["work-live:work-group:activity-3", "work-toggle"], | |
| 1283 | ]); | |
| 1284 | expect(rows.slice(0, 3).map((entry) => entry.type === "work-toggle" && entry.live)).toEqual([ | |
| 1285 | false, | |
| 1286 | false, | |
| 1287 | true, | |
| 1288 | ]); | |
| 1289 | expect(rows[2]).toMatchObject({ | |
| 1290 | summary: "Running pnpm", | |
| 1291 | summaryKind: "command", | |
| 1292 | live: true, | |
| 1293 | shimmer: true, | |
| 1294 | }); | |
| 1295 | expect(rows[0]).toMatchObject({ live: false, shimmer: false }); | |
| 1296 | ||
| 1297 | const stoppedRows = deriveThreadFeedPresentation(feed, latestTurn, new Set()); | |
| 1298 | expect(stoppedRows.filter((entry) => entry.type === "work-toggle")).toMatchObject([ | |
| 1299 | { live: false, shimmer: false }, | |
| 1300 | { live: false, shimmer: false }, | |
| 1301 | ]); | |
| 1302 | ||
| 1303 | const completedRows = deriveThreadFeedPresentation( | |
| 1304 | feed, | |
| 1305 | { ...latestTurn, state: "completed", completedAt: "2026-04-01T00:00:04.000Z" }, | |
| 1306 | new Set([turnId]), | |
| 1307 | new Set(), | |
| 1308 | latestTurn.startedAt, | |
| 1819 | thread.latestTurn!.startedAt, | |
| 1309 | 1820 | ); |
| 1310 | expect(completedRows.filter((entry) => entry.type === "work-toggle")).toMatchObject([ | |
| 1311 | { live: false, shimmer: false }, | |
| 1312 | { live: false, shimmer: false }, | |
| 1313 | ]); | |
| 1821 | expect(rows.some((entry) => entry.type === "work-toggle" && entry.shimmer)).toBe(shimmer); | |
| 1314 | 1822 | }); |
| 1315 | 1823 | |
| 1316 | 1824 | it("does not revive cached in-progress tools after work stops", () => { |
| ... | ... | @@ -1417,6 +1925,37 @@ describe("buildThreadFeed", () => { |
| 1417 | 1925 | summary: "Ran 2 commands", |
| 1418 | 1926 | live: false, |
| 1419 | 1927 | }); |
| 1928 | ||
| 1929 | const groupId = `work-group:tool:${turnId}:call-a`; | |
| 1930 | const startedAt = "2026-04-01T00:00:00.000Z"; | |
| 1931 | const runningRows = deriveThreadFeedPresentation( | |
| 1932 | buildThreadFeed({ ...thread, activities: thread.activities.slice(0, 2) }), | |
| 1933 | { turnId, state: "running", startedAt, completedAt: null }, | |
| 1934 | new Set(), | |
| 1935 | new Set([groupId]), | |
| 1936 | startedAt, | |
| 1937 | ); | |
| 1938 | expect(runningRows.find((entry) => entry.type === "activity-group")).toMatchObject({ | |
| 1939 | id: `work-details:${groupId}`, | |
| 1940 | activities: [ | |
| 1941 | { id: "call-a-1", lifecycleStatus: "inProgress", groupedToolDetail: true, live: false }, | |
| 1942 | { id: "call-b-2", lifecycleStatus: "inProgress", groupedToolDetail: true, live: true }, | |
| 1943 | ], | |
| 1944 | }); | |
| 1945 | ||
| 1946 | const completedRows = deriveThreadFeedPresentation( | |
| 1947 | feed, | |
| 1948 | null, | |
| 1949 | new Set([turnId]), | |
| 1950 | new Set([groupId]), | |
| 1951 | ); | |
| 1952 | expect(completedRows.find((entry) => entry.type === "activity-group")).toMatchObject({ | |
| 1953 | id: `work-details:${groupId}`, | |
| 1954 | activities: [ | |
| 1955 | { id: "call-a-1", lifecycleStatus: "completed", groupedToolDetail: true, live: false }, | |
| 1956 | { id: "call-b-2", lifecycleStatus: "completed", groupedToolDetail: true, live: false }, | |
| 1957 | ], | |
| 1958 | }); | |
| 1420 | 1959 | }); |
| 1421 | 1960 | }); |
| 1422 | 1961 |
apps/mobile/src/lib/threadActivity.ts+64-35| ... | ... | @@ -19,6 +19,7 @@ import { |
| 19 | 19 | isWorktreeSetupActivity, |
| 20 | 20 | normalizeCompactToolLabel, |
| 21 | 21 | omitSupersededLifecycleMarkers, |
| 22 | resolveWorkEntryToolPresentation, | |
| 22 | 23 | summarizeToolGroup, |
| 23 | 24 | toolGroupSummaryKind, |
| 24 | 25 | type ToolGroupSummaryKind, |
| ... | ... | @@ -146,6 +147,7 @@ export type ThreadFeedEntry = |
| 146 | 147 | readonly expanded: boolean; |
| 147 | 148 | readonly summary: string; |
| 148 | 149 | readonly summaryKind: ToolGroupSummaryKind; |
| 150 | readonly summaryToolIcon?: "browser" | "t3-code"; | |
| 149 | 151 | readonly hasFailure: boolean; |
| 150 | 152 | readonly live: boolean; |
| 151 | 153 | readonly shimmer: boolean; |
| ... | ... | @@ -477,8 +479,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo |
| 477 | 479 | } |
| 478 | 480 | if (itemType === "mcp_tool_call") { |
| 479 | 481 | const data = asRecord(payload?.data); |
| 480 | if (data?.item !== undefined) { | |
| 481 | entry.toolData = data.item; | |
| 482 | const toolData = typeof data?.toolName === "string" ? (data.item ?? data) : data?.item; | |
| 483 | if (toolData !== undefined) { | |
| 484 | entry.toolData = toolData; | |
| 482 | 485 | } |
| 483 | 486 | } |
| 484 | 487 | if (itemType) { |
| ... | ... | @@ -826,6 +829,8 @@ function capitalizePhrase(value: string): string { |
| 826 | 829 | } |
| 827 | 830 | |
| 828 | 831 | function workEntryHeading(workEntry: WorkLogEntry): string { |
| 832 | const presentation = resolveWorkEntryToolPresentation(workEntry); | |
| 833 | if (presentation) return presentation.displayName; | |
| 829 | 834 | if (!workEntry.toolTitle) { |
| 830 | 835 | return capitalizePhrase(normalizeCompactToolLabel(workEntry.label)); |
| 831 | 836 | } |
| ... | ... | @@ -1501,19 +1506,26 @@ function appendToolGroupRows( |
| 1501 | 1506 | : activities[0]!.id; |
| 1502 | 1507 | const groupId = `work-group:${identity}`; |
| 1503 | 1508 | const expanded = expandedWorkGroupIds.has(groupId); |
| 1504 | const latestInProgressActivity = activities.findLast( | |
| 1509 | const latestActiveActivity = activities.findLast( | |
| 1505 | 1510 | (activity) => |
| 1506 | isWorking && activity.lifecycleStatus === "inProgress" && activity.turnId === unsettledTurnId, | |
| 1511 | isWorking && | |
| 1512 | activity.turnId === unsettledTurnId && | |
| 1513 | (activity.lifecycleStatus === "inProgress" || | |
| 1514 | (activeTail && | |
| 1515 | activity.lifecycleStatus === undefined && | |
| 1516 | (activity.workEntry.sourceActivityKind === "task.progress" || activity.toolLike))), | |
| 1507 | 1517 | ); |
| 1508 | const live = activeTail || latestInProgressActivity !== undefined; | |
| 1509 | const latestActivity = activeTail | |
| 1510 | ? activities.at(-1)! | |
| 1511 | : (latestInProgressActivity ?? activities.at(-1)!); | |
| 1518 | const active = latestActiveActivity !== undefined; | |
| 1519 | const live = activeTail || active; | |
| 1520 | const latestActivity = latestActiveActivity ?? activities.at(-1)!; | |
| 1512 | 1521 | const summary = live |
| 1513 | ? liveToolActivitySummary(latestActivity) | |
| 1522 | ? liveToolActivitySummary(latestActivity, active) | |
| 1514 | 1523 | : activities.length === 1 && !activities[0]!.toolLike |
| 1515 | 1524 | ? activities[0]!.workEntry.label |
| 1516 | 1525 | : summarizeToolGroup(activities.map((activity) => activity.workEntry)); |
| 1526 | const summaryToolIcon = live | |
| 1527 | ? resolveWorkEntryToolPresentation(latestActivity.workEntry)?.icon | |
| 1528 | : undefined; | |
| 1517 | 1529 | result.push({ |
| 1518 | 1530 | type: "work-toggle", |
| 1519 | 1531 | id: `${live ? "work-live" : "work-toggle"}:${groupId}`, |
| ... | ... | @@ -1526,40 +1538,52 @@ function appendToolGroupRows( |
| 1526 | 1538 | summaryKind: toolGroupSummaryKind( |
| 1527 | 1539 | (live ? [latestActivity] : activities).map((activity) => activity.workEntry), |
| 1528 | 1540 | ), |
| 1541 | ...(summaryToolIcon ? { summaryToolIcon } : {}), | |
| 1529 | 1542 | hasFailure: activities.findLast((activity) => activity.toolLike)?.status === "failure", |
| 1530 | 1543 | live, |
| 1531 | // Match the live label until the turn or contiguous tool run settles. | |
| 1532 | shimmer: live, | |
| 1544 | shimmer: active, | |
| 1533 | 1545 | }); |
| 1534 | 1546 | if (!expanded) { |
| 1535 | 1547 | return; |
| 1536 | 1548 | } |
| 1537 | for (const activity of activities) { | |
| 1538 | result.push({ | |
| 1539 | type: "activity-group", | |
| 1540 | id: activity.id, | |
| 1541 | createdAt: activity.createdAt, | |
| 1542 | turnId: activity.turnId, | |
| 1543 | activities: [ | |
| 1544 | { | |
| 1545 | ...activity, | |
| 1546 | groupedToolDetail: true, | |
| 1547 | live: | |
| 1548 | isWorking && | |
| 1549 | activity.id === latestActivity.id && | |
| 1550 | activity.lifecycleStatus === "inProgress" && | |
| 1551 | activity.turnId === unsettledTurnId, | |
| 1552 | }, | |
| 1553 | ], | |
| 1554 | }); | |
| 1555 | } | |
| 1549 | result.push({ | |
| 1550 | type: "activity-group", | |
| 1551 | id: `work-details:${groupId}`, | |
| 1552 | createdAt: activities[0]!.createdAt, | |
| 1553 | turnId: activities[0]!.turnId, | |
| 1554 | activities: activities.map((activity) => ({ | |
| 1555 | ...activity, | |
| 1556 | groupedToolDetail: true, | |
| 1557 | live: | |
| 1558 | isWorking && | |
| 1559 | activity.id === latestActivity.id && | |
| 1560 | activity.lifecycleStatus === "inProgress" && | |
| 1561 | activity.turnId === unsettledTurnId, | |
| 1562 | })), | |
| 1563 | }); | |
| 1556 | 1564 | } |
| 1557 | 1565 | |
| 1558 | function liveToolActivitySummary(activity: ThreadFeedActivity): string { | |
| 1566 | function liveToolActivitySummary(activity: ThreadFeedActivity, active: boolean): string { | |
| 1567 | const presentation = resolveWorkEntryToolPresentation( | |
| 1568 | activity.workEntry, | |
| 1569 | active ? "inProgress" : "completed", | |
| 1570 | ); | |
| 1571 | if (presentation) return presentation.displayName; | |
| 1559 | 1572 | const command = activity.workEntry.command?.trim(); |
| 1560 | 1573 | if (command) { |
| 1561 | 1574 | const program = commandProgramName(command); |
| 1562 | return program ? `Running ${program}` : "Running command"; | |
| 1575 | const status = activity.lifecycleStatus ?? (active ? "inProgress" : "completed"); | |
| 1576 | const verb = | |
| 1577 | status === "inProgress" | |
| 1578 | ? "Running" | |
| 1579 | : status === "failed" | |
| 1580 | ? "Failed" | |
| 1581 | : status === "declined" | |
| 1582 | ? "Declined" | |
| 1583 | : status === "stopped" | |
| 1584 | ? "Stopped" | |
| 1585 | : "Ran"; | |
| 1586 | return `${verb} ${program ?? "command"}`; | |
| 1563 | 1587 | } |
| 1564 | 1588 | return activity.detail ?? activity.summary; |
| 1565 | 1589 | } |
| ... | ... | @@ -1773,15 +1797,20 @@ export function buildThreadFeed( |
| 1773 | 1797 | const detail = workEntryPreview(entry); |
| 1774 | 1798 | const getFullDetail = memoizeValue(() => buildWorkEntryExpandedBody(entry)); |
| 1775 | 1799 | const getCopyText = memoizeValue(() => { |
| 1800 | const copyLabel = capitalizePhrase( | |
| 1801 | normalizeCompactToolLabel(entry.toolTitle || entry.label), | |
| 1802 | ); | |
| 1776 | 1803 | const fullDetail = getFullDetail(); |
| 1777 | 1804 | if (entry.command) { |
| 1778 | 1805 | const normalizedCommand = |
| 1779 | entry.rawCommand && summary.trim() !== entry.command.trim() ? entry.command : null; | |
| 1780 | return [summary, normalizedCommand, fullDetail ?? entry.command] | |
| 1806 | entry.rawCommand && copyLabel.trim() !== entry.command.trim() | |
| 1807 | ? entry.command | |
| 1808 | : null; | |
| 1809 | return [copyLabel, normalizedCommand, fullDetail ?? entry.command] | |
| 1781 | 1810 | .filter((value): value is string => Boolean(value)) |
| 1782 | 1811 | .join("\n"); |
| 1783 | 1812 | } |
| 1784 | return [summary, detail, fullDetail] | |
| 1813 | return [copyLabel, detail, fullDetail] | |
| 1785 | 1814 | .filter((value, index, values): value is string => { |
| 1786 | 1815 | return Boolean(value) && values.indexOf(value) === index; |
| 1787 | 1816 | }) |
apps/web/src/components/ChatView.logic.test.ts+130-1| ... | ... | @@ -6,7 +6,7 @@ import { |
| 6 | 6 | ThreadId, |
| 7 | 7 | TurnId, |
| 8 | 8 | } from "@t3tools/contracts"; |
| 9 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 9 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 10 | 10 | |
| 11 | 11 | import type { Thread, ThreadShell } from "../types"; |
| 12 | 12 | import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; |
| ... | ... | @@ -41,6 +41,7 @@ import { |
| 41 | 41 | shouldShowBranchMismatchBanner, |
| 42 | 42 | shouldShowPlanFollowUpPrompt, |
| 43 | 43 | shouldWriteThreadErrorToCurrentServerThread, |
| 44 | toolGroupConsumesUpwardNavigation, | |
| 44 | 45 | } from "./ChatView.logic"; |
| 45 | 46 | |
| 46 | 47 | describe("isVideoPreviewRequestCurrent", () => { |
| ... | ... | @@ -51,6 +52,134 @@ describe("isVideoPreviewRequestCurrent", () => { |
| 51 | 52 | }); |
| 52 | 53 | }); |
| 53 | 54 | |
| 55 | describe("toolGroupConsumesUpwardNavigation", () => { | |
| 56 | class ScrollElement extends EventTarget { | |
| 57 | scrollTop = 0; | |
| 58 | scrollHeight = 100; | |
| 59 | clientHeight = 100; | |
| 60 | overflowY = "visible"; | |
| 61 | ||
| 62 | constructor( | |
| 63 | readonly parentElement: ScrollElement | null = null, | |
| 64 | readonly isToolGroup = false, | |
| 65 | ) { | |
| 66 | super(); | |
| 67 | } | |
| 68 | ||
| 69 | closest(selector: string): ScrollElement | null { | |
| 70 | if (selector !== "[data-tool-group-scroll]") return null; | |
| 71 | return this.isToolGroup ? this : (this.parentElement?.closest(selector) ?? null); | |
| 72 | } | |
| 73 | } | |
| 74 | ||
| 75 | beforeEach(() => { | |
| 76 | vi.stubGlobal("Element", ScrollElement); | |
| 77 | vi.stubGlobal("getComputedStyle", (element: ScrollElement) => ({ | |
| 78 | overflowY: element.overflowY, | |
| 79 | })); | |
| 80 | }); | |
| 81 | afterEach(() => vi.unstubAllGlobals()); | |
| 82 | ||
| 83 | it("releases upward navigation when an overflowing group is at the top", () => { | |
| 84 | const group = Object.assign(new ScrollElement(null, true), { | |
| 85 | overflowY: "auto", | |
| 86 | scrollHeight: 300, | |
| 87 | }); | |
| 88 | ||
| 89 | expect(toolGroupConsumesUpwardNavigation(new ScrollElement(group))).toBe(false); | |
| 90 | }); | |
| 91 | ||
| 92 | it.each([ | |
| 93 | { overflowY: "auto", scrollTop: 1 }, | |
| 94 | { overflowY: "auto", scrollTop: 0.25 }, | |
| 95 | { overflowY: "scroll", scrollTop: 80 }, | |
| 96 | ])("consumes upward navigation within a scrolled group: %j", (scroll) => { | |
| 97 | const group = Object.assign(new ScrollElement(null, true), { | |
| 98 | scrollHeight: 300, | |
| 99 | ...scroll, | |
| 100 | }); | |
| 101 | ||
| 102 | expect(toolGroupConsumesUpwardNavigation(group)).toBe(true); | |
| 103 | }); | |
| 104 | ||
| 105 | it.each([100, 300])( | |
| 106 | "consumes scrolling in a nested result with a group content height of %i", | |
| 107 | (scrollHeight) => { | |
| 108 | const group = Object.assign(new ScrollElement(null, true), { | |
| 109 | overflowY: "auto", | |
| 110 | scrollHeight, | |
| 111 | }); | |
| 112 | const result = Object.assign(new ScrollElement(group), { | |
| 113 | overflowY: "auto", | |
| 114 | scrollHeight: 300, | |
| 115 | scrollTop: 0.25, | |
| 116 | }); | |
| 117 | ||
| 118 | expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(true); | |
| 119 | }, | |
| 120 | ); | |
| 121 | ||
| 122 | it("releases upward navigation when the group and nested result are both at the top", () => { | |
| 123 | const group = Object.assign(new ScrollElement(null, true), { | |
| 124 | overflowY: "auto", | |
| 125 | scrollHeight: 300, | |
| 126 | }); | |
| 127 | const result = Object.assign(new ScrollElement(group), { | |
| 128 | overflowY: "scroll", | |
| 129 | scrollHeight: 300, | |
| 130 | }); | |
| 131 | ||
| 132 | expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(false); | |
| 133 | }); | |
| 134 | ||
| 135 | it("ignores targets outside a tool group and non-element targets", () => { | |
| 136 | const outside = Object.assign(new ScrollElement(), { | |
| 137 | overflowY: "auto", | |
| 138 | scrollHeight: 300, | |
| 139 | scrollTop: 40, | |
| 140 | }); | |
| 141 | ||
| 142 | expect(toolGroupConsumesUpwardNavigation(outside)).toBe(false); | |
| 143 | expect(toolGroupConsumesUpwardNavigation(new EventTarget())).toBe(false); | |
| 144 | expect(toolGroupConsumesUpwardNavigation(null)).toBe(false); | |
| 145 | }); | |
| 146 | ||
| 147 | it("does not consume scrolling from an ancestor beyond the tool group", () => { | |
| 148 | const timeline = Object.assign(new ScrollElement(), { | |
| 149 | overflowY: "auto", | |
| 150 | scrollHeight: 300, | |
| 151 | scrollTop: 40, | |
| 152 | }); | |
| 153 | const group = new ScrollElement(timeline, true); | |
| 154 | ||
| 155 | expect(toolGroupConsumesUpwardNavigation(new ScrollElement(group))).toBe(false); | |
| 156 | }); | |
| 157 | ||
| 158 | it.each(["hidden", "clip", "visible"])( | |
| 159 | "ignores a non-scrollable child with overflow-y %s", | |
| 160 | (overflowY) => { | |
| 161 | const group = new ScrollElement(null, true); | |
| 162 | const result = Object.assign(new ScrollElement(group), { | |
| 163 | overflowY, | |
| 164 | scrollHeight: 300, | |
| 165 | scrollTop: 40, | |
| 166 | }); | |
| 167 | ||
| 168 | expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(false); | |
| 169 | }, | |
| 170 | ); | |
| 171 | ||
| 172 | it("does not consume programmatic scrolling on an overflow-hidden group", () => { | |
| 173 | const group = Object.assign(new ScrollElement(null, true), { | |
| 174 | overflowY: "hidden", | |
| 175 | scrollHeight: 300, | |
| 176 | scrollTop: 40, | |
| 177 | }); | |
| 178 | ||
| 179 | expect(toolGroupConsumesUpwardNavigation(group)).toBe(false); | |
| 180 | }); | |
| 181 | }); | |
| 182 | ||
| 54 | 183 | const environmentId = EnvironmentId.make("environment-local"); |
| 55 | 184 | const projectId = ProjectId.make("project-1"); |
| 56 | 185 | const threadId = ThreadId.make("thread-1"); |
apps/web/src/components/ChatView.logic.ts+16| ... | ... | @@ -99,6 +99,22 @@ export function shouldReleaseTimelineAnchorForToolActivity(input: { |
| 99 | 99 | }); |
| 100 | 100 | } |
| 101 | 101 | |
| 102 | export function toolGroupConsumesUpwardNavigation(target: EventTarget | null): boolean { | |
| 103 | const elementTarget = target instanceof Element ? target : null; | |
| 104 | const group = elementTarget?.closest<HTMLElement>("[data-tool-group-scroll]"); | |
| 105 | if (!group) return false; | |
| 106 | ||
| 107 | // A nested result or the group itself can consume an upward scroll. | |
| 108 | for (let element = elementTarget; element; element = element.parentElement) { | |
| 109 | if (element.scrollTop > 0) { | |
| 110 | const overflowY = getComputedStyle(element).overflowY; | |
| 111 | if (overflowY === "auto" || overflowY === "scroll") return true; | |
| 112 | } | |
| 113 | if (element === group) break; | |
| 114 | } | |
| 115 | return false; | |
| 116 | } | |
| 117 | ||
| 102 | 118 | export function resolveDraftHeroState(input: { |
| 103 | 119 | isLocalDraftThread: boolean; |
| 104 | 120 | hasTimelineEntries: boolean; |
apps/web/src/components/ChatView.tsx+7-2| ... | ... | @@ -379,6 +379,7 @@ import { |
| 379 | 379 | shouldWriteThreadErrorToCurrentServerThread, |
| 380 | 380 | startNewThreadForProject, |
| 381 | 381 | codexArtifactTemplatePromptToAppend, |
| 382 | toolGroupConsumesUpwardNavigation, | |
| 382 | 383 | waitForStartedServerThread, |
| 383 | 384 | } from "./ChatView.logic"; |
| 384 | 385 | import type { ThreadSyncPhase } from "../threadSync"; |
| ... | ... | @@ -4218,7 +4219,11 @@ function ChatViewContent(props: ChatViewProps) { |
| 4218 | 4219 | // Only an upward wheel is a navigation intent; wheeling down while |
| 4219 | 4220 | // following either does nothing (at the end) or moves toward it. |
| 4220 | 4221 | const handleWheel = (event: WheelEvent) => { |
| 4221 | if (event.deltaY < 0 && contentScrollsUp()) { | |
| 4222 | if ( | |
| 4223 | event.deltaY < 0 && | |
| 4224 | contentScrollsUp() && | |
| 4225 | !toolGroupConsumesUpwardNavigation(event.target) | |
| 4226 | ) { | |
| 4222 | 4227 | handleManualNavigation(); |
| 4223 | 4228 | } |
| 4224 | 4229 | }; |
| ... | ... | @@ -4255,7 +4260,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 4255 | 4260 | case "PageUp": |
| 4256 | 4261 | case "Home": |
| 4257 | 4262 | case "ArrowUp": |
| 4258 | if (contentScrollsUp()) { | |
| 4263 | if (contentScrollsUp() && !toolGroupConsumesUpwardNavigation(event.target)) { | |
| 4259 | 4264 | handleManualNavigation(); |
| 4260 | 4265 | } |
| 4261 | 4266 | break; |
apps/web/src/components/T3Wordmark.tsx created+12| ... | ... | @@ -0,0 +1,12 @@ |
| 1 | import type { SVGProps } from "react"; | |
| 2 | ||
| 3 | export function T3Wordmark(props: SVGProps<SVGSVGElement>) { | |
| 4 | return ( | |
| 5 | <svg {...props} viewBox="15.5309 37 94.3941 56.96" xmlns="http://www.w3.org/2000/svg"> | |
| 6 | <path | |
| 7 | d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z" | |
| 8 | fill="currentColor" | |
| 9 | /> | |
| 10 | </svg> | |
| 11 | ); | |
| 12 | } |
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+262-3| ... | ... | @@ -4,11 +4,180 @@ import { |
| 4 | 4 | computeStableMessagesTimelineRows, |
| 5 | 5 | computeMessageDurationStart, |
| 6 | 6 | deriveMessagesTimelineRows, |
| 7 | liveWorkEntryLabel, | |
| 7 | 8 | normalizeCompactToolLabel, |
| 8 | 9 | resolveAssistantMessageCopyState, |
| 10 | resolveWorkGroupScrollIndex, | |
| 11 | shouldFollowWorkGroupAppend, | |
| 9 | 12 | shouldPreserveAssistantLineBreaks, |
| 13 | workEntryDisplayLabel, | |
| 10 | 14 | } from "./MessagesTimeline.logic"; |
| 11 | 15 | |
| 16 | describe("expanded tool group scrolling", () => { | |
| 17 | const entries = [{ id: "first" }, { id: "second" }]; | |
| 18 | ||
| 19 | it("follows appended calls only at the hard end", () => { | |
| 20 | const appended = [...entries, { id: "third" }]; | |
| 21 | expect(shouldFollowWorkGroupAppend(entries, appended, 0)).toBe(true); | |
| 22 | expect(shouldFollowWorkGroupAppend(entries, appended, 0.5)).toBe(true); | |
| 23 | expect(shouldFollowWorkGroupAppend(entries, appended, 1)).toBe(true); | |
| 24 | expect(shouldFollowWorkGroupAppend(entries, appended, 1.01)).toBe(false); | |
| 25 | expect(shouldFollowWorkGroupAppend(entries, appended, 10)).toBe(false); | |
| 26 | expect(shouldFollowWorkGroupAppend(entries, appended, Infinity)).toBe(false); | |
| 27 | }); | |
| 28 | ||
| 29 | it("does not follow output updates, prepends, or replacements", () => { | |
| 30 | expect( | |
| 31 | shouldFollowWorkGroupAppend( | |
| 32 | entries, | |
| 33 | entries.map((entry) => ({ ...entry })), | |
| 34 | 0, | |
| 35 | ), | |
| 36 | ).toBe(false); | |
| 37 | expect(shouldFollowWorkGroupAppend(entries, [{ id: "older" }, ...entries], 0)).toBe(false); | |
| 38 | expect( | |
| 39 | shouldFollowWorkGroupAppend( | |
| 40 | entries, | |
| 41 | [{ id: "replacement" }, entries[1]!, { id: "third" }], | |
| 42 | 0, | |
| 43 | ), | |
| 44 | ).toBe(false); | |
| 45 | expect(shouldFollowWorkGroupAppend([], entries, 0)).toBe(false); | |
| 46 | }); | |
| 47 | ||
| 48 | it("restores the visible tool and its offset inside expanded output", () => { | |
| 49 | const anchor = { entryId: "second", offset: 120 }; | |
| 50 | expect(resolveWorkGroupScrollIndex(entries, anchor)).toEqual({ index: 1, viewOffset: -120 }); | |
| 51 | expect(resolveWorkGroupScrollIndex([{ id: "older" }, ...entries], anchor)).toEqual({ | |
| 52 | index: 2, | |
| 53 | viewOffset: -120, | |
| 54 | }); | |
| 55 | }); | |
| 56 | ||
| 57 | it("starts normally when the saved tool no longer exists", () => { | |
| 58 | expect(resolveWorkGroupScrollIndex(entries, undefined)).toBeUndefined(); | |
| 59 | expect( | |
| 60 | resolveWorkGroupScrollIndex(entries, { entryId: "removed", offset: 120 }), | |
| 61 | ).toBeUndefined(); | |
| 62 | }); | |
| 63 | }); | |
| 64 | ||
| 65 | describe("work entry labels", () => { | |
| 66 | const entry = { | |
| 67 | id: "tool-1", | |
| 68 | createdAt: "2026-09-01T12:00:00Z", | |
| 69 | label: "Tool call", | |
| 70 | tone: "tool" as const, | |
| 71 | }; | |
| 72 | ||
| 73 | it.each([ | |
| 74 | ["inProgress", "Clicking in the preview browser"], | |
| 75 | ["completed", "Clicked in the preview browser"], | |
| 76 | ["failed", "Failed to click in the preview browser"], | |
| 77 | ["declined", "Declined to click in the preview browser"], | |
| 78 | ["stopped", "Stopped clicking in the preview browser"], | |
| 79 | ] as const)("uses the same friendly %s label in both views", (toolLifecycleStatus, label) => { | |
| 80 | const browserEntry = { | |
| 81 | ...entry, | |
| 82 | toolTitle: "T3-code.preview_click", | |
| 83 | detail: '{"ok":true}', | |
| 84 | toolLifecycleStatus, | |
| 85 | }; | |
| 86 | expect(liveWorkEntryLabel(browserEntry, undefined, toolLifecycleStatus === "inProgress")).toBe( | |
| 87 | label, | |
| 88 | ); | |
| 89 | expect(workEntryDisplayLabel(browserEntry, undefined)).toBe(label); | |
| 90 | }); | |
| 91 | ||
| 92 | it("uses the active summary state for legacy tools without a lifecycle status", () => { | |
| 93 | const browserEntry = { ...entry, toolTitle: "T3-code.preview_click" }; | |
| 94 | expect(liveWorkEntryLabel(browserEntry, undefined, true)).toBe( | |
| 95 | "Clicking in the preview browser", | |
| 96 | ); | |
| 97 | expect(liveWorkEntryLabel(browserEntry, undefined, false)).toBe( | |
| 98 | "Clicked in the preview browser", | |
| 99 | ); | |
| 100 | }); | |
| 101 | ||
| 102 | it("does not describe a finished call as still running while the turn continues", () => { | |
| 103 | const browserEntry = { | |
| 104 | ...entry, | |
| 105 | toolTitle: "T3-code.preview_click", | |
| 106 | toolLifecycleStatus: "completed" as const, | |
| 107 | }; | |
| 108 | expect(liveWorkEntryLabel(browserEntry, undefined, true)).toBe( | |
| 109 | "Clicked in the preview browser", | |
| 110 | ); | |
| 111 | }); | |
| 112 | ||
| 113 | it("keeps custom titles and output for unrecognized tools", () => { | |
| 114 | const unknownEntry = { ...entry, toolTitle: "mcp__github__search_issues" }; | |
| 115 | expect(liveWorkEntryLabel(unknownEntry, undefined, true)).toBe("Mcp__github__search_issues"); | |
| 116 | expect(workEntryDisplayLabel({ ...unknownEntry, detail: "Found 3 issues" }, undefined)).toBe( | |
| 117 | "Found 3 issues", | |
| 118 | ); | |
| 119 | }); | |
| 120 | ||
| 121 | it("keeps command summaries compact without replacing the full command in expanded rows", () => { | |
| 122 | const commandEntry = { ...entry, command: "vp test run", detail: "All tests passed" }; | |
| 123 | expect(liveWorkEntryLabel(commandEntry, undefined, true)).toBe("Running vp"); | |
| 124 | expect(liveWorkEntryLabel(commandEntry, undefined, false)).toBe("Ran vp"); | |
| 125 | expect(workEntryDisplayLabel(commandEntry, undefined)).toBe("vp test run"); | |
| 126 | }); | |
| 127 | ||
| 128 | it("summarizes the program inside a shell wrapper while preserving the expanded command", () => { | |
| 129 | const command = "/bin/zsh -lc 'vp test run apps/web/src/session-logic.test.ts'"; | |
| 130 | const commandEntry = { ...entry, command }; | |
| 131 | expect(liveWorkEntryLabel(commandEntry, undefined, true)).toBe("Running vp"); | |
| 132 | expect(liveWorkEntryLabel(commandEntry, undefined, false)).toBe("Ran vp"); | |
| 133 | expect(workEntryDisplayLabel(commandEntry, undefined)).toBe(command); | |
| 134 | }); | |
| 135 | ||
| 136 | it.each([ | |
| 137 | ["inProgress", "Running vp"], | |
| 138 | ["completed", "Ran vp"], | |
| 139 | ["failed", "Failed vp"], | |
| 140 | ["declined", "Declined vp"], | |
| 141 | ["stopped", "Stopped vp"], | |
| 142 | ] as const)( | |
| 143 | "uses the command's %s outcome even while the turn continues", | |
| 144 | (toolLifecycleStatus, label) => { | |
| 145 | const commandEntry = { | |
| 146 | ...entry, | |
| 147 | command: "/bin/bash -lc 'vp test run'", | |
| 148 | toolLifecycleStatus, | |
| 149 | }; | |
| 150 | expect(liveWorkEntryLabel(commandEntry, undefined, true)).toBe(label); | |
| 151 | expect(liveWorkEntryLabel(commandEntry, undefined, false)).toBe(label); | |
| 152 | }, | |
| 153 | ); | |
| 154 | ||
| 155 | it("gives a completed browser group its own count and summary icon category", () => { | |
| 156 | const rows = deriveMessagesTimelineRows({ | |
| 157 | timelineEntries: [ | |
| 158 | { | |
| 159 | id: "browser-entry", | |
| 160 | kind: "work", | |
| 161 | createdAt: entry.createdAt, | |
| 162 | entry: { | |
| 163 | ...entry, | |
| 164 | itemType: "mcp_tool_call", | |
| 165 | toolLifecycleStatus: "completed", | |
| 166 | toolData: { server: "t3-code", tool: "preview_click" }, | |
| 167 | }, | |
| 168 | }, | |
| 169 | ], | |
| 170 | isWorking: false, | |
| 171 | activeTurnStartedAt: null, | |
| 172 | turnDiffSummaryByAssistantMessageId: new Map(), | |
| 173 | revertTurnCountByUserMessageId: new Map(), | |
| 174 | }); | |
| 175 | expect(rows).toMatchObject([ | |
| 176 | { kind: "work-toggle", summary: "Used browser 1 time", summaryKind: "browser" }, | |
| 177 | ]); | |
| 178 | }); | |
| 179 | }); | |
| 180 | ||
| 12 | 181 | describe("shouldPreserveAssistantLineBreaks", () => { |
| 13 | 182 | it("preserves Claude insight formatting without changing regular markdown", () => { |
| 14 | 183 | expect( |
| ... | ... | @@ -1197,6 +1366,50 @@ describe("deriveMessagesTimelineRows", () => { |
| 1197 | 1366 | ]); |
| 1198 | 1367 | }); |
| 1199 | 1368 | |
| 1369 | it.each([ | |
| 1370 | [undefined, true], | |
| 1371 | ["inProgress", true], | |
| 1372 | ["completed", false], | |
| 1373 | ["failed", false], | |
| 1374 | ["declined", false], | |
| 1375 | ["stopped", false], | |
| 1376 | ] as const)( | |
| 1377 | "respects the %s lifecycle of trailing task progress", | |
| 1378 | (toolLifecycleStatus, active) => { | |
| 1379 | const turnId = TurnId.make("turn-task-progress"); | |
| 1380 | const rows = deriveMessagesTimelineRows({ | |
| 1381 | timelineEntries: [ | |
| 1382 | { | |
| 1383 | id: "task-progress-entry", | |
| 1384 | kind: "work", | |
| 1385 | createdAt: "2026-01-01T00:00:05Z", | |
| 1386 | entry: { | |
| 1387 | id: "task-progress", | |
| 1388 | createdAt: "2026-01-01T00:00:05Z", | |
| 1389 | turnId, | |
| 1390 | label: "Task progress", | |
| 1391 | tone: "thinking", | |
| 1392 | sourceActivityKind: "task.progress", | |
| 1393 | ...(toolLifecycleStatus ? { toolLifecycleStatus } : {}), | |
| 1394 | }, | |
| 1395 | }, | |
| 1396 | ], | |
| 1397 | latestTurn: { | |
| 1398 | turnId, | |
| 1399 | state: "running", | |
| 1400 | startedAt: "2026-01-01T00:00:00Z", | |
| 1401 | completedAt: null, | |
| 1402 | }, | |
| 1403 | isWorking: true, | |
| 1404 | activeTurnStartedAt: "2026-01-01T00:00:00Z", | |
| 1405 | turnDiffSummaryByAssistantMessageId: new Map(), | |
| 1406 | revertTurnCountByUserMessageId: new Map(), | |
| 1407 | }); | |
| 1408 | ||
| 1409 | expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ active }); | |
| 1410 | }, | |
| 1411 | ); | |
| 1412 | ||
| 1200 | 1413 | it("reuses one activity row for initial thinking and the latest tool", () => { |
| 1201 | 1414 | const deriveRows = (toolLifecycleStatus: "inProgress" | "completed" | "declined" | null) => |
| 1202 | 1415 | deriveMessagesTimelineRows({ |
| ... | ... | @@ -1464,15 +1677,61 @@ describe("deriveMessagesTimelineRows", () => { |
| 1464 | 1677 | }); |
| 1465 | 1678 | expect(expandedRows.map((row) => row.id)).toEqual([ |
| 1466 | 1679 | "work-toggle:work-entry-1", |
| 1467 | "work-1", | |
| 1468 | "work-2", | |
| 1469 | "work-3", | |
| 1680 | "work-group:work-entry-1:details", | |
| 1470 | 1681 | ]); |
| 1682 | expect(expandedRows.find((row) => row.kind === "work")).toMatchObject({ | |
| 1683 | isExpandedToolGroup: true, | |
| 1684 | groupedEntries: timelineEntries.map(({ entry }) => entry), | |
| 1685 | }); | |
| 1471 | 1686 | expect(expandedRows.find((row) => row.kind === "work-toggle")).toMatchObject({ |
| 1472 | 1687 | expanded: true, |
| 1473 | 1688 | }); |
| 1474 | 1689 | }); |
| 1475 | 1690 | |
| 1691 | it.each([true, false])( | |
| 1692 | "keeps a large expanded tool run inside one timeline item, live=%s", | |
| 1693 | (isWorking) => { | |
| 1694 | const turnId = TurnId.make("turn-many-tools"); | |
| 1695 | const createdAt = "2026-09-01T12:00:00Z"; | |
| 1696 | const timelineEntries = Array.from({ length: 1_000 }, (_, index) => ({ | |
| 1697 | id: `tool-entry-${index}`, | |
| 1698 | kind: "work" as const, | |
| 1699 | createdAt, | |
| 1700 | entry: { | |
| 1701 | id: `tool-${index}`, | |
| 1702 | toolCallId: `call-${index}`, | |
| 1703 | createdAt, | |
| 1704 | turnId, | |
| 1705 | label: "t3-code.preview_snapshot", | |
| 1706 | tone: "tool" as const, | |
| 1707 | toolLifecycleStatus: | |
| 1708 | isWorking && index === 999 ? ("inProgress" as const) : ("completed" as const), | |
| 1709 | }, | |
| 1710 | })); | |
| 1711 | const groupId = `work-group:tool:${turnId}:call-0`; | |
| 1712 | const input = { | |
| 1713 | timelineEntries, | |
| 1714 | isWorking, | |
| 1715 | expandedTurnIds: new Set([turnId]), | |
| 1716 | runningTurnId: isWorking ? turnId : null, | |
| 1717 | activeTurnStartedAt: isWorking ? createdAt : null, | |
| 1718 | turnDiffSummaryByAssistantMessageId: new Map(), | |
| 1719 | revertTurnCountByUserMessageId: new Map(), | |
| 1720 | }; | |
| 1721 | const expandedRows = deriveMessagesTimelineRows({ | |
| 1722 | ...input, | |
| 1723 | expandedWorkGroupIds: new Set([groupId]), | |
| 1724 | }); | |
| 1725 | const groupRows = expandedRows.filter((row) => row.kind === "work"); | |
| 1726 | expect(groupRows).toHaveLength(1); | |
| 1727 | expect(groupRows[0]?.groupedEntries.map(({ id }) => id)).toEqual( | |
| 1728 | timelineEntries.map(({ entry }) => entry.id), | |
| 1729 | ); | |
| 1730 | expect(groupRows[0]?.id).toBe(`${groupId}:details`); | |
| 1731 | expect(deriveMessagesTimelineRows(input).some((row) => row.kind === "work")).toBe(false); | |
| 1732 | }, | |
| 1733 | ); | |
| 1734 | ||
| 1476 | 1735 | it.each([ |
| 1477 | 1736 | ["recovered", ["failed", "completed"], false], |
| 1478 | 1737 | ["ending in failure", ["completed", "failed"], true], |
apps/web/src/components/chat/MessagesTimeline.logic.ts+109-38| ... | ... | @@ -1,7 +1,10 @@ |
| 1 | 1 | import * as Equal from "effect/Equal"; |
| 2 | 2 | import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives"; |
| 3 | import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; | |
| 3 | 4 | import { |
| 5 | normalizeCompactToolLabel, | |
| 4 | 6 | omitSupersededLifecycleMarkers, |
| 7 | resolveWorkEntryToolPresentation, | |
| 5 | 8 | summarizeToolGroup, |
| 6 | 9 | toolGroupSummaryKind, |
| 7 | 10 | type ToolGroupSummaryKind, |
| ... | ... | @@ -23,6 +26,7 @@ import { |
| 23 | 26 | } from "../../session-logic"; |
| 24 | 27 | import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; |
| 25 | 28 | import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts"; |
| 29 | import { formatWorkspaceRelativePath } from "../../filePathDisplay"; | |
| 26 | 30 | |
| 27 | 31 | export const TIMELINE_MINIMAP_ITEM_SPACING = 8; |
| 28 | 32 | export const TIMELINE_MINIMAP_MIN_ITEMS = 2; |
| ... | ... | @@ -30,6 +34,50 @@ export const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)"; |
| 30 | 34 | export const TIMELINE_CONTENT_MAX_WIDTH = 768; |
| 31 | 35 | export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48; |
| 32 | 36 | |
| 37 | export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string | undefined) { | |
| 38 | const toolPresentation = resolveWorkEntryToolPresentation(entry); | |
| 39 | if (toolPresentation) return toolPresentation.displayName; | |
| 40 | if (entry.command) return entry.command; | |
| 41 | if (entry.detail) return entry.detail; | |
| 42 | const [firstPath] = entry.changedFiles ?? []; | |
| 43 | if (firstPath) { | |
| 44 | const path = formatWorkspaceRelativePath(firstPath, workspaceRoot); | |
| 45 | return entry.changedFiles!.length === 1 | |
| 46 | ? path | |
| 47 | : `${path} +${entry.changedFiles!.length - 1} more`; | |
| 48 | } | |
| 49 | const heading = normalizeCompactToolLabel(entry.toolTitle || entry.label); | |
| 50 | return `${heading.charAt(0).toUpperCase()}${heading.slice(1)}`; | |
| 51 | } | |
| 52 | ||
| 53 | export function liveWorkEntryLabel( | |
| 54 | entry: WorkLogEntry, | |
| 55 | workspaceRoot: string | undefined, | |
| 56 | active: boolean, | |
| 57 | ) { | |
| 58 | const toolPresentation = resolveWorkEntryToolPresentation( | |
| 59 | entry, | |
| 60 | active ? "inProgress" : "completed", | |
| 61 | ); | |
| 62 | if (toolPresentation) return toolPresentation.displayName; | |
| 63 | const command = entry.command?.trim(); | |
| 64 | if (command) { | |
| 65 | const status = entry.toolLifecycleStatus ?? (active ? "inProgress" : "completed"); | |
| 66 | const verb = | |
| 67 | status === "inProgress" | |
| 68 | ? "Running" | |
| 69 | : status === "failed" | |
| 70 | ? "Failed" | |
| 71 | : status === "declined" | |
| 72 | ? "Declined" | |
| 73 | : status === "stopped" | |
| 74 | ? "Stopped" | |
| 75 | : "Ran"; | |
| 76 | return `${verb} ${commandProgramName(command) ?? "command"}`; | |
| 77 | } | |
| 78 | return workEntryDisplayLabel(entry, workspaceRoot); | |
| 79 | } | |
| 80 | ||
| 33 | 81 | export function workEntryIsVisibleInGroup( |
| 34 | 82 | entry: WorkLogEntry, |
| 35 | 83 | expandedToolGroupEntry = false, |
| ... | ... | @@ -42,6 +90,35 @@ export function workEntryIsVisibleInGroup( |
| 42 | 90 | ); |
| 43 | 91 | } |
| 44 | 92 | |
| 93 | export interface WorkGroupScrollAnchor { | |
| 94 | readonly entryId: string; | |
| 95 | readonly offset: number; | |
| 96 | } | |
| 97 | ||
| 98 | /** Restore a visible tool, including a position partway through its expanded output. */ | |
| 99 | export function resolveWorkGroupScrollIndex( | |
| 100 | entries: ReadonlyArray<{ readonly id: string }>, | |
| 101 | anchor: WorkGroupScrollAnchor | undefined, | |
| 102 | ): { index: number; viewOffset: number } | undefined { | |
| 103 | if (!anchor) return undefined; | |
| 104 | const index = entries.findIndex((entry) => entry.id === anchor.entryId); | |
| 105 | return index < 0 ? undefined : { index, viewOffset: -anchor.offset }; | |
| 106 | } | |
| 107 | ||
| 108 | /** Only newly appended calls may follow the end, never status or output updates. */ | |
| 109 | export function shouldFollowWorkGroupAppend( | |
| 110 | previous: ReadonlyArray<{ readonly id: string }>, | |
| 111 | entries: ReadonlyArray<{ readonly id: string }>, | |
| 112 | distanceFromEnd: number, | |
| 113 | ): boolean { | |
| 114 | return ( | |
| 115 | previous.length > 0 && | |
| 116 | entries.length > previous.length && | |
| 117 | distanceFromEnd <= 1 && | |
| 118 | previous.every((entry, index) => entry.id === entries[index]?.id) | |
| 119 | ); | |
| 120 | } | |
| 121 | ||
| 45 | 122 | export interface TimelineEndState { |
| 46 | 123 | readonly isAtEnd?: boolean; |
| 47 | 124 | readonly contentLength?: number; |
| ... | ... | @@ -197,8 +274,7 @@ export type MessagesTimelineRow = |
| 197 | 274 | id: string; |
| 198 | 275 | createdAt: string; |
| 199 | 276 | groupedEntries: WorkLogEntry[]; |
| 200 | isExpandedToolGroupEntry: boolean; | |
| 201 | isLastExpandedToolGroupEntry: boolean; | |
| 277 | isExpandedToolGroup: boolean; | |
| 202 | 278 | } |
| 203 | 279 | | { |
| 204 | 280 | kind: "work-live"; |
| ... | ... | @@ -292,6 +368,20 @@ function workGroupId(timelineEntryId: string, entry: WorkLogEntry): string { |
| 292 | 368 | return `work-group:${workGroupIdentity(timelineEntryId, entry)}`; |
| 293 | 369 | } |
| 294 | 370 | |
| 371 | function expandedWorkGroupRow( | |
| 372 | groupId: string, | |
| 373 | createdAt: string, | |
| 374 | groupedEntries: WorkLogEntry[], | |
| 375 | ): Extract<MessagesTimelineRow, { kind: "work" }> { | |
| 376 | return { | |
| 377 | kind: "work", | |
| 378 | id: `${groupId}:details`, | |
| 379 | createdAt, | |
| 380 | groupedEntries, | |
| 381 | isExpandedToolGroup: true, | |
| 382 | }; | |
| 383 | } | |
| 384 | ||
| 295 | 385 | export function resolveAssistantMessageCopyState({ |
| 296 | 386 | text, |
| 297 | 387 | showCopyButton, |
| ... | ... | @@ -384,8 +474,8 @@ function timelineEntryTurnId(entry: TimelineEntry): TurnId | null { |
| 384 | 474 | function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean { |
| 385 | 475 | return ( |
| 386 | 476 | entry.toolLifecycleStatus === "inProgress" || |
| 387 | entry.sourceActivityKind === "task.progress" || | |
| 388 | (entry.toolLifecycleStatus === undefined && workLogEntryIsToolLike(entry)) | |
| 477 | (entry.toolLifecycleStatus === undefined && | |
| 478 | (entry.sourceActivityKind === "task.progress" || workLogEntryIsToolLike(entry))) | |
| 389 | 479 | ); |
| 390 | 480 | } |
| 391 | 481 | |
| ... | ... | @@ -640,16 +730,13 @@ export function deriveMessagesTimelineRows(input: { |
| 640 | 730 | nextRows.push(activeWorkRow); |
| 641 | 731 | hasActivityRow ||= activeWorkRow.active; |
| 642 | 732 | if (!activeWorkRow.expanded) return; |
| 643 | for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) { | |
| 644 | nextRows.push({ | |
| 645 | kind: "work", | |
| 646 | id: workEntry.id, | |
| 647 | createdAt: workEntry.createdAt, | |
| 648 | groupedEntries: [workEntry], | |
| 649 | isExpandedToolGroupEntry: true, | |
| 650 | isLastExpandedToolGroupEntry: entryIndex === activeWorkRow.groupedEntries.length - 1, | |
| 651 | }); | |
| 652 | } | |
| 733 | nextRows.push( | |
| 734 | expandedWorkGroupRow( | |
| 735 | activeWorkRow.groupId, | |
| 736 | activeWorkRow.createdAt, | |
| 737 | activeWorkRow.groupedEntries, | |
| 738 | ), | |
| 739 | ); | |
| 653 | 740 | }; |
| 654 | 741 | |
| 655 | 742 | for (let index = 0; index < input.timelineEntries.length; index += 1) { |
| ... | ... | @@ -693,8 +780,7 @@ export function deriveMessagesTimelineRows(input: { |
| 693 | 780 | id: timelineEntry.id, |
| 694 | 781 | createdAt: timelineEntry.createdAt, |
| 695 | 782 | groupedEntries: [timelineEntry.entry], |
| 696 | isExpandedToolGroupEntry: false, | |
| 697 | isLastExpandedToolGroupEntry: false, | |
| 783 | isExpandedToolGroup: false, | |
| 698 | 784 | }); |
| 699 | 785 | continue; |
| 700 | 786 | } |
| ... | ... | @@ -740,16 +826,9 @@ export function deriveMessagesTimelineRows(input: { |
| 740 | 826 | }); |
| 741 | 827 | hasActivityRow = true; |
| 742 | 828 | if (expanded) { |
| 743 | for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { | |
| 744 | nextRows.push({ | |
| 745 | kind: "work", | |
| 746 | id: workEntry.id, | |
| 747 | createdAt: workEntry.createdAt, | |
| 748 | groupedEntries: [workEntry], | |
| 749 | isExpandedToolGroupEntry: true, | |
| 750 | isLastExpandedToolGroupEntry: entryIndex === visibleGroupedEntries.length - 1, | |
| 751 | }); | |
| 752 | } | |
| 829 | nextRows.push( | |
| 830 | expandedWorkGroupRow(groupId, timelineEntry.createdAt, visibleGroupedEntries), | |
| 831 | ); | |
| 753 | 832 | } |
| 754 | 833 | } else { |
| 755 | 834 | const groupId = workGroupId(timelineEntry.id, timelineEntry.entry); |
| ... | ... | @@ -774,16 +853,9 @@ export function deriveMessagesTimelineRows(input: { |
| 774 | 853 | workEntryDisplayIndicatesToolFailure(latestToolEntry), |
| 775 | 854 | }); |
| 776 | 855 | if (expanded) { |
| 777 | for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { | |
| 778 | nextRows.push({ | |
| 779 | kind: "work", | |
| 780 | id: workEntry.id, | |
| 781 | createdAt: workEntry.createdAt, | |
| 782 | groupedEntries: [workEntry], | |
| 783 | isExpandedToolGroupEntry: true, | |
| 784 | isLastExpandedToolGroupEntry: entryIndex === visibleGroupedEntries.length - 1, | |
| 785 | }); | |
| 786 | } | |
| 856 | nextRows.push( | |
| 857 | expandedWorkGroupRow(groupId, timelineEntry.createdAt, visibleGroupedEntries), | |
| 858 | ); | |
| 787 | 859 | } |
| 788 | 860 | } |
| 789 | 861 | } |
| ... | ... | @@ -891,8 +963,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean |
| 891 | 963 | case "work": { |
| 892 | 964 | const bw = b as typeof a; |
| 893 | 965 | return ( |
| 894 | a.isExpandedToolGroupEntry === bw.isExpandedToolGroupEntry && | |
| 895 | a.isLastExpandedToolGroupEntry === bw.isLastExpandedToolGroupEntry && | |
| 966 | a.isExpandedToolGroup === bw.isExpandedToolGroup && | |
| 896 | 967 | Equal.equals(a.groupedEntries, bw.groupedEntries) |
| 897 | 968 | ); |
| 898 | 969 | } |
apps/web/src/components/chat/MessagesTimeline.test.tsx+3-3| ... | ... | @@ -1206,7 +1206,7 @@ describe("MessagesTimeline", () => { |
| 1206 | 1206 | expect(markup).toContain('data-timeline-row-id="live-activity-row"'); |
| 1207 | 1207 | }); |
| 1208 | 1208 | |
| 1209 | it("keeps the completed command in the shared activity row", () => { | |
| 1209 | it("keeps the completed command in the shared activity row with a past-tense label", () => { | |
| 1210 | 1210 | const turnId = TurnId.make("turn-live"); |
| 1211 | 1211 | const markup = renderToStaticMarkup( |
| 1212 | 1212 | <MessagesTimeline |
| ... | ... | @@ -1241,10 +1241,10 @@ describe("MessagesTimeline", () => { |
| 1241 | 1241 | />, |
| 1242 | 1242 | ); |
| 1243 | 1243 | |
| 1244 | expect(markup).toContain("Running pnpm"); | |
| 1244 | expect(markup).toContain("Ran pnpm"); | |
| 1245 | 1245 | expect(markup).toContain("lucide-terminal"); |
| 1246 | 1246 | expect(markup).toContain("live-activity-focus"); |
| 1247 | expect(markup).not.toContain("Ran pnpm"); | |
| 1247 | expect(markup).not.toContain("Running pnpm"); | |
| 1248 | 1248 | expect(markup).not.toContain("Thinking"); |
| 1249 | 1249 | expect(markup).not.toContain('data-timeline-row-kind="thinking"'); |
| 1250 | 1250 | }); |
apps/web/src/components/chat/MessagesTimeline.tsx+248-90| ... | ... | @@ -9,11 +9,12 @@ import { |
| 9 | 9 | } from "@t3tools/contracts"; |
| 10 | 10 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 11 | 11 | import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; |
| 12 | import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; | |
| 13 | 12 | import { |
| 13 | resolveWorkEntryToolPresentation, | |
| 14 | 14 | resolveViewedImageAsset, |
| 15 | 15 | workEntryViewedImagePath, |
| 16 | 16 | } from "@t3tools/client-runtime/work-log/presentation"; |
| 17 | import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; | |
| 17 | 18 | import type { AgentPanelModel } from "@t3tools/client-runtime/state/subagentRuntime"; |
| 18 | 19 | import { |
| 19 | 20 | emptyAgentPanelModel, |
| ... | ... | @@ -62,6 +63,7 @@ import { |
| 62 | 63 | } from "../../lib/diffRendering"; |
| 63 | 64 | import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting"; |
| 64 | 65 | import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown"; |
| 66 | import { T3Wordmark } from "../T3Wordmark"; | |
| 65 | 67 | import { |
| 66 | 68 | BotIcon, |
| 67 | 69 | BrainIcon, |
| ... | ... | @@ -87,6 +89,7 @@ import { |
| 87 | 89 | ZapIcon, |
| 88 | 90 | } from "lucide-react"; |
| 89 | 91 | import { Button } from "../ui/button"; |
| 92 | import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; | |
| 90 | 93 | import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; |
| 91 | 94 | import { ProposedPlanCard } from "./ProposedPlanCard"; |
| 92 | 95 | import { ChangedFilesCard } from "./ChangedFilesTree"; |
| ... | ... | @@ -107,7 +110,7 @@ import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssis |
| 107 | 110 | import { |
| 108 | 111 | computeStableMessagesTimelineRows, |
| 109 | 112 | deriveMessagesTimelineRows, |
| 110 | normalizeCompactToolLabel, | |
| 113 | liveWorkEntryLabel, | |
| 111 | 114 | resolveAssistantMessageCopyState, |
| 112 | 115 | resolveTimelineIsAtEnd, |
| 113 | 116 | resolveTimelineMinimapHasPersistentGutter, |
| ... | ... | @@ -116,13 +119,17 @@ import { |
| 116 | 119 | resolveTimelineMinimapIndexFromPointer, |
| 117 | 120 | resolveTimelineMinimapInteractiveWidth, |
| 118 | 121 | resolveTimelineMinimapTopPercent, |
| 122 | resolveWorkGroupScrollIndex, | |
| 123 | shouldFollowWorkGroupAppend, | |
| 119 | 124 | shouldPreserveAssistantLineBreaks, |
| 120 | 125 | toolGroupAction, |
| 126 | workEntryDisplayLabel, | |
| 121 | 127 | workEntryIsVisibleInGroup, |
| 122 | 128 | type StableMessagesTimelineRowsState, |
| 123 | 129 | type MessagesTimelineRow, |
| 124 | 130 | TIMELINE_MINIMAP_MIN_ITEMS, |
| 125 | 131 | type TimelineLatestTurn, |
| 132 | type WorkGroupScrollAnchor, | |
| 126 | 133 | } from "./MessagesTimeline.logic"; |
| 127 | 134 | import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; |
| 128 | 135 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| ... | ... | @@ -183,6 +190,8 @@ interface TimelineRowSharedState { |
| 183 | 190 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; |
| 184 | 191 | onToggleTurnFold: (turnId: TurnId) => void; |
| 185 | 192 | onToggleWorkGroup: (groupId: string, anchorKey: string) => void; |
| 193 | onToggleWorkEntry: (anchorKey: string) => void; | |
| 194 | workGroupViewState: WorkGroupViewState; | |
| 186 | 195 | agentPanelModel: AgentPanelModel; |
| 187 | 196 | onOpenAgents: () => void; |
| 188 | 197 | } |
| ... | ... | @@ -196,6 +205,16 @@ interface TimelineRowActivityState { |
| 196 | 205 | |
| 197 | 206 | const TimelineRowCtx = createContext<TimelineRowSharedState>(null!); |
| 198 | 207 | const TimelineRowActivityCtx = createContext<TimelineRowActivityState>(null!); |
| 208 | ||
| 209 | interface WorkGroupViewState { | |
| 210 | scrollPositions: Map<string, WorkGroupScrollAnchor>; | |
| 211 | expandedEntries: Set<string>; | |
| 212 | } | |
| 213 | ||
| 214 | const WorkGroupViewCtx = createContext<{ | |
| 215 | state: WorkGroupViewState; | |
| 216 | onToggleEntry: () => void; | |
| 217 | } | null>(null); | |
| 199 | 218 | const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />; |
| 200 | 219 | const TIMELINE_LIST_FADE_HEADER = ( |
| 201 | 220 | <div className="h-[var(--workspace-titlebar-scroll-fade-height)]" /> |
| ... | ... | @@ -343,6 +362,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 343 | 362 | ); |
| 344 | 363 | }, []); |
| 345 | 364 | const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set()); |
| 365 | // Scroll/disclosure state outlives virtualized rows, but never the current thread. | |
| 366 | const workGroupViewState = useMemo<WorkGroupViewState>( | |
| 367 | () => ({ scrollPositions: new Map(), expandedEntries: new Set() }), | |
| 368 | [routeThreadKey], | |
| 369 | ); | |
| 346 | 370 | const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); |
| 347 | 371 | const [minimapStripMap] = useState(() => new Map<string, HTMLSpanElement>()); |
| 348 | 372 | const disclosureAnchorKeyRef = useRef<string | null>(null); |
| ... | ... | @@ -616,6 +640,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 616 | 640 | onOpenTurnDiff, |
| 617 | 641 | onToggleTurnFold, |
| 618 | 642 | onToggleWorkGroup, |
| 643 | onToggleWorkEntry: suspendEndScrollMaintenanceForDisclosure, | |
| 644 | workGroupViewState, | |
| 619 | 645 | agentPanelModel, |
| 620 | 646 | onOpenAgents, |
| 621 | 647 | }), |
| ... | ... | @@ -637,6 +663,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 637 | 663 | onOpenTurnDiff, |
| 638 | 664 | onToggleTurnFold, |
| 639 | 665 | onToggleWorkGroup, |
| 666 | suspendEndScrollMaintenanceForDisclosure, | |
| 667 | workGroupViewState, | |
| 640 | 668 | agentPanelModel, |
| 641 | 669 | onOpenAgents, |
| 642 | 670 | ], |
| ... | ... | @@ -1043,8 +1071,7 @@ type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["grouped |
| 1043 | 1071 | type TimelineRow = MessagesTimelineRow; |
| 1044 | 1072 | |
| 1045 | 1073 | const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) { |
| 1046 | const isExpandedToolGroupEntry = row.kind === "work" && row.isExpandedToolGroupEntry; | |
| 1047 | const isLastExpandedToolGroupEntry = row.kind === "work" && row.isLastExpandedToolGroupEntry; | |
| 1074 | const isExpandedToolGroup = row.kind === "work" && row.isExpandedToolGroup; | |
| 1048 | 1075 | const isExpandedToolGroupHeader = |
| 1049 | 1076 | (row.kind === "work-toggle" && row.expanded) || (row.kind === "work-live" && row.expanded); |
| 1050 | 1077 | |
| ... | ... | @@ -1053,10 +1080,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time |
| 1053 | 1080 | className={cn( |
| 1054 | 1081 | // Commentary (non-terminal assistant) rows carry no metadata row, so |
| 1055 | 1082 | // they sit closer to the work that follows them. |
| 1056 | isExpandedToolGroupEntry | |
| 1057 | ? isLastExpandedToolGroupEntry | |
| 1058 | ? "pb-1" | |
| 1059 | : "pb-0" | |
| 1083 | isExpandedToolGroup | |
| 1084 | ? "pb-1" | |
| 1060 | 1085 | : isExpandedToolGroupHeader |
| 1061 | 1086 | ? "pb-0" |
| 1062 | 1087 | : row.kind === "turn-fold" || row.kind === "working" |
| ... | ... | @@ -1079,8 +1104,9 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time |
| 1079 | 1104 | > |
| 1080 | 1105 | {row.kind === "work" ? ( |
| 1081 | 1106 | <WorkGroupSection |
| 1107 | anchorKey={row.id} | |
| 1082 | 1108 | groupedEntries={row.groupedEntries} |
| 1083 | isExpandedToolGroupEntry={row.isExpandedToolGroupEntry} | |
| 1109 | isExpandedToolGroup={row.isExpandedToolGroup} | |
| 1084 | 1110 | /> |
| 1085 | 1111 | ) : null} |
| 1086 | 1112 | {row.kind === "work-live" ? <LiveWorkEntryTimelineRow row={row} /> : null} |
| ... | ... | @@ -1485,43 +1511,189 @@ function WorkingTimer({ createdAt }: { createdAt: string }) { |
| 1485 | 1511 | // re-render only the affected row, not the entire list. |
| 1486 | 1512 | // --------------------------------------------------------------------------- |
| 1487 | 1513 | |
| 1488 | /** Renders one or more already-derived work log rows. Overflow expansion is modeled as LegendList data. */ | |
| 1514 | /** Renders standalone activity or one bounded, virtualized expanded tool group. */ | |
| 1489 | 1515 | const WorkGroupSection = memo(function WorkGroupSection({ |
| 1516 | anchorKey, | |
| 1490 | 1517 | groupedEntries, |
| 1491 | isExpandedToolGroupEntry, | |
| 1518 | isExpandedToolGroup, | |
| 1492 | 1519 | }: { |
| 1520 | anchorKey: string; | |
| 1493 | 1521 | groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"]; |
| 1494 | isExpandedToolGroupEntry: boolean; | |
| 1522 | isExpandedToolGroup: boolean; | |
| 1495 | 1523 | }) { |
| 1496 | const { workspaceRoot } = use(TimelineRowCtx); | |
| 1524 | const { workspaceRoot, routeThreadKey } = use(TimelineRowCtx); | |
| 1497 | 1525 | const nonEmptyEntries = useMemo( |
| 1498 | () => | |
| 1499 | groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroupEntry)), | |
| 1500 | [groupedEntries, isExpandedToolGroupEntry], | |
| 1526 | () => groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroup)), | |
| 1527 | [groupedEntries, isExpandedToolGroup], | |
| 1501 | 1528 | ); |
| 1502 | const GroupContainer = isExpandedToolGroupEntry ? "div" : "section"; | |
| 1503 | 1529 | |
| 1504 | 1530 | if (nonEmptyEntries.length === 0) return null; |
| 1531 | if (isExpandedToolGroup) { | |
| 1532 | return ( | |
| 1533 | <ExpandedWorkGroupEntries | |
| 1534 | key={`${routeThreadKey}:${anchorKey}`} | |
| 1535 | anchorKey={anchorKey} | |
| 1536 | entries={nonEmptyEntries} | |
| 1537 | workspaceRoot={workspaceRoot} | |
| 1538 | /> | |
| 1539 | ); | |
| 1540 | } | |
| 1505 | 1541 | |
| 1506 | 1542 | return ( |
| 1507 | <GroupContainer | |
| 1508 | className={cn("-mx-1 px-1", isExpandedToolGroupEntry ? "py-0" : "space-y-0.5 py-0.5")} | |
| 1509 | aria-label={isExpandedToolGroupEntry ? undefined : "Activity"} | |
| 1510 | > | |
| 1543 | <section className="-mx-1 space-y-0.5 px-1 py-0.5" aria-label="Activity"> | |
| 1511 | 1544 | <div className="space-y-px"> |
| 1512 | 1545 | {nonEmptyEntries.map((workEntry) => ( |
| 1513 | 1546 | <SimpleWorkEntryRow |
| 1514 | 1547 | key={workEntry.id} |
| 1515 | 1548 | workEntry={workEntry} |
| 1516 | 1549 | workspaceRoot={workspaceRoot} |
| 1517 | isExpandedToolGroupEntry={isExpandedToolGroupEntry} | |
| 1550 | isExpandedToolGroupEntry={false} | |
| 1518 | 1551 | /> |
| 1519 | 1552 | ))} |
| 1520 | 1553 | </div> |
| 1521 | </GroupContainer> | |
| 1554 | </section> | |
| 1522 | 1555 | ); |
| 1523 | 1556 | }); |
| 1524 | 1557 | |
| 1558 | function ExpandedWorkGroupEntries({ | |
| 1559 | anchorKey, | |
| 1560 | entries, | |
| 1561 | workspaceRoot, | |
| 1562 | }: { | |
| 1563 | anchorKey: string; | |
| 1564 | entries: TimelineWorkEntry[]; | |
| 1565 | workspaceRoot: string | undefined; | |
| 1566 | }) { | |
| 1567 | const { workGroupViewState: viewState, onToggleWorkEntry } = use(TimelineRowCtx); | |
| 1568 | const [initialScrollIndex] = useState(() => | |
| 1569 | resolveWorkGroupScrollIndex(entries, viewState.scrollPositions.get(anchorKey)), | |
| 1570 | ); | |
| 1571 | const [restoringPosition, setRestoringPosition] = useState(initialScrollIndex !== undefined); | |
| 1572 | const listRef = useRef<LegendListRef>(null); | |
| 1573 | const [fades, setFades] = useState({ top: false, bottom: false, viewportHeight: 0 }); | |
| 1574 | const [appendState, setAppendState] = useState({ entries, follow: false }); | |
| 1575 | // Capture the pre-change edge once per incoming array, before new layout | |
| 1576 | // metrics arrive. Edge/viewport changes never turn a status update into a follow. | |
| 1577 | if (appendState.entries !== entries) { | |
| 1578 | setAppendState({ | |
| 1579 | entries, | |
| 1580 | follow: | |
| 1581 | fades.viewportHeight > 0 && | |
| 1582 | shouldFollowWorkGroupAppend(appendState.entries, entries, fades.bottom ? Infinity : 0), | |
| 1583 | }); | |
| 1584 | } | |
| 1585 | ||
| 1586 | const groupView = useMemo( | |
| 1587 | () => ({ state: viewState, onToggleEntry: () => onToggleWorkEntry(anchorKey) }), | |
| 1588 | [anchorKey, onToggleWorkEntry, viewState], | |
| 1589 | ); | |
| 1590 | const updateScrollFades = useCallback(() => { | |
| 1591 | const element = listRef.current?.getScrollableNode(); | |
| 1592 | if (!element) return; | |
| 1593 | const distanceFromEnd = element.scrollHeight - element.clientHeight - element.scrollTop; | |
| 1594 | const viewportHeight = element.clientHeight; | |
| 1595 | const top = element.scrollTop > 1; | |
| 1596 | const bottom = distanceFromEnd > 1; | |
| 1597 | setFades((previous) => | |
| 1598 | previous.top === top && | |
| 1599 | previous.bottom === bottom && | |
| 1600 | previous.viewportHeight === viewportHeight | |
| 1601 | ? previous | |
| 1602 | : { top, bottom, viewportHeight }, | |
| 1603 | ); | |
| 1604 | }, []); | |
| 1605 | ||
| 1606 | const handleScroll = useCallback(() => { | |
| 1607 | const state = listRef.current?.getState(); | |
| 1608 | const position = state && resolveWorkGroupScrollAnchor(state); | |
| 1609 | if (position) { | |
| 1610 | viewState.scrollPositions.set(anchorKey, { | |
| 1611 | entryId: position.rowId, | |
| 1612 | offset: position.offsetWithinRow, | |
| 1613 | }); | |
| 1614 | } | |
| 1615 | updateScrollFades(); | |
| 1616 | }, [anchorKey, updateScrollFades, viewState]); | |
| 1617 | ||
| 1618 | const handleLoad = useCallback(() => { | |
| 1619 | const list = listRef.current; | |
| 1620 | const element = list?.getScrollableNode(); | |
| 1621 | if (initialScrollIndex && list && element) { | |
| 1622 | // Bootstrap can report the restored target before the DOM has applied it. | |
| 1623 | // Reconcile once at load, before releasing the measured anchor row. | |
| 1624 | const offset = Math.max( | |
| 1625 | 0, | |
| 1626 | Math.min(list.getState().scroll, element.scrollHeight - element.clientHeight), | |
| 1627 | ); | |
| 1628 | if (Math.abs(element.scrollTop - offset) > 1) { | |
| 1629 | void list.scrollToOffset({ offset, animated: false }); | |
| 1630 | } | |
| 1631 | } | |
| 1632 | setRestoringPosition(false); | |
| 1633 | }, [initialScrollIndex]); | |
| 1634 | ||
| 1635 | useLayoutEffect(() => { | |
| 1636 | const element = listRef.current?.getScrollableNode(); | |
| 1637 | if (!element) return; | |
| 1638 | updateScrollFades(); | |
| 1639 | const observer = new ResizeObserver(updateScrollFades); | |
| 1640 | observer.observe(element); | |
| 1641 | if (element.firstElementChild) observer.observe(element.firstElementChild); | |
| 1642 | return () => observer.disconnect(); | |
| 1643 | }, [updateScrollFades]); | |
| 1644 | ||
| 1645 | const renderEntry = useCallback( | |
| 1646 | ({ item }: { item: TimelineWorkEntry }) => ( | |
| 1647 | <SimpleWorkEntryRow | |
| 1648 | key={item.id} | |
| 1649 | workEntry={item} | |
| 1650 | workspaceRoot={workspaceRoot} | |
| 1651 | isExpandedToolGroupEntry | |
| 1652 | /> | |
| 1653 | ), | |
| 1654 | [workspaceRoot], | |
| 1655 | ); | |
| 1656 | ||
| 1657 | return ( | |
| 1658 | <WorkGroupViewCtx value={groupView}> | |
| 1659 | <LegendList | |
| 1660 | ref={listRef} | |
| 1661 | data={entries} | |
| 1662 | extraData={workspaceRoot} | |
| 1663 | keyExtractor={workEntryKey} | |
| 1664 | renderItem={renderEntry} | |
| 1665 | estimatedItemSize={24} | |
| 1666 | drawDistance={240} | |
| 1667 | recycleItems | |
| 1668 | {...(initialScrollIndex ? { initialScrollIndex } : {})} | |
| 1669 | maintainScrollAtEnd={ | |
| 1670 | appendState.follow ? { animated: false, on: { dataChange: true } } : false | |
| 1671 | } | |
| 1672 | maintainScrollAtEndThreshold={1 / Math.max(1, fades.viewportHeight)} | |
| 1673 | // Measure the restored row even when an intra-row offset puts its | |
| 1674 | // estimated bounds outside the list's small bootstrap render window. | |
| 1675 | {...(restoringPosition && initialScrollIndex | |
| 1676 | ? { alwaysRender: { indices: [initialScrollIndex.index] } } | |
| 1677 | : {})} | |
| 1678 | maintainVisibleContentPosition | |
| 1679 | onLoad={handleLoad} | |
| 1680 | onScroll={handleScroll} | |
| 1681 | onLayout={updateScrollFades} | |
| 1682 | tabIndex={0} | |
| 1683 | role="region" | |
| 1684 | aria-label="Tool calls" | |
| 1685 | data-tool-group-scroll | |
| 1686 | className={cn( | |
| 1687 | "scrollbar-gutter-stable max-h-[min(18rem,50dvh)] scroll-py-6 overflow-x-hidden rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70", | |
| 1688 | getVirtualizedScrollFadeClassName(fades), | |
| 1689 | )} | |
| 1690 | /> | |
| 1691 | </WorkGroupViewCtx> | |
| 1692 | ); | |
| 1693 | } | |
| 1694 | ||
| 1695 | const workEntryKey = (entry: TimelineWorkEntry) => entry.id; | |
| 1696 | ||
| 1525 | 1697 | function ActivityShimmerOverlay({ children }: { children: ReactNode }) { |
| 1526 | 1698 | return ( |
| 1527 | 1699 | <span |
| ... | ... | @@ -1572,7 +1744,8 @@ function LiveActivityContent({ |
| 1572 | 1744 | announceFailure?: boolean; |
| 1573 | 1745 | highlighted?: boolean; |
| 1574 | 1746 | }) { |
| 1575 | const resolvedIconName = failed ? "circle-alert" : iconName; | |
| 1747 | const isSpecialToolIcon = iconName === "browser" || iconName === "t3-code"; | |
| 1748 | const resolvedIconName = failed && !isSpecialToolIcon ? "circle-alert" : iconName; | |
| 1576 | 1749 | |
| 1577 | 1750 | return ( |
| 1578 | 1751 | <span |
| ... | ... | @@ -1591,13 +1764,14 @@ function LiveActivityContent({ |
| 1591 | 1764 | role={announceFailure ? "img" : undefined} |
| 1592 | 1765 | aria-label={announceFailure ? "Tool call failed" : undefined} |
| 1593 | 1766 | > |
| 1594 | <WorkEntryIconSvg | |
| 1767 | <WorkEntryIcon | |
| 1595 | 1768 | name={resolvedIconName} |
| 1596 | 1769 | className={cn("block size-4 shrink-0 stroke-[1.8]", !highlighted && "opacity-70")} |
| 1597 | 1770 | /> |
| 1598 | 1771 | </span> |
| 1599 | 1772 | ) : null} |
| 1600 | 1773 | <span className="min-w-0 flex-1 truncate">{label}</span> |
| 1774 | {failed && isSpecialToolIcon ? <XIcon aria-hidden className="size-3 shrink-0" /> : null} | |
| 1601 | 1775 | </span> |
| 1602 | 1776 | ); |
| 1603 | 1777 | } |
| ... | ... | @@ -1641,6 +1815,8 @@ function toolGroupSummaryIconName( |
| 1641 | 1815 | return "square-pen"; |
| 1642 | 1816 | case "command": |
| 1643 | 1817 | return "terminal"; |
| 1818 | case "browser": | |
| 1819 | return "browser"; | |
| 1644 | 1820 | case "search": |
| 1645 | 1821 | return "globe"; |
| 1646 | 1822 | case "code-search": |
| ... | ... | @@ -1674,7 +1850,7 @@ function WorkGroupToggleTimelineRow({ |
| 1674 | 1850 | onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)} |
| 1675 | 1851 | > |
| 1676 | 1852 | <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted"> |
| 1677 | <WorkEntryIconSvg | |
| 1853 | <WorkEntryIcon | |
| 1678 | 1854 | name={toolGroupSummaryIconName(row.summaryKind)} |
| 1679 | 1855 | className="size-4 shrink-0 stroke-[1.8] opacity-70" |
| 1680 | 1856 | /> |
| ... | ... | @@ -2208,6 +2384,7 @@ function formatWorkingTimerNow(startIso: string): string { |
| 2208 | 2384 | type WorkEntryIconName = |
| 2209 | 2385 | | "bot" |
| 2210 | 2386 | | "brain" |
| 2387 | | "browser" | |
| 2211 | 2388 | | "check" |
| 2212 | 2389 | | "circle-alert" |
| 2213 | 2390 | | "eye" |
| ... | ... | @@ -2217,16 +2394,21 @@ type WorkEntryIconName = |
| 2217 | 2394 | | "search" |
| 2218 | 2395 | | "square-pen" |
| 2219 | 2396 | | "terminal" |
| 2397 | | "t3-code" | |
| 2220 | 2398 | | "wrench" |
| 2221 | 2399 | | "x" |
| 2222 | 2400 | | "zap"; |
| 2223 | 2401 | |
| 2224 | function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; className: string }) { | |
| 2402 | function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className: string }) { | |
| 2225 | 2403 | switch (name) { |
| 2226 | 2404 | case "bot": |
| 2227 | 2405 | return <BotIcon className={className} aria-hidden />; |
| 2228 | 2406 | case "brain": |
| 2229 | 2407 | return <BrainIcon className={className} aria-hidden />; |
| 2408 | case "browser": | |
| 2409 | return <GlobeIcon className={className} aria-hidden />; | |
| 2410 | case "t3-code": | |
| 2411 | return <T3Wordmark className={className} aria-hidden />; | |
| 2230 | 2412 | case "check": |
| 2231 | 2413 | return <CheckIcon className={className} aria-hidden />; |
| 2232 | 2414 | case "circle-alert": |
| ... | ... | @@ -2282,21 +2464,6 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): { |
| 2282 | 2464 | }; |
| 2283 | 2465 | } |
| 2284 | 2466 | |
| 2285 | function workEntryPreview( | |
| 2286 | workEntry: Pick<TimelineWorkEntry, "detail" | "command" | "changedFiles">, | |
| 2287 | workspaceRoot: string | undefined, | |
| 2288 | ) { | |
| 2289 | if (workEntry.command) return workEntry.command; | |
| 2290 | if (workEntry.detail) return workEntry.detail; | |
| 2291 | if ((workEntry.changedFiles?.length ?? 0) === 0) return null; | |
| 2292 | const [firstPath] = workEntry.changedFiles ?? []; | |
| 2293 | if (!firstPath) return null; | |
| 2294 | const displayPath = formatWorkspaceRelativePath(firstPath, workspaceRoot); | |
| 2295 | return workEntry.changedFiles!.length === 1 | |
| 2296 | ? displayPath | |
| 2297 | : `${displayPath} +${workEntry.changedFiles!.length - 1} more`; | |
| 2298 | } | |
| 2299 | ||
| 2300 | 2467 | function workEntryRawCommand( |
| 2301 | 2468 | workEntry: Pick<TimelineWorkEntry, "command" | "rawCommand">, |
| 2302 | 2469 | ): string | null { |
| ... | ... | @@ -2307,26 +2474,6 @@ function workEntryRawCommand( |
| 2307 | 2474 | return rawCommand === workEntry.command.trim() ? null : rawCommand; |
| 2308 | 2475 | } |
| 2309 | 2476 | |
| 2310 | function liveWorkEntryLabel( | |
| 2311 | workEntry: TimelineWorkEntry, | |
| 2312 | workspaceRoot: string | undefined, | |
| 2313 | active: boolean, | |
| 2314 | ): string { | |
| 2315 | const command = workEntry.command?.trim(); | |
| 2316 | if (command) { | |
| 2317 | const program = commandProgramName(command); | |
| 2318 | const verb = active | |
| 2319 | ? "Running" | |
| 2320 | : workEntry.toolLifecycleStatus === "declined" | |
| 2321 | ? "Declined" | |
| 2322 | : "Ran"; | |
| 2323 | if (program) return `${verb} ${program}`; | |
| 2324 | return `${verb} command`; | |
| 2325 | } | |
| 2326 | ||
| 2327 | return workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); | |
| 2328 | } | |
| 2329 | ||
| 2330 | 2477 | function buildToolCallExpandedBody( |
| 2331 | 2478 | workEntry: TimelineWorkEntry, |
| 2332 | 2479 | workspaceRoot: string | undefined, |
| ... | ... | @@ -2365,6 +2512,8 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { |
| 2365 | 2512 | ) { |
| 2366 | 2513 | return "message-circle"; |
| 2367 | 2514 | } |
| 2515 | const toolPresentation = resolveWorkEntryToolPresentation(workEntry); | |
| 2516 | if (toolPresentation) return toolPresentation.icon; | |
| 2368 | 2517 | const action = toolGroupAction(workEntry); |
| 2369 | 2518 | if (action !== "other") return toolGroupSummaryIconName(action); |
| 2370 | 2519 | |
| ... | ... | @@ -2385,21 +2534,6 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { |
| 2385 | 2534 | return workToneIcon(workEntry.tone).iconName; |
| 2386 | 2535 | } |
| 2387 | 2536 | |
| 2388 | function capitalizePhrase(value: string): string { | |
| 2389 | const trimmed = value.trim(); | |
| 2390 | if (trimmed.length === 0) { | |
| 2391 | return value; | |
| 2392 | } | |
| 2393 | return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`; | |
| 2394 | } | |
| 2395 | ||
| 2396 | function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string { | |
| 2397 | if (!workEntry.toolTitle) { | |
| 2398 | return capitalizePhrase(normalizeCompactToolLabel(workEntry.label)); | |
| 2399 | } | |
| 2400 | return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle)); | |
| 2401 | } | |
| 2402 | ||
| 2403 | 2537 | const stopRowToggle = (e: { stopPropagation: () => void }) => e.stopPropagation(); |
| 2404 | 2538 | |
| 2405 | 2539 | /** |
| ... | ... | @@ -2480,7 +2614,7 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time |
| 2480 | 2614 | className="flex w-full items-center gap-2 rounded-md border border-border/60 bg-card/50 px-2.5 py-1.5 text-left text-[13px] transition hover:bg-accent/50" |
| 2481 | 2615 | > |
| 2482 | 2616 | <span aria-hidden className={cn("size-1.5 shrink-0 rounded-full", dotClass)} /> |
| 2483 | <WorkEntryIconSvg name="bot" className="size-3.5 shrink-0 text-muted-foreground" /> | |
| 2617 | <WorkEntryIcon name="bot" className="size-3.5 shrink-0 text-muted-foreground" /> | |
| 2484 | 2618 | <span className="min-w-0 truncate"> |
| 2485 | 2619 | <span className="font-medium">{lead}</span> |
| 2486 | 2620 | {workflowName ? <span className="text-muted-foreground"> · {workflowName}</span> : null} |
| ... | ... | @@ -2522,15 +2656,39 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 2522 | 2656 | }) { |
| 2523 | 2657 | const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props; |
| 2524 | 2658 | const { threadRef, onImageExpand } = use(TimelineRowCtx); |
| 2525 | const [expanded, setExpanded] = useState(false); | |
| 2659 | const groupView = use(WorkGroupViewCtx); | |
| 2660 | const [expanded, setExpanded] = useState( | |
| 2661 | () => groupView?.state.expandedEntries.has(workEntry.id) ?? false, | |
| 2662 | ); | |
| 2663 | const toggleExpanded = () => { | |
| 2664 | const next = !expanded; | |
| 2665 | if (groupView) { | |
| 2666 | groupView.onToggleEntry(); | |
| 2667 | if (next) groupView.state.expandedEntries.add(workEntry.id); | |
| 2668 | else groupView.state.expandedEntries.delete(workEntry.id); | |
| 2669 | } | |
| 2670 | setExpanded(next); | |
| 2671 | }; | |
| 2526 | 2672 | const iconConfig = workToneIcon(workEntry.tone); |
| 2527 | 2673 | const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; |
| 2528 | 2674 | const showFailedIndicator = workEntryDisplayIndicatesToolFailure(workEntry); |
| 2675 | const toolPresentation = resolveWorkEntryToolPresentation(workEntry); | |
| 2529 | 2676 | const entryIconName = |
| 2530 | showWarningIndicator || showFailedIndicator ? "circle-alert" : workEntryIconName(workEntry); | |
| 2531 | const previewText = workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); | |
| 2532 | const displayText = expanded && workEntry.command?.trim() ? "Command" : previewText; | |
| 2533 | const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot); | |
| 2677 | showWarningIndicator || (showFailedIndicator && !toolPresentation) | |
| 2678 | ? "circle-alert" | |
| 2679 | : workEntryIconName(workEntry); | |
| 2680 | const previewText = workEntryDisplayLabel(workEntry, workspaceRoot); | |
| 2681 | const displayText = | |
| 2682 | !toolPresentation && expanded && workEntry.command?.trim() ? "Command" : previewText; | |
| 2683 | const canExpand = | |
| 2684 | (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) || | |
| 2685 | Boolean( | |
| 2686 | workEntryRawCommand(workEntry) || | |
| 2687 | workEntry.command?.trim() || | |
| 2688 | workEntry.detail?.trim() || | |
| 2689 | workEntry.changedFiles?.length, | |
| 2690 | ); | |
| 2691 | const expandedBody = expanded ? buildToolCallExpandedBody(workEntry, workspaceRoot) : null; | |
| 2534 | 2692 | const viewedImagePath = workEntryViewedImagePath(workEntry); |
| 2535 | 2693 | const viewedImage = |
| 2536 | 2694 | viewedImagePath && threadRef |
| ... | ... | @@ -2539,7 +2697,6 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 2539 | 2697 | workspaceRoot, |
| 2540 | 2698 | }) |
| 2541 | 2699 | : null; |
| 2542 | const canExpand = expandedBody !== null; | |
| 2543 | 2700 | const showDestructiveRowStyle = |
| 2544 | 2701 | showFailedIndicator && |
| 2545 | 2702 | (workEntrySignalsSevereFailure(workEntry) || !workLogEntryIsToolLike(workEntry)); |
| ... | ... | @@ -2562,7 +2719,6 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 2562 | 2719 | : workLogEntryIsToolLike(workEntry) |
| 2563 | 2720 | ? "text-secondary-label" |
| 2564 | 2721 | : "text-foreground/80"; |
| 2565 | const showEntryIcon = !isExpandedToolGroupEntry || showWarningIndicator || showFailedIndicator; | |
| 2566 | 2722 | const accessibleDisplayText = showFailedIndicator |
| 2567 | 2723 | ? `${previewText}, tool call failed` |
| 2568 | 2724 | : previewText; |
| ... | ... | @@ -2572,11 +2728,11 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 2572 | 2728 | tabIndex: 0 as const, |
| 2573 | 2729 | "aria-label": accessibleDisplayText, |
| 2574 | 2730 | "aria-expanded": expanded, |
| 2575 | onClick: () => setExpanded((v) => !v), | |
| 2731 | onClick: toggleExpanded, | |
| 2576 | 2732 | onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => { |
| 2577 | 2733 | if (e.key === "Enter" || e.key === " ") { |
| 2578 | 2734 | e.preventDefault(); |
| 2579 | setExpanded((v) => !v); | |
| 2735 | toggleExpanded(); | |
| 2580 | 2736 | } |
| 2581 | 2737 | }, |
| 2582 | 2738 | } |
| ... | ... | @@ -2594,12 +2750,11 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 2594 | 2750 | > |
| 2595 | 2751 | <div className="flex select-none items-center gap-1.5 transition-[opacity,translate] duration-200"> |
| 2596 | 2752 | <span |
| 2597 | className={cn(iconWrapperClass, !showEntryIcon && "invisible")} | |
| 2753 | className={iconWrapperClass} | |
| 2598 | 2754 | role={showFailedIndicator ? "img" : undefined} |
| 2599 | 2755 | aria-label={showFailedIndicator ? "Tool call failed" : undefined} |
| 2600 | aria-hidden={!showEntryIcon} | |
| 2601 | 2756 | > |
| 2602 | <WorkEntryIconSvg | |
| 2757 | <WorkEntryIcon | |
| 2603 | 2758 | name={entryIconName} |
| 2604 | 2759 | className="block size-4 shrink-0 stroke-[1.8] opacity-70" |
| 2605 | 2760 | /> |
| ... | ... | @@ -2610,6 +2765,9 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 2610 | 2765 | <span className={cn("min-w-0 flex-1 truncate", headingClass)}>{displayText}</span> |
| 2611 | 2766 | </p> |
| 2612 | 2767 | </div> |
| 2768 | {showFailedIndicator && toolPresentation ? ( | |
| 2769 | <XIcon aria-hidden className="size-3 shrink-0 text-icon-muted" /> | |
| 2770 | ) : null} | |
| 2613 | 2771 | <span |
| 2614 | 2772 | className={cn( |
| 2615 | 2773 | "flex size-4 shrink-0 items-center justify-center", |
apps/web/src/components/sidebar/SidebarChrome.tsx+2-17| ... | ... | @@ -11,6 +11,7 @@ import { Link, useCanGoBack, useLocation, useNavigate } from "@tanstack/react-ro |
| 11 | 11 | import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; |
| 12 | 12 | import { cn } from "../../lib/utils"; |
| 13 | 13 | import { useEnvironments } from "../../state/environments"; |
| 14 | import { T3Wordmark } from "../T3Wordmark"; | |
| 14 | 15 | import { |
| 15 | 16 | resolveEnvironmentIdentificationPillLabel, |
| 16 | 17 | resolveSidebarStageBackdropVariant, |
| ... | ... | @@ -89,7 +90,7 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { |
| 89 | 90 | )} |
| 90 | 91 | to="/" |
| 91 | 92 | > |
| 92 | <T3Wordmark /> | |
| 93 | <T3Wordmark aria-label="T3" className="h-2.5 w-auto shrink-0" /> | |
| 93 | 94 | <span |
| 94 | 95 | className={cn( |
| 95 | 96 | "-translate-y-px truncate text-sm font-medium tracking-tight", |
| ... | ... | @@ -102,22 +103,6 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { |
| 102 | 103 | ); |
| 103 | 104 | } |
| 104 | 105 | |
| 105 | function T3Wordmark() { | |
| 106 | return ( | |
| 107 | <svg | |
| 108 | aria-label="T3" | |
| 109 | className="h-2.5 w-auto shrink-0" | |
| 110 | viewBox="15.5309 37 94.3941 56.96" | |
| 111 | xmlns="http://www.w3.org/2000/svg" | |
| 112 | > | |
| 113 | <path | |
| 114 | d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z" | |
| 115 | fill="currentColor" | |
| 116 | /> | |
| 117 | </svg> | |
| 118 | ); | |
| 119 | } | |
| 120 | ||
| 121 | 106 | function SidebarUtilityItem({ |
| 122 | 107 | icon, |
| 123 | 108 | label, |
apps/web/src/session-logic.test.ts+34| ... | ... | @@ -7,6 +7,7 @@ import { |
| 7 | 7 | type OrchestrationThreadActivity, |
| 8 | 8 | } from "@t3tools/contracts"; |
| 9 | 9 | import { describe, expect, it } from "vite-plus/test"; |
| 10 | import { resolveWorkEntryToolPresentation } from "@t3tools/client-runtime/work-log/presentation"; | |
| 10 | 11 | |
| 11 | 12 | import { |
| 12 | 13 | deriveActiveWorkStartedAt, |
| ... | ... | @@ -1249,6 +1250,36 @@ describe("deriveWorkLogEntries", () => { |
| 1249 | 1250 | expect(entry?.toolData).toEqual(item); |
| 1250 | 1251 | }); |
| 1251 | 1252 | |
| 1253 | it.each([ | |
| 1254 | ["inProgress", "Clicking in the preview browser"], | |
| 1255 | ["completed", "Clicked in the preview browser"], | |
| 1256 | ["failed", "Failed to click in the preview browser"], | |
| 1257 | ] as const)( | |
| 1258 | "preserves Claude MCP identity behind generic titles while %s", | |
| 1259 | (status, displayName) => { | |
| 1260 | const data = { | |
| 1261 | toolName: "mcp__t3_code__preview_click", | |
| 1262 | input: { selector: "#submit" }, | |
| 1263 | ...(status === "inProgress" | |
| 1264 | ? {} | |
| 1265 | : { result: { type: "tool_result", is_error: status === "failed", content: "Result" } }), | |
| 1266 | }; | |
| 1267 | const [entry] = deriveWorkLogEntries([ | |
| 1268 | makeActivity({ | |
| 1269 | kind: status === "inProgress" ? "tool.updated" : "tool.completed", | |
| 1270 | summary: "MCP tool call", | |
| 1271 | payload: { itemType: "mcp_tool_call", title: "MCP tool call", status, data }, | |
| 1272 | }), | |
| 1273 | ]); | |
| 1274 | ||
| 1275 | expect(entry).toMatchObject({ toolTitle: "MCP tool call", toolData: data }); | |
| 1276 | expect(resolveWorkEntryToolPresentation(entry!)).toEqual({ | |
| 1277 | displayName, | |
| 1278 | icon: "browser", | |
| 1279 | }); | |
| 1280 | }, | |
| 1281 | ); | |
| 1282 | ||
| 1252 | 1283 | it("keeps MCP payloads while collapsing lifecycle updates", () => { |
| 1253 | 1284 | const item = { |
| 1254 | 1285 | type: "mcpToolCall", |
| ... | ... | @@ -1282,6 +1313,9 @@ describe("deriveWorkLogEntries", () => { |
| 1282 | 1313 | const [entry] = deriveWorkLogEntries(activities); |
| 1283 | 1314 | expect(entry?.toolData).toEqual(item); |
| 1284 | 1315 | expect(entry?.toolCallId).toBe("call-1"); |
| 1316 | expect(resolveWorkEntryToolPresentation(entry!)?.displayName).toBe( | |
| 1317 | "Took a snapshot of the preview page", | |
| 1318 | ); | |
| 1285 | 1319 | }); |
| 1286 | 1320 | |
| 1287 | 1321 | it("collapses interleaved lifecycle updates by tool call id", () => { |
apps/web/src/session-logic.ts+3-2| ... | ... | @@ -950,8 +950,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo |
| 950 | 950 | } |
| 951 | 951 | if (itemType === "mcp_tool_call") { |
| 952 | 952 | const data = asRecord(payload?.data); |
| 953 | if (data?.item !== undefined) { | |
| 954 | entry.toolData = data.item; | |
| 953 | const toolData = typeof data?.toolName === "string" ? (data.item ?? data) : data?.item; | |
| 954 | if (toolData !== undefined) { | |
| 955 | entry.toolData = toolData; | |
| 955 | 956 | } |
| 956 | 957 | } |
| 957 | 958 | if (itemType) { |
docs/user/tool-activity.md created+18| ... | ... | @@ -0,0 +1,18 @@ |
| 1 | # Tool activity | |
| 2 | ||
| 3 | Open a tool-group summary in the conversation to see its individual calls. Each row has an icon; | |
| 4 | select a row to inspect its details. Select the group summary again to collapse it. | |
| 5 | ||
| 6 | Long groups scroll inside a bounded area without expanding the whole conversation. Faded edges | |
| 7 | indicate more calls above or below. Short groups use only the space they need. | |
| 8 | Collapsing and reopening a group preserves your reading position and any open call details. | |
| 9 | ||
| 10 | Recognized T3 tools use descriptive labels in both the running summary and individual rows. | |
| 11 | Labels follow the call's state, such as "Clicking" while running and "Clicked" after success. | |
| 12 | Failed, declined, and stopped calls say what happened without implying success. | |
| 13 | Preview browser actions use a globe icon. Other T3 tools keep the T3 mark. | |
| 14 | Group summaries count browser actions separately, such as "Used browser 18 times" or | |
| 15 | "Ran 4 commands and used browser 15 times". Browser-only groups also use a globe icon. | |
| 16 | ||
| 17 | Command summaries show the program inside a shell wrapper, such as "Running vp" for | |
| 18 | `/bin/zsh -lc 'vp test run'`. Expanded rows keep the full command. |
packages/client-runtime/package.json+4| ... | ... | @@ -194,6 +194,10 @@ |
| 194 | 194 | "./work-log/command-label": { |
| 195 | 195 | "types": "./src/work-log/commandLabel.ts", |
| 196 | 196 | "default": "./src/work-log/commandLabel.ts" |
| 197 | }, | |
| 198 | "./work-log/scroll-anchor": { | |
| 199 | "types": "./src/work-log/scrollAnchor.ts", | |
| 200 | "default": "./src/work-log/scrollAnchor.ts" | |
| 197 | 201 | } |
| 198 | 202 | }, |
| 199 | 203 | "scripts": { |
packages/client-runtime/src/work-log/commandLabel.test.ts created+57| ... | ... | @@ -0,0 +1,57 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { commandProgramName } from "./commandLabel.ts"; | |
| 4 | ||
| 5 | describe("commandProgramName", () => { | |
| 6 | it.each([ | |
| 7 | ["/bin/zsh -lc 'vp test run apps/web/src/session-logic.test.ts'", "vp"], | |
| 8 | ["/bin/zsh -lc 'git diff --check'", "git"], | |
| 9 | ['/bin/zsh -lc "npx -y react-doctor@latest apps/web"', "npx"], | |
| 10 | ["/bin/zsh -lc 'rg -n \"registerHooks|worker\" apps/web/src'", "rg"], | |
| 11 | ["/bin/bash --noprofile --norc -l -c 'sed -n 1,270p file.ts'", "sed"], | |
| 12 | ["/bin/bash -o pipefail -lc 'vp test run'", "vp"], | |
| 13 | ["/bin/bash --rcfile /tmp/config -c 'git status'", "git"], | |
| 14 | ["sh -ec 'node scripts/check.js'", "node"], | |
| 15 | ["fish --command 'rg --files'", "rg"], | |
| 16 | ["zsh -lc 'CI=1 env -u DEBUG sudo -u root vp test run'", "vp"], | |
| 17 | ["env CI=1 /bin/zsh -lc '\"/Applications/My Tools/bin/check\" --verbose'", "check"], | |
| 18 | ["bash -lc \"zsh -c 'git status'\"", "git"], | |
| 19 | ['"C:\\Program Files\\Git\\bin\\bash.exe" -lc "git status"', "git"], | |
| 20 | ])("unwraps shell scripts without executing them: %s", (command, program) => { | |
| 21 | expect(commandProgramName(command)).toBe(program); | |
| 22 | }); | |
| 23 | ||
| 24 | it.each([ | |
| 25 | ["vp test run", "vp"], | |
| 26 | ["sudo -u root pnpm test", "pnpm"], | |
| 27 | ["env --split-string='CI=1 node scripts/check.js'", "node"], | |
| 28 | ['"C:\\Program Files\\nodejs\\node.exe" script.js', "node.exe"], | |
| 29 | ["/bin/zsh", "zsh"], | |
| 30 | ["/bin/bash -l", "bash"], | |
| 31 | ["zsh script.sh -c 'git status'", "zsh"], | |
| 32 | ["bash -- -c 'git status'", "bash"], | |
| 33 | ["bash --rcfile config.sh", "bash"], | |
| 34 | ["my-shell -c 'git status'", "my-shell"], | |
| 35 | ])("preserves ordinary programs and actual shell launches: %s", (command, program) => { | |
| 36 | expect(commandProgramName(command)).toBe(program); | |
| 37 | }); | |
| 38 | ||
| 39 | it.each([ | |
| 40 | "", | |
| 41 | "zsh -lc", | |
| 42 | "zsh -lc ''", | |
| 43 | "zsh -lc 'git status", | |
| 44 | "zsh -lc 'env'", | |
| 45 | 'zsh -lc "git \\\"', | |
| 46 | ])("falls back for missing or malformed scripts: %s", (command) => { | |
| 47 | expect(commandProgramName(command)).toBeNull(); | |
| 48 | }); | |
| 49 | ||
| 50 | it("bounds nested shell unwrapping", () => { | |
| 51 | let command = "git status"; | |
| 52 | for (let depth = 0; depth < 9; depth += 1) { | |
| 53 | command = `sh -c '${command.replaceAll("'", "'\\''")}'`; | |
| 54 | } | |
| 55 | expect(commandProgramName(command)).toBeNull(); | |
| 56 | }); | |
| 57 | }); |
packages/client-runtime/src/work-log/commandLabel.ts+24-1| ... | ... | @@ -1,5 +1,21 @@ |
| 1 | 1 | type CommandWrapper = "env" | "sudo"; |
| 2 | 2 | |
| 3 | const SHELL_PROGRAMS = new Set(["sh", "bash", "zsh", "dash", "ash", "ksh", "fish"]); | |
| 4 | const SHELL_OPTIONS_WITH_VALUE = new Set(["-o", "-O", "--rcfile", "--init-file"]); | |
| 5 | ||
| 6 | function shellCommandArgumentIndex(tokens: ReadonlyArray<string>, start: number): number | null { | |
| 7 | for (let index = start; index < tokens.length; index += 1) { | |
| 8 | const option = tokens[index]!; | |
| 9 | if (option === "--" || !option.startsWith("-")) return null; | |
| 10 | if (SHELL_OPTIONS_WITH_VALUE.has(option)) { | |
| 11 | index += 1; | |
| 12 | continue; | |
| 13 | } | |
| 14 | if (option === "--command" || /^-[a-zA-Z]*c[a-zA-Z]*$/.test(option)) return index + 1; | |
| 15 | } | |
| 16 | return null; | |
| 17 | } | |
| 18 | ||
| 3 | 19 | const COMMAND_WRAPPER_OPTIONS_WITH_VALUE: Record<CommandWrapper, ReadonlySet<string>> = { |
| 4 | 20 | env: new Set(["-C", "--chdir", "-S", "--split-string", "-u", "--unset"]), |
| 5 | 21 | sudo: new Set(["-C", "--close-from", "-D", "--chdir", "-g", "--group", "-u", "--user"]), |
| ... | ... | @@ -162,7 +178,14 @@ export function commandProgramName(command: string, depth = 0): string | null { |
| 162 | 178 | } |
| 163 | 179 | return null; |
| 164 | 180 | } |
| 165 | return token.split(/[\\/]/).at(-1) || null; | |
| 181 | if (tokenProgram && SHELL_PROGRAMS.has(tokenProgram.replace(/\.exe$/i, "").toLowerCase())) { | |
| 182 | const scriptIndex = shellCommandArgumentIndex(tokens, index + 1); | |
| 183 | if (scriptIndex !== null) { | |
| 184 | const script = tokens[scriptIndex]; | |
| 185 | return script ? commandProgramName(script, depth + 1) : null; | |
| 186 | } | |
| 187 | } | |
| 188 | return tokenProgram || null; | |
| 166 | 189 | } |
| 167 | 190 | |
| 168 | 191 | return null; |
packages/client-runtime/src/work-log/presentation.test.ts+208| ... | ... | @@ -6,10 +6,218 @@ import { |
| 6 | 6 | commandDetailRepeatsCommand, |
| 7 | 7 | extractCommandOutputText, |
| 8 | 8 | resolveViewedImageAsset, |
| 9 | resolveWorkEntryToolPresentation, | |
| 10 | summarizeToolGroup, | |
| 9 | 11 | toolGroupAction, |
| 12 | toolGroupSummaryKind, | |
| 13 | type WorkLogPresentationEntry, | |
| 10 | 14 | workEntryViewedImagePath, |
| 11 | 15 | } from "./presentation.js"; |
| 12 | 16 | |
| 17 | describe("resolveWorkEntryToolPresentation", () => { | |
| 18 | it.each([ | |
| 19 | "mcp__t3-code__preview_click", | |
| 20 | "mcp__t3_code__preview_click", | |
| 21 | "mcp__t3code__preview_click", | |
| 22 | "T3-code.preview_click", | |
| 23 | "t3-code · preview_click completed", | |
| 24 | "t3_code/preview_click", | |
| 25 | "preview_click", | |
| 26 | ])("recognizes browser tool names across providers: %s", (label) => { | |
| 27 | expect(resolveWorkEntryToolPresentation({ label })).toEqual({ | |
| 28 | displayName: "Click in the preview browser", | |
| 29 | icon: "browser", | |
| 30 | }); | |
| 31 | }); | |
| 32 | ||
| 33 | it("uses structured MCP identity when the provider supplies a custom title", () => { | |
| 34 | expect( | |
| 35 | resolveWorkEntryToolPresentation({ | |
| 36 | label: "Tool call complete", | |
| 37 | toolTitle: "Inspect the current page", | |
| 38 | toolData: { server: "t3-code", tool: "preview_snapshot", result: { title: "Example" } }, | |
| 39 | }), | |
| 40 | ).toEqual({ displayName: "Take a snapshot of the preview page", icon: "browser" }); | |
| 41 | }); | |
| 42 | ||
| 43 | it.each([ | |
| 44 | ["inProgress", "Clicking in the preview browser"], | |
| 45 | ["completed", "Clicked in the preview browser"], | |
| 46 | ["failed", "Failed to click in the preview browser"], | |
| 47 | ["declined", "Declined to click in the preview browser"], | |
| 48 | ["stopped", "Stopped clicking in the preview browser"], | |
| 49 | ["unknown", "Click in the preview browser"], | |
| 50 | ])("describes the tool's own %s state", (toolLifecycleStatus, displayName) => { | |
| 51 | expect( | |
| 52 | resolveWorkEntryToolPresentation({ | |
| 53 | label: "T3-code.preview_click", | |
| 54 | toolLifecycleStatus, | |
| 55 | }), | |
| 56 | ).toEqual({ displayName, icon: "browser" }); | |
| 57 | }); | |
| 58 | ||
| 59 | it("uses the summary's state only when the provider omitted a lifecycle status", () => { | |
| 60 | const entry = { label: "T3-code.preview_click" }; | |
| 61 | expect(resolveWorkEntryToolPresentation(entry, "inProgress")?.displayName).toBe( | |
| 62 | "Clicking in the preview browser", | |
| 63 | ); | |
| 64 | expect(resolveWorkEntryToolPresentation(entry, "completed")?.displayName).toBe( | |
| 65 | "Clicked in the preview browser", | |
| 66 | ); | |
| 67 | expect( | |
| 68 | resolveWorkEntryToolPresentation({ ...entry, toolLifecycleStatus: "completed" }, "inProgress") | |
| 69 | ?.displayName, | |
| 70 | ).toBe("Clicked in the preview browser"); | |
| 71 | expect( | |
| 72 | resolveWorkEntryToolPresentation({ ...entry, toolLifecycleStatus: "failed" }, "completed") | |
| 73 | ?.displayName, | |
| 74 | ).toBe("Failed to click in the preview browser"); | |
| 75 | }); | |
| 76 | ||
| 77 | it.each([ | |
| 78 | ["preview_type", "Typing in the preview browser", "Typed in the preview browser"], | |
| 79 | [ | |
| 80 | "preview_set_appearance", | |
| 81 | "Setting preview browser appearance", | |
| 82 | "Set preview browser appearance", | |
| 83 | ], | |
| 84 | [ | |
| 85 | "preview_snapshot", | |
| 86 | "Taking a snapshot of the preview page", | |
| 87 | "Took a snapshot of the preview page", | |
| 88 | ], | |
| 89 | [ | |
| 90 | "preview_recording_stop", | |
| 91 | "Stopping recording the preview browser", | |
| 92 | "Stopped recording the preview browser", | |
| 93 | ], | |
| 94 | ["t3_thread_read", "Reading a T3 thread", "Read a T3 thread"], | |
| 95 | ["t3_thread_send", "Sending to a T3 thread", "Sent to a T3 thread"], | |
| 96 | [ | |
| 97 | "t3_worktree_handoff", | |
| 98 | "Handing off thread to a git worktree", | |
| 99 | "Handed off thread to a git worktree", | |
| 100 | ], | |
| 101 | ])("preserves verb forms and the rest of %s's label", (tool, running, completed) => { | |
| 102 | const entry = { label: `t3-code.${tool}` }; | |
| 103 | expect( | |
| 104 | resolveWorkEntryToolPresentation({ ...entry, toolLifecycleStatus: "inProgress" }) | |
| 105 | ?.displayName, | |
| 106 | ).toBe(running); | |
| 107 | expect( | |
| 108 | resolveWorkEntryToolPresentation({ ...entry, toolLifecycleStatus: "completed" })?.displayName, | |
| 109 | ).toBe(completed); | |
| 110 | }); | |
| 111 | ||
| 112 | it("keeps T3 branding for non-browser tools and falls back to the original tool label", () => { | |
| 113 | expect( | |
| 114 | resolveWorkEntryToolPresentation({ | |
| 115 | label: "mcp__t3_code__task_status", | |
| 116 | toolTitle: "Check the child task", | |
| 117 | }), | |
| 118 | ).toEqual({ displayName: "Get delegated task status", icon: "t3-code" }); | |
| 119 | }); | |
| 120 | ||
| 121 | it("does not brand unknown tools or another server's matching tool name", () => { | |
| 122 | for (const label of [ | |
| 123 | "mcp__github__preview_click", | |
| 124 | "t3-code.unknown_tool", | |
| 125 | "t3-code.toString", | |
| 126 | "Search files", | |
| 127 | ]) { | |
| 128 | expect(resolveWorkEntryToolPresentation({ label })).toBeNull(); | |
| 129 | } | |
| 130 | expect( | |
| 131 | resolveWorkEntryToolPresentation({ | |
| 132 | label: "preview_click", | |
| 133 | toolData: { server: "another-server", tool: "preview_click" }, | |
| 134 | }), | |
| 135 | ).toBeNull(); | |
| 136 | }); | |
| 137 | }); | |
| 138 | ||
| 139 | describe("browser group summaries", () => { | |
| 140 | const browserEntry: WorkLogPresentationEntry = { | |
| 141 | label: "MCP tool call", | |
| 142 | toolData: { server: "t3-code", tool: "preview_click" }, | |
| 143 | itemType: "mcp_tool_call", | |
| 144 | toolLifecycleStatus: "completed", | |
| 145 | tone: "tool", | |
| 146 | }; | |
| 147 | const commandEntry: WorkLogPresentationEntry = { | |
| 148 | label: "Ran command", | |
| 149 | command: "/bin/bash -lc 'vp test run'", | |
| 150 | itemType: "command_execution", | |
| 151 | toolLifecycleStatus: "completed", | |
| 152 | tone: "tool", | |
| 153 | }; | |
| 154 | ||
| 155 | it.each([1, 18])("counts %s browser calls separately from generic tools", (count) => { | |
| 156 | const entries = Array.from({ length: count }, (_, index) => ({ | |
| 157 | ...browserEntry, | |
| 158 | toolCallId: `browser-${index}`, | |
| 159 | })); | |
| 160 | expect(summarizeToolGroup(entries)).toBe( | |
| 161 | `Used browser ${count} ${count === 1 ? "time" : "times"}`, | |
| 162 | ); | |
| 163 | expect(toolGroupSummaryKind(entries)).toBe("browser"); | |
| 164 | }); | |
| 165 | ||
| 166 | it("combines command and browser counts in a single sentence", () => { | |
| 167 | const entries = [ | |
| 168 | ...Array.from({ length: 4 }, () => commandEntry), | |
| 169 | ...Array.from({ length: 15 }, () => browserEntry), | |
| 170 | ]; | |
| 171 | expect(summarizeToolGroup(entries)).toBe("Ran 4 commands and used browser 15 times"); | |
| 172 | expect(toolGroupSummaryKind(entries)).toBe("mixed"); | |
| 173 | }); | |
| 174 | ||
| 175 | it("preserves first-seen action ordering alongside non-browser tools", () => { | |
| 176 | expect( | |
| 177 | summarizeToolGroup([ | |
| 178 | browserEntry, | |
| 179 | commandEntry, | |
| 180 | { | |
| 181 | ...browserEntry, | |
| 182 | toolData: { server: "t3-code", tool: "task_status" }, | |
| 183 | }, | |
| 184 | ]), | |
| 185 | ).toBe("Used browser 1 time, ran 1 command, and used 1 tool"); | |
| 186 | }); | |
| 187 | ||
| 188 | it("recognizes Claude browser identity without treating script metadata as a shell command", () => { | |
| 189 | expect( | |
| 190 | summarizeToolGroup([ | |
| 191 | { | |
| 192 | ...browserEntry, | |
| 193 | command: "node inspect-page.js", | |
| 194 | toolData: { toolName: "mcp__t3_code__preview_evaluate" }, | |
| 195 | }, | |
| 196 | ]), | |
| 197 | ).toBe("Used browser 1 time"); | |
| 198 | }); | |
| 199 | ||
| 200 | it("keeps foreign tools and web searches out of the browser count", () => { | |
| 201 | expect( | |
| 202 | summarizeToolGroup([ | |
| 203 | browserEntry, | |
| 204 | { | |
| 205 | ...browserEntry, | |
| 206 | label: "preview_click", | |
| 207 | toolData: { server: "another-server", tool: "preview_click" }, | |
| 208 | }, | |
| 209 | { label: "Search", tone: "tool", itemType: "web_search" }, | |
| 210 | ]), | |
| 211 | ).toBe("Used browser 1 time, used 1 tool, and searched the web 1 time"); | |
| 212 | }); | |
| 213 | ||
| 214 | it("keeps browser screenshots in the browser count while preserving their image path", () => { | |
| 215 | const entry = { ...browserEntry, viewedImagePath: "/workspace/page.png" }; | |
| 216 | expect(summarizeToolGroup([entry])).toBe("Used browser 1 time"); | |
| 217 | expect(workEntryViewedImagePath(entry)).toBe("/workspace/page.png"); | |
| 218 | }); | |
| 219 | }); | |
| 220 | ||
| 13 | 221 | describe("command work-log details", () => { |
| 14 | 222 | it("extracts Claude result blocks and projected output", () => { |
| 15 | 223 | expect( |
packages/client-runtime/src/work-log/presentation.ts+102| ... | ... | @@ -17,6 +17,7 @@ export function isWorktreeSetupActivity(kind: string): boolean { |
| 17 | 17 | export interface WorkLogPresentationEntry { |
| 18 | 18 | readonly label: string; |
| 19 | 19 | readonly toolTitle?: string; |
| 20 | readonly toolData?: unknown; | |
| 20 | 21 | readonly tone: "thinking" | "tool" | "info" | "error"; |
| 21 | 22 | readonly command?: string; |
| 22 | 23 | readonly detail?: string; |
| ... | ... | @@ -35,6 +36,7 @@ export type ToolGroupAction = |
| 35 | 36 | | "read" |
| 36 | 37 | | "edit" |
| 37 | 38 | | "command" |
| 39 | | "browser" | |
| 38 | 40 | | "code-search" |
| 39 | 41 | | "search" |
| 40 | 42 | | "other" |
| ... | ... | @@ -51,6 +53,103 @@ export function normalizeCompactToolLabel(value: string): string { |
| 51 | 53 | return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); |
| 52 | 54 | } |
| 53 | 55 | |
| 56 | const T3_MCP_TOOL_LABELS: Record< | |
| 57 | string, | |
| 58 | readonly [action: string, running: string, completed: string, detail: string] | |
| 59 | > = { | |
| 60 | orchestrator_capabilities: ["Get", "Getting", "Got", "orchestration capabilities"], | |
| 61 | delegate_task: ["Delegate", "Delegating", "Delegated", "a child task"], | |
| 62 | task_status: ["Get", "Getting", "Got", "delegated task status"], | |
| 63 | task_cancel: ["Cancel", "Canceling", "Canceled", "delegated task"], | |
| 64 | schedule_task: ["Schedule", "Scheduling", "Scheduled", "a recurring task"], | |
| 65 | list_scheduled_tasks: ["List", "Listing", "Listed", "scheduled tasks"], | |
| 66 | update_scheduled_task: ["Update", "Updating", "Updated", "a scheduled task"], | |
| 67 | delete_scheduled_task: ["Delete", "Deleting", "Deleted", "a scheduled task"], | |
| 68 | create_threads: ["Create", "Creating", "Created", "T3 threads"], | |
| 69 | t3_thread_start: ["Start", "Starting", "Started", "a T3 thread"], | |
| 70 | t3_thread_list: ["List", "Listing", "Listed", "T3 threads"], | |
| 71 | t3_thread_read: ["Read", "Reading", "Read", "a T3 thread"], | |
| 72 | t3_thread_send: ["Send", "Sending", "Sent", "to a T3 thread"], | |
| 73 | t3_thread_wait: ["Wait", "Waiting", "Waited", "for a T3 thread"], | |
| 74 | t3_thread_interrupt: ["Interrupt", "Interrupting", "Interrupted", "a T3 thread"], | |
| 75 | t3_worktree_handoff: ["Hand off", "Handing off", "Handed off", "thread to a git worktree"], | |
| 76 | t3_worktree_status: ["Get", "Getting", "Got", "thread worktree status"], | |
| 77 | preview_status: ["Get", "Getting", "Got", "preview browser status"], | |
| 78 | preview_open: ["Open", "Opening", "Opened", "a page in the preview browser"], | |
| 79 | preview_navigate: ["Navigate", "Navigating", "Navigated", "the preview browser"], | |
| 80 | preview_snapshot: [ | |
| 81 | "Take a snapshot of", | |
| 82 | "Taking a snapshot of", | |
| 83 | "Took a snapshot of", | |
| 84 | "the preview page", | |
| 85 | ], | |
| 86 | preview_click: ["Click", "Clicking", "Clicked", "in the preview browser"], | |
| 87 | preview_press: ["Press", "Pressing", "Pressed", "a key in the preview browser"], | |
| 88 | preview_type: ["Type", "Typing", "Typed", "in the preview browser"], | |
| 89 | preview_scroll: ["Scroll", "Scrolling", "Scrolled", "the preview browser"], | |
| 90 | preview_resize: ["Resize", "Resizing", "Resized", "the preview browser"], | |
| 91 | preview_evaluate: ["Evaluate", "Evaluating", "Evaluated", "script in the preview browser"], | |
| 92 | preview_wait_for: ["Wait", "Waiting", "Waited", "for the preview page"], | |
| 93 | preview_set_appearance: ["Set", "Setting", "Set", "preview browser appearance"], | |
| 94 | preview_recording_start: ["Start", "Starting", "Started", "recording the preview browser"], | |
| 95 | preview_recording_stop: ["Stop", "Stopping", "Stopped", "recording the preview browser"], | |
| 96 | }; | |
| 97 | ||
| 98 | function resolveT3McpToolPresentation(value: string | undefined, status: string | undefined) { | |
| 99 | if (!value) return null; | |
| 100 | const name = normalizeCompactToolLabel(value).replace( | |
| 101 | /^(?:mcp__(?:t3-code|t3_code|t3code)__|(?:t3-code|t3_code|t3code)(?:[.:/]|\s*·\s*))/i, | |
| 102 | "", | |
| 103 | ); | |
| 104 | if (!Object.hasOwn(T3_MCP_TOOL_LABELS, name)) return null; | |
| 105 | ||
| 106 | const [action, running, completed, detail] = T3_MCP_TOOL_LABELS[name]!; | |
| 107 | const verb = | |
| 108 | status === "inProgress" | |
| 109 | ? running | |
| 110 | : status === "completed" | |
| 111 | ? completed | |
| 112 | : status === "failed" | |
| 113 | ? `Failed to ${action.toLowerCase()}` | |
| 114 | : status === "declined" | |
| 115 | ? `Declined to ${action.toLowerCase()}` | |
| 116 | : status === "stopped" | |
| 117 | ? `Stopped ${running.toLowerCase()}` | |
| 118 | : action; | |
| 119 | ||
| 120 | return { | |
| 121 | displayName: `${verb} ${detail}`, | |
| 122 | icon: name.startsWith("preview_") ? ("browser" as const) : ("t3-code" as const), | |
| 123 | }; | |
| 124 | } | |
| 125 | ||
| 126 | /** Resolves tool identity before choosing labels or icons in either client. */ | |
| 127 | export function resolveWorkEntryToolPresentation( | |
| 128 | entry: Pick<WorkLogPresentationEntry, "label" | "toolTitle" | "toolData" | "toolLifecycleStatus">, | |
| 129 | fallbackStatus?: "inProgress" | "completed", | |
| 130 | ) { | |
| 131 | const status = entry.toolLifecycleStatus ?? fallbackStatus; | |
| 132 | const data = entry.toolData; | |
| 133 | if (data !== null && typeof data === "object") { | |
| 134 | if ( | |
| 135 | "server" in data && | |
| 136 | typeof data.server === "string" && | |
| 137 | "tool" in data && | |
| 138 | typeof data.tool === "string" | |
| 139 | ) { | |
| 140 | return resolveT3McpToolPresentation(`${data.server}.${data.tool}`, status); | |
| 141 | } | |
| 142 | if ("toolName" in data && typeof data.toolName === "string") { | |
| 143 | return resolveT3McpToolPresentation(data.toolName, status); | |
| 144 | } | |
| 145 | } | |
| 146 | ||
| 147 | return ( | |
| 148 | resolveT3McpToolPresentation(entry.toolTitle, status) ?? | |
| 149 | resolveT3McpToolPresentation(entry.label, status) | |
| 150 | ); | |
| 151 | } | |
| 152 | ||
| 54 | 153 | function asRecord(value: unknown): Record<string, unknown> | null { |
| 55 | 154 | return value !== null && typeof value === "object" && !Array.isArray(value) |
| 56 | 155 | ? (value as Record<string, unknown>) |
| ... | ... | @@ -207,6 +306,7 @@ export function workLogEntryIsLocalCodeSearch(entry: WorkLogPresentationEntry): |
| 207 | 306 | } |
| 208 | 307 | |
| 209 | 308 | export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupAction { |
| 309 | if (resolveWorkEntryToolPresentation(entry)?.icon === "browser") return "browser"; | |
| 210 | 310 | if ( |
| 211 | 311 | entry.requestKind === "file-read" || |
| 212 | 312 | entry.itemType === "image_view" || |
| ... | ... | @@ -312,6 +412,8 @@ function toolGroupActionLabel(action: ToolGroupAction, count: number): string { |
| 312 | 412 | return `Changed ${count} ${count === 1 ? "file" : "files"}`; |
| 313 | 413 | case "command": |
| 314 | 414 | return `Ran ${count} ${count === 1 ? "command" : "commands"}`; |
| 415 | case "browser": | |
| 416 | return `Used browser ${count} ${count === 1 ? "time" : "times"}`; | |
| 315 | 417 | case "search": |
| 316 | 418 | return `Searched the web ${count} ${count === 1 ? "time" : "times"}`; |
| 317 | 419 | case "code-search": |
packages/client-runtime/src/work-log/scrollAnchor.test.ts created+63| ... | ... | @@ -0,0 +1,63 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { resolveWorkGroupScrollAnchor } from "./scrollAnchor.js"; | |
| 4 | ||
| 5 | describe("tool-group scroll anchors", () => { | |
| 6 | const data = Array.from({ length: 18 }, (_, index) => ({ id: `call-${index}` })); | |
| 7 | const state = { data, positionAtIndex: (index: number) => index * 33 }; | |
| 8 | ||
| 9 | it("returns to the top after a fast flick even when the cached start is five rows behind", () => { | |
| 10 | const snapshot = { ...state, scroll: 0, start: 5 }; | |
| 11 | expect(resolveWorkGroupScrollAnchor(snapshot)).toEqual({ | |
| 12 | rowId: "call-0", | |
| 13 | offsetWithinRow: 0, | |
| 14 | scrollOffset: 0, | |
| 15 | }); | |
| 16 | }); | |
| 17 | ||
| 18 | it.each([ | |
| 19 | { scroll: 302, start: 0, rowId: "call-9", offsetWithinRow: 5 }, | |
| 20 | { scroll: 40, start: 12, rowId: "call-1", offsetWithinRow: 7 }, | |
| 21 | { scroll: 165, start: 4, rowId: "call-5", offsetWithinRow: 0 }, | |
| 22 | ])("uses the measured position at $scroll rather than cached row $start", (example) => { | |
| 23 | expect(resolveWorkGroupScrollAnchor({ ...state, ...example })).toEqual({ | |
| 24 | rowId: example.rowId, | |
| 25 | offsetWithinRow: example.offsetWithinRow, | |
| 26 | scrollOffset: example.scroll, | |
| 27 | }); | |
| 28 | }); | |
| 29 | ||
| 30 | it("preserves an offset within expanded output", () => { | |
| 31 | const positions = [0, 33, 600]; | |
| 32 | expect( | |
| 33 | resolveWorkGroupScrollAnchor({ | |
| 34 | data: [{ id: "first" }, { id: "output" }, { id: "last" }], | |
| 35 | scroll: 153, | |
| 36 | positionAtIndex: (index) => positions[index], | |
| 37 | }), | |
| 38 | ).toEqual({ rowId: "output", offsetWithinRow: 120, scrollOffset: 153 }); | |
| 39 | }); | |
| 40 | ||
| 41 | it("does not preserve overscroll before the first row", () => { | |
| 42 | expect(resolveWorkGroupScrollAnchor({ ...state, scroll: -20 })).toEqual({ | |
| 43 | rowId: "call-0", | |
| 44 | offsetWithinRow: 0, | |
| 45 | scrollOffset: 0, | |
| 46 | }); | |
| 47 | }); | |
| 48 | ||
| 49 | it("skips incomplete layout snapshots instead of saving an invalid anchor", () => { | |
| 50 | expect(resolveWorkGroupScrollAnchor({ ...state, data: [], scroll: 0 })).toBeUndefined(); | |
| 51 | expect(resolveWorkGroupScrollAnchor({ ...state, scroll: Number.NaN })).toBeUndefined(); | |
| 52 | expect( | |
| 53 | resolveWorkGroupScrollAnchor({ ...state, scroll: 165, positionAtIndex: () => undefined }), | |
| 54 | ).toBeUndefined(); | |
| 55 | expect( | |
| 56 | resolveWorkGroupScrollAnchor({ | |
| 57 | ...state, | |
| 58 | scroll: 165, | |
| 59 | positionAtIndex: (index) => (index === 0 ? 0 : Number.NaN), | |
| 60 | }), | |
| 61 | ).toBeUndefined(); | |
| 62 | }); | |
| 63 | }); |
packages/client-runtime/src/work-log/scrollAnchor.ts created+33| ... | ... | @@ -0,0 +1,33 @@ |
| 1 | /** Save the row at the current offset; a virtualizer's cached visible range can lag a fling. */ | |
| 2 | export function resolveWorkGroupScrollAnchor(state: { | |
| 3 | readonly data: ReadonlyArray<{ readonly id: string }>; | |
| 4 | readonly scroll: number; | |
| 5 | readonly positionAtIndex: (index: number) => number | undefined; | |
| 6 | }) { | |
| 7 | if (state.data.length === 0 || !Number.isFinite(state.scroll)) return undefined; | |
| 8 | const scrollOffset = Math.max(0, state.scroll); | |
| 9 | let low = 0; | |
| 10 | let high = state.data.length - 1; | |
| 11 | let index = 0; | |
| 12 | let rowTop = state.positionAtIndex(0); | |
| 13 | if (rowTop === undefined || !Number.isFinite(rowTop)) return undefined; | |
| 14 | ||
| 15 | while (low <= high) { | |
| 16 | const middle = Math.floor((low + high) / 2); | |
| 17 | const top = state.positionAtIndex(middle); | |
| 18 | if (top === undefined || !Number.isFinite(top)) return undefined; | |
| 19 | if (top <= scrollOffset) { | |
| 20 | index = middle; | |
| 21 | rowTop = top; | |
| 22 | low = middle + 1; | |
| 23 | } else { | |
| 24 | high = middle - 1; | |
| 25 | } | |
| 26 | } | |
| 27 | ||
| 28 | return { | |
| 29 | rowId: state.data[index]!.id, | |
| 30 | offsetWithinRow: Math.max(0, scrollOffset - rowTop), | |
| 31 | scrollOffset, | |
| 32 | }; | |
| 33 | } |