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";...@@ -125,6 +125,7 @@ import { cn } from "../../lib/cn";
125import {125import {
126 deriveCenteredContentHorizontalPadding,126 deriveCenteredContentHorizontalPadding,
127 deriveThreadFeedInitialContentInset,127 deriveThreadFeedInitialContentInset,
128 deriveThreadWorkLogSizing,
128 type LayoutVariant,129 type LayoutVariant,
129} from "../../lib/layout";130} from "../../lib/layout";
130import {131import {
...@@ -148,6 +149,7 @@ import type { ThreadContentPresentation } from "./threadContentPresentation";...@@ -148,6 +149,7 @@ import type { ThreadContentPresentation } from "./threadContentPresentation";
148import {149import {
149 resolveThreadFeedLiveFollow,150 resolveThreadFeedLiveFollow,
150 type ThreadFeedLiveFollowEvent,151 type ThreadFeedLiveFollowEvent,
152 type ThreadWorkGroupScrollPosition,
151} from "./thread-feed-live-follow";153} from "./thread-feed-live-follow";
152import {154import {
153 collapsedWorkLogHeight,155 collapsedWorkLogHeight,
...@@ -191,12 +193,9 @@ function formatMessageTime(input: string): string {...@@ -191,12 +193,9 @@ function formatMessageTime(input: string): string {
191 return MESSAGE_TIME_FORMATTER.format(timestamp);193 return MESSAGE_TIME_FORMATTER.format(timestamp);
192}194}
193195
194// Pre-measurement heights for getFixedItemSize, mirroring renderFeedEntry's196// Fixed heights mirror renderFeedEntry's classNames and are only used while
195// classNames. The fold row's min-h-11 (44px) stays taller than its single197// text fits at the current font settings. Larger accessibility text is measured.
196// text-sm line at every supported base font size (26px at the 22pt maximum),198const TURN_FOLD_HEIGHT = 42; // min-h-11 (38.5) + mb-1 (3.5), with the mobile 14px rem
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)
200const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);199const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
201// Let neighboring rows move out of the new rows' space before showing their text.200// Let neighboring rows move out of the new rows' space before showing their text.
202const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay(201const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay(
...@@ -1452,11 +1451,13 @@ function renderFeedEntry(...@@ -1452,11 +1451,13 @@ function renderFeedEntry(
1452 props: Pick<ThreadFeedProps, "environmentId" | "onUseArtifactTemplate" | "skills"> & {1451 props: Pick<ThreadFeedProps, "environmentId" | "onUseArtifactTemplate" | "skills"> & {
1453 readonly copiedRowId: string | null;1452 readonly copiedRowId: string | null;
1454 readonly expandedWorkRows: Record<string, boolean>;1453 readonly expandedWorkRows: Record<string, boolean>;
1454 readonly workRowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
1455 readonly workGroupScrollPositions: Map<string, ThreadWorkGroupScrollPosition>;
1455 readonly terminalAssistantMessageIds: ReadonlySet<string>;1456 readonly terminalAssistantMessageIds: ReadonlySet<string>;
1456 readonly unsettledTurnId: TurnId | null;1457 readonly unsettledTurnId: TurnId | null;
1457 readonly onCopyWorkRow: (rowId: string, value: string) => void;1458 readonly onCopyWorkRow: (rowId: string, value: string) => void;
1458 readonly onToggleWorkGroup: (groupId: string) => void;1459 readonly onToggleWorkGroup: (groupId: string, anchorKey: string) => void;
1459 readonly onToggleWorkRow: (rowId: string) => void;1460 readonly onToggleWorkRow: (rowId: string, anchorKey: string) => void;
1460 readonly onToggleTurnFold: (turnId: TurnId) => void;1461 readonly onToggleTurnFold: (turnId: TurnId) => void;
1461 readonly onPressPreview: (source: FilePreviewSource) => void;1462 readonly onPressPreview: (source: FilePreviewSource) => void;
1462 readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;1463 readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;
...@@ -1482,8 +1483,14 @@ function renderFeedEntry(...@@ -1482,8 +1483,14 @@ function renderFeedEntry(
1482 onPress={() => props.onToggleTurnFold(entry.turnId)}1483 onPress={() => props.onToggleTurnFold(entry.turnId)}
1483 hitSlop={4}1484 hitSlop={4}
1484 className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2"1485 className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2"
1486 style={{
1487 minHeight: Math.max(TURN_FOLD_HEIGHT - 3.5, props.workRowSizing.estimatedRowHeight),
1488 }}
1485 >1489 >
1486 <Text className="font-t3-medium text-sm tabular-nums text-foreground-muted">1490 <Text
1491 key={props.workRowSizing.textSizeKey}
1492 className="font-t3-medium text-sm tabular-nums text-foreground-muted"
1493 >
1487 {entry.label}1494 {entry.label}
1488 </Text>1495 </Text>
1489 <ThreadDisclosureChevron1496 <ThreadDisclosureChevron
...@@ -1499,14 +1506,16 @@ function renderFeedEntry(...@@ -1499,14 +1506,16 @@ function renderFeedEntry(
1499 if (entry.type === "work-toggle") {1506 if (entry.type === "work-toggle") {
1500 return (1507 return (
1501 <ThreadWorkGroupToggle1508 <ThreadWorkGroupToggle
1509 rowSizing={props.workRowSizing}
1502 expanded={entry.expanded}1510 expanded={entry.expanded}
1503 hiddenCount={entry.hiddenCount}1511 hiddenCount={entry.hiddenCount}
1504 iconSubtleColor={iconSubtleColor}1512 iconSubtleColor={iconSubtleColor}
1505 summary={entry.summary}1513 summary={entry.summary}
1506 summaryKind={entry.summaryKind}1514 summaryKind={entry.summaryKind}
1515 summaryToolIcon={entry.summaryToolIcon}
1507 hasFailure={entry.hasFailure}1516 hasFailure={entry.hasFailure}
1508 shimmer={entry.shimmer}1517 shimmer={entry.shimmer}
1509 onToggle={() => props.onToggleWorkGroup(entry.groupId)}1518 onToggle={() => props.onToggleWorkGroup(entry.groupId, entry.id)}
1510 />1519 />
1511 );1520 );
1512 }1521 }
...@@ -1669,9 +1678,15 @@ function renderFeedEntry(...@@ -1669,9 +1678,15 @@ function renderFeedEntry(
16691678
1670 return (1679 return (
1671 <ThreadWorkLog1680 <ThreadWorkLog
1681 // Fixed native rows need fresh measurement after a text-size change.
1682 // Anchors/details live in ThreadFeed and survive this group-only remount.
1683 key={`${entry.id}:${props.workRowSizing.textSizeKey}`}
1672 activities={entry.activities}1684 activities={entry.activities}
1685 anchorKey={entry.id}
1673 copiedRowId={props.copiedRowId}1686 copiedRowId={props.copiedRowId}
1674 expandedRows={props.expandedWorkRows}1687 expandedRows={props.expandedWorkRows}
1688 rowSizing={props.workRowSizing}
1689 scrollPositions={props.workGroupScrollPositions}
1675 iconSubtleColor={iconSubtleColor}1690 iconSubtleColor={iconSubtleColor}
1676 onCopyRow={props.onCopyWorkRow}1691 onCopyRow={props.onCopyWorkRow}
1677 onToggleRow={props.onToggleWorkRow}1692 onToggleRow={props.onToggleWorkRow}
...@@ -1959,7 +1974,22 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1959,7 +1974,22 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1959 const headerMaterialVisibleRef = useRef(false);1974 const headerMaterialVisibleRef = useRef(false);
1960 const previousLatestTurnRef = useRef(props.latestTurn);1975 const previousLatestTurnRef = useRef(props.latestTurn);
1961 const userScrollSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);1976 const userScrollSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1962 const { width: windowWidth } = useWindowDimensions();1977 const { width: windowWidth, fontScale } = useWindowDimensions();
1978 const { appearance } = useAppearancePreferences();
1979 const workRowSizing = useMemo(
1980 () => deriveThreadWorkLogSizing({ baseFontSize: appearance.baseFontSize, fontScale }),
1981 [appearance.baseFontSize, fontScale],
1982 );
1983 const previousTextSize = useRef(workRowSizing.textSizeKey);
1984 useLayoutEffect(() => {
1985 if (previousTextSize.current === workRowSizing.textSizeKey) {
1986 return;
1987 }
1988 previousTextSize.current = workRowSizing.textSizeKey;
1989 // Text-size changes invalidate the outer list's fixed-height cache too.
1990 // This never runs for scrolling, streamed output, or disclosure toggles.
1991 props.listRef.current?.clearCaches({ mode: "sizes" });
1992 }, [workRowSizing.textSizeKey, props.listRef]);
1963 const [viewportWidth, setViewportWidth] = useState(() =>1993 const [viewportWidth, setViewportWidth] = useState(() =>
1964 props.layoutVariant === "split" ? 0 : windowWidth,1994 props.layoutVariant === "split" ? 0 : windowWidth,
1965 );1995 );
...@@ -2227,6 +2257,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2227,6 +2257,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2227 () => ({2257 () => ({
2228 copiedRowId,2258 copiedRowId,
2229 expandedWorkRows,2259 expandedWorkRows,
2260 workRowSizing,
2230 iconSubtleColor,2261 iconSubtleColor,
2231 markdownStyles,2262 markdownStyles,
2232 reviewCommentColors,2263 reviewCommentColors,
...@@ -2236,6 +2267,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2236,6 +2267,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2236 [2267 [
2237 copiedRowId,2268 copiedRowId,
2238 expandedWorkRows,2269 expandedWorkRows,
2270 workRowSizing,
2239 iconSubtleColor,2271 iconSubtleColor,
2240 markdownStyles,2272 markdownStyles,
2241 reviewCommentColors,2273 reviewCommentColors,
...@@ -2338,6 +2370,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2338,6 +2370,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2338 // ThreadId, and keying resets (or the list mount) on the bare id would2370 // ThreadId, and keying resets (or the list mount) on the bare id would
2339 // carry stale scroll/follow state across an environment switch.2371 // carry stale scroll/follow state across an environment switch.
2340 const feedThreadKey = scopedThreadKey(props.environmentId, props.threadId);2372 const feedThreadKey = scopedThreadKey(props.environmentId, props.threadId);
2373 // Virtualized groups can unmount without losing the reader's place. This cache
2374 // belongs to this thread view only and never causes per-scroll React updates.
2375 const workGroupScrollPositions = useMemo(
2376 () => new Map<string, ThreadWorkGroupScrollPosition>(),
2377 [feedThreadKey],
2378 );
23412379
2342 useEffect(() => {2380 useEffect(() => {
2343 reportHeaderMaterialVisibility(false);2381 reportHeaderMaterialVisibility(false);
...@@ -2541,8 +2579,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2541,8 +2579,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2541 }, []);2579 }, []);
25422580
2543 const onToggleWorkGroup = useCallback(2581 const onToggleWorkGroup = useCallback(
2544 (groupId: string) => {2582 (groupId: string, anchorKey: string) => {
2545 suspendEndScrollMaintenanceForDisclosure(`work-toggle:${groupId}`);2583 suspendEndScrollMaintenanceForDisclosure(anchorKey);
2546 setInteractionState((current) => ({2584 setInteractionState((current) => ({
2547 ...current,2585 ...current,
2548 expandedWorkGroups: {2586 expandedWorkGroups: {
...@@ -2555,8 +2593,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2555,8 +2593,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2555 );2593 );
25562594
2557 const onToggleWorkRow = useCallback(2595 const onToggleWorkRow = useCallback(
2558 (rowId: string) => {2596 (rowId: string, anchorKey: string) => {
2559 suspendEndScrollMaintenanceForDisclosure(rowId);2597 suspendEndScrollMaintenanceForDisclosure(anchorKey);
2560 setInteractionState((current) => ({2598 setInteractionState((current) => ({
2561 ...current,2599 ...current,
2562 expandedWorkRows: {2600 expandedWorkRows: {
...@@ -2610,6 +2648,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2610,6 +2648,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2610 // average once one of their type has been measured.2648 // average once one of their type has been measured.
2611 const getFixedItemSize = useCallback(2649 const getFixedItemSize = useCallback(
2612 (entry: ThreadFeedEntry) => {2650 (entry: ThreadFeedEntry) => {
2651 if (workRowSizing.fixedRowHeight === undefined) {
2652 return undefined;
2653 }
2613 switch (entry.type) {2654 switch (entry.type) {
2614 case "turn-fold":2655 case "turn-fold":
2615 return TURN_FOLD_HEIGHT;2656 return TURN_FOLD_HEIGHT;
...@@ -2625,7 +2666,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2625,7 +2666,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2625 return undefined;2666 return undefined;
2626 }2667 }
2627 },2668 },
2628 [expandedWorkRows],2669 [expandedWorkRows, workRowSizing.fixedRowHeight],
2629 );2670 );
26302671
2631 // Disclosures can mount existing offscreen rows as well as new work rows.2672 // Disclosures can mount existing offscreen rows as well as new work rows.
...@@ -2641,6 +2682,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2641,6 +2682,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2641 environmentId: props.environmentId,2682 environmentId: props.environmentId,
2642 copiedRowId,2683 copiedRowId,
2643 expandedWorkRows,2684 expandedWorkRows,
2685 workRowSizing,
2686 workGroupScrollPositions,
2644 terminalAssistantMessageIds,2687 terminalAssistantMessageIds,
2645 unsettledTurnId,2688 unsettledTurnId,
2646 onCopyWorkRow,2689 onCopyWorkRow,
...@@ -2668,6 +2711,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2668,6 +2711,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2668 copiedRowId,2711 copiedRowId,
2669 disclosureToggleSettling,2712 disclosureToggleSettling,
2670 expandedWorkRows,2713 expandedWorkRows,
2714 workRowSizing,
2715 workGroupScrollPositions,
2671 terminalAssistantMessageIds,2716 terminalAssistantMessageIds,
2672 unsettledTurnId,2717 unsettledTurnId,
2673 iconSubtleColor,2718 iconSubtleColor,
apps/mobile/src/features/threads/thread-feed-live-follow.test.ts+77
...@@ -3,8 +3,85 @@ import { describe, expect, it } from "vite-plus/test";...@@ -3,8 +3,85 @@ import { describe, expect, it } from "vite-plus/test";
3import {3import {
4 resolveThreadFeedLiveFollow,4 resolveThreadFeedLiveFollow,
5 resolveThreadFeedSubmissionAnchor,5 resolveThreadFeedSubmissionAnchor,
6 resolveThreadWorkGroupInitialScroll,
7 shouldFollowThreadWorkGroupAppend,
6} from "./thread-feed-live-follow";8} 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
8describe("resolveThreadFeedSubmissionAnchor", () => {85describe("resolveThreadFeedSubmissionAnchor", () => {
9 it("anchors the first user message in a thread", () => {86 it("anchors the first user message in a thread", () => {
10 expect(87 expect(
apps/mobile/src/features/threads/thread-feed-live-follow.ts+35
...@@ -12,6 +12,41 @@ export type ThreadFeedLiveFollowEvent =...@@ -12,6 +12,41 @@ export type ThreadFeedLiveFollowEvent =
12 readonly userScrollSessionActive: boolean;12 readonly userScrollSessionActive: boolean;
13 };13 };
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
15export function resolveThreadFeedSubmissionAnchor<AnchorId>(input: {50export function resolveThreadFeedSubmissionAnchor<AnchorId>(input: {
16 readonly currentAnchorMessageId: AnchorId | null;51 readonly currentAnchorMessageId: AnchorId | null;
17 readonly submittedMessageId: AnchorId;52 readonly submittedMessageId: AnchorId;
apps/mobile/src/features/threads/thread-work-log.tsx+492-166
...@@ -1,8 +1,21 @@...@@ -1,8 +1,21 @@
1import * as Haptics from "expo-haptics";1import * as Haptics from "expo-haptics";
2import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";2import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
3import { MaskedView } from "@expo/ui/community/masked-view";3import { MaskedView } from "@expo/ui/community/masked-view";
4import type { LegendListRef } from "@legendapp/list/react-native";
5import { AnimatedLegendList } from "@legendapp/list/reanimated";
4import { useIsFocused } from "@react-navigation/native";6import { 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";
6import {19import {
7 AccessibilityInfo,20 AccessibilityInfo,
8 AppState,21 AppState,
...@@ -15,12 +28,21 @@ import {...@@ -15,12 +28,21 @@ import {
15import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";28import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
1629
17import { AppText as Text } from "../../components/AppText";30import { AppText as Text } from "../../components/AppText";
31import { T3Wordmark } from "../../components/T3Wordmark";
18import { cn } from "../../lib/cn";32import { cn } from "../../lib/cn";
33import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout";
19import type { ThreadFeedActivity } from "../../lib/threadActivity";34import type { ThreadFeedActivity } from "../../lib/threadActivity";
20import {35import {
36 resolveThreadWorkGroupInitialScroll,
37 shouldFollowThreadWorkGroupAppend,
38 type ThreadWorkGroupScrollPosition,
39} from "./thread-feed-live-follow";
40import {
41 resolveWorkEntryToolPresentation,
21 type ToolGroupSummaryKind,42 type ToolGroupSummaryKind,
22 workEntryViewedImagePath,43 workEntryViewedImagePath,
23} from "@t3tools/client-runtime/work-log/presentation";44} from "@t3tools/client-runtime/work-log/presentation";
45import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor";
24import type { MarkdownImageRenderer } from "../../native/SelectableMarkdownText";46import type { MarkdownImageRenderer } from "../../native/SelectableMarkdownText";
25import Animated, {47import Animated, {
26 cancelAnimation,48 cancelAnimation,
...@@ -45,6 +67,33 @@ export const THREAD_DISCLOSURE_TRANSITION_MS = 180;...@@ -45,6 +67,33 @@ export const THREAD_DISCLOSURE_TRANSITION_MS = 180;
45const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);67const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
46const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140);68const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140);
47const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120);69const 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
49export function ThreadDisclosureChevron(props: {98export function ThreadDisclosureChevron(props: {
50 readonly expanded: boolean;99 readonly expanded: boolean;
...@@ -86,7 +135,7 @@ export function ThreadDisclosureChevron(props: {...@@ -86,7 +135,7 @@ export function ThreadDisclosureChevron(props: {
86135
87function ShimmerWorkContent(props: {136function ShimmerWorkContent(props: {
88 readonly highlighted: boolean;137 readonly highlighted: boolean;
89 readonly icon: AppSymbolName;138 readonly icon: WorkContentIcon;
90 readonly iconSubtleColor: ColorValue;139 readonly iconSubtleColor: ColorValue;
91 readonly label: string;140 readonly label: string;
92 readonly onTextLayout?: ComponentProps<typeof Text>["onTextLayout"];141 readonly onTextLayout?: ComponentProps<typeof Text>["onTextLayout"];
...@@ -96,14 +145,10 @@ function ShimmerWorkContent(props: {...@@ -96,14 +145,10 @@ function ShimmerWorkContent(props: {
96 <View className="flex-row items-center gap-1.5">145 <View className="flex-row items-center gap-1.5">
97 <View className="h-6 w-6 shrink-0 items-center justify-center">146 <View className="h-6 w-6 shrink-0 items-center justify-center">
98 {props.showIcon ? (147 {props.showIcon ? (
99 <SymbolView148 <WorkLogIcon
100 name={props.icon}149 icon={props.icon}
101 size={14}150 color={props.iconSubtleColor}
102 weight="medium"151 highlighted={props.highlighted}
103 {...(props.highlighted
104 ? { tintColorClassName: "accent-foreground" as const }
105 : { tintColor: props.iconSubtleColor })}
106 type="monochrome"
107 />152 />
108 ) : null}153 ) : null}
109 </View>154 </View>
...@@ -122,7 +167,7 @@ function ShimmerWorkContent(props: {...@@ -122,7 +167,7 @@ function ShimmerWorkContent(props: {
122}167}
123168
124export function ShimmeringWorkContent(props: {169export function ShimmeringWorkContent(props: {
125 readonly icon: AppSymbolName;170 readonly icon: WorkContentIcon;
126 readonly iconSubtleColor: ColorValue;171 readonly iconSubtleColor: ColorValue;
127 readonly label: string;172 readonly label: string;
128 readonly showIcon: boolean;173 readonly showIcon: boolean;
...@@ -293,182 +338,461 @@ function isFreshRow(createdAt: string): boolean {...@@ -293,182 +338,461 @@ function isFreshRow(createdAt: string): boolean {
293 return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ROW_WINDOW_MS;338 return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ROW_WINDOW_MS;
294}339}
295340
296// Pre-measurement heights for the feed's getFixedItemSize. Collapsed work-log341// The minimum matches min-h-8 below. Exact sizing is disabled when native
297// rows are single-line (numberOfLines={1}) inside a min-height that stays342// accessibility scaling can make the single-line text taller than that minimum.
298// taller than text-sm at every supported base font size, so row height is343const WORK_ROW_HEIGHT = THREAD_WORK_ROW_MIN_HEIGHT;
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
302const WORK_ROW_GAP = 1; // gap-px344const WORK_ROW_GAP = 1; // gap-px
303const WORK_LOG_BOTTOM_MARGIN = 4; // mb-1345const 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-8351function 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
307export function collapsedWorkLogHeight(activities: ReadonlyArray<ThreadFeedActivity>): number {358export function collapsedWorkLogHeight(activities: ReadonlyArray<ThreadFeedActivity>): number {
308 const rows = activities;359 if (activities.length === 0) {
309 if (rows.length === 0) {
310 return 0;360 return 0;
311 }361 }
312 return WORK_LOG_BOTTOM_MARGIN + rows.length * WORK_ROW_HEIGHT + (rows.length - 1) * WORK_ROW_GAP;362 const height = workLogRowsHeight(activities);
363 return (
364 WORK_LOG_BOTTOM_MARGIN +
365 (activities[0]?.groupedToolDetail ? Math.min(height, WORK_GROUP_MAX_HEIGHT) : height)
366 );
313}367}
314368
315export function ThreadWorkLog(props: {369interface ThreadWorkLogProps {
316 readonly activities: ReadonlyArray<ThreadFeedActivity>;370 readonly activities: ReadonlyArray<ThreadFeedActivity>;
371 readonly anchorKey: string;
317 readonly copiedRowId: string | null;372 readonly copiedRowId: string | null;
318 readonly expandedRows: Readonly<Record<string, boolean>>;373 readonly expandedRows: Readonly<Record<string, boolean>>;
319 readonly iconSubtleColor: import("react-native").ColorValue;374 readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
375 readonly scrollPositions: Map<string, ThreadWorkGroupScrollPosition>;
376 readonly iconSubtleColor: ColorValue;
320 readonly onCopyRow: (rowId: string, value: string) => void;377 readonly onCopyRow: (rowId: string, value: string) => void;
321 readonly onToggleRow: (rowId: string) => void;378 readonly onToggleRow: (rowId: string, anchorKey: string) => void;
322 readonly renderImage: MarkdownImageRenderer;379 readonly renderImage: MarkdownImageRenderer;
323}) {380}
324 const rows = props.activities.map((activity) => ({381
325 ...activity,382export function ThreadWorkLog(props: ThreadWorkLogProps) {
326 detail: compactActivityDetail(activity.detail),383 const renderRow = useCallback(
327 }));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) {
330 return null;409 return null;
331 }410 }
332411
333 return (412 return (
334 <View className="-mx-1 mb-1 px-1 py-0">413 <View className="-mx-1 mb-1 px-1 py-0">
335 <View className="gap-px">414 {props.activities[0]?.groupedToolDetail ? (
336 {rows.map((row) => {415 <ThreadWorkGroupList
337 const expanded = props.expandedRows[row.id] ?? false;416 activities={props.activities}
338 const canExpand = row.canExpand;417 expandedRows={props.expandedRows}
339 const fullDetail = expanded ? row.getFullDetail() : null;418 groupId={props.anchorKey}
340 const viewedImagePath = workEntryViewedImagePath(row.workEntry);419 rowSizing={props.rowSizing}
341 const previewText = row.detail ?? row.summary;420 scrollPositions={props.scrollPositions}
342 const displayText = expanded && row.workEntry.command?.trim() ? "Command" : previewText;421 renderRow={renderRow}
343 const iconIsDestructive = row.icon === "alert" || row.icon === "warning";422 />
344 const failed = row.status === "failure";423 ) : (
345 const showIcon = !row.groupedToolDetail || iconIsDestructive || failed;424 <View className="gap-px">{props.activities.map(renderRow)}</View>
346425 )}
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>
462 </View>426 </View>
463 );427 );
464}428}
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
466export function ThreadWorkGroupToggle(props: {788export function ThreadWorkGroupToggle(props: {
789 readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
467 readonly expanded: boolean;790 readonly expanded: boolean;
468 readonly hiddenCount: number;791 readonly hiddenCount: number;
469 readonly iconSubtleColor: import("react-native").ColorValue;792 readonly iconSubtleColor: import("react-native").ColorValue;
470 readonly summary: string;793 readonly summary: string;
471 readonly summaryKind: ToolGroupSummaryKind;794 readonly summaryKind: ToolGroupSummaryKind;
795 readonly summaryToolIcon?: "browser" | "t3-code";
472 readonly hasFailure: boolean;796 readonly hasFailure: boolean;
473 readonly shimmer: boolean;797 readonly shimmer: boolean;
474 readonly onToggle: () => void;798 readonly onToggle: () => void;
...@@ -476,7 +800,7 @@ export function ThreadWorkGroupToggle(props: {...@@ -476,7 +800,7 @@ export function ThreadWorkGroupToggle(props: {
476 const accessibilityLabel = props.hasFailure800 const accessibilityLabel = props.hasFailure
477 ? `${props.summary}, tool call failed`801 ? `${props.summary}, tool call failed`
478 : props.summary;802 : props.summary;
479 const icon = toolGroupSummarySymbolName(props.summaryKind);803 const icon = props.summaryToolIcon ?? toolGroupSummarySymbolName(props.summaryKind);
480804
481 return (805 return (
482 <View className="-mx-1 px-1 py-0">806 <View className="-mx-1 px-1 py-0">
...@@ -491,9 +815,11 @@ export function ThreadWorkGroupToggle(props: {...@@ -491,9 +815,11 @@ export function ThreadWorkGroupToggle(props: {
491 props.onToggle();815 props.onToggle();
492 }}816 }}
493 className="min-h-8 flex-row items-center gap-1.5 rounded-md px-0.5 py-0 active:bg-subtle"817 className="min-h-8 flex-row items-center gap-1.5 rounded-md px-0.5 py-0 active:bg-subtle"
818 style={{ minHeight: props.rowSizing.estimatedRowHeight }}
494 >819 >
495 {props.shimmer ? (820 {props.shimmer ? (
496 <ShimmeringWorkContent821 <ShimmeringWorkContent
822 key={props.rowSizing.textSizeKey}
497 icon={icon}823 icon={icon}
498 iconSubtleColor={props.iconSubtleColor}824 iconSubtleColor={props.iconSubtleColor}
499 label={props.summary}825 label={props.summary}
...@@ -502,14 +828,13 @@ export function ThreadWorkGroupToggle(props: {...@@ -502,14 +828,13 @@ export function ThreadWorkGroupToggle(props: {
502 ) : (828 ) : (
503 <>829 <>
504 <View className="h-6 w-6 items-center justify-center">830 <View className="h-6 w-6 items-center justify-center">
505 <SymbolView831 <WorkLogIcon icon={icon} color={props.iconSubtleColor} />
506 name={icon}
507 size={14}
508 tintColor={props.iconSubtleColor}
509 type="monochrome"
510 />
511 </View>832 </View>
512 <Text className="min-w-0 flex-1 text-sm text-foreground-muted" numberOfLines={1}>833 <Text
834 key={props.rowSizing.textSizeKey}
835 className="min-w-0 flex-1 text-sm text-foreground-muted"
836 numberOfLines={1}
837 >
513 {props.summary}838 {props.summary}
514 </Text>839 </Text>
515 </>840 </>
...@@ -533,6 +858,7 @@ function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName {...@@ -533,6 +858,7 @@ function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName {
533 return { ios: "square.and.pencil", android: "edit" };858 return { ios: "square.and.pencil", android: "edit" };
534 case "command":859 case "command":
535 return { ios: "terminal", android: "terminal" };860 return { ios: "terminal", android: "terminal" };
861 case "browser":
536 case "search":862 case "search":
537 return { ios: "globe", android: "public" };863 return { ios: "globe", android: "public" };
538 case "code-search":864 case "code-search":
apps/mobile/src/lib/layout.test.ts+39
...@@ -8,11 +8,50 @@ import {...@@ -8,11 +8,50 @@ import {
8 deriveLayout,8 deriveLayout,
9 deriveStableFormSheetDetent,9 deriveStableFormSheetDetent,
10 deriveThreadFeedInitialContentInset,10 deriveThreadFeedInitialContentInset,
11 deriveThreadWorkLogSizing,
11 deriveWorkspacePaneLayout,12 deriveWorkspacePaneLayout,
12 SPLIT_LAYOUT_MIN_HEIGHT,13 SPLIT_LAYOUT_MIN_HEIGHT,
13 SPLIT_LAYOUT_MIN_WIDTH,14 SPLIT_LAYOUT_MIN_WIDTH,
14} from "./layout";15} 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
16describe("deriveThreadFeedInitialContentInset", () => {55describe("deriveThreadFeedInitialContentInset", () => {
17 it("seeds Android scroll math with the composer overlay estimate", () => {56 it("seeds Android scroll math with the composer overlay estimate", () => {
18 expect(57 expect(
apps/mobile/src/lib/layout.ts+26
...@@ -1,3 +1,6 @@...@@ -1,3 +1,6 @@
1import { scaledTypographyLineHeight } from "./appearancePreferences";
2import { MOBILE_TYPOGRAPHY } from "./typography";
3
1function clamp(value: number, min: number, max: number): number {4function clamp(value: number, min: number, max: number): number {
2 return Math.min(Math.max(value, min), max);5 return Math.min(Math.max(value, min), max);
3}6}
...@@ -18,6 +21,29 @@ const SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH = 380;...@@ -18,6 +21,29 @@ const SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH = 380;
1821
19export const AUXILIARY_PANE_MIN_CONTENT_WIDTH = 960;22export const AUXILIARY_PANE_MIN_CONTENT_WIDTH = 960;
20export const CHAT_CONTENT_MAX_WIDTH = 960;23export 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
22export const AUXILIARY_PANE_MIN_WIDTH = 260;48export const AUXILIARY_PANE_MIN_WIDTH = 260;
23export const AUXILIARY_PANE_MAX_WIDTH = 480;49export const AUXILIARY_PANE_MAX_WIDTH = 480;
apps/mobile/src/lib/threadActivity.test.ts+636-97
...@@ -825,6 +825,408 @@ describe("buildThreadFeed", () => {...@@ -825,6 +825,408 @@ describe("buildThreadFeed", () => {
825 expect(group.activities[0]?.getFullDetail()).toContain("repository.search");825 expect(group.activities[0]?.getFullDetail()).toContain("repository.search");
826 });826 });
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
828 it("defers large tool output expansion until a work row is opened or copied", () => {1230 it("defers large tool output expansion until a work row is opened or copied", () => {
829 let serializedToolOutputs = 0;1231 let serializedToolOutputs = 0;
830 const activities = Array.from({ length: 5_000 }, (_, index) =>1232 const activities = Array.from({ length: 5_000 }, (_, index) =>
...@@ -866,6 +1268,22 @@ describe("buildThreadFeed", () => {...@@ -866,6 +1268,22 @@ describe("buildThreadFeed", () => {
866 }1268 }
8671269
868 expect(group.activities).toHaveLength(5_000);1270 expect(group.activities).toHaveLength(5_000);
1271 const expanded = deriveThreadFeedPresentation(
1272 feed,
1273 null,
1274 new Set(),
1275 new Set(["work-group:large-tool-0"]),
1276 );
1277 expect(expanded).toHaveLength(2);
1278 expect(expanded[1]).toMatchObject({
1279 type: "activity-group",
1280 id: "work-details:work-group:large-tool-0",
1281 });
1282 if (expanded[1]?.type === "activity-group") {
1283 expect(expanded[1].activities).toHaveLength(5_000);
1284 expect(expanded[1].activities[0]?.getFullDetail).toBe(group.activities[0]?.getFullDetail);
1285 }
1286 expect(serializedToolOutputs).toBe(0);
869 expect(group.activities[0]?.getFullDetail()).toContain('"output"');1287 expect(group.activities[0]?.getFullDetail()).toContain('"output"');
870 expect(serializedToolOutputs).toBe(1);1288 expect(serializedToolOutputs).toBe(1);
871 expect(group.activities[0]?.getCopyText()).toContain('"output"');1289 expect(group.activities[0]?.getCopyText()).toContain('"output"');
...@@ -1137,17 +1555,26 @@ describe("buildThreadFeed", () => {...@@ -1137,17 +1555,26 @@ describe("buildThreadFeed", () => {
1137 type: "activity-group",1555 type: "activity-group",
1138 activities: [{ status: "success" }, { status: "failure" }],1556 activities: [{ status: "success" }, { status: "failure" }],
1139 });1557 });
1140 expect(1558 const expanded = deriveThreadFeedPresentation(
1141 deriveThreadFeedPresentation(1559 feed,
1142 feed,1560 thread.latestTurn,
1143 thread.latestTurn,1561 new Set(),
1144 new Set(),1562 new Set(["work-group:tool-succeeded"]),
1145 new Set(["work-group:tool-succeeded"]),1563 );
1146 ).map((entry) => entry.id),1564 expect(expanded.map((entry) => entry.id)).toEqual([
1147 ).toEqual(["work-toggle:work-group:tool-succeeded", "tool-succeeded", "tool-failed"]);1565 "work-toggle:work-group:tool-succeeded",
1566 "work-details:work-group:tool-succeeded",
1567 ]);
1568 expect(expanded[1]).toMatchObject({
1569 type: "activity-group",
1570 activities: [
1571 { id: "tool-succeeded", status: "success", groupedToolDetail: true },
1572 { id: "tool-failed", status: "failure", groupedToolDetail: true },
1573 ],
1574 });
1148 });1575 });
11491576
1150 it("models work-log overflow as list rows", () => {1577 it("keeps expanded work in one group with stable row identities", () => {
1151 const activity = (1578 const activity = (
1152 id: string,1579 id: string,
1153 createdAt: string,1580 createdAt: string,
...@@ -1206,111 +1633,192 @@ describe("buildThreadFeed", () => {...@@ -1206,111 +1633,192 @@ describe("buildThreadFeed", () => {
1206 );1633 );
1207 expect(expanded.map((entry) => entry.id)).toEqual([1634 expect(expanded.map((entry) => entry.id)).toEqual([
1208 "work-toggle:work-group:activity-1",1635 "work-toggle:work-group:activity-1",
1209 "activity-1",1636 "work-details:work-group:activity-1",
1210 "activity-2",
1211 "activity-3",
1212 ]);1637 ]);
1213 expect(expanded[0]).toMatchObject({1638 expect(expanded[0]).toMatchObject({
1214 type: "work-toggle",1639 type: "work-toggle",
1215 expanded: true,1640 expanded: true,
1216 });1641 });
1642 expect(expanded[1]).toMatchObject({
1643 type: "activity-group",
1644 activities: [
1645 { id: "activity-1", groupedToolDetail: true, live: false },
1646 { id: "activity-2", groupedToolDetail: true, live: false },
1647 { id: "activity-3", groupedToolDetail: true, live: false },
1648 ],
1649 });
1217 });1650 });
12181651
1219 it("keeps live state on the active uninterrupted tool run", () => {1652 it.each(
1220 const turnId = TurnId.make("turn-live-tools");1653 [
1221 const activity = (1654 "sudo -u root pnpm test",
1222 id: string,1655 "/bin/zsh -lc 'sudo -u root pnpm test'",
1223 status: ThreadFeedActivity["status"],1656 "/bin/bash -lc 'sudo -u root pnpm test'",
1224 lifecycleStatus: ThreadFeedActivity["lifecycleStatus"],1657 ].flatMap((command) =>
1225 tone: "tool" | "error" = "tool",1658 (
1226 command?: string,1659 [
1227 ): ThreadFeedActivity => ({1660 { lifecycleStatus: "inProgress", summary: "Running pnpm", shimmer: true },
1228 id,1661 { lifecycleStatus: "completed", summary: "Ran pnpm", shimmer: false },
1229 createdAt: `2026-04-01T00:00:0${id.at(-1)}.000Z`,1662 { lifecycleStatus: "failed", summary: "Failed pnpm", shimmer: false },
1230 turnId,1663 { lifecycleStatus: "declined", summary: "Declined pnpm", shimmer: false },
1231 summary: `Tool ${id}`,1664 { lifecycleStatus: "stopped", summary: "Stopped pnpm", shimmer: false },
1232 detail: null,1665 ] as const
1233 canExpand: false,1666 ).map((state) => ({ command, ...state })),
1234 getFullDetail: () => null,1667 ),
1235 getCopyText: () => id,1668 )(
1236 icon: "command",1669 "keeps the command summary in sync with $lifecycleStatus: $command",
1237 toolLike: true,1670 ({ command, lifecycleStatus, summary, shimmer }) => {
1238 status,1671 const turnId = TurnId.make("turn-live-tools");
1239 lifecycleStatus,1672 const activity = (
1240 workEntry: {1673 id: string,
1674 status: ThreadFeedActivity["status"],
1675 lifecycleStatus: ThreadFeedActivity["lifecycleStatus"],
1676 tone: "tool" | "error" = "tool",
1677 command?: string,
1678 ): ThreadFeedActivity => ({
1241 id,1679 id,
1242 createdAt: `2026-04-01T00:00:0${id.at(-1)}.000Z`,1680 createdAt: `2026-04-01T00:00:0${id.at(-1)}.000Z`,
1243 turnId,1681 turnId,
1244 label: `Tool ${id}`,1682 summary: `Tool ${id}`,
1245 tone,1683 detail: lifecycleStatus === "stopped" ? "Exit code 130" : null,
1246 toolLifecycleStatus: lifecycleStatus,1684 canExpand: false,
1247 ...(command ? { command, itemType: "command_execution" as const } : {}),1685 getFullDetail: () => null,
1248 },1686 getCopyText: () => id,
1249 });1687 icon: "command",
1250 const feed: ThreadFeedEntry[] = [1688 toolLike: true,
1251 {1689 status,
1252 type: "activity-group",1690 lifecycleStatus,
1253 id: "activity-1",1691 workEntry: {
1254 createdAt: "2026-04-01T00:00:01.000Z",1692 id,
1693 createdAt: `2026-04-01T00:00:0${id.at(-1)}.000Z`,
1694 turnId,
1695 label: `Tool ${id}`,
1696 tone,
1697 toolLifecycleStatus: lifecycleStatus,
1698 ...(lifecycleStatus === "stopped" ? { detail: "Exit code 130" } : {}),
1699 ...(command ? { command, itemType: "command_execution" as const } : {}),
1700 },
1701 });
1702 const feed: ThreadFeedEntry[] = [
1703 {
1704 type: "activity-group",
1705 id: "activity-1",
1706 createdAt: "2026-04-01T00:00:01.000Z",
1707 turnId,
1708 activities: [
1709 activity("activity-1", "success", "completed"),
1710 activity("activity-2", "failure", "failed", "error"),
1711 activity(
1712 "activity-3",
1713 lifecycleStatus === "inProgress"
1714 ? "neutral"
1715 : lifecycleStatus === "completed"
1716 ? "success"
1717 : "failure",
1718 lifecycleStatus,
1719 "tool",
1720 command,
1721 ),
1722 ...(lifecycleStatus === "inProgress"
1723 ? [activity("activity-4", "success", "completed", "tool", "printf done")]
1724 : []),
1725 ],
1726 },
1727 ];
1728 const latestTurn = {
1255 turnId,1729 turnId,
1256 activities: [1730 state: "running" as const,
1257 activity("activity-1", "success", "completed"),1731 requestedAt: "2026-04-01T00:00:00.000Z",
1258 activity("activity-2", "failure", "failed", "error"),1732 startedAt: "2026-04-01T00:00:00.000Z",
1259 activity("activity-3", "success", "completed", "tool", "sudo -u root pnpm test"),1733 completedAt: null,
1260 ],1734 assistantMessageId: null,
1735 };
1736
1737 const rows = deriveThreadFeedPresentation(
1738 feed,
1739 latestTurn,
1740 new Set(),
1741 new Set(),
1742 latestTurn.startedAt,
1743 );
1744 expect(rows.slice(0, 3).map((entry) => [entry.id, entry.type])).toEqual([
1745 ["work-toggle:work-group:activity-1", "work-toggle"],
1746 ["activity-2", "activity-group"],
1747 ["work-live:work-group:activity-3", "work-toggle"],
1748 ]);
1749 expect(rows.slice(0, 3).map((entry) => entry.type === "work-toggle" && entry.live)).toEqual([
1750 false,
1751 false,
1752 true,
1753 ]);
1754 expect(rows[2]).toMatchObject({
1755 summary,
1756 summaryKind: "command",
1757 live: true,
1758 shimmer,
1759 });
1760 expect(rows[0]).toMatchObject({ live: false, shimmer: false });
1761
1762 const stoppedRows = deriveThreadFeedPresentation(feed, latestTurn, new Set());
1763 expect(stoppedRows.filter((entry) => entry.type === "work-toggle")).toMatchObject([
1764 { live: false, shimmer: false },
1765 { live: false, shimmer: false },
1766 ]);
1767
1768 const completedRows = deriveThreadFeedPresentation(
1769 feed,
1770 { ...latestTurn, state: "completed", completedAt: "2026-04-01T00:00:04.000Z" },
1771 new Set([turnId]),
1772 new Set(),
1773 latestTurn.startedAt,
1774 );
1775 expect(completedRows.filter((entry) => entry.type === "work-toggle")).toMatchObject([
1776 { live: false, shimmer: false },
1777 { live: false, shimmer: false },
1778 ]);
1779 },
1780 );
1781
1782 it.each([
1783 ["inProgress", true],
1784 ["completed", false],
1785 ["failed", false],
1786 ["declined", false],
1787 ["stopped", false],
1788 ] as const)("respects the %s lifecycle of trailing task progress", (status, shimmer) => {
1789 const turnId = TurnId.make("turn-task-progress");
1790 const thread = makeThread({
1791 id: ThreadId.make("thread-task-progress"),
1792 projectId: ProjectId.make("project-1"),
1793 title: "Task lifecycle",
1794 latestTurn: {
1795 turnId,
1796 state: "running",
1797 requestedAt: "2026-04-01T00:00:00.000Z",
1798 startedAt: "2026-04-01T00:00:01.000Z",
1799 completedAt: null,
1800 assistantMessageId: null,
1261 },1801 },
1262 ];1802 activities: [
1263 const latestTurn = {1803 makeActivity({
1264 turnId,1804 id: EventId.make("task-progress"),
1265 state: "running" as const,1805 kind: "task.progress",
1266 requestedAt: "2026-04-01T00:00:00.000Z",1806 summary: "Task progress",
1267 startedAt: "2026-04-01T00:00:00.000Z",1807 createdAt: "2026-04-01T00:00:02.000Z",
1268 completedAt: null,1808 turnId,
1269 assistantMessageId: null,1809 payload: { taskId: "task-1", status },
1270 };1810 }),
1811 ],
1812 });
12711813
1272 const rows = deriveThreadFeedPresentation(1814 const rows = deriveThreadFeedPresentation(
1273 feed,1815 buildThreadFeed(thread),
1274 latestTurn,1816 thread.latestTurn,
1275 new Set(),1817 new Set(),
1276 new Set(),1818 new Set(),
1277 latestTurn.startedAt,1819 thread.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,
1309 );1820 );
1310 expect(completedRows.filter((entry) => entry.type === "work-toggle")).toMatchObject([1821 expect(rows.some((entry) => entry.type === "work-toggle" && entry.shimmer)).toBe(shimmer);
1311 { live: false, shimmer: false },
1312 { live: false, shimmer: false },
1313 ]);
1314 });1822 });
13151823
1316 it("does not revive cached in-progress tools after work stops", () => {1824 it("does not revive cached in-progress tools after work stops", () => {
...@@ -1417,6 +1925,37 @@ describe("buildThreadFeed", () => {...@@ -1417,6 +1925,37 @@ describe("buildThreadFeed", () => {
1417 summary: "Ran 2 commands",1925 summary: "Ran 2 commands",
1418 live: false,1926 live: false,
1419 });1927 });
1928
1929 const groupId = `work-group:tool:${turnId}:call-a`;
1930 const startedAt = "2026-04-01T00:00:00.000Z";
1931 const runningRows = deriveThreadFeedPresentation(
1932 buildThreadFeed({ ...thread, activities: thread.activities.slice(0, 2) }),
1933 { turnId, state: "running", startedAt, completedAt: null },
1934 new Set(),
1935 new Set([groupId]),
1936 startedAt,
1937 );
1938 expect(runningRows.find((entry) => entry.type === "activity-group")).toMatchObject({
1939 id: `work-details:${groupId}`,
1940 activities: [
1941 { id: "call-a-1", lifecycleStatus: "inProgress", groupedToolDetail: true, live: false },
1942 { id: "call-b-2", lifecycleStatus: "inProgress", groupedToolDetail: true, live: true },
1943 ],
1944 });
1945
1946 const completedRows = deriveThreadFeedPresentation(
1947 feed,
1948 null,
1949 new Set([turnId]),
1950 new Set([groupId]),
1951 );
1952 expect(completedRows.find((entry) => entry.type === "activity-group")).toMatchObject({
1953 id: `work-details:${groupId}`,
1954 activities: [
1955 { id: "call-a-1", lifecycleStatus: "completed", groupedToolDetail: true, live: false },
1956 { id: "call-b-2", lifecycleStatus: "completed", groupedToolDetail: true, live: false },
1957 ],
1958 });
1420 });1959 });
1421});1960});
14221961
apps/mobile/src/lib/threadActivity.ts+64-35
...@@ -19,6 +19,7 @@ import {...@@ -19,6 +19,7 @@ import {
19 isWorktreeSetupActivity,19 isWorktreeSetupActivity,
20 normalizeCompactToolLabel,20 normalizeCompactToolLabel,
21 omitSupersededLifecycleMarkers,21 omitSupersededLifecycleMarkers,
22 resolveWorkEntryToolPresentation,
22 summarizeToolGroup,23 summarizeToolGroup,
23 toolGroupSummaryKind,24 toolGroupSummaryKind,
24 type ToolGroupSummaryKind,25 type ToolGroupSummaryKind,
...@@ -146,6 +147,7 @@ export type ThreadFeedEntry =...@@ -146,6 +147,7 @@ export type ThreadFeedEntry =
146 readonly expanded: boolean;147 readonly expanded: boolean;
147 readonly summary: string;148 readonly summary: string;
148 readonly summaryKind: ToolGroupSummaryKind;149 readonly summaryKind: ToolGroupSummaryKind;
150 readonly summaryToolIcon?: "browser" | "t3-code";
149 readonly hasFailure: boolean;151 readonly hasFailure: boolean;
150 readonly live: boolean;152 readonly live: boolean;
151 readonly shimmer: boolean;153 readonly shimmer: boolean;
...@@ -477,8 +479,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo...@@ -477,8 +479,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
477 }479 }
478 if (itemType === "mcp_tool_call") {480 if (itemType === "mcp_tool_call") {
479 const data = asRecord(payload?.data);481 const data = asRecord(payload?.data);
480 if (data?.item !== undefined) {482 const toolData = typeof data?.toolName === "string" ? (data.item ?? data) : data?.item;
481 entry.toolData = data.item;483 if (toolData !== undefined) {
484 entry.toolData = toolData;
482 }485 }
483 }486 }
484 if (itemType) {487 if (itemType) {
...@@ -826,6 +829,8 @@ function capitalizePhrase(value: string): string {...@@ -826,6 +829,8 @@ function capitalizePhrase(value: string): string {
826}829}
827830
828function workEntryHeading(workEntry: WorkLogEntry): string {831function workEntryHeading(workEntry: WorkLogEntry): string {
832 const presentation = resolveWorkEntryToolPresentation(workEntry);
833 if (presentation) return presentation.displayName;
829 if (!workEntry.toolTitle) {834 if (!workEntry.toolTitle) {
830 return capitalizePhrase(normalizeCompactToolLabel(workEntry.label));835 return capitalizePhrase(normalizeCompactToolLabel(workEntry.label));
831 }836 }
...@@ -1501,19 +1506,26 @@ function appendToolGroupRows(...@@ -1501,19 +1506,26 @@ function appendToolGroupRows(
1501 : activities[0]!.id;1506 : activities[0]!.id;
1502 const groupId = `work-group:${identity}`;1507 const groupId = `work-group:${identity}`;
1503 const expanded = expandedWorkGroupIds.has(groupId);1508 const expanded = expandedWorkGroupIds.has(groupId);
1504 const latestInProgressActivity = activities.findLast(1509 const latestActiveActivity = activities.findLast(
1505 (activity) =>1510 (activity) =>
1506 isWorking && activity.lifecycleStatus === "inProgress" && activity.turnId === unsettledTurnId,1511 isWorking &&
1512 activity.turnId === unsettledTurnId &&
1513 (activity.lifecycleStatus === "inProgress" ||
1514 (activeTail &&
1515 activity.lifecycleStatus === undefined &&
1516 (activity.workEntry.sourceActivityKind === "task.progress" || activity.toolLike))),
1507 );1517 );
1508 const live = activeTail || latestInProgressActivity !== undefined;1518 const active = latestActiveActivity !== undefined;
1509 const latestActivity = activeTail1519 const live = activeTail || active;
1510 ? activities.at(-1)!1520 const latestActivity = latestActiveActivity ?? activities.at(-1)!;
1511 : (latestInProgressActivity ?? activities.at(-1)!);
1512 const summary = live1521 const summary = live
1513 ? liveToolActivitySummary(latestActivity)1522 ? liveToolActivitySummary(latestActivity, active)
1514 : activities.length === 1 && !activities[0]!.toolLike1523 : activities.length === 1 && !activities[0]!.toolLike
1515 ? activities[0]!.workEntry.label1524 ? activities[0]!.workEntry.label
1516 : summarizeToolGroup(activities.map((activity) => activity.workEntry));1525 : summarizeToolGroup(activities.map((activity) => activity.workEntry));
1526 const summaryToolIcon = live
1527 ? resolveWorkEntryToolPresentation(latestActivity.workEntry)?.icon
1528 : undefined;
1517 result.push({1529 result.push({
1518 type: "work-toggle",1530 type: "work-toggle",
1519 id: `${live ? "work-live" : "work-toggle"}:${groupId}`,1531 id: `${live ? "work-live" : "work-toggle"}:${groupId}`,
...@@ -1526,40 +1538,52 @@ function appendToolGroupRows(...@@ -1526,40 +1538,52 @@ function appendToolGroupRows(
1526 summaryKind: toolGroupSummaryKind(1538 summaryKind: toolGroupSummaryKind(
1527 (live ? [latestActivity] : activities).map((activity) => activity.workEntry),1539 (live ? [latestActivity] : activities).map((activity) => activity.workEntry),
1528 ),1540 ),
1541 ...(summaryToolIcon ? { summaryToolIcon } : {}),
1529 hasFailure: activities.findLast((activity) => activity.toolLike)?.status === "failure",1542 hasFailure: activities.findLast((activity) => activity.toolLike)?.status === "failure",
1530 live,1543 live,
1531 // Match the live label until the turn or contiguous tool run settles.1544 shimmer: active,
1532 shimmer: live,
1533 });1545 });
1534 if (!expanded) {1546 if (!expanded) {
1535 return;1547 return;
1536 }1548 }
1537 for (const activity of activities) {1549 result.push({
1538 result.push({1550 type: "activity-group",
1539 type: "activity-group",1551 id: `work-details:${groupId}`,
1540 id: activity.id,1552 createdAt: activities[0]!.createdAt,
1541 createdAt: activity.createdAt,1553 turnId: activities[0]!.turnId,
1542 turnId: activity.turnId,1554 activities: activities.map((activity) => ({
1543 activities: [1555 ...activity,
1544 {1556 groupedToolDetail: true,
1545 ...activity,1557 live:
1546 groupedToolDetail: true,1558 isWorking &&
1547 live:1559 activity.id === latestActivity.id &&
1548 isWorking &&1560 activity.lifecycleStatus === "inProgress" &&
1549 activity.id === latestActivity.id &&1561 activity.turnId === unsettledTurnId,
1550 activity.lifecycleStatus === "inProgress" &&1562 })),
1551 activity.turnId === unsettledTurnId,1563 });
1552 },
1553 ],
1554 });
1555 }
1556}1564}
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;
1559 const command = activity.workEntry.command?.trim();1572 const command = activity.workEntry.command?.trim();
1560 if (command) {1573 if (command) {
1561 const program = commandProgramName(command);1574 const program = commandProgramName(command);
1562 return program ? `Running ${program}` : "Running command";1575 const status = activity.lifecycleStatus ?? (active ? "inProgress" : "completed");
1576 const verb =
1577 status === "inProgress"
1578 ? "Running"
1579 : status === "failed"
1580 ? "Failed"
1581 : status === "declined"
1582 ? "Declined"
1583 : status === "stopped"
1584 ? "Stopped"
1585 : "Ran";
1586 return `${verb} ${program ?? "command"}`;
1563 }1587 }
1564 return activity.detail ?? activity.summary;1588 return activity.detail ?? activity.summary;
1565}1589}
...@@ -1773,15 +1797,20 @@ export function buildThreadFeed(...@@ -1773,15 +1797,20 @@ export function buildThreadFeed(
1773 const detail = workEntryPreview(entry);1797 const detail = workEntryPreview(entry);
1774 const getFullDetail = memoizeValue(() => buildWorkEntryExpandedBody(entry));1798 const getFullDetail = memoizeValue(() => buildWorkEntryExpandedBody(entry));
1775 const getCopyText = memoizeValue(() => {1799 const getCopyText = memoizeValue(() => {
1800 const copyLabel = capitalizePhrase(
1801 normalizeCompactToolLabel(entry.toolTitle || entry.label),
1802 );
1776 const fullDetail = getFullDetail();1803 const fullDetail = getFullDetail();
1777 if (entry.command) {1804 if (entry.command) {
1778 const normalizedCommand =1805 const normalizedCommand =
1779 entry.rawCommand && summary.trim() !== entry.command.trim() ? entry.command : null;1806 entry.rawCommand && copyLabel.trim() !== entry.command.trim()
1780 return [summary, normalizedCommand, fullDetail ?? entry.command]1807 ? entry.command
1808 : null;
1809 return [copyLabel, normalizedCommand, fullDetail ?? entry.command]
1781 .filter((value): value is string => Boolean(value))1810 .filter((value): value is string => Boolean(value))
1782 .join("\n");1811 .join("\n");
1783 }1812 }
1784 return [summary, detail, fullDetail]1813 return [copyLabel, detail, fullDetail]
1785 .filter((value, index, values): value is string => {1814 .filter((value, index, values): value is string => {
1786 return Boolean(value) && values.indexOf(value) === index;1815 return Boolean(value) && values.indexOf(value) === index;
1787 })1816 })
apps/web/src/components/ChatView.logic.test.ts+130-1
...@@ -6,7 +6,7 @@ import {...@@ -6,7 +6,7 @@ import {
6 ThreadId,6 ThreadId,
7 TurnId,7 TurnId,
8} from "@t3tools/contracts";8} 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
11import type { Thread, ThreadShell } from "../types";11import type { Thread, ThreadShell } from "../types";
12import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";12import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
...@@ -41,6 +41,7 @@ import {...@@ -41,6 +41,7 @@ import {
41 shouldShowBranchMismatchBanner,41 shouldShowBranchMismatchBanner,
42 shouldShowPlanFollowUpPrompt,42 shouldShowPlanFollowUpPrompt,
43 shouldWriteThreadErrorToCurrentServerThread,43 shouldWriteThreadErrorToCurrentServerThread,
44 toolGroupConsumesUpwardNavigation,
44} from "./ChatView.logic";45} from "./ChatView.logic";
4546
46describe("isVideoPreviewRequestCurrent", () => {47describe("isVideoPreviewRequestCurrent", () => {
...@@ -51,6 +52,134 @@ describe("isVideoPreviewRequestCurrent", () => {...@@ -51,6 +52,134 @@ describe("isVideoPreviewRequestCurrent", () => {
51 });52 });
52});53});
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
54const environmentId = EnvironmentId.make("environment-local");183const environmentId = EnvironmentId.make("environment-local");
55const projectId = ProjectId.make("project-1");184const projectId = ProjectId.make("project-1");
56const threadId = ThreadId.make("thread-1");185const threadId = ThreadId.make("thread-1");
apps/web/src/components/ChatView.logic.ts+16
...@@ -99,6 +99,22 @@ export function shouldReleaseTimelineAnchorForToolActivity(input: {...@@ -99,6 +99,22 @@ export function shouldReleaseTimelineAnchorForToolActivity(input: {
99 });99 });
100}100}
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
102export function resolveDraftHeroState(input: {118export function resolveDraftHeroState(input: {
103 isLocalDraftThread: boolean;119 isLocalDraftThread: boolean;
104 hasTimelineEntries: boolean;120 hasTimelineEntries: boolean;
apps/web/src/components/ChatView.tsx+7-2
...@@ -379,6 +379,7 @@ import {...@@ -379,6 +379,7 @@ import {
379 shouldWriteThreadErrorToCurrentServerThread,379 shouldWriteThreadErrorToCurrentServerThread,
380 startNewThreadForProject,380 startNewThreadForProject,
381 codexArtifactTemplatePromptToAppend,381 codexArtifactTemplatePromptToAppend,
382 toolGroupConsumesUpwardNavigation,
382 waitForStartedServerThread,383 waitForStartedServerThread,
383} from "./ChatView.logic";384} from "./ChatView.logic";
384import type { ThreadSyncPhase } from "../threadSync";385import type { ThreadSyncPhase } from "../threadSync";
...@@ -4218,7 +4219,11 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4218,7 +4219,11 @@ function ChatViewContent(props: ChatViewProps) {
4218 // Only an upward wheel is a navigation intent; wheeling down while4219 // Only an upward wheel is a navigation intent; wheeling down while
4219 // following either does nothing (at the end) or moves toward it.4220 // following either does nothing (at the end) or moves toward it.
4220 const handleWheel = (event: WheelEvent) => {4221 const handleWheel = (event: WheelEvent) => {
4221 if (event.deltaY < 0 && contentScrollsUp()) {4222 if (
4223 event.deltaY < 0 &&
4224 contentScrollsUp() &&
4225 !toolGroupConsumesUpwardNavigation(event.target)
4226 ) {
4222 handleManualNavigation();4227 handleManualNavigation();
4223 }4228 }
4224 };4229 };
...@@ -4255,7 +4260,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4255,7 +4260,7 @@ function ChatViewContent(props: ChatViewProps) {
4255 case "PageUp":4260 case "PageUp":
4256 case "Home":4261 case "Home":
4257 case "ArrowUp":4262 case "ArrowUp":
4258 if (contentScrollsUp()) {4263 if (contentScrollsUp() && !toolGroupConsumesUpwardNavigation(event.target)) {
4259 handleManualNavigation();4264 handleManualNavigation();
4260 }4265 }
4261 break;4266 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 {...@@ -4,11 +4,180 @@ import {
4 computeStableMessagesTimelineRows,4 computeStableMessagesTimelineRows,
5 computeMessageDurationStart,5 computeMessageDurationStart,
6 deriveMessagesTimelineRows,6 deriveMessagesTimelineRows,
7 liveWorkEntryLabel,
7 normalizeCompactToolLabel,8 normalizeCompactToolLabel,
8 resolveAssistantMessageCopyState,9 resolveAssistantMessageCopyState,
10 resolveWorkGroupScrollIndex,
11 shouldFollowWorkGroupAppend,
9 shouldPreserveAssistantLineBreaks,12 shouldPreserveAssistantLineBreaks,
13 workEntryDisplayLabel,
10} from "./MessagesTimeline.logic";14} 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
12describe("shouldPreserveAssistantLineBreaks", () => {181describe("shouldPreserveAssistantLineBreaks", () => {
13 it("preserves Claude insight formatting without changing regular markdown", () => {182 it("preserves Claude insight formatting without changing regular markdown", () => {
14 expect(183 expect(
...@@ -1197,6 +1366,50 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1197,6 +1366,50 @@ describe("deriveMessagesTimelineRows", () => {
1197 ]);1366 ]);
1198 });1367 });
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
1200 it("reuses one activity row for initial thinking and the latest tool", () => {1413 it("reuses one activity row for initial thinking and the latest tool", () => {
1201 const deriveRows = (toolLifecycleStatus: "inProgress" | "completed" | "declined" | null) =>1414 const deriveRows = (toolLifecycleStatus: "inProgress" | "completed" | "declined" | null) =>
1202 deriveMessagesTimelineRows({1415 deriveMessagesTimelineRows({
...@@ -1464,15 +1677,61 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1464,15 +1677,61 @@ describe("deriveMessagesTimelineRows", () => {
1464 });1677 });
1465 expect(expandedRows.map((row) => row.id)).toEqual([1678 expect(expandedRows.map((row) => row.id)).toEqual([
1466 "work-toggle:work-entry-1",1679 "work-toggle:work-entry-1",
1467 "work-1",1680 "work-group:work-entry-1:details",
1468 "work-2",
1469 "work-3",
1470 ]);1681 ]);
1682 expect(expandedRows.find((row) => row.kind === "work")).toMatchObject({
1683 isExpandedToolGroup: true,
1684 groupedEntries: timelineEntries.map(({ entry }) => entry),
1685 });
1471 expect(expandedRows.find((row) => row.kind === "work-toggle")).toMatchObject({1686 expect(expandedRows.find((row) => row.kind === "work-toggle")).toMatchObject({
1472 expanded: true,1687 expanded: true,
1473 });1688 });
1474 });1689 });
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
1476 it.each([1735 it.each([
1477 ["recovered", ["failed", "completed"], false],1736 ["recovered", ["failed", "completed"], false],
1478 ["ending in failure", ["completed", "failed"], true],1737 ["ending in failure", ["completed", "failed"], true],
apps/web/src/components/chat/MessagesTimeline.logic.ts+109-38
...@@ -1,7 +1,10 @@...@@ -1,7 +1,10 @@
1import * as Equal from "effect/Equal";1import * as Equal from "effect/Equal";
2import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives";2import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives";
3import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";
3import {4import {
5 normalizeCompactToolLabel,
4 omitSupersededLifecycleMarkers,6 omitSupersededLifecycleMarkers,
7 resolveWorkEntryToolPresentation,
5 summarizeToolGroup,8 summarizeToolGroup,
6 toolGroupSummaryKind,9 toolGroupSummaryKind,
7 type ToolGroupSummaryKind,10 type ToolGroupSummaryKind,
...@@ -23,6 +26,7 @@ import {...@@ -23,6 +26,7 @@ import {
23} from "../../session-logic";26} from "../../session-logic";
24import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";27import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";
25import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts";28import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts";
29import { formatWorkspaceRelativePath } from "../../filePathDisplay";
2630
27export const TIMELINE_MINIMAP_ITEM_SPACING = 8;31export const TIMELINE_MINIMAP_ITEM_SPACING = 8;
28export const TIMELINE_MINIMAP_MIN_ITEMS = 2;32export const TIMELINE_MINIMAP_MIN_ITEMS = 2;
...@@ -30,6 +34,50 @@ export const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)";...@@ -30,6 +34,50 @@ export const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)";
30export const TIMELINE_CONTENT_MAX_WIDTH = 768;34export const TIMELINE_CONTENT_MAX_WIDTH = 768;
31export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48;35export 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
33export function workEntryIsVisibleInGroup(81export function workEntryIsVisibleInGroup(
34 entry: WorkLogEntry,82 entry: WorkLogEntry,
35 expandedToolGroupEntry = false,83 expandedToolGroupEntry = false,
...@@ -42,6 +90,35 @@ export function workEntryIsVisibleInGroup(...@@ -42,6 +90,35 @@ export function workEntryIsVisibleInGroup(
42 );90 );
43}91}
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
45export interface TimelineEndState {122export interface TimelineEndState {
46 readonly isAtEnd?: boolean;123 readonly isAtEnd?: boolean;
47 readonly contentLength?: number;124 readonly contentLength?: number;
...@@ -197,8 +274,7 @@ export type MessagesTimelineRow =...@@ -197,8 +274,7 @@ export type MessagesTimelineRow =
197 id: string;274 id: string;
198 createdAt: string;275 createdAt: string;
199 groupedEntries: WorkLogEntry[];276 groupedEntries: WorkLogEntry[];
200 isExpandedToolGroupEntry: boolean;277 isExpandedToolGroup: boolean;
201 isLastExpandedToolGroupEntry: boolean;
202 }278 }
203 | {279 | {
204 kind: "work-live";280 kind: "work-live";
...@@ -292,6 +368,20 @@ function workGroupId(timelineEntryId: string, entry: WorkLogEntry): string {...@@ -292,6 +368,20 @@ function workGroupId(timelineEntryId: string, entry: WorkLogEntry): string {
292 return `work-group:${workGroupIdentity(timelineEntryId, entry)}`;368 return `work-group:${workGroupIdentity(timelineEntryId, entry)}`;
293}369}
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
295export function resolveAssistantMessageCopyState({385export function resolveAssistantMessageCopyState({
296 text,386 text,
297 showCopyButton,387 showCopyButton,
...@@ -384,8 +474,8 @@ function timelineEntryTurnId(entry: TimelineEntry): TurnId | null {...@@ -384,8 +474,8 @@ function timelineEntryTurnId(entry: TimelineEntry): TurnId | null {
384function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean {474function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean {
385 return (475 return (
386 entry.toolLifecycleStatus === "inProgress" ||476 entry.toolLifecycleStatus === "inProgress" ||
387 entry.sourceActivityKind === "task.progress" ||477 (entry.toolLifecycleStatus === undefined &&
388 (entry.toolLifecycleStatus === undefined && workLogEntryIsToolLike(entry))478 (entry.sourceActivityKind === "task.progress" || workLogEntryIsToolLike(entry)))
389 );479 );
390}480}
391481
...@@ -640,16 +730,13 @@ export function deriveMessagesTimelineRows(input: {...@@ -640,16 +730,13 @@ export function deriveMessagesTimelineRows(input: {
640 nextRows.push(activeWorkRow);730 nextRows.push(activeWorkRow);
641 hasActivityRow ||= activeWorkRow.active;731 hasActivityRow ||= activeWorkRow.active;
642 if (!activeWorkRow.expanded) return;732 if (!activeWorkRow.expanded) return;
643 for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) {733 nextRows.push(
644 nextRows.push({734 expandedWorkGroupRow(
645 kind: "work",735 activeWorkRow.groupId,
646 id: workEntry.id,736 activeWorkRow.createdAt,
647 createdAt: workEntry.createdAt,737 activeWorkRow.groupedEntries,
648 groupedEntries: [workEntry],738 ),
649 isExpandedToolGroupEntry: true,739 );
650 isLastExpandedToolGroupEntry: entryIndex === activeWorkRow.groupedEntries.length - 1,
651 });
652 }
653 };740 };
654741
655 for (let index = 0; index < input.timelineEntries.length; index += 1) {742 for (let index = 0; index < input.timelineEntries.length; index += 1) {
...@@ -693,8 +780,7 @@ export function deriveMessagesTimelineRows(input: {...@@ -693,8 +780,7 @@ export function deriveMessagesTimelineRows(input: {
693 id: timelineEntry.id,780 id: timelineEntry.id,
694 createdAt: timelineEntry.createdAt,781 createdAt: timelineEntry.createdAt,
695 groupedEntries: [timelineEntry.entry],782 groupedEntries: [timelineEntry.entry],
696 isExpandedToolGroupEntry: false,783 isExpandedToolGroup: false,
697 isLastExpandedToolGroupEntry: false,
698 });784 });
699 continue;785 continue;
700 }786 }
...@@ -740,16 +826,9 @@ export function deriveMessagesTimelineRows(input: {...@@ -740,16 +826,9 @@ export function deriveMessagesTimelineRows(input: {
740 });826 });
741 hasActivityRow = true;827 hasActivityRow = true;
742 if (expanded) {828 if (expanded) {
743 for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) {829 nextRows.push(
744 nextRows.push({830 expandedWorkGroupRow(groupId, timelineEntry.createdAt, visibleGroupedEntries),
745 kind: "work",831 );
746 id: workEntry.id,
747 createdAt: workEntry.createdAt,
748 groupedEntries: [workEntry],
749 isExpandedToolGroupEntry: true,
750 isLastExpandedToolGroupEntry: entryIndex === visibleGroupedEntries.length - 1,
751 });
752 }
753 }832 }
754 } else {833 } else {
755 const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);834 const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);
...@@ -774,16 +853,9 @@ export function deriveMessagesTimelineRows(input: {...@@ -774,16 +853,9 @@ export function deriveMessagesTimelineRows(input: {
774 workEntryDisplayIndicatesToolFailure(latestToolEntry),853 workEntryDisplayIndicatesToolFailure(latestToolEntry),
775 });854 });
776 if (expanded) {855 if (expanded) {
777 for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) {856 nextRows.push(
778 nextRows.push({857 expandedWorkGroupRow(groupId, timelineEntry.createdAt, visibleGroupedEntries),
779 kind: "work",858 );
780 id: workEntry.id,
781 createdAt: workEntry.createdAt,
782 groupedEntries: [workEntry],
783 isExpandedToolGroupEntry: true,
784 isLastExpandedToolGroupEntry: entryIndex === visibleGroupedEntries.length - 1,
785 });
786 }
787 }859 }
788 }860 }
789 }861 }
...@@ -891,8 +963,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean...@@ -891,8 +963,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
891 case "work": {963 case "work": {
892 const bw = b as typeof a;964 const bw = b as typeof a;
893 return (965 return (
894 a.isExpandedToolGroupEntry === bw.isExpandedToolGroupEntry &&966 a.isExpandedToolGroup === bw.isExpandedToolGroup &&
895 a.isLastExpandedToolGroupEntry === bw.isLastExpandedToolGroupEntry &&
896 Equal.equals(a.groupedEntries, bw.groupedEntries)967 Equal.equals(a.groupedEntries, bw.groupedEntries)
897 );968 );
898 }969 }
apps/web/src/components/chat/MessagesTimeline.test.tsx+3-3
...@@ -1206,7 +1206,7 @@ describe("MessagesTimeline", () => {...@@ -1206,7 +1206,7 @@ describe("MessagesTimeline", () => {
1206 expect(markup).toContain('data-timeline-row-id="live-activity-row"');1206 expect(markup).toContain('data-timeline-row-id="live-activity-row"');
1207 });1207 });
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", () => {
1210 const turnId = TurnId.make("turn-live");1210 const turnId = TurnId.make("turn-live");
1211 const markup = renderToStaticMarkup(1211 const markup = renderToStaticMarkup(
1212 <MessagesTimeline1212 <MessagesTimeline
...@@ -1241,10 +1241,10 @@ describe("MessagesTimeline", () => {...@@ -1241,10 +1241,10 @@ describe("MessagesTimeline", () => {
1241 />,1241 />,
1242 );1242 );
12431243
1244 expect(markup).toContain("Running pnpm");1244 expect(markup).toContain("Ran pnpm");
1245 expect(markup).toContain("lucide-terminal");1245 expect(markup).toContain("lucide-terminal");
1246 expect(markup).toContain("live-activity-focus");1246 expect(markup).toContain("live-activity-focus");
1247 expect(markup).not.toContain("Ran pnpm");1247 expect(markup).not.toContain("Running pnpm");
1248 expect(markup).not.toContain("Thinking");1248 expect(markup).not.toContain("Thinking");
1249 expect(markup).not.toContain('data-timeline-row-kind="thinking"');1249 expect(markup).not.toContain('data-timeline-row-kind="thinking"');
1250 });1250 });
apps/web/src/components/chat/MessagesTimeline.tsx+248-90
...@@ -9,11 +9,12 @@ import {...@@ -9,11 +9,12 @@ import {
9} from "@t3tools/contracts";9} from "@t3tools/contracts";
10import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";10import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
11import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";11import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
12import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";
13import {12import {
13 resolveWorkEntryToolPresentation,
14 resolveViewedImageAsset,14 resolveViewedImageAsset,
15 workEntryViewedImagePath,15 workEntryViewedImagePath,
16} from "@t3tools/client-runtime/work-log/presentation";16} from "@t3tools/client-runtime/work-log/presentation";
17import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor";
17import type { AgentPanelModel } from "@t3tools/client-runtime/state/subagentRuntime";18import type { AgentPanelModel } from "@t3tools/client-runtime/state/subagentRuntime";
18import {19import {
19 emptyAgentPanelModel,20 emptyAgentPanelModel,
...@@ -62,6 +63,7 @@ import {...@@ -62,6 +63,7 @@ import {
62} from "../../lib/diffRendering";63} from "../../lib/diffRendering";
63import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting";64import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting";
64import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown";65import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown";
66import { T3Wordmark } from "../T3Wordmark";
65import {67import {
66 BotIcon,68 BotIcon,
67 BrainIcon,69 BrainIcon,
...@@ -87,6 +89,7 @@ import {...@@ -87,6 +89,7 @@ import {
87 ZapIcon,89 ZapIcon,
88} from "lucide-react";90} from "lucide-react";
89import { Button } from "../ui/button";91import { Button } from "../ui/button";
92import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area";
90import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview";93import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview";
91import { ProposedPlanCard } from "./ProposedPlanCard";94import { ProposedPlanCard } from "./ProposedPlanCard";
92import { ChangedFilesCard } from "./ChangedFilesTree";95import { ChangedFilesCard } from "./ChangedFilesTree";
...@@ -107,7 +110,7 @@ import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssis...@@ -107,7 +110,7 @@ import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssis
107import {110import {
108 computeStableMessagesTimelineRows,111 computeStableMessagesTimelineRows,
109 deriveMessagesTimelineRows,112 deriveMessagesTimelineRows,
110 normalizeCompactToolLabel,113 liveWorkEntryLabel,
111 resolveAssistantMessageCopyState,114 resolveAssistantMessageCopyState,
112 resolveTimelineIsAtEnd,115 resolveTimelineIsAtEnd,
113 resolveTimelineMinimapHasPersistentGutter,116 resolveTimelineMinimapHasPersistentGutter,
...@@ -116,13 +119,17 @@ import {...@@ -116,13 +119,17 @@ import {
116 resolveTimelineMinimapIndexFromPointer,119 resolveTimelineMinimapIndexFromPointer,
117 resolveTimelineMinimapInteractiveWidth,120 resolveTimelineMinimapInteractiveWidth,
118 resolveTimelineMinimapTopPercent,121 resolveTimelineMinimapTopPercent,
122 resolveWorkGroupScrollIndex,
123 shouldFollowWorkGroupAppend,
119 shouldPreserveAssistantLineBreaks,124 shouldPreserveAssistantLineBreaks,
120 toolGroupAction,125 toolGroupAction,
126 workEntryDisplayLabel,
121 workEntryIsVisibleInGroup,127 workEntryIsVisibleInGroup,
122 type StableMessagesTimelineRowsState,128 type StableMessagesTimelineRowsState,
123 type MessagesTimelineRow,129 type MessagesTimelineRow,
124 TIMELINE_MINIMAP_MIN_ITEMS,130 TIMELINE_MINIMAP_MIN_ITEMS,
125 type TimelineLatestTurn,131 type TimelineLatestTurn,
132 type WorkGroupScrollAnchor,
126} from "./MessagesTimeline.logic";133} from "./MessagesTimeline.logic";
127import { TerminalContextInlineChip } from "./TerminalContextInlineChip";134import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
128import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";135import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
...@@ -183,6 +190,8 @@ interface TimelineRowSharedState {...@@ -183,6 +190,8 @@ interface TimelineRowSharedState {
183 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;190 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
184 onToggleTurnFold: (turnId: TurnId) => void;191 onToggleTurnFold: (turnId: TurnId) => void;
185 onToggleWorkGroup: (groupId: string, anchorKey: string) => void;192 onToggleWorkGroup: (groupId: string, anchorKey: string) => void;
193 onToggleWorkEntry: (anchorKey: string) => void;
194 workGroupViewState: WorkGroupViewState;
186 agentPanelModel: AgentPanelModel;195 agentPanelModel: AgentPanelModel;
187 onOpenAgents: () => void;196 onOpenAgents: () => void;
188}197}
...@@ -196,6 +205,16 @@ interface TimelineRowActivityState {...@@ -196,6 +205,16 @@ interface TimelineRowActivityState {
196205
197const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);206const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);
198const TimelineRowActivityCtx = createContext<TimelineRowActivityState>(null!);207const 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);
199const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />;218const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />;
200const TIMELINE_LIST_FADE_HEADER = (219const TIMELINE_LIST_FADE_HEADER = (
201 <div className="h-[var(--workspace-titlebar-scroll-fade-height)]" />220 <div className="h-[var(--workspace-titlebar-scroll-fade-height)]" />
...@@ -343,6 +362,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -343,6 +362,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({
343 );362 );
344 }, []);363 }, []);
345 const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set());364 const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set());
365 // Scroll/disclosure state outlives virtualized rows, but never the current thread.
366 const workGroupViewState = useMemo<WorkGroupViewState>(
367 () => ({ scrollPositions: new Map(), expandedEntries: new Set() }),
368 [routeThreadKey],
369 );
346 const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);370 const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);
347 const [minimapStripMap] = useState(() => new Map<string, HTMLSpanElement>());371 const [minimapStripMap] = useState(() => new Map<string, HTMLSpanElement>());
348 const disclosureAnchorKeyRef = useRef<string | null>(null);372 const disclosureAnchorKeyRef = useRef<string | null>(null);
...@@ -616,6 +640,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -616,6 +640,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
616 onOpenTurnDiff,640 onOpenTurnDiff,
617 onToggleTurnFold,641 onToggleTurnFold,
618 onToggleWorkGroup,642 onToggleWorkGroup,
643 onToggleWorkEntry: suspendEndScrollMaintenanceForDisclosure,
644 workGroupViewState,
619 agentPanelModel,645 agentPanelModel,
620 onOpenAgents,646 onOpenAgents,
621 }),647 }),
...@@ -637,6 +663,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -637,6 +663,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
637 onOpenTurnDiff,663 onOpenTurnDiff,
638 onToggleTurnFold,664 onToggleTurnFold,
639 onToggleWorkGroup,665 onToggleWorkGroup,
666 suspendEndScrollMaintenanceForDisclosure,
667 workGroupViewState,
640 agentPanelModel,668 agentPanelModel,
641 onOpenAgents,669 onOpenAgents,
642 ],670 ],
...@@ -1043,8 +1071,7 @@ type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["grouped...@@ -1043,8 +1071,7 @@ type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["grouped
1043type TimelineRow = MessagesTimelineRow;1071type TimelineRow = MessagesTimelineRow;
10441072
1045const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) {1073const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) {
1046 const isExpandedToolGroupEntry = row.kind === "work" && row.isExpandedToolGroupEntry;1074 const isExpandedToolGroup = row.kind === "work" && row.isExpandedToolGroup;
1047 const isLastExpandedToolGroupEntry = row.kind === "work" && row.isLastExpandedToolGroupEntry;
1048 const isExpandedToolGroupHeader =1075 const isExpandedToolGroupHeader =
1049 (row.kind === "work-toggle" && row.expanded) || (row.kind === "work-live" && row.expanded);1076 (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...@@ -1053,10 +1080,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
1053 className={cn(1080 className={cn(
1054 // Commentary (non-terminal assistant) rows carry no metadata row, so1081 // Commentary (non-terminal assistant) rows carry no metadata row, so
1055 // they sit closer to the work that follows them.1082 // they sit closer to the work that follows them.
1056 isExpandedToolGroupEntry1083 isExpandedToolGroup
1057 ? isLastExpandedToolGroupEntry1084 ? "pb-1"
1058 ? "pb-1"
1059 : "pb-0"
1060 : isExpandedToolGroupHeader1085 : isExpandedToolGroupHeader
1061 ? "pb-0"1086 ? "pb-0"
1062 : row.kind === "turn-fold" || row.kind === "working"1087 : row.kind === "turn-fold" || row.kind === "working"
...@@ -1079,8 +1104,9 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time...@@ -1079,8 +1104,9 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
1079 >1104 >
1080 {row.kind === "work" ? (1105 {row.kind === "work" ? (
1081 <WorkGroupSection1106 <WorkGroupSection
1107 anchorKey={row.id}
1082 groupedEntries={row.groupedEntries}1108 groupedEntries={row.groupedEntries}
1083 isExpandedToolGroupEntry={row.isExpandedToolGroupEntry}1109 isExpandedToolGroup={row.isExpandedToolGroup}
1084 />1110 />
1085 ) : null}1111 ) : null}
1086 {row.kind === "work-live" ? <LiveWorkEntryTimelineRow row={row} /> : null}1112 {row.kind === "work-live" ? <LiveWorkEntryTimelineRow row={row} /> : null}
...@@ -1485,43 +1511,189 @@ function WorkingTimer({ createdAt }: { createdAt: string }) {...@@ -1485,43 +1511,189 @@ function WorkingTimer({ createdAt }: { createdAt: string }) {
1485// re-render only the affected row, not the entire list.1511// re-render only the affected row, not the entire list.
1486// ---------------------------------------------------------------------------1512// ---------------------------------------------------------------------------
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. */
1489const WorkGroupSection = memo(function WorkGroupSection({1515const WorkGroupSection = memo(function WorkGroupSection({
1516 anchorKey,
1490 groupedEntries,1517 groupedEntries,
1491 isExpandedToolGroupEntry,1518 isExpandedToolGroup,
1492}: {1519}: {
1520 anchorKey: string;
1493 groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"];1521 groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"];
1494 isExpandedToolGroupEntry: boolean;1522 isExpandedToolGroup: boolean;
1495}) {1523}) {
1496 const { workspaceRoot } = use(TimelineRowCtx);1524 const { workspaceRoot, routeThreadKey } = use(TimelineRowCtx);
1497 const nonEmptyEntries = useMemo(1525 const nonEmptyEntries = useMemo(
1498 () =>1526 () => groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroup)),
1499 groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroupEntry)),1527 [groupedEntries, isExpandedToolGroup],
1500 [groupedEntries, isExpandedToolGroupEntry],
1501 );1528 );
1502 const GroupContainer = isExpandedToolGroupEntry ? "div" : "section";
15031529
1504 if (nonEmptyEntries.length === 0) return null;1530 if (nonEmptyEntries.length === 0) return null;
1531 if (isExpandedToolGroup) {
1532 return (
1533 <ExpandedWorkGroupEntries
1534 key={`${routeThreadKey}:${anchorKey}`}
1535 anchorKey={anchorKey}
1536 entries={nonEmptyEntries}
1537 workspaceRoot={workspaceRoot}
1538 />
1539 );
1540 }
15051541
1506 return (1542 return (
1507 <GroupContainer1543 <section className="-mx-1 space-y-0.5 px-1 py-0.5" aria-label="Activity">
1508 className={cn("-mx-1 px-1", isExpandedToolGroupEntry ? "py-0" : "space-y-0.5 py-0.5")}
1509 aria-label={isExpandedToolGroupEntry ? undefined : "Activity"}
1510 >
1511 <div className="space-y-px">1544 <div className="space-y-px">
1512 {nonEmptyEntries.map((workEntry) => (1545 {nonEmptyEntries.map((workEntry) => (
1513 <SimpleWorkEntryRow1546 <SimpleWorkEntryRow
1514 key={workEntry.id}1547 key={workEntry.id}
1515 workEntry={workEntry}1548 workEntry={workEntry}
1516 workspaceRoot={workspaceRoot}1549 workspaceRoot={workspaceRoot}
1517 isExpandedToolGroupEntry={isExpandedToolGroupEntry}1550 isExpandedToolGroupEntry={false}
1518 />1551 />
1519 ))}1552 ))}
1520 </div>1553 </div>
1521 </GroupContainer>1554 </section>
1522 );1555 );
1523});1556});
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
1525function ActivityShimmerOverlay({ children }: { children: ReactNode }) {1697function ActivityShimmerOverlay({ children }: { children: ReactNode }) {
1526 return (1698 return (
1527 <span1699 <span
...@@ -1572,7 +1744,8 @@ function LiveActivityContent({...@@ -1572,7 +1744,8 @@ function LiveActivityContent({
1572 announceFailure?: boolean;1744 announceFailure?: boolean;
1573 highlighted?: boolean;1745 highlighted?: boolean;
1574}) {1746}) {
1575 const resolvedIconName = failed ? "circle-alert" : iconName;1747 const isSpecialToolIcon = iconName === "browser" || iconName === "t3-code";
1748 const resolvedIconName = failed && !isSpecialToolIcon ? "circle-alert" : iconName;
15761749
1577 return (1750 return (
1578 <span1751 <span
...@@ -1591,13 +1764,14 @@ function LiveActivityContent({...@@ -1591,13 +1764,14 @@ function LiveActivityContent({
1591 role={announceFailure ? "img" : undefined}1764 role={announceFailure ? "img" : undefined}
1592 aria-label={announceFailure ? "Tool call failed" : undefined}1765 aria-label={announceFailure ? "Tool call failed" : undefined}
1593 >1766 >
1594 <WorkEntryIconSvg1767 <WorkEntryIcon
1595 name={resolvedIconName}1768 name={resolvedIconName}
1596 className={cn("block size-4 shrink-0 stroke-[1.8]", !highlighted && "opacity-70")}1769 className={cn("block size-4 shrink-0 stroke-[1.8]", !highlighted && "opacity-70")}
1597 />1770 />
1598 </span>1771 </span>
1599 ) : null}1772 ) : null}
1600 <span className="min-w-0 flex-1 truncate">{label}</span>1773 <span className="min-w-0 flex-1 truncate">{label}</span>
1774 {failed && isSpecialToolIcon ? <XIcon aria-hidden className="size-3 shrink-0" /> : null}
1601 </span>1775 </span>
1602 );1776 );
1603}1777}
...@@ -1641,6 +1815,8 @@ function toolGroupSummaryIconName(...@@ -1641,6 +1815,8 @@ function toolGroupSummaryIconName(
1641 return "square-pen";1815 return "square-pen";
1642 case "command":1816 case "command":
1643 return "terminal";1817 return "terminal";
1818 case "browser":
1819 return "browser";
1644 case "search":1820 case "search":
1645 return "globe";1821 return "globe";
1646 case "code-search":1822 case "code-search":
...@@ -1674,7 +1850,7 @@ function WorkGroupToggleTimelineRow({...@@ -1674,7 +1850,7 @@ function WorkGroupToggleTimelineRow({
1674 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}1850 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
1675 >1851 >
1676 <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">1852 <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
1677 <WorkEntryIconSvg1853 <WorkEntryIcon
1678 name={toolGroupSummaryIconName(row.summaryKind)}1854 name={toolGroupSummaryIconName(row.summaryKind)}
1679 className="size-4 shrink-0 stroke-[1.8] opacity-70"1855 className="size-4 shrink-0 stroke-[1.8] opacity-70"
1680 />1856 />
...@@ -2208,6 +2384,7 @@ function formatWorkingTimerNow(startIso: string): string {...@@ -2208,6 +2384,7 @@ function formatWorkingTimerNow(startIso: string): string {
2208type WorkEntryIconName =2384type WorkEntryIconName =
2209 | "bot"2385 | "bot"
2210 | "brain"2386 | "brain"
2387 | "browser"
2211 | "check"2388 | "check"
2212 | "circle-alert"2389 | "circle-alert"
2213 | "eye"2390 | "eye"
...@@ -2217,16 +2394,21 @@ type WorkEntryIconName =...@@ -2217,16 +2394,21 @@ type WorkEntryIconName =
2217 | "search"2394 | "search"
2218 | "square-pen"2395 | "square-pen"
2219 | "terminal"2396 | "terminal"
2397 | "t3-code"
2220 | "wrench"2398 | "wrench"
2221 | "x"2399 | "x"
2222 | "zap";2400 | "zap";
22232401
2224function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; className: string }) {2402function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className: string }) {
2225 switch (name) {2403 switch (name) {
2226 case "bot":2404 case "bot":
2227 return <BotIcon className={className} aria-hidden />;2405 return <BotIcon className={className} aria-hidden />;
2228 case "brain":2406 case "brain":
2229 return <BrainIcon className={className} aria-hidden />;2407 return <BrainIcon className={className} aria-hidden />;
2408 case "browser":
2409 return <GlobeIcon className={className} aria-hidden />;
2410 case "t3-code":
2411 return <T3Wordmark className={className} aria-hidden />;
2230 case "check":2412 case "check":
2231 return <CheckIcon className={className} aria-hidden />;2413 return <CheckIcon className={className} aria-hidden />;
2232 case "circle-alert":2414 case "circle-alert":
...@@ -2282,21 +2464,6 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): {...@@ -2282,21 +2464,6 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): {
2282 };2464 };
2283}2465}
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
2300function workEntryRawCommand(2467function workEntryRawCommand(
2301 workEntry: Pick<TimelineWorkEntry, "command" | "rawCommand">,2468 workEntry: Pick<TimelineWorkEntry, "command" | "rawCommand">,
2302): string | null {2469): string | null {
...@@ -2307,26 +2474,6 @@ function workEntryRawCommand(...@@ -2307,26 +2474,6 @@ function workEntryRawCommand(
2307 return rawCommand === workEntry.command.trim() ? null : rawCommand;2474 return rawCommand === workEntry.command.trim() ? null : rawCommand;
2308}2475}
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
2330function buildToolCallExpandedBody(2477function buildToolCallExpandedBody(
2331 workEntry: TimelineWorkEntry,2478 workEntry: TimelineWorkEntry,
2332 workspaceRoot: string | undefined,2479 workspaceRoot: string | undefined,
...@@ -2365,6 +2512,8 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName {...@@ -2365,6 +2512,8 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName {
2365 ) {2512 ) {
2366 return "message-circle";2513 return "message-circle";
2367 }2514 }
2515 const toolPresentation = resolveWorkEntryToolPresentation(workEntry);
2516 if (toolPresentation) return toolPresentation.icon;
2368 const action = toolGroupAction(workEntry);2517 const action = toolGroupAction(workEntry);
2369 if (action !== "other") return toolGroupSummaryIconName(action);2518 if (action !== "other") return toolGroupSummaryIconName(action);
23702519
...@@ -2385,21 +2534,6 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName {...@@ -2385,21 +2534,6 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName {
2385 return workToneIcon(workEntry.tone).iconName;2534 return workToneIcon(workEntry.tone).iconName;
2386}2535}
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
2403const stopRowToggle = (e: { stopPropagation: () => void }) => e.stopPropagation();2537const stopRowToggle = (e: { stopPropagation: () => void }) => e.stopPropagation();
24042538
2405/**2539/**
...@@ -2480,7 +2614,7 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time...@@ -2480,7 +2614,7 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time
2480 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"2614 className="flex w-full items-center gap-2 rounded-md border border-border/60 bg-card/50 px-2.5 py-1.5 text-left text-[13px] transition hover:bg-accent/50"
2481 >2615 >
2482 <span aria-hidden className={cn("size-1.5 shrink-0 rounded-full", dotClass)} />2616 <span aria-hidden className={cn("size-1.5 shrink-0 rounded-full", dotClass)} />
2483 <WorkEntryIconSvg name="bot" className="size-3.5 shrink-0 text-muted-foreground" />2617 <WorkEntryIcon name="bot" className="size-3.5 shrink-0 text-muted-foreground" />
2484 <span className="min-w-0 truncate">2618 <span className="min-w-0 truncate">
2485 <span className="font-medium">{lead}</span>2619 <span className="font-medium">{lead}</span>
2486 {workflowName ? <span className="text-muted-foreground"> · {workflowName}</span> : null}2620 {workflowName ? <span className="text-muted-foreground"> · {workflowName}</span> : null}
...@@ -2522,15 +2656,39 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {...@@ -2522,15 +2656,39 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
2522}) {2656}) {
2523 const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props;2657 const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props;
2524 const { threadRef, onImageExpand } = use(TimelineRowCtx);2658 const { threadRef, onImageExpand } = use(TimelineRowCtx);
2525 const [expanded, setExpanded] = useState(false);2659 const groupView = use(WorkGroupViewCtx);
2660 const [expanded, setExpanded] = useState(
2661 () => groupView?.state.expandedEntries.has(workEntry.id) ?? false,
2662 );
2663 const toggleExpanded = () => {
2664 const next = !expanded;
2665 if (groupView) {
2666 groupView.onToggleEntry();
2667 if (next) groupView.state.expandedEntries.add(workEntry.id);
2668 else groupView.state.expandedEntries.delete(workEntry.id);
2669 }
2670 setExpanded(next);
2671 };
2526 const iconConfig = workToneIcon(workEntry.tone);2672 const iconConfig = workToneIcon(workEntry.tone);
2527 const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning";2673 const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning";
2528 const showFailedIndicator = workEntryDisplayIndicatesToolFailure(workEntry);2674 const showFailedIndicator = workEntryDisplayIndicatesToolFailure(workEntry);
2675 const toolPresentation = resolveWorkEntryToolPresentation(workEntry);
2529 const entryIconName =2676 const entryIconName =
2530 showWarningIndicator || showFailedIndicator ? "circle-alert" : workEntryIconName(workEntry);2677 showWarningIndicator || (showFailedIndicator && !toolPresentation)
2531 const previewText = workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry);2678 ? "circle-alert"
2532 const displayText = expanded && workEntry.command?.trim() ? "Command" : previewText;2679 : workEntryIconName(workEntry);
2533 const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot);2680 const previewText = workEntryDisplayLabel(workEntry, workspaceRoot);
2681 const displayText =
2682 !toolPresentation && expanded && workEntry.command?.trim() ? "Command" : previewText;
2683 const canExpand =
2684 (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) ||
2685 Boolean(
2686 workEntryRawCommand(workEntry) ||
2687 workEntry.command?.trim() ||
2688 workEntry.detail?.trim() ||
2689 workEntry.changedFiles?.length,
2690 );
2691 const expandedBody = expanded ? buildToolCallExpandedBody(workEntry, workspaceRoot) : null;
2534 const viewedImagePath = workEntryViewedImagePath(workEntry);2692 const viewedImagePath = workEntryViewedImagePath(workEntry);
2535 const viewedImage =2693 const viewedImage =
2536 viewedImagePath && threadRef2694 viewedImagePath && threadRef
...@@ -2539,7 +2697,6 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {...@@ -2539,7 +2697,6 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
2539 workspaceRoot,2697 workspaceRoot,
2540 })2698 })
2541 : null;2699 : null;
2542 const canExpand = expandedBody !== null;
2543 const showDestructiveRowStyle =2700 const showDestructiveRowStyle =
2544 showFailedIndicator &&2701 showFailedIndicator &&
2545 (workEntrySignalsSevereFailure(workEntry) || !workLogEntryIsToolLike(workEntry));2702 (workEntrySignalsSevereFailure(workEntry) || !workLogEntryIsToolLike(workEntry));
...@@ -2562,7 +2719,6 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {...@@ -2562,7 +2719,6 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
2562 : workLogEntryIsToolLike(workEntry)2719 : workLogEntryIsToolLike(workEntry)
2563 ? "text-secondary-label"2720 ? "text-secondary-label"
2564 : "text-foreground/80";2721 : "text-foreground/80";
2565 const showEntryIcon = !isExpandedToolGroupEntry || showWarningIndicator || showFailedIndicator;
2566 const accessibleDisplayText = showFailedIndicator2722 const accessibleDisplayText = showFailedIndicator
2567 ? `${previewText}, tool call failed`2723 ? `${previewText}, tool call failed`
2568 : previewText;2724 : previewText;
...@@ -2572,11 +2728,11 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {...@@ -2572,11 +2728,11 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
2572 tabIndex: 0 as const,2728 tabIndex: 0 as const,
2573 "aria-label": accessibleDisplayText,2729 "aria-label": accessibleDisplayText,
2574 "aria-expanded": expanded,2730 "aria-expanded": expanded,
2575 onClick: () => setExpanded((v) => !v),2731 onClick: toggleExpanded,
2576 onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {2732 onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {
2577 if (e.key === "Enter" || e.key === " ") {2733 if (e.key === "Enter" || e.key === " ") {
2578 e.preventDefault();2734 e.preventDefault();
2579 setExpanded((v) => !v);2735 toggleExpanded();
2580 }2736 }
2581 },2737 },
2582 }2738 }
...@@ -2594,12 +2750,11 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {...@@ -2594,12 +2750,11 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
2594 >2750 >
2595 <div className="flex select-none items-center gap-1.5 transition-[opacity,translate] duration-200">2751 <div className="flex select-none items-center gap-1.5 transition-[opacity,translate] duration-200">
2596 <span2752 <span
2597 className={cn(iconWrapperClass, !showEntryIcon && "invisible")}2753 className={iconWrapperClass}
2598 role={showFailedIndicator ? "img" : undefined}2754 role={showFailedIndicator ? "img" : undefined}
2599 aria-label={showFailedIndicator ? "Tool call failed" : undefined}2755 aria-label={showFailedIndicator ? "Tool call failed" : undefined}
2600 aria-hidden={!showEntryIcon}
2601 >2756 >
2602 <WorkEntryIconSvg2757 <WorkEntryIcon
2603 name={entryIconName}2758 name={entryIconName}
2604 className="block size-4 shrink-0 stroke-[1.8] opacity-70"2759 className="block size-4 shrink-0 stroke-[1.8] opacity-70"
2605 />2760 />
...@@ -2610,6 +2765,9 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {...@@ -2610,6 +2765,9 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
2610 <span className={cn("min-w-0 flex-1 truncate", headingClass)}>{displayText}</span>2765 <span className={cn("min-w-0 flex-1 truncate", headingClass)}>{displayText}</span>
2611 </p>2766 </p>
2612 </div>2767 </div>
2768 {showFailedIndicator && toolPresentation ? (
2769 <XIcon aria-hidden className="size-3 shrink-0 text-icon-muted" />
2770 ) : null}
2613 <span2771 <span
2614 className={cn(2772 className={cn(
2615 "flex size-4 shrink-0 items-center justify-center",2773 "flex size-4 shrink-0 items-center justify-center",
apps/web/src/components/sidebar/SidebarChrome.tsx+2-17
...@@ -11,6 +11,7 @@ import { Link, useCanGoBack, useLocation, useNavigate } from "@tanstack/react-ro...@@ -11,6 +11,7 @@ import { Link, useCanGoBack, useLocation, useNavigate } from "@tanstack/react-ro
11import { useEnvironmentIdentificationMode } from "../../hooks/useSettings";11import { useEnvironmentIdentificationMode } from "../../hooks/useSettings";
12import { cn } from "../../lib/utils";12import { cn } from "../../lib/utils";
13import { useEnvironments } from "../../state/environments";13import { useEnvironments } from "../../state/environments";
14import { T3Wordmark } from "../T3Wordmark";
14import {15import {
15 resolveEnvironmentIdentificationPillLabel,16 resolveEnvironmentIdentificationPillLabel,
16 resolveSidebarStageBackdropVariant,17 resolveSidebarStageBackdropVariant,
...@@ -89,7 +90,7 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) {...@@ -89,7 +90,7 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) {
89 )}90 )}
90 to="/"91 to="/"
91 >92 >
92 <T3Wordmark />93 <T3Wordmark aria-label="T3" className="h-2.5 w-auto shrink-0" />
93 <span94 <span
94 className={cn(95 className={cn(
95 "-translate-y-px truncate text-sm font-medium tracking-tight",96 "-translate-y-px truncate text-sm font-medium tracking-tight",
...@@ -102,22 +103,6 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) {...@@ -102,22 +103,6 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) {
102 );103 );
103}104}
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
121function SidebarUtilityItem({106function SidebarUtilityItem({
122 icon,107 icon,
123 label,108 label,
apps/web/src/session-logic.test.ts+34
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7 type OrchestrationThreadActivity,7 type OrchestrationThreadActivity,
8} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import { describe, expect, it } from "vite-plus/test";9import { describe, expect, it } from "vite-plus/test";
10import { resolveWorkEntryToolPresentation } from "@t3tools/client-runtime/work-log/presentation";
1011
11import {12import {
12 deriveActiveWorkStartedAt,13 deriveActiveWorkStartedAt,
...@@ -1249,6 +1250,36 @@ describe("deriveWorkLogEntries", () => {...@@ -1249,6 +1250,36 @@ describe("deriveWorkLogEntries", () => {
1249 expect(entry?.toolData).toEqual(item);1250 expect(entry?.toolData).toEqual(item);
1250 });1251 });
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
1252 it("keeps MCP payloads while collapsing lifecycle updates", () => {1283 it("keeps MCP payloads while collapsing lifecycle updates", () => {
1253 const item = {1284 const item = {
1254 type: "mcpToolCall",1285 type: "mcpToolCall",
...@@ -1282,6 +1313,9 @@ describe("deriveWorkLogEntries", () => {...@@ -1282,6 +1313,9 @@ describe("deriveWorkLogEntries", () => {
1282 const [entry] = deriveWorkLogEntries(activities);1313 const [entry] = deriveWorkLogEntries(activities);
1283 expect(entry?.toolData).toEqual(item);1314 expect(entry?.toolData).toEqual(item);
1284 expect(entry?.toolCallId).toBe("call-1");1315 expect(entry?.toolCallId).toBe("call-1");
1316 expect(resolveWorkEntryToolPresentation(entry!)?.displayName).toBe(
1317 "Took a snapshot of the preview page",
1318 );
1285 });1319 });
12861320
1287 it("collapses interleaved lifecycle updates by tool call id", () => {1321 it("collapses interleaved lifecycle updates by tool call id", () => {
apps/web/src/session-logic.ts+3-2
...@@ -950,8 +950,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo...@@ -950,8 +950,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
950 }950 }
951 if (itemType === "mcp_tool_call") {951 if (itemType === "mcp_tool_call") {
952 const data = asRecord(payload?.data);952 const data = asRecord(payload?.data);
953 if (data?.item !== undefined) {953 const toolData = typeof data?.toolName === "string" ? (data.item ?? data) : data?.item;
954 entry.toolData = data.item;954 if (toolData !== undefined) {
955 entry.toolData = toolData;
955 }956 }
956 }957 }
957 if (itemType) {958 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 @@...@@ -194,6 +194,10 @@
194 "./work-log/command-label": {194 "./work-log/command-label": {
195 "types": "./src/work-log/commandLabel.ts",195 "types": "./src/work-log/commandLabel.ts",
196 "default": "./src/work-log/commandLabel.ts"196 "default": "./src/work-log/commandLabel.ts"
197 },
198 "./work-log/scroll-anchor": {
199 "types": "./src/work-log/scrollAnchor.ts",
200 "default": "./src/work-log/scrollAnchor.ts"
197 }201 }
198 },202 },
199 "scripts": {203 "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 @@...@@ -1,5 +1,21 @@
1type CommandWrapper = "env" | "sudo";1type 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
3const COMMAND_WRAPPER_OPTIONS_WITH_VALUE: Record<CommandWrapper, ReadonlySet<string>> = {19const COMMAND_WRAPPER_OPTIONS_WITH_VALUE: Record<CommandWrapper, ReadonlySet<string>> = {
4 env: new Set(["-C", "--chdir", "-S", "--split-string", "-u", "--unset"]),20 env: new Set(["-C", "--chdir", "-S", "--split-string", "-u", "--unset"]),
5 sudo: new Set(["-C", "--close-from", "-D", "--chdir", "-g", "--group", "-u", "--user"]),21 sudo: new Set(["-C", "--close-from", "-D", "--chdir", "-g", "--group", "-u", "--user"]),
...@@ -162,7 +178,14 @@ export function commandProgramName(command: string, depth = 0): string | null {...@@ -162,7 +178,14 @@ export function commandProgramName(command: string, depth = 0): string | null {
162 }178 }
163 return null;179 return null;
164 }180 }
165 return token.split(/[\\/]/).at(-1) || null;181 if (tokenProgram && SHELL_PROGRAMS.has(tokenProgram.replace(/\.exe$/i, "").toLowerCase())) {
182 const scriptIndex = shellCommandArgumentIndex(tokens, index + 1);
183 if (scriptIndex !== null) {
184 const script = tokens[scriptIndex];
185 return script ? commandProgramName(script, depth + 1) : null;
186 }
187 }
188 return tokenProgram || null;
166 }189 }
167190
168 return null;191 return null;
packages/client-runtime/src/work-log/presentation.test.ts+208
...@@ -6,10 +6,218 @@ import {...@@ -6,10 +6,218 @@ import {
6 commandDetailRepeatsCommand,6 commandDetailRepeatsCommand,
7 extractCommandOutputText,7 extractCommandOutputText,
8 resolveViewedImageAsset,8 resolveViewedImageAsset,
9 resolveWorkEntryToolPresentation,
10 summarizeToolGroup,
9 toolGroupAction,11 toolGroupAction,
12 toolGroupSummaryKind,
13 type WorkLogPresentationEntry,
10 workEntryViewedImagePath,14 workEntryViewedImagePath,
11} from "./presentation.js";15} 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
13describe("command work-log details", () => {221describe("command work-log details", () => {
14 it("extracts Claude result blocks and projected output", () => {222 it("extracts Claude result blocks and projected output", () => {
15 expect(223 expect(
packages/client-runtime/src/work-log/presentation.ts+102
...@@ -17,6 +17,7 @@ export function isWorktreeSetupActivity(kind: string): boolean {...@@ -17,6 +17,7 @@ export function isWorktreeSetupActivity(kind: string): boolean {
17export interface WorkLogPresentationEntry {17export interface WorkLogPresentationEntry {
18 readonly label: string;18 readonly label: string;
19 readonly toolTitle?: string;19 readonly toolTitle?: string;
20 readonly toolData?: unknown;
20 readonly tone: "thinking" | "tool" | "info" | "error";21 readonly tone: "thinking" | "tool" | "info" | "error";
21 readonly command?: string;22 readonly command?: string;
22 readonly detail?: string;23 readonly detail?: string;
...@@ -35,6 +36,7 @@ export type ToolGroupAction =...@@ -35,6 +36,7 @@ export type ToolGroupAction =
35 | "read"36 | "read"
36 | "edit"37 | "edit"
37 | "command"38 | "command"
39 | "browser"
38 | "code-search"40 | "code-search"
39 | "search"41 | "search"
40 | "other"42 | "other"
...@@ -51,6 +53,103 @@ export function normalizeCompactToolLabel(value: string): string {...@@ -51,6 +53,103 @@ export function normalizeCompactToolLabel(value: string): string {
51 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();53 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
52}54}
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
54function asRecord(value: unknown): Record<string, unknown> | null {153function asRecord(value: unknown): Record<string, unknown> | null {
55 return value !== null && typeof value === "object" && !Array.isArray(value)154 return value !== null && typeof value === "object" && !Array.isArray(value)
56 ? (value as Record<string, unknown>)155 ? (value as Record<string, unknown>)
...@@ -207,6 +306,7 @@ export function workLogEntryIsLocalCodeSearch(entry: WorkLogPresentationEntry):...@@ -207,6 +306,7 @@ export function workLogEntryIsLocalCodeSearch(entry: WorkLogPresentationEntry):
207}306}
208307
209export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupAction {308export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupAction {
309 if (resolveWorkEntryToolPresentation(entry)?.icon === "browser") return "browser";
210 if (310 if (
211 entry.requestKind === "file-read" ||311 entry.requestKind === "file-read" ||
212 entry.itemType === "image_view" ||312 entry.itemType === "image_view" ||
...@@ -312,6 +412,8 @@ function toolGroupActionLabel(action: ToolGroupAction, count: number): string {...@@ -312,6 +412,8 @@ function toolGroupActionLabel(action: ToolGroupAction, count: number): string {
312 return `Changed ${count} ${count === 1 ? "file" : "files"}`;412 return `Changed ${count} ${count === 1 ? "file" : "files"}`;
313 case "command":413 case "command":
314 return `Ran ${count} ${count === 1 ? "command" : "commands"}`;414 return `Ran ${count} ${count === 1 ? "command" : "commands"}`;
415 case "browser":
416 return `Used browser ${count} ${count === 1 ? "time" : "times"}`;
315 case "search":417 case "search":
316 return `Searched the web ${count} ${count === 1 ? "time" : "times"}`;418 return `Searched the web ${count} ${count === 1 ? "time" : "times"}`;
317 case "code-search":419 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}