authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-01 21:52:46-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 21:52:46-07:00
logb520120cf169ce63a5606447a292451e97622c9a
tree3365af6cc0511b94733e8043ff04602a369d7072
parentdb4bf9497b524f35e665af9e941aa8faeb05ea9e
signature Signed by PGP key B5690EEEBB952194

fix(chat): improve tool group summaries and scrolling (#9106)


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";
125125import {
126126 deriveCenteredContentHorizontalPadding,
127127 deriveThreadFeedInitialContentInset,
128 deriveThreadWorkLogSizing,
128129 type LayoutVariant,
129130} from "../../lib/layout";
130131import {
......@@ -148,6 +149,7 @@ import type { ThreadContentPresentation } from "./threadContentPresentation";
148149import {
149150 resolveThreadFeedLiveFollow,
150151 type ThreadFeedLiveFollowEvent,
152 type ThreadWorkGroupScrollPosition,
151153} from "./thread-feed-live-follow";
152154import {
153155 collapsedWorkLogHeight,
......@@ -191,12 +193,9 @@ function formatMessageTime(input: string): string {
191193 return MESSAGE_TIME_FORMATTER.format(timestamp);
192194}
193195
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.
199const 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.
198const TURN_FOLD_HEIGHT = 42; // min-h-11 (38.5) + mb-1 (3.5), with the mobile 14px rem
200199const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
201200// Let neighboring rows move out of the new rows' space before showing their text.
202201const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay(
......@@ -1452,11 +1451,13 @@ function renderFeedEntry(
14521451 props: Pick<ThreadFeedProps, "environmentId" | "onUseArtifactTemplate" | "skills"> & {
14531452 readonly copiedRowId: string | null;
14541453 readonly expandedWorkRows: Record<string, boolean>;
1454 readonly workRowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
1455 readonly workGroupScrollPositions: Map<string, ThreadWorkGroupScrollPosition>;
14551456 readonly terminalAssistantMessageIds: ReadonlySet<string>;
14561457 readonly unsettledTurnId: TurnId | null;
14571458 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;
14601461 readonly onToggleTurnFold: (turnId: TurnId) => void;
14611462 readonly onPressPreview: (source: FilePreviewSource) => void;
14621463 readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;
......@@ -1482,8 +1483,14 @@ function renderFeedEntry(
14821483 onPress={() => props.onToggleTurnFold(entry.turnId)}
14831484 hitSlop={4}
14841485 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 }}
14851489 >
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 >
14871494 {entry.label}
14881495 </Text>
14891496 <ThreadDisclosureChevron
......@@ -1499,14 +1506,16 @@ function renderFeedEntry(
14991506 if (entry.type === "work-toggle") {
15001507 return (
15011508 <ThreadWorkGroupToggle
1509 rowSizing={props.workRowSizing}
15021510 expanded={entry.expanded}
15031511 hiddenCount={entry.hiddenCount}
15041512 iconSubtleColor={iconSubtleColor}
15051513 summary={entry.summary}
15061514 summaryKind={entry.summaryKind}
1515 summaryToolIcon={entry.summaryToolIcon}
15071516 hasFailure={entry.hasFailure}
15081517 shimmer={entry.shimmer}
1509 onToggle={() => props.onToggleWorkGroup(entry.groupId)}
1518 onToggle={() => props.onToggleWorkGroup(entry.groupId, entry.id)}
15101519 />
15111520 );
15121521 }
......@@ -1669,9 +1678,15 @@ function renderFeedEntry(
16691678
16701679 return (
16711680 <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}`}
16721684 activities={entry.activities}
1685 anchorKey={entry.id}
16731686 copiedRowId={props.copiedRowId}
16741687 expandedRows={props.expandedWorkRows}
1688 rowSizing={props.workRowSizing}
1689 scrollPositions={props.workGroupScrollPositions}
16751690 iconSubtleColor={iconSubtleColor}
16761691 onCopyRow={props.onCopyWorkRow}
16771692 onToggleRow={props.onToggleWorkRow}
......@@ -1959,7 +1974,22 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
19591974 const headerMaterialVisibleRef = useRef(false);
19601975 const previousLatestTurnRef = useRef(props.latestTurn);
19611976 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]);
19631993 const [viewportWidth, setViewportWidth] = useState(() =>
19641994 props.layoutVariant === "split" ? 0 : windowWidth,
19651995 );
......@@ -2227,6 +2257,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
22272257 () => ({
22282258 copiedRowId,
22292259 expandedWorkRows,
2260 workRowSizing,
22302261 iconSubtleColor,
22312262 markdownStyles,
22322263 reviewCommentColors,
......@@ -2236,6 +2267,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
22362267 [
22372268 copiedRowId,
22382269 expandedWorkRows,
2270 workRowSizing,
22392271 iconSubtleColor,
22402272 markdownStyles,
22412273 reviewCommentColors,
......@@ -2338,6 +2370,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
23382370 // ThreadId, and keying resets (or the list mount) on the bare id would
23392371 // carry stale scroll/follow state across an environment switch.
23402372 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 );
23412379
23422380 useEffect(() => {
23432381 reportHeaderMaterialVisibility(false);
......@@ -2541,8 +2579,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
25412579 }, []);
25422580
25432581 const onToggleWorkGroup = useCallback(
2544 (groupId: string) => {
2545 suspendEndScrollMaintenanceForDisclosure(`work-toggle:${groupId}`);
2582 (groupId: string, anchorKey: string) => {
2583 suspendEndScrollMaintenanceForDisclosure(anchorKey);
25462584 setInteractionState((current) => ({
25472585 ...current,
25482586 expandedWorkGroups: {
......@@ -2555,8 +2593,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
25552593 );
25562594
25572595 const onToggleWorkRow = useCallback(
2558 (rowId: string) => {
2559 suspendEndScrollMaintenanceForDisclosure(rowId);
2596 (rowId: string, anchorKey: string) => {
2597 suspendEndScrollMaintenanceForDisclosure(anchorKey);
25602598 setInteractionState((current) => ({
25612599 ...current,
25622600 expandedWorkRows: {
......@@ -2610,6 +2648,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
26102648 // average once one of their type has been measured.
26112649 const getFixedItemSize = useCallback(
26122650 (entry: ThreadFeedEntry) => {
2651 if (workRowSizing.fixedRowHeight === undefined) {
2652 return undefined;
2653 }
26132654 switch (entry.type) {
26142655 case "turn-fold":
26152656 return TURN_FOLD_HEIGHT;
......@@ -2625,7 +2666,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
26252666 return undefined;
26262667 }
26272668 },
2628 [expandedWorkRows],
2669 [expandedWorkRows, workRowSizing.fixedRowHeight],
26292670 );
26302671
26312672 // Disclosures can mount existing offscreen rows as well as new work rows.
......@@ -2641,6 +2682,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
26412682 environmentId: props.environmentId,
26422683 copiedRowId,
26432684 expandedWorkRows,
2685 workRowSizing,
2686 workGroupScrollPositions,
26442687 terminalAssistantMessageIds,
26452688 unsettledTurnId,
26462689 onCopyWorkRow,
......@@ -2668,6 +2711,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
26682711 copiedRowId,
26692712 disclosureToggleSettling,
26702713 expandedWorkRows,
2714 workRowSizing,
2715 workGroupScrollPositions,
26712716 terminalAssistantMessageIds,
26722717 unsettledTurnId,
26732718 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";
33import {
44 resolveThreadFeedLiveFollow,
55 resolveThreadFeedSubmissionAnchor,
6 resolveThreadWorkGroupInitialScroll,
7 shouldFollowThreadWorkGroupAppend,
68} from "./thread-feed-live-follow";
79
10describe("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
37describe("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
885describe("resolveThreadFeedSubmissionAnchor", () => {
986 it("anchors the first user message in a thread", () => {
1087 expect(
apps/mobile/src/features/threads/thread-feed-live-follow.ts+35
......@@ -12,6 +12,41 @@ export type ThreadFeedLiveFollowEvent =
1212 readonly userScrollSessionActive: boolean;
1313 };
1414
15export interface ThreadWorkGroupScrollPosition {
16 readonly rowId: string;
17 readonly offsetWithinRow: number;
18 readonly scrollOffset: number;
19 readonly contentHeight: number;
20}
21
22export 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
30export 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
1550export function resolveThreadFeedSubmissionAnchor<AnchorId>(input: {
1651 readonly currentAnchorMessageId: AnchorId | null;
1752 readonly submittedMessageId: AnchorId;
apps/mobile/src/features/threads/thread-work-log.tsx+492-166
......@@ -1,8 +1,21 @@
11import * as Haptics from "expo-haptics";
22import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
33import { MaskedView } from "@expo/ui/community/masked-view";
4import type { LegendListRef } from "@legendapp/list/react-native";
5import { AnimatedLegendList } from "@legendapp/list/reanimated";
46import { useIsFocused } from "@react-navigation/native";
5import { useEffect, useId, useLayoutEffect, useState, type ComponentProps } from "react";
7import {
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";
619import {
720 AccessibilityInfo,
821 AppState,
......@@ -15,12 +28,21 @@ import {
1528import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
1629
1730import { AppText as Text } from "../../components/AppText";
31import { T3Wordmark } from "../../components/T3Wordmark";
1832import { cn } from "../../lib/cn";
33import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout";
1934import type { ThreadFeedActivity } from "../../lib/threadActivity";
2035import {
36 resolveThreadWorkGroupInitialScroll,
37 shouldFollowThreadWorkGroupAppend,
38 type ThreadWorkGroupScrollPosition,
39} from "./thread-feed-live-follow";
40import {
41 resolveWorkEntryToolPresentation,
2142 type ToolGroupSummaryKind,
2243 workEntryViewedImagePath,
2344} from "@t3tools/client-runtime/work-log/presentation";
45import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor";
2446import type { MarkdownImageRenderer } from "../../native/SelectableMarkdownText";
2547import Animated, {
2648 cancelAnimation,
......@@ -45,6 +67,33 @@ export const THREAD_DISCLOSURE_TRANSITION_MS = 180;
4567const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
4668const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140);
4769const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120);
70type WorkContentIcon = AppSymbolName | "browser" | "t3-code";
71
72function 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}
4897
4998export function ThreadDisclosureChevron(props: {
5099 readonly expanded: boolean;
......@@ -86,7 +135,7 @@ export function ThreadDisclosureChevron(props: {
86135
87136function ShimmerWorkContent(props: {
88137 readonly highlighted: boolean;
89 readonly icon: AppSymbolName;
138 readonly icon: WorkContentIcon;
90139 readonly iconSubtleColor: ColorValue;
91140 readonly label: string;
92141 readonly onTextLayout?: ComponentProps<typeof Text>["onTextLayout"];
......@@ -96,14 +145,10 @@ function ShimmerWorkContent(props: {
96145 <View className="flex-row items-center gap-1.5">
97146 <View className="h-6 w-6 shrink-0 items-center justify-center">
98147 {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}
107152 />
108153 ) : null}
109154 </View>
......@@ -122,7 +167,7 @@ function ShimmerWorkContent(props: {
122167}
123168
124169export function ShimmeringWorkContent(props: {
125 readonly icon: AppSymbolName;
170 readonly icon: WorkContentIcon;
126171 readonly iconSubtleColor: ColorValue;
127172 readonly label: string;
128173 readonly showIcon: boolean;
......@@ -293,182 +338,461 @@ function isFreshRow(createdAt: string): boolean {
293338 return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ROW_WINDOW_MS;
294339}
295340
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.
301const 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.
343const WORK_ROW_HEIGHT = THREAD_WORK_ROW_MIN_HEIGHT;
302344const WORK_ROW_GAP = 1; // gap-px
303const WORK_LOG_BOTTOM_MARGIN = 4; // mb-1
345const WORK_LOG_BOTTOM_MARGIN = 3.5; // mb-1 with the mobile 14px rem
346const WORK_GROUP_MAX_HEIGHT = 256;
347const WORK_GROUP_EDGE_FADE_HEIGHT = 12;
348
349export const WORK_GROUP_TOGGLE_HEIGHT = THREAD_WORK_ROW_MIN_HEIGHT;
304350
305export const WORK_GROUP_TOGGLE_HEIGHT = 32; // min-h-8
351function 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}
306357
307358export function collapsedWorkLogHeight(activities: ReadonlyArray<ThreadFeedActivity>): number {
308 const rows = activities;
309 if (rows.length === 0) {
359 if (activities.length === 0) {
310360 return 0;
311361 }
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 );
313367}
314368
315export function ThreadWorkLog(props: {
369interface ThreadWorkLogProps {
316370 readonly activities: ReadonlyArray<ThreadFeedActivity>;
371 readonly anchorKey: string;
317372 readonly copiedRowId: string | null;
318373 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;
320377 readonly onCopyRow: (rowId: string, value: string) => void;
321 readonly onToggleRow: (rowId: string) => void;
378 readonly onToggleRow: (rowId: string, anchorKey: string) => void;
322379 readonly renderImage: MarkdownImageRenderer;
323}) {
324 const rows = props.activities.map((activity) => ({
325 ...activity,
326 detail: compactActivityDetail(activity.detail),
327 }));
380}
381
382export 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 );
328407
329 if (rows.length === 0) {
408 if (props.activities.length === 0) {
330409 return null;
331410 }
332411
333412 return (
334413 <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 )}
462426 </View>
463427 );
464428}
465429
430function 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
647function workLogRowKey(row: ThreadFeedActivity): string {
648 return row.id;
649}
650
651const 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
466788export function ThreadWorkGroupToggle(props: {
789 readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
467790 readonly expanded: boolean;
468791 readonly hiddenCount: number;
469792 readonly iconSubtleColor: import("react-native").ColorValue;
470793 readonly summary: string;
471794 readonly summaryKind: ToolGroupSummaryKind;
795 readonly summaryToolIcon?: "browser" | "t3-code";
472796 readonly hasFailure: boolean;
473797 readonly shimmer: boolean;
474798 readonly onToggle: () => void;
......@@ -476,7 +800,7 @@ export function ThreadWorkGroupToggle(props: {
476800 const accessibilityLabel = props.hasFailure
477801 ? `${props.summary}, tool call failed`
478802 : props.summary;
479 const icon = toolGroupSummarySymbolName(props.summaryKind);
803 const icon = props.summaryToolIcon ?? toolGroupSummarySymbolName(props.summaryKind);
480804
481805 return (
482806 <View className="-mx-1 px-1 py-0">
......@@ -491,9 +815,11 @@ export function ThreadWorkGroupToggle(props: {
491815 props.onToggle();
492816 }}
493817 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 }}
494819 >
495820 {props.shimmer ? (
496821 <ShimmeringWorkContent
822 key={props.rowSizing.textSizeKey}
497823 icon={icon}
498824 iconSubtleColor={props.iconSubtleColor}
499825 label={props.summary}
......@@ -502,14 +828,13 @@ export function ThreadWorkGroupToggle(props: {
502828 ) : (
503829 <>
504830 <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} />
511832 </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 >
513838 {props.summary}
514839 </Text>
515840 </>
......@@ -533,6 +858,7 @@ function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName {
533858 return { ios: "square.and.pencil", android: "edit" };
534859 case "command":
535860 return { ios: "terminal", android: "terminal" };
861 case "browser":
536862 case "search":
537863 return { ios: "globe", android: "public" };
538864 case "code-search":
apps/mobile/src/lib/layout.test.ts+39
......@@ -8,11 +8,50 @@ import {
88 deriveLayout,
99 deriveStableFormSheetDetent,
1010 deriveThreadFeedInitialContentInset,
11 deriveThreadWorkLogSizing,
1112 deriveWorkspacePaneLayout,
1213 SPLIT_LAYOUT_MIN_HEIGHT,
1314 SPLIT_LAYOUT_MIN_WIDTH,
1415} from "./layout";
1516
17describe("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
1655describe("deriveThreadFeedInitialContentInset", () => {
1756 it("seeds Android scroll math with the composer overlay estimate", () => {
1857 expect(
apps/mobile/src/lib/layout.ts+26
......@@ -1,3 +1,6 @@
1import { scaledTypographyLineHeight } from "./appearancePreferences";
2import { MOBILE_TYPOGRAPHY } from "./typography";
3
14function clamp(value: number, min: number, max: number): number {
25 return Math.min(Math.max(value, min), max);
36}
......@@ -18,6 +21,29 @@ const SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH = 380;
1821
1922export const AUXILIARY_PANE_MIN_CONTENT_WIDTH = 960;
2023export const CHAT_CONTENT_MAX_WIDTH = 960;
24// min-h-8 uses the 14px rem configured in metro.config.js.
25export const THREAD_WORK_ROW_MIN_HEIGHT = 28;
26
27export 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}
2147
2248export const AUXILIARY_PANE_MIN_WIDTH = 260;
2349export const AUXILIARY_PANE_MAX_WIDTH = 480;
apps/mobile/src/lib/threadActivity.test.ts+636-97
......@@ -825,6 +825,408 @@ describe("buildThreadFeed", () => {
825825 expect(group.activities[0]?.getFullDetail()).toContain("repository.search");
826826 });
827827
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
8281230 it("defers large tool output expansion until a work row is opened or copied", () => {
8291231 let serializedToolOutputs = 0;
8301232 const activities = Array.from({ length: 5_000 }, (_, index) =>
......@@ -866,6 +1268,22 @@ describe("buildThreadFeed", () => {
8661268 }
8671269
8681270 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);
8691287 expect(group.activities[0]?.getFullDetail()).toContain('"output"');
8701288 expect(serializedToolOutputs).toBe(1);
8711289 expect(group.activities[0]?.getCopyText()).toContain('"output"');
......@@ -1137,17 +1555,26 @@ describe("buildThreadFeed", () => {
11371555 type: "activity-group",
11381556 activities: [{ status: "success" }, { status: "failure" }],
11391557 });
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 });
11481575 });
11491576
1150 it("models work-log overflow as list rows", () => {
1577 it("keeps expanded work in one group with stable row identities", () => {
11511578 const activity = (
11521579 id: string,
11531580 createdAt: string,
......@@ -1206,111 +1633,192 @@ describe("buildThreadFeed", () => {
12061633 );
12071634 expect(expanded.map((entry) => entry.id)).toEqual([
12081635 "work-toggle:work-group:activity-1",
1209 "activity-1",
1210 "activity-2",
1211 "activity-3",
1636 "work-details:work-group:activity-1",
12121637 ]);
12131638 expect(expanded[0]).toMatchObject({
12141639 type: "work-toggle",
12151640 expanded: true,
12161641 });
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 });
12171650 });
12181651
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 => ({
12411679 id,
12421680 createdAt: `2026-04-01T00:00:0${id.at(-1)}.000Z`,
12431681 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 = {
12551729 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,
12611801 },
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 });
12711813
12721814 const rows = deriveThreadFeedPresentation(
1273 feed,
1274 latestTurn,
1815 buildThreadFeed(thread),
1816 thread.latestTurn,
12751817 new Set(),
12761818 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,
13091820 );
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);
13141822 });
13151823
13161824 it("does not revive cached in-progress tools after work stops", () => {
......@@ -1417,6 +1925,37 @@ describe("buildThreadFeed", () => {
14171925 summary: "Ran 2 commands",
14181926 live: false,
14191927 });
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 });
14201959 });
14211960});
14221961
apps/mobile/src/lib/threadActivity.ts+64-35
......@@ -19,6 +19,7 @@ import {
1919 isWorktreeSetupActivity,
2020 normalizeCompactToolLabel,
2121 omitSupersededLifecycleMarkers,
22 resolveWorkEntryToolPresentation,
2223 summarizeToolGroup,
2324 toolGroupSummaryKind,
2425 type ToolGroupSummaryKind,
......@@ -146,6 +147,7 @@ export type ThreadFeedEntry =
146147 readonly expanded: boolean;
147148 readonly summary: string;
148149 readonly summaryKind: ToolGroupSummaryKind;
150 readonly summaryToolIcon?: "browser" | "t3-code";
149151 readonly hasFailure: boolean;
150152 readonly live: boolean;
151153 readonly shimmer: boolean;
......@@ -477,8 +479,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
477479 }
478480 if (itemType === "mcp_tool_call") {
479481 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;
482485 }
483486 }
484487 if (itemType) {
......@@ -826,6 +829,8 @@ function capitalizePhrase(value: string): string {
826829}
827830
828831function workEntryHeading(workEntry: WorkLogEntry): string {
832 const presentation = resolveWorkEntryToolPresentation(workEntry);
833 if (presentation) return presentation.displayName;
829834 if (!workEntry.toolTitle) {
830835 return capitalizePhrase(normalizeCompactToolLabel(workEntry.label));
831836 }
......@@ -1501,19 +1506,26 @@ function appendToolGroupRows(
15011506 : activities[0]!.id;
15021507 const groupId = `work-group:${identity}`;
15031508 const expanded = expandedWorkGroupIds.has(groupId);
1504 const latestInProgressActivity = activities.findLast(
1509 const latestActiveActivity = activities.findLast(
15051510 (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))),
15071517 );
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)!;
15121521 const summary = live
1513 ? liveToolActivitySummary(latestActivity)
1522 ? liveToolActivitySummary(latestActivity, active)
15141523 : activities.length === 1 && !activities[0]!.toolLike
15151524 ? activities[0]!.workEntry.label
15161525 : summarizeToolGroup(activities.map((activity) => activity.workEntry));
1526 const summaryToolIcon = live
1527 ? resolveWorkEntryToolPresentation(latestActivity.workEntry)?.icon
1528 : undefined;
15171529 result.push({
15181530 type: "work-toggle",
15191531 id: `${live ? "work-live" : "work-toggle"}:${groupId}`,
......@@ -1526,40 +1538,52 @@ function appendToolGroupRows(
15261538 summaryKind: toolGroupSummaryKind(
15271539 (live ? [latestActivity] : activities).map((activity) => activity.workEntry),
15281540 ),
1541 ...(summaryToolIcon ? { summaryToolIcon } : {}),
15291542 hasFailure: activities.findLast((activity) => activity.toolLike)?.status === "failure",
15301543 live,
1531 // Match the live label until the turn or contiguous tool run settles.
1532 shimmer: live,
1544 shimmer: active,
15331545 });
15341546 if (!expanded) {
15351547 return;
15361548 }
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 });
15561564}
15571565
1558function liveToolActivitySummary(activity: ThreadFeedActivity): string {
1566function liveToolActivitySummary(activity: ThreadFeedActivity, active: boolean): string {
1567 const presentation = resolveWorkEntryToolPresentation(
1568 activity.workEntry,
1569 active ? "inProgress" : "completed",
1570 );
1571 if (presentation) return presentation.displayName;
15591572 const command = activity.workEntry.command?.trim();
15601573 if (command) {
15611574 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"}`;
15631587 }
15641588 return activity.detail ?? activity.summary;
15651589}
......@@ -1773,15 +1797,20 @@ export function buildThreadFeed(
17731797 const detail = workEntryPreview(entry);
17741798 const getFullDetail = memoizeValue(() => buildWorkEntryExpandedBody(entry));
17751799 const getCopyText = memoizeValue(() => {
1800 const copyLabel = capitalizePhrase(
1801 normalizeCompactToolLabel(entry.toolTitle || entry.label),
1802 );
17761803 const fullDetail = getFullDetail();
17771804 if (entry.command) {
17781805 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]
17811810 .filter((value): value is string => Boolean(value))
17821811 .join("\n");
17831812 }
1784 return [summary, detail, fullDetail]
1813 return [copyLabel, detail, fullDetail]
17851814 .filter((value, index, values): value is string => {
17861815 return Boolean(value) && values.indexOf(value) === index;
17871816 })
apps/web/src/components/ChatView.logic.test.ts+130-1
......@@ -6,7 +6,7 @@ import {
66 ThreadId,
77 TurnId,
88} from "@t3tools/contracts";
9import { afterEach, describe, expect, it, vi } from "vite-plus/test";
9import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
1010
1111import type { Thread, ThreadShell } from "../types";
1212import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
......@@ -41,6 +41,7 @@ import {
4141 shouldShowBranchMismatchBanner,
4242 shouldShowPlanFollowUpPrompt,
4343 shouldWriteThreadErrorToCurrentServerThread,
44 toolGroupConsumesUpwardNavigation,
4445} from "./ChatView.logic";
4546
4647describe("isVideoPreviewRequestCurrent", () => {
......@@ -51,6 +52,134 @@ describe("isVideoPreviewRequestCurrent", () => {
5152 });
5253});
5354
55describe("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
54183const environmentId = EnvironmentId.make("environment-local");
55184const projectId = ProjectId.make("project-1");
56185const threadId = ThreadId.make("thread-1");
apps/web/src/components/ChatView.logic.ts+16
......@@ -99,6 +99,22 @@ export function shouldReleaseTimelineAnchorForToolActivity(input: {
9999 });
100100}
101101
102export 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
102118export function resolveDraftHeroState(input: {
103119 isLocalDraftThread: boolean;
104120 hasTimelineEntries: boolean;
apps/web/src/components/ChatView.tsx+7-2
......@@ -379,6 +379,7 @@ import {
379379 shouldWriteThreadErrorToCurrentServerThread,
380380 startNewThreadForProject,
381381 codexArtifactTemplatePromptToAppend,
382 toolGroupConsumesUpwardNavigation,
382383 waitForStartedServerThread,
383384} from "./ChatView.logic";
384385import type { ThreadSyncPhase } from "../threadSync";
......@@ -4218,7 +4219,11 @@ function ChatViewContent(props: ChatViewProps) {
42184219 // Only an upward wheel is a navigation intent; wheeling down while
42194220 // following either does nothing (at the end) or moves toward it.
42204221 const handleWheel = (event: WheelEvent) => {
4221 if (event.deltaY < 0 && contentScrollsUp()) {
4222 if (
4223 event.deltaY < 0 &&
4224 contentScrollsUp() &&
4225 !toolGroupConsumesUpwardNavigation(event.target)
4226 ) {
42224227 handleManualNavigation();
42234228 }
42244229 };
......@@ -4255,7 +4260,7 @@ function ChatViewContent(props: ChatViewProps) {
42554260 case "PageUp":
42564261 case "Home":
42574262 case "ArrowUp":
4258 if (contentScrollsUp()) {
4263 if (contentScrollsUp() && !toolGroupConsumesUpwardNavigation(event.target)) {
42594264 handleManualNavigation();
42604265 }
42614266 break;
apps/web/src/components/T3Wordmark.tsx created+12
......@@ -0,0 +1,12 @@
1import type { SVGProps } from "react";
2
3export 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 {
44 computeStableMessagesTimelineRows,
55 computeMessageDurationStart,
66 deriveMessagesTimelineRows,
7 liveWorkEntryLabel,
78 normalizeCompactToolLabel,
89 resolveAssistantMessageCopyState,
10 resolveWorkGroupScrollIndex,
11 shouldFollowWorkGroupAppend,
912 shouldPreserveAssistantLineBreaks,
13 workEntryDisplayLabel,
1014} from "./MessagesTimeline.logic";
1115
16describe("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
65describe("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
12181describe("shouldPreserveAssistantLineBreaks", () => {
13182 it("preserves Claude insight formatting without changing regular markdown", () => {
14183 expect(
......@@ -1197,6 +1366,50 @@ describe("deriveMessagesTimelineRows", () => {
11971366 ]);
11981367 });
11991368
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
12001413 it("reuses one activity row for initial thinking and the latest tool", () => {
12011414 const deriveRows = (toolLifecycleStatus: "inProgress" | "completed" | "declined" | null) =>
12021415 deriveMessagesTimelineRows({
......@@ -1464,15 +1677,61 @@ describe("deriveMessagesTimelineRows", () => {
14641677 });
14651678 expect(expandedRows.map((row) => row.id)).toEqual([
14661679 "work-toggle:work-entry-1",
1467 "work-1",
1468 "work-2",
1469 "work-3",
1680 "work-group:work-entry-1:details",
14701681 ]);
1682 expect(expandedRows.find((row) => row.kind === "work")).toMatchObject({
1683 isExpandedToolGroup: true,
1684 groupedEntries: timelineEntries.map(({ entry }) => entry),
1685 });
14711686 expect(expandedRows.find((row) => row.kind === "work-toggle")).toMatchObject({
14721687 expanded: true,
14731688 });
14741689 });
14751690
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
14761735 it.each([
14771736 ["recovered", ["failed", "completed"], false],
14781737 ["ending in failure", ["completed", "failed"], true],
apps/web/src/components/chat/MessagesTimeline.logic.ts+109-38
......@@ -1,7 +1,10 @@
11import * as Equal from "effect/Equal";
22import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives";
3import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";
34import {
5 normalizeCompactToolLabel,
46 omitSupersededLifecycleMarkers,
7 resolveWorkEntryToolPresentation,
58 summarizeToolGroup,
69 toolGroupSummaryKind,
710 type ToolGroupSummaryKind,
......@@ -23,6 +26,7 @@ import {
2326} from "../../session-logic";
2427import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";
2528import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts";
29import { formatWorkspaceRelativePath } from "../../filePathDisplay";
2630
2731export const TIMELINE_MINIMAP_ITEM_SPACING = 8;
2832export const TIMELINE_MINIMAP_MIN_ITEMS = 2;
......@@ -30,6 +34,50 @@ export const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)";
3034export const TIMELINE_CONTENT_MAX_WIDTH = 768;
3135export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48;
3236
37export 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
53export 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
3381export function workEntryIsVisibleInGroup(
3482 entry: WorkLogEntry,
3583 expandedToolGroupEntry = false,
......@@ -42,6 +90,35 @@ export function workEntryIsVisibleInGroup(
4290 );
4391}
4492
93export 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. */
99export 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. */
109export 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
45122export interface TimelineEndState {
46123 readonly isAtEnd?: boolean;
47124 readonly contentLength?: number;
......@@ -197,8 +274,7 @@ export type MessagesTimelineRow =
197274 id: string;
198275 createdAt: string;
199276 groupedEntries: WorkLogEntry[];
200 isExpandedToolGroupEntry: boolean;
201 isLastExpandedToolGroupEntry: boolean;
277 isExpandedToolGroup: boolean;
202278 }
203279 | {
204280 kind: "work-live";
......@@ -292,6 +368,20 @@ function workGroupId(timelineEntryId: string, entry: WorkLogEntry): string {
292368 return `work-group:${workGroupIdentity(timelineEntryId, entry)}`;
293369}
294370
371function 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
295385export function resolveAssistantMessageCopyState({
296386 text,
297387 showCopyButton,
......@@ -384,8 +474,8 @@ function timelineEntryTurnId(entry: TimelineEntry): TurnId | null {
384474function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean {
385475 return (
386476 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)))
389479 );
390480}
391481
......@@ -640,16 +730,13 @@ export function deriveMessagesTimelineRows(input: {
640730 nextRows.push(activeWorkRow);
641731 hasActivityRow ||= activeWorkRow.active;
642732 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 );
653740 };
654741
655742 for (let index = 0; index < input.timelineEntries.length; index += 1) {
......@@ -693,8 +780,7 @@ export function deriveMessagesTimelineRows(input: {
693780 id: timelineEntry.id,
694781 createdAt: timelineEntry.createdAt,
695782 groupedEntries: [timelineEntry.entry],
696 isExpandedToolGroupEntry: false,
697 isLastExpandedToolGroupEntry: false,
783 isExpandedToolGroup: false,
698784 });
699785 continue;
700786 }
......@@ -740,16 +826,9 @@ export function deriveMessagesTimelineRows(input: {
740826 });
741827 hasActivityRow = true;
742828 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 );
753832 }
754833 } else {
755834 const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);
......@@ -774,16 +853,9 @@ export function deriveMessagesTimelineRows(input: {
774853 workEntryDisplayIndicatesToolFailure(latestToolEntry),
775854 });
776855 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 );
787859 }
788860 }
789861 }
......@@ -891,8 +963,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
891963 case "work": {
892964 const bw = b as typeof a;
893965 return (
894 a.isExpandedToolGroupEntry === bw.isExpandedToolGroupEntry &&
895 a.isLastExpandedToolGroupEntry === bw.isLastExpandedToolGroupEntry &&
966 a.isExpandedToolGroup === bw.isExpandedToolGroup &&
896967 Equal.equals(a.groupedEntries, bw.groupedEntries)
897968 );
898969 }
apps/web/src/components/chat/MessagesTimeline.test.tsx+3-3
......@@ -1206,7 +1206,7 @@ describe("MessagesTimeline", () => {
12061206 expect(markup).toContain('data-timeline-row-id="live-activity-row"');
12071207 });
12081208
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", () => {
12101210 const turnId = TurnId.make("turn-live");
12111211 const markup = renderToStaticMarkup(
12121212 <MessagesTimeline
......@@ -1241,10 +1241,10 @@ describe("MessagesTimeline", () => {
12411241 />,
12421242 );
12431243
1244 expect(markup).toContain("Running pnpm");
1244 expect(markup).toContain("Ran pnpm");
12451245 expect(markup).toContain("lucide-terminal");
12461246 expect(markup).toContain("live-activity-focus");
1247 expect(markup).not.toContain("Ran pnpm");
1247 expect(markup).not.toContain("Running pnpm");
12481248 expect(markup).not.toContain("Thinking");
12491249 expect(markup).not.toContain('data-timeline-row-kind="thinking"');
12501250 });
apps/web/src/components/chat/MessagesTimeline.tsx+248-90
......@@ -9,11 +9,12 @@ import {
99} from "@t3tools/contracts";
1010import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
1111import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
12import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";
1312import {
13 resolveWorkEntryToolPresentation,
1414 resolveViewedImageAsset,
1515 workEntryViewedImagePath,
1616} from "@t3tools/client-runtime/work-log/presentation";
17import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor";
1718import type { AgentPanelModel } from "@t3tools/client-runtime/state/subagentRuntime";
1819import {
1920 emptyAgentPanelModel,
......@@ -62,6 +63,7 @@ import {
6263} from "../../lib/diffRendering";
6364import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting";
6465import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown";
66import { T3Wordmark } from "../T3Wordmark";
6567import {
6668 BotIcon,
6769 BrainIcon,
......@@ -87,6 +89,7 @@ import {
8789 ZapIcon,
8890} from "lucide-react";
8991import { Button } from "../ui/button";
92import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area";
9093import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview";
9194import { ProposedPlanCard } from "./ProposedPlanCard";
9295import { ChangedFilesCard } from "./ChangedFilesTree";
......@@ -107,7 +110,7 @@ import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssis
107110import {
108111 computeStableMessagesTimelineRows,
109112 deriveMessagesTimelineRows,
110 normalizeCompactToolLabel,
113 liveWorkEntryLabel,
111114 resolveAssistantMessageCopyState,
112115 resolveTimelineIsAtEnd,
113116 resolveTimelineMinimapHasPersistentGutter,
......@@ -116,13 +119,17 @@ import {
116119 resolveTimelineMinimapIndexFromPointer,
117120 resolveTimelineMinimapInteractiveWidth,
118121 resolveTimelineMinimapTopPercent,
122 resolveWorkGroupScrollIndex,
123 shouldFollowWorkGroupAppend,
119124 shouldPreserveAssistantLineBreaks,
120125 toolGroupAction,
126 workEntryDisplayLabel,
121127 workEntryIsVisibleInGroup,
122128 type StableMessagesTimelineRowsState,
123129 type MessagesTimelineRow,
124130 TIMELINE_MINIMAP_MIN_ITEMS,
125131 type TimelineLatestTurn,
132 type WorkGroupScrollAnchor,
126133} from "./MessagesTimeline.logic";
127134import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
128135import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
......@@ -183,6 +190,8 @@ interface TimelineRowSharedState {
183190 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
184191 onToggleTurnFold: (turnId: TurnId) => void;
185192 onToggleWorkGroup: (groupId: string, anchorKey: string) => void;
193 onToggleWorkEntry: (anchorKey: string) => void;
194 workGroupViewState: WorkGroupViewState;
186195 agentPanelModel: AgentPanelModel;
187196 onOpenAgents: () => void;
188197}
......@@ -196,6 +205,16 @@ interface TimelineRowActivityState {
196205
197206const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);
198207const TimelineRowActivityCtx = createContext<TimelineRowActivityState>(null!);
208
209interface WorkGroupViewState {
210 scrollPositions: Map<string, WorkGroupScrollAnchor>;
211 expandedEntries: Set<string>;
212}
213
214const WorkGroupViewCtx = createContext<{
215 state: WorkGroupViewState;
216 onToggleEntry: () => void;
217} | null>(null);
199218const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />;
200219const TIMELINE_LIST_FADE_HEADER = (
201220 <div className="h-[var(--workspace-titlebar-scroll-fade-height)]" />
......@@ -343,6 +362,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({
343362 );
344363 }, []);
345364 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 );
346370 const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);
347371 const [minimapStripMap] = useState(() => new Map<string, HTMLSpanElement>());
348372 const disclosureAnchorKeyRef = useRef<string | null>(null);
......@@ -616,6 +640,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
616640 onOpenTurnDiff,
617641 onToggleTurnFold,
618642 onToggleWorkGroup,
643 onToggleWorkEntry: suspendEndScrollMaintenanceForDisclosure,
644 workGroupViewState,
619645 agentPanelModel,
620646 onOpenAgents,
621647 }),
......@@ -637,6 +663,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
637663 onOpenTurnDiff,
638664 onToggleTurnFold,
639665 onToggleWorkGroup,
666 suspendEndScrollMaintenanceForDisclosure,
667 workGroupViewState,
640668 agentPanelModel,
641669 onOpenAgents,
642670 ],
......@@ -1043,8 +1071,7 @@ type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["grouped
10431071type TimelineRow = MessagesTimelineRow;
10441072
10451073const 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;
10481075 const isExpandedToolGroupHeader =
10491076 (row.kind === "work-toggle" && row.expanded) || (row.kind === "work-live" && row.expanded);
10501077
......@@ -1053,10 +1080,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
10531080 className={cn(
10541081 // Commentary (non-terminal assistant) rows carry no metadata row, so
10551082 // they sit closer to the work that follows them.
1056 isExpandedToolGroupEntry
1057 ? isLastExpandedToolGroupEntry
1058 ? "pb-1"
1059 : "pb-0"
1083 isExpandedToolGroup
1084 ? "pb-1"
10601085 : isExpandedToolGroupHeader
10611086 ? "pb-0"
10621087 : row.kind === "turn-fold" || row.kind === "working"
......@@ -1079,8 +1104,9 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
10791104 >
10801105 {row.kind === "work" ? (
10811106 <WorkGroupSection
1107 anchorKey={row.id}
10821108 groupedEntries={row.groupedEntries}
1083 isExpandedToolGroupEntry={row.isExpandedToolGroupEntry}
1109 isExpandedToolGroup={row.isExpandedToolGroup}
10841110 />
10851111 ) : null}
10861112 {row.kind === "work-live" ? <LiveWorkEntryTimelineRow row={row} /> : null}
......@@ -1485,43 +1511,189 @@ function WorkingTimer({ createdAt }: { createdAt: string }) {
14851511// re-render only the affected row, not the entire list.
14861512// ---------------------------------------------------------------------------
14871513
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. */
14891515const WorkGroupSection = memo(function WorkGroupSection({
1516 anchorKey,
14901517 groupedEntries,
1491 isExpandedToolGroupEntry,
1518 isExpandedToolGroup,
14921519}: {
1520 anchorKey: string;
14931521 groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"];
1494 isExpandedToolGroupEntry: boolean;
1522 isExpandedToolGroup: boolean;
14951523}) {
1496 const { workspaceRoot } = use(TimelineRowCtx);
1524 const { workspaceRoot, routeThreadKey } = use(TimelineRowCtx);
14971525 const nonEmptyEntries = useMemo(
1498 () =>
1499 groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroupEntry)),
1500 [groupedEntries, isExpandedToolGroupEntry],
1526 () => groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroup)),
1527 [groupedEntries, isExpandedToolGroup],
15011528 );
1502 const GroupContainer = isExpandedToolGroupEntry ? "div" : "section";
15031529
15041530 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 }
15051541
15061542 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">
15111544 <div className="space-y-px">
15121545 {nonEmptyEntries.map((workEntry) => (
15131546 <SimpleWorkEntryRow
15141547 key={workEntry.id}
15151548 workEntry={workEntry}
15161549 workspaceRoot={workspaceRoot}
1517 isExpandedToolGroupEntry={isExpandedToolGroupEntry}
1550 isExpandedToolGroupEntry={false}
15181551 />
15191552 ))}
15201553 </div>
1521 </GroupContainer>
1554 </section>
15221555 );
15231556});
15241557
1558function 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
1695const workEntryKey = (entry: TimelineWorkEntry) => entry.id;
1696
15251697function ActivityShimmerOverlay({ children }: { children: ReactNode }) {
15261698 return (
15271699 <span
......@@ -1572,7 +1744,8 @@ function LiveActivityContent({
15721744 announceFailure?: boolean;
15731745 highlighted?: boolean;
15741746}) {
1575 const resolvedIconName = failed ? "circle-alert" : iconName;
1747 const isSpecialToolIcon = iconName === "browser" || iconName === "t3-code";
1748 const resolvedIconName = failed && !isSpecialToolIcon ? "circle-alert" : iconName;
15761749
15771750 return (
15781751 <span
......@@ -1591,13 +1764,14 @@ function LiveActivityContent({
15911764 role={announceFailure ? "img" : undefined}
15921765 aria-label={announceFailure ? "Tool call failed" : undefined}
15931766 >
1594 <WorkEntryIconSvg
1767 <WorkEntryIcon
15951768 name={resolvedIconName}
15961769 className={cn("block size-4 shrink-0 stroke-[1.8]", !highlighted && "opacity-70")}
15971770 />
15981771 </span>
15991772 ) : null}
16001773 <span className="min-w-0 flex-1 truncate">{label}</span>
1774 {failed && isSpecialToolIcon ? <XIcon aria-hidden className="size-3 shrink-0" /> : null}
16011775 </span>
16021776 );
16031777}
......@@ -1641,6 +1815,8 @@ function toolGroupSummaryIconName(
16411815 return "square-pen";
16421816 case "command":
16431817 return "terminal";
1818 case "browser":
1819 return "browser";
16441820 case "search":
16451821 return "globe";
16461822 case "code-search":
......@@ -1674,7 +1850,7 @@ function WorkGroupToggleTimelineRow({
16741850 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
16751851 >
16761852 <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
1677 <WorkEntryIconSvg
1853 <WorkEntryIcon
16781854 name={toolGroupSummaryIconName(row.summaryKind)}
16791855 className="size-4 shrink-0 stroke-[1.8] opacity-70"
16801856 />
......@@ -2208,6 +2384,7 @@ function formatWorkingTimerNow(startIso: string): string {
22082384type WorkEntryIconName =
22092385 | "bot"
22102386 | "brain"
2387 | "browser"
22112388 | "check"
22122389 | "circle-alert"
22132390 | "eye"
......@@ -2217,16 +2394,21 @@ type WorkEntryIconName =
22172394 | "search"
22182395 | "square-pen"
22192396 | "terminal"
2397 | "t3-code"
22202398 | "wrench"
22212399 | "x"
22222400 | "zap";
22232401
2224function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; className: string }) {
2402function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className: string }) {
22252403 switch (name) {
22262404 case "bot":
22272405 return <BotIcon className={className} aria-hidden />;
22282406 case "brain":
22292407 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 />;
22302412 case "check":
22312413 return <CheckIcon className={className} aria-hidden />;
22322414 case "circle-alert":
......@@ -2282,21 +2464,6 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): {
22822464 };
22832465}
22842466
2285function 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
23002467function workEntryRawCommand(
23012468 workEntry: Pick<TimelineWorkEntry, "command" | "rawCommand">,
23022469): string | null {
......@@ -2307,26 +2474,6 @@ function workEntryRawCommand(
23072474 return rawCommand === workEntry.command.trim() ? null : rawCommand;
23082475}
23092476
2310function 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
23302477function buildToolCallExpandedBody(
23312478 workEntry: TimelineWorkEntry,
23322479 workspaceRoot: string | undefined,
......@@ -2365,6 +2512,8 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName {
23652512 ) {
23662513 return "message-circle";
23672514 }
2515 const toolPresentation = resolveWorkEntryToolPresentation(workEntry);
2516 if (toolPresentation) return toolPresentation.icon;
23682517 const action = toolGroupAction(workEntry);
23692518 if (action !== "other") return toolGroupSummaryIconName(action);
23702519
......@@ -2385,21 +2534,6 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName {
23852534 return workToneIcon(workEntry.tone).iconName;
23862535}
23872536
2388function 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
2396function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string {
2397 if (!workEntry.toolTitle) {
2398 return capitalizePhrase(normalizeCompactToolLabel(workEntry.label));
2399 }
2400 return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle));
2401}
2402
24032537const stopRowToggle = (e: { stopPropagation: () => void }) => e.stopPropagation();
24042538
24052539/**
......@@ -2480,7 +2614,7 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time
24802614 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"
24812615 >
24822616 <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" />
24842618 <span className="min-w-0 truncate">
24852619 <span className="font-medium">{lead}</span>
24862620 {workflowName ? <span className="text-muted-foreground"> · {workflowName}</span> : null}
......@@ -2522,15 +2656,39 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
25222656}) {
25232657 const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props;
25242658 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 };
25262672 const iconConfig = workToneIcon(workEntry.tone);
25272673 const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning";
25282674 const showFailedIndicator = workEntryDisplayIndicatesToolFailure(workEntry);
2675 const toolPresentation = resolveWorkEntryToolPresentation(workEntry);
25292676 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;
25342692 const viewedImagePath = workEntryViewedImagePath(workEntry);
25352693 const viewedImage =
25362694 viewedImagePath && threadRef
......@@ -2539,7 +2697,6 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
25392697 workspaceRoot,
25402698 })
25412699 : null;
2542 const canExpand = expandedBody !== null;
25432700 const showDestructiveRowStyle =
25442701 showFailedIndicator &&
25452702 (workEntrySignalsSevereFailure(workEntry) || !workLogEntryIsToolLike(workEntry));
......@@ -2562,7 +2719,6 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
25622719 : workLogEntryIsToolLike(workEntry)
25632720 ? "text-secondary-label"
25642721 : "text-foreground/80";
2565 const showEntryIcon = !isExpandedToolGroupEntry || showWarningIndicator || showFailedIndicator;
25662722 const accessibleDisplayText = showFailedIndicator
25672723 ? `${previewText}, tool call failed`
25682724 : previewText;
......@@ -2572,11 +2728,11 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
25722728 tabIndex: 0 as const,
25732729 "aria-label": accessibleDisplayText,
25742730 "aria-expanded": expanded,
2575 onClick: () => setExpanded((v) => !v),
2731 onClick: toggleExpanded,
25762732 onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {
25772733 if (e.key === "Enter" || e.key === " ") {
25782734 e.preventDefault();
2579 setExpanded((v) => !v);
2735 toggleExpanded();
25802736 }
25812737 },
25822738 }
......@@ -2594,12 +2750,11 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
25942750 >
25952751 <div className="flex select-none items-center gap-1.5 transition-[opacity,translate] duration-200">
25962752 <span
2597 className={cn(iconWrapperClass, !showEntryIcon && "invisible")}
2753 className={iconWrapperClass}
25982754 role={showFailedIndicator ? "img" : undefined}
25992755 aria-label={showFailedIndicator ? "Tool call failed" : undefined}
2600 aria-hidden={!showEntryIcon}
26012756 >
2602 <WorkEntryIconSvg
2757 <WorkEntryIcon
26032758 name={entryIconName}
26042759 className="block size-4 shrink-0 stroke-[1.8] opacity-70"
26052760 />
......@@ -2610,6 +2765,9 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
26102765 <span className={cn("min-w-0 flex-1 truncate", headingClass)}>{displayText}</span>
26112766 </p>
26122767 </div>
2768 {showFailedIndicator && toolPresentation ? (
2769 <XIcon aria-hidden className="size-3 shrink-0 text-icon-muted" />
2770 ) : null}
26132771 <span
26142772 className={cn(
26152773 "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
1111import { useEnvironmentIdentificationMode } from "../../hooks/useSettings";
1212import { cn } from "../../lib/utils";
1313import { useEnvironments } from "../../state/environments";
14import { T3Wordmark } from "../T3Wordmark";
1415import {
1516 resolveEnvironmentIdentificationPillLabel,
1617 resolveSidebarStageBackdropVariant,
......@@ -89,7 +90,7 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) {
8990 )}
9091 to="/"
9192 >
92 <T3Wordmark />
93 <T3Wordmark aria-label="T3" className="h-2.5 w-auto shrink-0" />
9394 <span
9495 className={cn(
9596 "-translate-y-px truncate text-sm font-medium tracking-tight",
......@@ -102,22 +103,6 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) {
102103 );
103104}
104105
105function 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
121106function SidebarUtilityItem({
122107 icon,
123108 label,
apps/web/src/session-logic.test.ts+34
......@@ -7,6 +7,7 @@ import {
77 type OrchestrationThreadActivity,
88} from "@t3tools/contracts";
99import { describe, expect, it } from "vite-plus/test";
10import { resolveWorkEntryToolPresentation } from "@t3tools/client-runtime/work-log/presentation";
1011
1112import {
1213 deriveActiveWorkStartedAt,
......@@ -1249,6 +1250,36 @@ describe("deriveWorkLogEntries", () => {
12491250 expect(entry?.toolData).toEqual(item);
12501251 });
12511252
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
12521283 it("keeps MCP payloads while collapsing lifecycle updates", () => {
12531284 const item = {
12541285 type: "mcpToolCall",
......@@ -1282,6 +1313,9 @@ describe("deriveWorkLogEntries", () => {
12821313 const [entry] = deriveWorkLogEntries(activities);
12831314 expect(entry?.toolData).toEqual(item);
12841315 expect(entry?.toolCallId).toBe("call-1");
1316 expect(resolveWorkEntryToolPresentation(entry!)?.displayName).toBe(
1317 "Took a snapshot of the preview page",
1318 );
12851319 });
12861320
12871321 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
950950 }
951951 if (itemType === "mcp_tool_call") {
952952 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;
955956 }
956957 }
957958 if (itemType) {
docs/user/tool-activity.md created+18
......@@ -0,0 +1,18 @@
1# Tool activity
2
3Open a tool-group summary in the conversation to see its individual calls. Each row has an icon;
4select a row to inspect its details. Select the group summary again to collapse it.
5
6Long groups scroll inside a bounded area without expanding the whole conversation. Faded edges
7indicate more calls above or below. Short groups use only the space they need.
8Collapsing and reopening a group preserves your reading position and any open call details.
9
10Recognized T3 tools use descriptive labels in both the running summary and individual rows.
11Labels follow the call's state, such as "Clicking" while running and "Clicked" after success.
12Failed, declined, and stopped calls say what happened without implying success.
13Preview browser actions use a globe icon. Other T3 tools keep the T3 mark.
14Group 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
17Command 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 @@
194194 "./work-log/command-label": {
195195 "types": "./src/work-log/commandLabel.ts",
196196 "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"
197201 }
198202 },
199203 "scripts": {
packages/client-runtime/src/work-log/commandLabel.test.ts created+57
......@@ -0,0 +1,57 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { commandProgramName } from "./commandLabel.ts";
4
5describe("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 @@
11type CommandWrapper = "env" | "sudo";
22
3const SHELL_PROGRAMS = new Set(["sh", "bash", "zsh", "dash", "ash", "ksh", "fish"]);
4const SHELL_OPTIONS_WITH_VALUE = new Set(["-o", "-O", "--rcfile", "--init-file"]);
5
6function 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
319const COMMAND_WRAPPER_OPTIONS_WITH_VALUE: Record<CommandWrapper, ReadonlySet<string>> = {
420 env: new Set(["-C", "--chdir", "-S", "--split-string", "-u", "--unset"]),
521 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 {
162178 }
163179 return null;
164180 }
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;
166189 }
167190
168191 return null;
packages/client-runtime/src/work-log/presentation.test.ts+208
......@@ -6,10 +6,218 @@ import {
66 commandDetailRepeatsCommand,
77 extractCommandOutputText,
88 resolveViewedImageAsset,
9 resolveWorkEntryToolPresentation,
10 summarizeToolGroup,
911 toolGroupAction,
12 toolGroupSummaryKind,
13 type WorkLogPresentationEntry,
1014 workEntryViewedImagePath,
1115} from "./presentation.js";
1216
17describe("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
139describe("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
13221describe("command work-log details", () => {
14222 it("extracts Claude result blocks and projected output", () => {
15223 expect(
packages/client-runtime/src/work-log/presentation.ts+102
......@@ -17,6 +17,7 @@ export function isWorktreeSetupActivity(kind: string): boolean {
1717export interface WorkLogPresentationEntry {
1818 readonly label: string;
1919 readonly toolTitle?: string;
20 readonly toolData?: unknown;
2021 readonly tone: "thinking" | "tool" | "info" | "error";
2122 readonly command?: string;
2223 readonly detail?: string;
......@@ -35,6 +36,7 @@ export type ToolGroupAction =
3536 | "read"
3637 | "edit"
3738 | "command"
39 | "browser"
3840 | "code-search"
3941 | "search"
4042 | "other"
......@@ -51,6 +53,103 @@ export function normalizeCompactToolLabel(value: string): string {
5153 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
5254}
5355
56const 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
98function 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. */
127export 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
54153function asRecord(value: unknown): Record<string, unknown> | null {
55154 return value !== null && typeof value === "object" && !Array.isArray(value)
56155 ? (value as Record<string, unknown>)
......@@ -207,6 +306,7 @@ export function workLogEntryIsLocalCodeSearch(entry: WorkLogPresentationEntry):
207306}
208307
209308export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupAction {
309 if (resolveWorkEntryToolPresentation(entry)?.icon === "browser") return "browser";
210310 if (
211311 entry.requestKind === "file-read" ||
212312 entry.itemType === "image_view" ||
......@@ -312,6 +412,8 @@ function toolGroupActionLabel(action: ToolGroupAction, count: number): string {
312412 return `Changed ${count} ${count === 1 ? "file" : "files"}`;
313413 case "command":
314414 return `Ran ${count} ${count === 1 ? "command" : "commands"}`;
415 case "browser":
416 return `Used browser ${count} ${count === 1 ? "time" : "times"}`;
315417 case "search":
316418 return `Searched the web ${count} ${count === 1 ? "time" : "times"}`;
317419 case "code-search":
packages/client-runtime/src/work-log/scrollAnchor.test.ts created+63
......@@ -0,0 +1,63 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { resolveWorkGroupScrollAnchor } from "./scrollAnchor.js";
4
5describe("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. */
2export 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}