diff --git a/apps/mobile/src/features/settings/SettingsFollowUpRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsFollowUpRouteScreen.tsx index 29b47a9ddd462..5a1c74460f504 100644 --- a/apps/mobile/src/features/settings/SettingsFollowUpRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsFollowUpRouteScreen.tsx @@ -1,6 +1,7 @@ import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/reactivity"; +import type { TranscriptView } from "@t3tools/contracts"; import { Platform, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -28,6 +29,28 @@ const FOLLOW_UP_OPTIONS: ReadonlyArray<{ }, ]; +const TRANSCRIPT_VIEW_OPTIONS: ReadonlyArray<{ + readonly view: TranscriptView; + readonly label: string; + readonly description: string; +}> = [ + { + view: "normal", + label: "Normal", + description: "Each finished turn folds its work behind “Worked for”.", + }, + { + view: "thinking", + label: "Thinking", + description: "Every turn opens with its tool calls and intermediate messages.", + }, + { + view: "verbose", + label: "Verbose", + description: "Every turn opens, and so does each tool call's thinking text and output.", + }, +]; + export function SettingsFollowUpRouteScreen() { const insets = useSafeAreaInsets(); const preferencesResult = useAtomValue(mobilePreferencesAtom); @@ -36,32 +59,50 @@ export function SettingsFollowUpRouteScreen() { const selectedBehavior = AsyncResult.isSuccess(preferencesResult) ? (preferencesResult.value.followUpBehavior ?? DEFAULT_FOLLOW_UP_BEHAVIOR) : null; + const selectedTranscriptView = AsyncResult.isSuccess(preferencesResult) + ? (preferencesResult.value.transcriptView ?? "normal") + : null; const content = ( - - {FOLLOW_UP_OPTIONS.map((option, index) => ( + + + {FOLLOW_UP_OPTIONS.map((option, index) => ( + 0} + disabled={!preferencesReady} + onPress={() => savePreferences({ followUpBehavior: option.behavior })} + /> + ))} + + + Long-press the send button to use the other option for a single message. With a hardware + keyboard, hold Command while sending. + + + + {TRANSCRIPT_VIEW_OPTIONS.map((option, index) => ( 0} disabled={!preferencesReady} - onPress={() => savePreferences({ followUpBehavior: option.behavior })} + onPress={() => savePreferences({ transcriptView: option.view })} /> ))} - - Long-press the send button to use the other option for a single message. With a hardware - keyboard, hold Command while sending. - ); diff --git a/apps/mobile/src/features/settings/settings-search.ts b/apps/mobile/src/features/settings/settings-search.ts index c11a89a7c3a10..7d5b0b96d3e28 100644 --- a/apps/mobile/src/features/settings/settings-search.ts +++ b/apps/mobile/src/features/settings/settings-search.ts @@ -56,7 +56,18 @@ const SETTINGS_SEARCH_TERMS = { "auto-resume", "thread behavior", ], - SettingsFollowUp: ["follow-ups", "queue", "steer", "while the agent is running"], + SettingsFollowUp: [ + "follow-ups", + "queue", + "steer", + "while the agent is running", + "transcript view", + "normal", + "thinking", + "verbose", + "expand all turns", + "worked for", + ], SettingsArchive: ["archive", "archived", "restore", "unarchive"], SettingsProviderAccounts: [ "provider accounts", diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 0cc08fdb3fe40..bfdfd7ee35e67 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -75,6 +75,7 @@ import { import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model"; import { isProviderNativeSubagentThread } from "@t3tools/contracts"; import type { QueuedRunEdit } from "../../state/queued-run-edit"; +import { mobilePreferencesAtom } from "../../state/preferences"; import type { FollowUpBehavior } from "../../lib/followUpBehavior"; import type { ActiveTurnComposerAction } from "@t3tools/client-runtime/state/composer-dispatch"; import * as Haptics from "expo-haptics"; @@ -477,6 +478,10 @@ function ThreadDetailBody( threadId: props.selectedThread.id, }); const composerError = useAtomValue(threadComposerErrorsAtom)[selectedThreadKey]?.message ?? null; + const preferences = useAtomValue(mobilePreferencesAtom); + const transcriptView = AsyncResult.isSuccess(preferences) + ? (preferences.value.transcriptView ?? "normal") + : "normal"; const queuedCount = useThreadQueuedCount(props.selectedThreadDetailRef); const turnSubagents = useThreadTurnSubagents(props.selectedThreadDetailRef); const agentsSegment = resolveSubagentPillSegment(turnSubagents); @@ -1439,6 +1444,7 @@ function ThreadDetailBody( isProviderSubagent || pendingMessageEditBlocked ? null : handleEditPendingMessage } contentPresentation={props.contentPresentation} + transcriptView={transcriptView} threadTitle={props.selectedThread.title} latestRun={props.activityRun} activeWorkStartedAt={props.activeWorkStartedAt} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index b43e246fc9c4f..54645d0cc653f 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -54,6 +54,7 @@ import { type RunId, type ScheduledTaskId, type ScopedThreadRef, + type TranscriptView, } from "@t3tools/contracts"; import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations"; import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; @@ -83,6 +84,12 @@ import { markdownImageSourceFragment, } from "@t3tools/client-runtime/markdown-images"; import { resolveViewedImageAsset } from "@t3tools/client-runtime/work-log/presentation"; +import { + disclosureOpen, + transcriptFoldsStartOpen, + transcriptRowsStartOpen, + withDisclosureOpen, +} from "@t3tools/client-runtime/work-log/disclosure"; import { renderCodexInlineDirectivesAsMarkdown, splitCodexArtifactTemplateMarkdown, @@ -351,6 +358,7 @@ export interface ThreadFeedProps { readonly feed: ReadonlyArray; readonly pendingHandoff?: HandoffEndpoints | null; readonly contentPresentation: ThreadContentPresentation; + readonly transcriptView: TranscriptView; readonly latestRun: ThreadFeedLatestRun | null; readonly activeWorkStartedAt: string | null; readonly runlessWorkActive?: boolean; @@ -1664,6 +1672,8 @@ function renderFeedEntry( > & { readonly copiedRowId: string | null; readonly expandedWorkRows: Record; + readonly foldsStartOpen: boolean; + readonly rowsStartOpen: boolean; readonly workRowSizing: ReturnType; readonly workGroupScrollPositions: Map; readonly terminalAssistantMessageIds: ReadonlySet; @@ -2313,6 +2323,8 @@ function renderFeedEntry( anchorKey={entry.id} copiedRowId={props.copiedRowId} expandedRows={props.expandedWorkRows} + foldsStartOpen={props.foldsStartOpen} + rowsStartOpen={props.rowsStartOpen} rowSizing={props.workRowSizing} scrollPositions={props.workGroupScrollPositions} iconSubtleColor={iconSubtleColor} @@ -2569,6 +2581,7 @@ function useThreadFeedPresentation( props: ThreadFeedProps, expandedTurnIds: ReadonlySet, expandedWorkGroups: Record, + transcriptView: TranscriptView, ) { const [feedCache] = useState(createThreadFeedCache); return appendPendingThreadMessages( @@ -2584,6 +2597,7 @@ function useThreadFeedPresentation( props.activeWorkStartedAt, props.runlessWorkActive ?? false, feedCache, + transcriptView, ), props.feed, props.queuedMessages, @@ -2677,7 +2691,25 @@ export const ThreadFeed = memo(function ThreadFeed({ expandedWorkRows: {}, expandedTurnIds: new Set(), }); - const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState; + const { copiedRowId } = interactionState; + let { expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState; + const { transcriptView } = props; + const foldsStartOpen = transcriptFoldsStartOpen(transcriptView); + const rowsStartOpen = transcriptRowsStartOpen(transcriptView); + // Switching the view starts every disclosure from the new default. + const [disclosureView, setDisclosureView] = useState(transcriptView); + if (disclosureView !== transcriptView) { + setDisclosureView(transcriptView); + expandedWorkGroups = {}; + expandedWorkRows = {}; + expandedTurnIds = new Set(); + setInteractionState((current) => ({ + ...current, + expandedWorkGroups, + expandedWorkRows, + expandedTurnIds, + })); + } const markdownLinkRequest = useRef(null); const onInlinePlaybackStart = () => { markdownLinkRequest.current?.abort(); @@ -3236,7 +3268,7 @@ export const ThreadFeed = memo(function ThreadFeed({ const { jumpToMessageId } = props; const jumpReady = !!jumpToMessageId && loadedListKey === `${feedThreadKey}:filled`; const presentedFeed = appendGuestPendingMessages( - useThreadFeedPresentation(props, expandedTurnIds, expandedWorkGroups), + useThreadFeedPresentation(props, expandedTurnIds, expandedWorkGroups, transcriptView), guestShare, ); const jumpRunId = @@ -3244,10 +3276,10 @@ export const ThreadFeed = memo(function ThreadFeed({ !presentedFeed.some((entry) => entry.type === "message" && entry.id === jumpToMessageId) ? messageFoldRunId(props.feed, jumpToMessageId) : null; - if (jumpRunId && !expandedTurnIds.has(jumpRunId)) { + if (jumpRunId && !disclosureOpen(expandedTurnIds, jumpRunId, foldsStartOpen)) { setInteractionState((current) => ({ ...current, - expandedTurnIds: new Set(current.expandedTurnIds).add(jumpRunId), + expandedTurnIds: withDisclosureOpen(current.expandedTurnIds, jumpRunId, foldsStartOpen), })); } const setupAnchorIndex = presentedFeed.findIndex( @@ -3365,7 +3397,8 @@ export const ThreadFeed = memo(function ThreadFeed({ } return new Set(terminalIdsByTurn.values()); })(); - // An interrupted run unfolds; a run that is no longer latest folds back. + // An interrupted run unfolds; a run that is no longer latest folds back + // unless every fold starts open. const [previousLatestRun, setPreviousLatestRun] = useState(props.latestRun); if (previousLatestRun !== props.latestRun) { setPreviousLatestRun(props.latestRun); @@ -3373,7 +3406,7 @@ export const ThreadFeed = memo(function ThreadFeed({ const previous = previousLatestRun; if (latest && previous) { if (latest.runId !== previous.runId) { - if (expandedTurnIds.has(previous.runId)) { + if (!foldsStartOpen && expandedTurnIds.has(previous.runId)) { setInteractionState((current) => { const next = new Set(current.expandedTurnIds); next.delete(previous.runId); @@ -3383,7 +3416,11 @@ export const ThreadFeed = memo(function ThreadFeed({ } else if (previous.status === "running" && latest.status === "interrupted") { setInteractionState((current) => ({ ...current, - expandedTurnIds: new Set(current.expandedTurnIds).add(latest.runId), + expandedTurnIds: withDisclosureOpen( + current.expandedTurnIds, + latest.runId, + foldsStartOpen, + ), })); } } @@ -3486,7 +3523,8 @@ export const ThreadFeed = memo(function ThreadFeed({ })); }; - // Lone rows share their group's disclosure state so opened details survive a second call joining. + // Lone rows share their group's disclosure state so opened details survive a second call + // joining. An expanded transcript already opens that group. const onToggleWorkRow = (rowId: string, anchorKey?: string, groupId?: string) => { suspendEndScrollMaintenanceForDisclosure(anchorKey ?? null); setInteractionState((current) => { @@ -3494,7 +3532,7 @@ export const ThreadFeed = memo(function ThreadFeed({ return { ...current, expandedWorkRows: { ...current.expandedWorkRows, [rowId]: expanded }, - ...(groupId === undefined + ...(groupId === undefined || foldsStartOpen ? {} : { expandedWorkGroups: { ...current.expandedWorkGroups, [groupId]: expanded } }), }; @@ -3551,7 +3589,9 @@ export const ThreadFeed = memo(function ThreadFeed({ return undefined; } return entry.activities.some( - (activity) => activity.prominent || expandedWorkRows[activity.id], + (activity) => + activity.prominent || + (activity.canExpand && (expandedWorkRows[activity.id] ?? false) !== rowsStartOpen), ) ? undefined : collapsedWorkLogHeight(entry.activities, entry.continuesWorkLog); @@ -3599,6 +3639,8 @@ export const ThreadFeed = memo(function ThreadFeed({ threadId: props.threadId, copiedRowId, expandedWorkRows, + foldsStartOpen, + rowsStartOpen, workRowSizing, workGroupScrollPositions, terminalAssistantMessageIds, diff --git a/apps/mobile/src/features/threads/thread-command-output.tsx b/apps/mobile/src/features/threads/thread-command-output.tsx index 4d3f89f21b9d9..f06429e732d88 100644 --- a/apps/mobile/src/features/threads/thread-command-output.tsx +++ b/apps/mobile/src/features/threads/thread-command-output.tsx @@ -1,13 +1,15 @@ import type { EnvironmentId, ThreadId, TurnItemId } from "@t3tools/contracts"; import { TERMINAL_PALETTES, terminalOutputSpans } from "@t3tools/shared/terminalOutput"; -import type React from "react"; import { useMemo, useRef } from "react"; -import { ScrollView, View } from "react-native"; +import { View } from "react-native"; +import type Animated from "react-native-reanimated"; +import { useAnimatedRef } from "react-native-reanimated"; import { AppText as Text } from "../../components/ui/AppText"; import { useServerConfigs } from "../../state/entities"; import { orchestrationEnvironment } from "../../state/orchestration"; import { useEnvironmentQuery } from "../../state/query"; +import { WorkLogScrollView } from "./thread-work-log-scroll"; const FOLLOW_SLACK = 8; @@ -23,6 +25,7 @@ export function ThreadCommandOutput(props: { readonly threadId: ThreadId; readonly itemId: TurnItemId; readonly themeAppearance: "light" | "dark"; + readonly fadeColor: string; }) { const { data, error } = useEnvironmentQuery( orchestrationEnvironment.v2.commandOutput({ @@ -30,7 +33,7 @@ export function ThreadCommandOutput(props: { input: { threadId: props.threadId, itemId: props.itemId }, }), ); - const scrollRef = useRef>(null); + const scrollRef = useAnimatedRef(); const followRef = useRef(true); const text = data?.output.text ?? ""; const spans = useMemo( @@ -56,8 +59,9 @@ export function ThreadCommandOutput(props: { {data?.output.truncated ? ( Earlier output not shown ) : null} - - + ); } diff --git a/apps/mobile/src/features/threads/thread-subagent-group.tsx b/apps/mobile/src/features/threads/thread-subagent-group.tsx index 4a42a83c9a371..13088bd0e958d 100644 --- a/apps/mobile/src/features/threads/thread-subagent-group.tsx +++ b/apps/mobile/src/features/threads/thread-subagent-group.tsx @@ -103,6 +103,7 @@ export function ThreadSubagentGroup(props: { readonly environmentId: EnvironmentId; readonly anchorKey: string; readonly expandedRows: Readonly>; + readonly foldsStartOpen: boolean; readonly iconSubtleColor: ColorValue; readonly onToggleRow: (rowId: string, anchorKey: string) => void; }) { @@ -133,7 +134,7 @@ export function ThreadSubagentGroup(props: { const grouped = agents.length > 1; const label = `${agents.length} subagents`; const summary = summarizeSubagentStatuses(agents.map((agent) => agent.status)); - const expanded = props.expandedRows[props.anchorKey] ?? false; + const expanded = (props.expandedRows[props.anchorKey] ?? false) !== props.foldsStartOpen; const serverConfigs = useServerConfigs(); // A task in a linked environment runs a provider instance of that environment. const configOf = (item: SubagentItem) => diff --git a/apps/mobile/src/features/threads/thread-work-log-scroll.tsx b/apps/mobile/src/features/threads/thread-work-log-scroll.tsx new file mode 100644 index 0000000000000..d12221882b3fb --- /dev/null +++ b/apps/mobile/src/features/threads/thread-work-log-scroll.tsx @@ -0,0 +1,112 @@ +import { useId } from "react"; +import { View, type ScrollViewProps } from "react-native"; +import Animated, { + useAnimatedRef, + useAnimatedStyle, + useDerivedValue, + useScrollOffset, + useSharedValue, + type AnimatedRef, + type DerivedValue, +} from "react-native-reanimated"; +import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; + +// Matches web's 1.5rem scroll fade; a shorter fade reads as a clipped line. +const WORK_LOG_EDGE_FADE_HEIGHT = 24; + +/** Fades whichever edge of a capped work-log scroller still hides content. */ +export function WorkLogEdgeFades(props: { + readonly color: string; + readonly scrollOffset: DerivedValue; + /** Content height minus viewport height; zero or less when nothing is hidden. */ + readonly scrollableHeight: DerivedValue; +}) { + const { scrollOffset, scrollableHeight } = props; + const topStyle = useAnimatedStyle(() => ({ + opacity: Math.min(1, Math.max(0, scrollOffset.value) / WORK_LOG_EDGE_FADE_HEIGHT), + })); + const bottomStyle = useAnimatedStyle(() => ({ + opacity: Math.min( + 1, + Math.max(0, scrollableHeight.value - scrollOffset.value) / WORK_LOG_EDGE_FADE_HEIGHT, + ), + })); + return ( + <> + + + + + + + + ); +} + +/** A capped work-log scroller whose clipped edges fade into the screen. */ +export function WorkLogScrollView( + props: Omit & { + readonly fadeColor: string; + readonly scrollRef?: AnimatedRef; + }, +) { + const { fadeColor, scrollRef, onLayout, onContentSizeChange, ...scrollProps } = props; + const ownRef = useAnimatedRef(); + const ref = scrollRef ?? ownRef; + const scrollOffset = useScrollOffset(ref); + const viewportHeight = useSharedValue(0); + const contentHeight = useSharedValue(0); + const scrollableHeight = useDerivedValue(() => contentHeight.value - viewportHeight.value); + return ( + + { + viewportHeight.set(event.nativeEvent.layout.height); + onLayout?.(event); + }} + onContentSizeChange={(width, height) => { + contentHeight.set(height); + onContentSizeChange?.(width, height); + }} + /> + + + ); +} + +function EdgeFade(props: { readonly color: string; readonly direction: "up" | "down" }) { + const gradientId = `work-log-fade-${useId().replaceAll(":", "")}`; + return ( + + + + + + + + + + ); +} diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index f83382d7531e3..f4097653465ed 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -10,6 +10,7 @@ import { } from "./work-log-layout"; import { QuestionAnswerHistory } from "./QuestionAnswerHistory"; import { ThreadCommandOutput, useCommandOutputStreaming } from "./thread-command-output"; +import { WorkLogEdgeFades, WorkLogScrollView } from "./thread-work-log-scroll"; import { getQuestionAnswerPreview, hasQuestionAnswer, @@ -36,7 +37,6 @@ import { type ColorValue, Easing, Pressable, - ScrollView, StyleSheet, useAnimatedValue, View, @@ -96,8 +96,8 @@ import Animated, { FadeOut, LinearTransition, ReduceMotion, - useAnimatedScrollHandler, useAnimatedStyle, + useDerivedValue, useSharedValue, withTiming, } from "react-native-reanimated"; @@ -437,7 +437,6 @@ const WORK_ROW_HEIGHT = THREAD_WORK_ROW_MIN_HEIGHT; const WORK_ROW_GAP = 1; const WORK_LOG_BOTTOM_MARGIN = 3.5; const WORK_GROUP_MAX_HEIGHT = 256; -const WORK_GROUP_EDGE_FADE_HEIGHT = 12; export const WORK_GROUP_TOGGLE_HEIGHT = THREAD_WORK_ROW_MIN_HEIGHT; @@ -474,6 +473,10 @@ interface ThreadWorkLogProps { readonly workspaceRoot?: string | null | undefined; readonly copiedRowId: string | null; readonly expandedRows: Readonly>; + /** Subagent groups start open; `expandedRows` then names the ones closed. */ + readonly foldsStartOpen: boolean; + /** Each row's details start open; `expandedRows` then names the ones closed. */ + readonly rowsStartOpen: boolean; readonly rowSizing: ReturnType; readonly scrollPositions: Map; readonly iconSubtleColor: ColorValue; @@ -494,7 +497,8 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) { row={row} anchorKey={props.anchorKey} copied={props.copiedRowId === row.id} - expanded={props.expandedRows[row.id] ?? false} + expanded={(props.expandedRows[row.id] ?? false) !== props.rowsStartOpen} + edgeFadeColor={props.edgeFadeColor} environmentId={props.environmentId} iconSubtleColor={props.iconSubtleColor} onCopyRow={props.onCopyRow} @@ -526,17 +530,26 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) { return ; } + // Above Normal, thinking rows stay individually foldable instead of opening as one trace. if ( + !props.foldsStartOpen && props.activities[0]?.groupedToolDetail && props.activities.every((row) => row.projectedItem.item.type === "reasoning") ) { return ( - + + + {props.activities.map((row) => ( + {props.renderReasoning(row.workEntry.detail ?? "")} + ))} + + ); } @@ -548,6 +561,7 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) { activities={props.activities} edgeFadeColor={props.edgeFadeColor} expandedRows={props.expandedRows} + rowsStartOpen={props.rowsStartOpen} groupId={props.anchorKey} rowSizing={props.rowSizing} scrollPositions={props.scrollPositions} @@ -560,59 +574,11 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) { ); } -function ThreadReasoningGroup(props: { - readonly activities: ReadonlyArray; - readonly edgeFadeColor: string; - readonly renderReasoning: (text: string) => ReactNode; -}) { - const scrollOffset = useSharedValue(0); - const contentHeight = useSharedValue(0); - const viewportHeight = useSharedValue(0); - const onScroll = useAnimatedScrollHandler((event) => { - scrollOffset.set(event.contentOffset.y); - }); - const bottomFadeStyle = useAnimatedStyle(() => ({ - opacity: Math.min( - 1, - Math.max(0, contentHeight.value - viewportHeight.value - scrollOffset.value) / - WORK_GROUP_EDGE_FADE_HEIGHT, - ), - })); - - return ( - - { - viewportHeight.set(event.nativeEvent.layout.height); - }} - onContentSizeChange={(_width, height) => { - contentHeight.set(height); - }} - > - {props.activities.map((row) => ( - {props.renderReasoning(row.workEntry.detail ?? "")} - ))} - - - - - - ); -} - function ThreadWorkGroupList(props: { readonly activities: ReadonlyArray; readonly edgeFadeColor: string; readonly expandedRows: Readonly>; + readonly rowsStartOpen: boolean; readonly groupId: string; readonly rowSizing: ReturnType; readonly scrollPositions: Map; @@ -652,16 +618,7 @@ function ThreadWorkGroupList(props: { const height = Math.min(contentHeight, WORK_GROUP_MAX_HEIGHT); const scrollOffset = useSharedValue(initialPosition?.scrollOffset ?? 0); const sharedValues = { scrollOffset }; - - const topFadeStyle = useAnimatedStyle(() => ({ - opacity: Math.min(1, Math.max(0, scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT), - })); - const bottomFadeStyle = useAnimatedStyle(() => ({ - opacity: Math.min( - 1, - Math.max(0, contentHeight - height - scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT, - ), - })); + const scrollableHeight = useDerivedValue(() => contentHeight - height); const rememberPosition = () => { if (!loadedRef.current) return; const state = listRef.current?.getState(); @@ -735,7 +692,8 @@ function ThreadWorkGroupList(props: { return unsubscribe; }, []); const getFixedItemSize = (row: ThreadFeedActivity, index: number) => - props.expandedRows[row.id] || props.rowSizing.fixedRowHeight === undefined + (row.canExpand && (props.expandedRows[row.id] ?? false) !== props.rowsStartOpen) || + props.rowSizing.fixedRowHeight === undefined ? undefined : props.rowSizing.fixedRowHeight + (index < props.activities.length - 1 ? WORK_ROW_GAP : 0); const renderItem = ({ item, index }: { item: ThreadFeedActivity; index: number }) => ( @@ -793,47 +751,15 @@ function ThreadWorkGroupList(props: { keyboardShouldPersistTaps="handled" style={{ height }} /> - - - - - - + ); } -function EdgeFade(props: { readonly color: string; readonly direction: "up" | "down" }) { - const gradientId = `work-group-fade-${useId().replaceAll(":", "")}`; - return ( - - - - - - - - - - ); -} - function workLogRowKey(row: ThreadFeedActivity): string { return row.id; } @@ -912,8 +838,9 @@ const ThreadWorkLogRow = function ThreadWorkLogRow( | "continuesWorkLog" | "activities" | "copiedRowId" - | "edgeFadeColor" | "expandedRows" + | "foldsStartOpen" + | "rowsStartOpen" | "rowSizing" | "scrollPositions" > & { @@ -922,7 +849,14 @@ const ThreadWorkLogRow = function ThreadWorkLogRow( readonly expanded: boolean; }, ) { - const { row, expanded } = props; + const { row } = props; + const notifiedSubagentThreadId = + row.projectedItem.item.type === "notification" + ? notificationChildThreadId(row.projectedItem.item.source) + : undefined; + const canExpand = row.canExpand && notifiedSubagentThreadId === undefined; + // Verbose opens rows by default, but a row that can't fold must not open. + const expanded = props.expanded && canExpand; const openLinkedThread = useOpenLinkedThread(); const streamsOutput = useCommandOutputStreaming(props.environmentId) && @@ -1006,11 +940,6 @@ const ThreadWorkLogRow = function ThreadWorkLogRow( ); } - const notifiedSubagentThreadId = - row.projectedItem.item.type === "notification" - ? notificationChildThreadId(row.projectedItem.item.source) - : undefined; - const canExpand = row.canExpand && notifiedSubagentThreadId === undefined; const reasoning = row.projectedItem.item.type === "reasoning" ? row.projectedItem.item : null; const fetchedItem = fetchedDetail.data?.item ?? null; const isRead = toolGroupAction(row.workEntry) === "read"; @@ -1242,12 +1171,13 @@ const ThreadWorkLogRow = function ThreadWorkLogRow( ))} - {reasoning ? ( props.renderReasoning(reasoning.text) @@ -1303,7 +1233,7 @@ const ThreadWorkLogRow = function ThreadWorkLogRow( ) : null} {streamsOutput ? null : exitLine} - + {streamsOutput ? ( <> {exitLine} diff --git a/apps/mobile/src/lib/filePreview.test.ts b/apps/mobile/src/lib/filePreview.test.ts index 0363f6862448f..564172db46724 100644 --- a/apps/mobile/src/lib/filePreview.test.ts +++ b/apps/mobile/src/lib/filePreview.test.ts @@ -445,6 +445,7 @@ describe("ThreadInlineFilePreview playback", () => { dispatchingMessageId: null, onEditPendingMessage: null, contentPresentation: { kind: "ready" }, + transcriptView: "normal", latestRun: null, activeWorkStartedAt: null, listRef: { current: null }, @@ -1384,6 +1385,7 @@ describe("ThreadFeed previews", () => { dispatchingMessageId: null, onEditPendingMessage: null, contentPresentation, + transcriptView: "normal", latestRun: null, activeWorkStartedAt: null, listRef: { current: null }, diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 583cbe7098e15..c74e9b718de32 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -888,6 +888,59 @@ describe("buildThreadFeed", () => { ]); }); + it("opens every fold and tool group in a Thinking transcript until the user closes one", () => { + const feed = buildThreadFeed([ + projected(userMessage(), 0), + projected(command(), 1), + projected({ ...command(), id: TurnItemId.make("second-command") }, 2), + projected(assistantMessage(), 3), + ]); + const latestRun = { + runId, + status: "completed" as const, + startedAt: "2026-06-20T00:00:01.000Z", + completedAt: "2026-06-20T00:00:03.000Z", + }; + const present = ( + expandedRunIds: ReadonlySet, + expandedWorkGroupIds: ReadonlySet, + transcriptView?: "thinking" | "verbose", + ) => + deriveThreadFeedPresentation( + feed, + latestRun, + expandedRunIds, + expandedWorkGroupIds, + null, + false, + undefined, + transcriptView, + ).map((entry) => + "expanded" in entry ? `${entry.id} ${entry.expanded ? "open" : "closed"}` : entry.id, + ); + + const folded = present(new Set(), new Set()); + expect(folded).toEqual(["message-user", "run-fold:run-1 closed", "message-assistant"]); + const groupId = "work-group:local:thread-1:item-command"; + expect(present(new Set(), new Set(), "thinking")).toEqual([ + "message-user", + "run-fold:run-1 open", + `work-toggle:${groupId} open`, + `work-details:${groupId}`, + "message-assistant", + ]); + expect(present(new Set(), new Set([groupId]), "thinking")).toEqual([ + "message-user", + "run-fold:run-1 open", + `work-toggle:${groupId} closed`, + "message-assistant", + ]); + expect(present(new Set([runId]), new Set(), "thinking")).toEqual(folded); + expect(present(new Set(), new Set(), "verbose")).toEqual( + present(new Set(), new Set(), "thinking"), + ); + }); + // regression: #21393 show plans and fold settled progress notes it("folds progress even when a response has no tool calls", () => { const feed = buildThreadFeed([ diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 91681731aab16..00f3313be7b01 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -16,6 +16,10 @@ import { commandDisplayText, commandProgramName, } from "@t3tools/client-runtime/work-log/command-label"; +import { + disclosureOpen, + transcriptFoldsStartOpen, +} from "@t3tools/client-runtime/work-log/disclosure"; import { contextCompactionLabel, liveThoughtLine, @@ -50,6 +54,7 @@ import type { OrchestrationV2UserMessageInputIntent, RunAttemptId, ScheduledTaskId, + TranscriptView, TurnItemId, } from "@t3tools/contracts"; import { idleCompactionOf, isCompactionCommandMessage, RunId, ThreadId } from "@t3tools/contracts"; @@ -1281,6 +1286,7 @@ interface ThreadFeedPresentationBuild { readonly expandedWorkGroupIds: ReadonlySet; readonly activeWorkStartedAt: string | null; readonly runlessWorkActive: boolean; + readonly transcriptView: TranscriptView; readonly rows: ReadonlyArray; /** Feed entry `entry` is a user message whose rows start at `rowsLength`. */ readonly restarts: ReadonlyArray<{ readonly entry: number; readonly rowsLength: number }>; @@ -1323,12 +1329,14 @@ function presentationRestart( expandedWorkGroupIds: ReadonlySet, activeWorkStartedAt: string | null, runlessWorkActive: boolean, + transcriptView: TranscriptView, ): { readonly entry: number; readonly rowsLength: number; readonly index: number } | null { if ( previous === null || !latestRunsEqual(previous.latestRun, latestRun) || previous.activeWorkStartedAt !== activeWorkStartedAt || previous.runlessWorkActive !== runlessWorkActive || + previous.transcriptView !== transcriptView || !setsEqual(previous.expandedRunIds, expandedRunIds) || !setsEqual(previous.expandedWorkGroupIds, expandedWorkGroupIds) ) { @@ -1355,7 +1363,10 @@ export function deriveThreadFeedPresentation( activeWorkStartedAt: string | null = null, runlessWorkActive = false, cache?: ThreadFeedCache, + /** Above "normal" every fold starts open, and the expanded-id sets name the ones closed. */ + transcriptView: TranscriptView = "normal", ): ThreadFeedEntry[] { + const startsOpen = transcriptFoldsStartOpen(transcriptView); const previous = cache?.presentation ?? null; const restart = presentationRestart( previous, @@ -1365,6 +1376,7 @@ export function deriveThreadFeedPresentation( expandedWorkGroupIds, activeWorkStartedAt, runlessWorkActive, + transcriptView, ); const start = restart?.entry ?? 0; const result: ThreadFeedEntry[] = @@ -1425,7 +1437,7 @@ export function deriveThreadFeedPresentation( ); const collapsedEntryIds = new Set(); for (const fold of foldsByAnchorId.values()) { - if (!expandedRunIds.has(fold.runId)) { + if (!disclosureOpen(expandedRunIds, fold.runId, startsOpen)) { for (const entryId of fold.hiddenEntryIds) collapsedEntryIds.add(entryId); } } @@ -1449,7 +1461,7 @@ export function deriveThreadFeedPresentation( entry.runId === activeRunId; const fold = foldsByAnchorId.get(entry.id); if (fold) { - const expanded = expandedRunIds.has(fold.runId); + const expanded = disclosureOpen(expandedRunIds, fold.runId, startsOpen); let row = runFoldRowsCache.get(entry); if ( !row || @@ -1511,6 +1523,7 @@ export function deriveThreadFeedPresentation( result, entry, expandedWorkGroupIds, + startsOpen, activeRunId, isWorking, isActiveTailGroup, @@ -1605,6 +1618,7 @@ export function deriveThreadFeedPresentation( expandedWorkGroupIds, activeWorkStartedAt, runlessWorkActive, + transcriptView, rows: result, restarts: cleanRestarts, firstEntryByRun, @@ -1645,6 +1659,7 @@ function appendPresentedFeedEntry( result: ThreadFeedEntry[], entry: Exclude, expandedWorkGroupIds: ReadonlySet, + startsOpen: boolean, activeRunId: RunId | null, isWorking: boolean, activeTail: boolean, @@ -1670,11 +1685,21 @@ function appendPresentedFeedEntry( cached.isWorking !== isWorking || cached.activeTail !== activeTail || cached.rows.some( - (row) => row.type === "work-toggle" && expandedWorkGroupIds.has(row.groupId) !== row.expanded, + (row) => + row.type === "work-toggle" && + disclosureOpen(expandedWorkGroupIds, row.groupId, startsOpen) !== row.expanded, ) ) { const rows: ThreadFeedEntry[] = []; - appendActivityGroupRows(rows, entry, expandedWorkGroupIds, activeRunId, isWorking, activeTail); + appendActivityGroupRows( + rows, + entry, + expandedWorkGroupIds, + startsOpen, + activeRunId, + isWorking, + activeTail, + ); cached = { activeRunId, isWorking, activeTail, rows }; presentedActivityGroupsCache.set(entry, cached); } @@ -1687,6 +1712,7 @@ function appendActivityGroupRows( result: ThreadFeedEntry[], entry: ThreadFeedActivityGroup, expandedWorkGroupIds: ReadonlySet, + startsOpen: boolean, activeRunId: RunId | null, isWorking: boolean, activeTail: boolean, @@ -1720,6 +1746,7 @@ function appendActivityGroupRows( groupableRun, `work-group:${groupAnchorIdByActivityId.get(groupableRun[0]!.id) ?? groupableRun[0]!.id}`, expandedWorkGroupIds, + startsOpen, activeRunId, isWorking, activeTail && isTrailingRun, @@ -1751,11 +1778,12 @@ function appendToolGroupRows( activities: ReadonlyArray, groupId: string, expandedWorkGroupIds: ReadonlySet, + startsOpen: boolean, activeRunId: RunId | null, isWorking: boolean, activeTail: boolean, ): void { - const expanded = expandedWorkGroupIds.has(groupId); + const expanded = disclosureOpen(expandedWorkGroupIds, groupId, startsOpen); const latestActiveActivity = activities.findLast( (activity) => isWorking && activity.lifecycleStatus === "inProgress" && activity.runId === activeRunId, diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 43ac75e185411..1eb86aad565e2 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -13,6 +13,7 @@ import { type ProviderInstanceId, type PullRequestMergeMethod, type SidebarProjectGroupingMode, + type TranscriptView, } from "@t3tools/contracts"; import type { AndroidNavigationStyle } from "../lib/androidNavigationStyle"; import type { ComposerEnterBehavior } from "../lib/composerEnterBehavior"; @@ -111,6 +112,7 @@ export interface Preferences { /** Hardware Return behavior is iOS-only. */ readonly composerEnterBehavior?: ComposerEnterBehavior; readonly followUpBehavior?: FollowUpBehavior; + readonly transcriptView?: TranscriptView; readonly microphones?: ReadonlyArray; readonly projectGroupingEnabled?: boolean; readonly projectGroupingMode?: SidebarProjectGroupingMode; @@ -191,6 +193,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { dismissedDatabaseStorageNotices?: readonly string[]; composerEnterBehavior?: ComposerEnterBehavior; followUpBehavior?: FollowUpBehavior; + transcriptView?: TranscriptView; microphones?: ReadonlyArray; projectGroupingEnabled?: boolean; projectGroupingMode?: SidebarProjectGroupingMode; @@ -299,6 +302,13 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (parsed.followUpBehavior === "queue" || parsed.followUpBehavior === "steer") { preferences.followUpBehavior = parsed.followUpBehavior; } + if ( + parsed.transcriptView === "normal" || + parsed.transcriptView === "thinking" || + parsed.transcriptView === "verbose" + ) { + preferences.transcriptView = parsed.transcriptView; + } if (Array.isArray(parsed.microphones)) { preferences.microphones = parsed.microphones.flatMap((entry): MicrophoneEntry[] => { if (typeof entry !== "object" || entry === null || !isMicrophoneKind(entry.kind)) return []; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6cd1c7775d96b..d0e7e13e171e6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3466,6 +3466,7 @@ export default function ChatView(props: ChatViewProps) { markdownCwd={gitCwd ?? undefined} resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} + transcriptView={settings.transcriptView} workspaceRoot={activeWorkspaceRoot} skills={timelineSkills} anchorMessageId={timelineAnchorMessageId} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index deede26763fdd..99ef8c06f3056 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -132,7 +132,11 @@ import { useTerminalFocus } from "../hooks/useTerminalFocus"; import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { registerSettleAndAdvance } from "../settleAndAdvance"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; -import { useBoringSidebarEnabled, useClientSettings } from "../hooks/useSettings"; +import { + updateClientSettings, + useBoringSidebarEnabled, + useClientSettings, +} from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { useNowMinute } from "../hooks/useNowMinute"; @@ -175,6 +179,7 @@ import { buildDraftActionMenuItems, buildThreadActionMenuItems, threadActionRequiresOperate, + transcriptViewFromMenuAction, } from "./threadActionMenu.logic"; import { applySidebarThreadDrop, @@ -930,6 +935,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const timestampFormat = useClientSettings((s) => s.timestampFormat); + const transcriptView = useClientSettings((s) => s.transcriptView); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const { settleThread, @@ -2637,6 +2643,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve isPinned, isSettled, autoSettleEnabled: thread.autoSettleDisabledAt == null, + transcriptView, isSnoozed, canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }), isRegeneratingTitle, @@ -2658,6 +2665,11 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ), ); if (clicked._tag === "Failure" || clicked.value === null) return; + const chosenView = transcriptViewFromMenuAction(clicked.value); + if (chosenView) { + await updateClientSettings({ transcriptView: chosenView }); + return; + } if (threadActionRequiresOperate(clicked.value) && !checkThreadOperations([thread])) return; if (clicked.value?.startsWith("snooze:")) { const preset = diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 2fcaa596a5b6c..bee670f1b5164 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -9,6 +9,7 @@ import { ProviderDriverKind, ProviderInstanceId, RunAttemptId, + type TranscriptView, RunId, RuntimeRequestId, TurnItemId, @@ -1440,3 +1441,239 @@ it("keeps concurrent approvals actionable outside collapsed work and restores se .element(screen.getByRole("button", { name: "Open the current tab", exact: true })) .toBeVisible(); }); + +describe("MessagesTimeline transcript view", () => { + it("opens finished turns, keeps a fold the user closes, and starts over when the view changes", async () => { + setup(); + const base = baseProps(); + const runId = RunId.make("run-transcript"); + const entries = [ + message("Find the bug"), + message("Looking around first.", { role: "assistant", runId }, "thought"), + message("Fixed it.", { role: "assistant", runId }, "final"), + ]; + const finished = { + runId, + status: "completed" as const, + startedAt: CREATED_AT, + completedAt: CREATED_AT, + }; + const next = { + ...finished, + runId: RunId.make("run-next"), + status: "running" as const, + completedAt: null, + }; + const timeline = ( + transcriptView: TranscriptView, + latestRun: TimelineProps["latestRun"] = finished, + ) => ( + + ); + await using screen = await renderElement(timeline("thinking")); + await vi.waitFor(() => expect(screen.text()).toContain("Looking around first.")); + + await click(screen.button(/^Worked for/)); + await vi.waitFor(() => expect(screen.text()).not.toContain("Looking around first.")); + await screen.rerender(timeline("thinking", next)); + expect(screen.text()).toContain("Fixed it."); + expect(screen.text()).not.toContain("Looking around first."); + + await screen.rerender(timeline("normal")); + await vi.waitFor(() => expect(screen.text()).toContain("Fixed it.")); + expect(screen.text()).not.toContain("Looking around first."); + + await screen.rerender(timeline("thinking")); + await vi.waitFor(() => expect(screen.text()).toContain("Looking around first.")); + }); + + it("lets find reveal a match inside a fold the user closed in Thinking view", async () => { + setup(); + const base = baseProps(); + const runId = RunId.make("run-transcript-find"); + const entries = [ + message("Find the bug"), + message("Looking around first.", { role: "assistant", runId }, "thought"), + message("Fixed it.", { role: "assistant", runId }, "final"), + ]; + const timeline = (find: Partial = {}) => ( + + ); + await using screen = await renderElement(timeline()); + await vi.waitFor(() => expect(screen.text()).toContain("Looking around first.")); + await click(screen.button(/^Worked for/)); + await vi.waitFor(() => expect(screen.text()).not.toContain("Looking around first.")); + + await screen.rerender( + timeline({ + findOpen: true, + findExpanded: true, + findQuery: "Looking", + activeFindMatch: { entryId: "entry-thought", runId: null, occurrence: 0 }, + }), + ); + await vi.waitFor(() => expect(screen.text()).toContain("Looking around first.")); + }); + + it("restores a thread's remembered folds only under the view they were saved in", async () => { + setup(); + const base = baseProps(); + const runId = RunId.make("run-transcript-remembered"); + const entries = [ + message("Find the bug"), + message("Looking around first.", { role: "assistant", runId }, "thought"), + message("Fixed it.", { role: "assistant", runId }, "final"), + ]; + const remember = (threadKey: string, transcriptView: TranscriptView) => + rememberTimelinePosition( + threadKey, + { + rowId: "entry-final", + offsetWithinRow: 0, + scrollOffset: 0, + atEnd: true, + disclosures: { + transcriptView, + runs: new Set([runId]), + workGroups: new Set(), + attempts: new Set(), + workGroupState: { scrollPositions: new Map(), expandedEntries: new Set() }, + }, + }, + true, + ); + const openedUnderNormal = "environment-local:thread-transcript-saved-normal"; + const closedUnderThinking = "environment-local:thread-transcript-saved-thinking"; + remember(openedUnderNormal, "normal"); + remember(closedUnderThinking, "thinking"); + const timeline = (routeThreadKey: string, timelineEntries: Entry[]) => ( + + ); + await using screen = await renderElement(timeline(base.routeThreadKey, [message("Short")])); + + await screen.rerender(timeline(closedUnderThinking, entries)); + await vi.waitFor(() => expect(screen.text()).toContain("Fixed it.")); + expect(screen.text()).not.toContain("Looking around first."); + + await screen.rerender(timeline(openedUnderNormal, entries)); + await vi.waitFor(() => expect(screen.text()).toContain("Looking around first.")); + }); + + it("opens each row in Verbose view, keeps the one the user closes, and resets for Thinking", async () => { + setup(); + const base = baseProps(); + const runId = RunId.make("run-transcript-verbose"); + const entries = [ + message("Find the bug"), + work("thought", { + runId, + label: "Thinking", + tone: "thinking", + itemType: "reasoning", + detail: "The tokenizer drops trailing commas.", + }), + work("command", { + runId, + label: "Ran command", + tone: "tool", + itemType: "command_execution", + command: "npm test", + }), + message("Fixed it.", { role: "assistant", runId }, "final"), + ]; + const timeline = (transcriptView: TranscriptView) => ( + + ); + await using screen = await renderElement(timeline("verbose")); + const rows = () => [ + ...screen.container.querySelectorAll( + '[aria-label="Tool calls"] [aria-expanded]', + ), + ]; + const rowStates = () => rows().map((row) => row.getAttribute("aria-expanded")); + await vi.waitFor(() => expect(rowStates()).toEqual(["true", "true"])); + + await click(rows()[0]!); + await vi.waitFor(() => expect(rowStates()).toEqual(["false", "true"])); + + await screen.rerender(timeline("thinking")); + await vi.waitFor(() => expect(rowStates()).toEqual(["false", "false"])); + + await screen.rerender(timeline("verbose")); + await vi.waitFor(() => expect(rowStates()).toEqual(["true", "true"])); + }); + + it("keeps a thinking-only group as foldable rows above Normal", async () => { + setup(); + const base = baseProps(); + const runId = RunId.make("run-transcript-thoughts"); + const thought = (id: string, detail: string) => + work(id, { runId, label: "Thinking", tone: "thinking", itemType: "reasoning", detail }); + const entries = [ + message("Find the bug"), + thought("first", "The tokenizer drops trailing commas."), + thought("second", "The parser never sees the last field."), + message("Fixed it.", { role: "assistant", runId }, "final"), + ]; + const timeline = (transcriptView: TranscriptView) => ( + + ); + await using screen = await renderElement(timeline("thinking")); + const rowStates = () => + [ + ...screen.container.querySelectorAll( + '[aria-label="Tool calls"] [aria-expanded]', + ), + ].map((row) => row.getAttribute("aria-expanded")); + await vi.waitFor(() => expect(rowStates()).toEqual(["false", "false"])); + + await screen.rerender(timeline("verbose")); + await vi.waitFor(() => expect(rowStates()).toEqual(["true", "true"])); + }); + + it("keeps a standalone row closed in Verbose closed after its turn folds and reopens", async () => { + setup(); + const base = baseProps(); + const runId = RunId.make("run-transcript-standalone"); + const entries = [ + message("Find the bug"), + work("command", { + runId, + label: "Ran command", + tone: "tool", + itemType: "command_execution", + command: "npm test", + }), + message("Fixed it.", { role: "assistant", runId }, "final"), + ]; + await using screen = await renderElement( + , + ); + const rowStates = () => + [ + ...screen.container.querySelectorAll( + '[aria-label="Activity"] [aria-expanded]', + ), + ].map((row) => row.getAttribute("aria-expanded")); + await vi.waitFor(() => expect(rowStates()).toEqual(["true"])); + + await click( + screen.container.querySelector('[aria-label="Activity"] [aria-expanded]')!, + ); + await vi.waitFor(() => expect(rowStates()).toEqual(["false"])); + await click(screen.button(/^Worked for/)); + await vi.waitFor(() => expect(rowStates()).toEqual([])); + await click(screen.button(/^Worked for/)); + await vi.waitFor(() => expect(rowStates()).toEqual(["false"])); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index e32a7eb485fc6..fc66f3db02081 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -2744,6 +2744,24 @@ describe("v2 run and attempt history", () => { expect.objectContaining({ id: "work-1" }), expect.objectContaining({ id: "provider-recovered" }), ]); + + // Thinking starts where Normal needs both sets; the sets then name what's closed. + const expandedView = (closed: Partial[0]> = {}) => + deriveMessagesTimelineRows({ + timelineEntries, + transcriptView: "thinking", + isWorking: false, + turnDiffSummaries: [], + supportsConversationRollback: false, + ...closed, + }); + expect(expandedView()).toEqual(openedActivity); + expect(expandedView({ expandedWorkGroupIds: new Set(["work-group:work-entry-1"]) })).toEqual( + expandedRows, + ); + expect(expandedView({ expandedRunIds: new Set(["turn-1" as never]) })).toEqual(collapsedRows); + // Verbose opens rows in the component; its folds match Thinking. + expect(expandedView({ transcriptView: "verbose" })).toEqual(openedActivity); }); it("collapses only output from a superseded V2 attempt within the active logical run", () => { const runId = "run-steered" as never; @@ -2913,6 +2931,16 @@ describe("v2 run and attempt history", () => { "steer-user-entry", "active-assistant-entry", ]); + const expandedView = deriveMessagesTimelineRows({ ...common, transcriptView: "thinking" }); + expect(expandedView.find((row) => row.kind === "attempt-fold")?.expanded).toBe(true); + expect(expandedView.some((row) => row.id === "superseded-assistant-entry")).toBe(true); + expect( + deriveMessagesTimelineRows({ + ...common, + transcriptView: "thinking", + expandedAttemptIds: new Set([supersededAttemptId]), + }).map((row) => row.id), + ).toEqual(collapsedRows.map((row) => row.id)); const settledRows = deriveMessagesTimelineRows({ ...common, latestRun: { ...common.latestRun, status: "completed", completedAt: "2026-01-01T00:00:08Z" }, diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 8a4a7552901a2..663d392524ec8 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -19,6 +19,10 @@ import { commandDisplayText, commandProgramName, } from "@t3tools/client-runtime/work-log/command-label"; +import { + disclosureOpen, + transcriptFoldsStartOpen, +} from "@t3tools/client-runtime/work-log/disclosure"; import { liveActivityToolStatus, normalizeCompactToolLabel, @@ -59,6 +63,7 @@ import { type RuntimeRequestId, RunId, type ThreadId, + type TranscriptView, type TurnItemId, } from "@t3tools/contracts"; import type { ThreadRunSummary } from "@t3tools/client-runtime/state/shell"; @@ -1400,6 +1405,8 @@ export function deriveMessagesTimelineRows(input: { expandedRunIds?: ReadonlySet; expandedAttemptIds?: ReadonlySet; expandedWorkGroupIds?: ReadonlySet; + /** Above "normal" every fold starts open, and the expanded-id sets name the ones closed. */ + transcriptView?: TranscriptView; isWorking: boolean; /** Native subagent work has no app run; runless entries form the live response. */ runlessWorkActive?: boolean; @@ -1411,6 +1418,7 @@ export function deriveMessagesTimelineRows(input: { worktreeSetup?: WorktreeSetupSnapshot | null; }): MessagesTimelineRow[] { const timelineEntries = timelineRowEntries(input.timelineEntries); + const startsOpen = transcriptFoldsStartOpen(input.transcriptView ?? "normal"); const isPendingApproval = (entry: WorkLogEntry) => { const item = entry.projectedItem?.item; return ( @@ -1468,7 +1476,7 @@ export function deriveMessagesTimelineRows(input: { const collapsedEntryIds = new Set(); const collapsedFoldMemberIds = new Set(); for (const fold of foldsByAnchorEntryId.values()) { - if (!input.expandedRunIds?.has(fold.runId)) { + if (!disclosureOpen(input.expandedRunIds, fold.runId, startsOpen)) { for (const entryId of fold.hiddenEntryIds) { collapsedEntryIds.add(entryId); } @@ -1479,7 +1487,7 @@ export function deriveMessagesTimelineRows(input: { } const collapsedSupersededEntryIds = new Set(); for (const fold of supersededFoldsByAnchorEntryId.values()) { - if (!input.expandedAttemptIds?.has(fold.attemptId)) { + if (!disclosureOpen(input.expandedAttemptIds, fold.attemptId, startsOpen)) { for (const entryId of fold.hiddenEntryIds) { collapsedSupersededEntryIds.add(entryId); } @@ -1571,7 +1579,7 @@ export function deriveMessagesTimelineRows(input: { entry: (latestRunningToolEntry ?? latestVisibleToolEntry).entry, groupedEntries: visibleActiveToolEntries.map((entry) => entry.entry), groupId, - expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, + expanded: disclosureOpen(input.expandedWorkGroupIds, groupId, startsOpen), active: latestToolKeepsActivityLive, ...(latestThoughtEntry ? { thought: latestThoughtEntry.entry } : {}), }; @@ -1725,7 +1733,7 @@ export function deriveMessagesTimelineRows(input: { createdAt: turnFold.createdAt, runId: turnFold.runId, label: turnFold.label, - expanded: input.expandedRunIds?.has(turnFold.runId) ?? false, + expanded: disclosureOpen(input.expandedRunIds, turnFold.runId, startsOpen), }); } @@ -1743,7 +1751,7 @@ export function deriveMessagesTimelineRows(input: { runId: supersededFold.runId, attemptId: supersededFold.attemptId, label: "Superseded attempt", - expanded: input.expandedAttemptIds?.has(supersededFold.attemptId) ?? false, + expanded: disclosureOpen(input.expandedAttemptIds, supersededFold.attemptId, startsOpen), }); } @@ -1854,7 +1862,7 @@ export function deriveMessagesTimelineRows(input: { const activeInProgressToolEntries = visibleGroupedEntries.filter(workEntryIsInActiveRun); if (activeInProgressToolEntries.length > 0) { const groupId = workGroupId(timelineEntry.id); - const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; + const expanded = disclosureOpen(input.expandedWorkGroupIds, groupId, startsOpen); const latestActiveToolEntry = activeInProgressToolEntries.at(-1)!; nextRows.push({ kind: "work-live", @@ -1890,7 +1898,7 @@ export function deriveMessagesTimelineRows(input: { }); } else { const groupId = workGroupId(timelineEntry.id); - const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; + const expanded = disclosureOpen(input.expandedWorkGroupIds, groupId, startsOpen); const summaryKind = toolGroupSummaryKind(visibleGroupedEntries); const primarySourceEntry = visibleGroupedEntries.find( (entry) => entry.toolSource !== undefined, @@ -2118,7 +2126,7 @@ export function deriveMessagesTimelineRows(input: { const failedGroupAnchor = latestToolFailed ? activeWorkAnchor : undefined; if (failedGroupAnchor) { const groupId = workGroupId(failedGroupAnchor.id); - const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; + const expanded = disclosureOpen(input.expandedWorkGroupIds, groupId, startsOpen); nextRows.push({ kind: "thinking", id: LIVE_ACTIVITY_ROW_ID, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 4c28163766058..24ea826c1e3e9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -21,6 +21,10 @@ import { } from "@t3tools/contracts"; import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; +import { + transcriptFoldsStartOpen, + withDisclosureOpen, +} from "@t3tools/client-runtime/work-log/disclosure"; import type { WorkflowOpening } from "@t3tools/client-runtime/state/subagentRuntime"; import { observeResize } from "~/lib/observeResize"; import { useStableCallback } from "~/hooks/useStableCallback"; @@ -94,7 +98,7 @@ import { WorktreeSetupCard } from "./WorktreeSetupCard"; import { useOpenPrLink } from "~/lib/openPullRequestLink"; import { useMediaQuery } from "~/hooks/useMediaQuery"; import { cn } from "~/lib/utils"; -import { type TimestampFormat } from "@t3tools/contracts/settings"; +import { type TimestampFormat, type TranscriptView } from "@t3tools/contracts/settings"; import { type HandoffTimelineRun } from "./V2LifecycleRow"; import { TimelineSystemDivider } from "./TimelineSystemDivider"; @@ -136,22 +140,32 @@ import { useThreadFindPositionReader } from "./useThreadFindPositionReader"; import { ThreadActivityAnnouncer } from "./ThreadActivityAnnouncer"; import { useStableHandoffRuns, useIncrementalTimelineRows, useStableRows } from "./useTimelineRows"; -function timelineListStateFor(identity: string): { +function timelineListStateFor( + identity: string, + transcriptView: TranscriptView, +): { readonly identity: string; readonly rememberedPosition: RememberedTimelinePosition | undefined; readonly workGroupViewState: WorkGroupViewState; } { - const rememberedPosition = readTimelinePosition(identity); + const remembered = readTimelinePosition(identity); + // Folds and scroll offsets remembered under another view don't fit this view's layout. + const rememberedPosition = + remembered?.disclosures && remembered.disclosures.transcriptView !== transcriptView + ? undefined + : remembered; return { identity, rememberedPosition, - workGroupViewState: rememberedPosition?.disclosures?.workGroupState ?? { - scrollPositions: new Map(), - expandedEntries: new Set(), - }, + workGroupViewState: + rememberedPosition?.disclosures?.workGroupState ?? emptyWorkGroupViewState(), }; } +function emptyWorkGroupViewState(): WorkGroupViewState { + return { scrollPositions: new Map(), expandedEntries: new Set() }; +} + const TIMELINE_LIST_HEADER =
; const TIMELINE_LIST_FADE_HEADER = (
@@ -289,6 +303,7 @@ interface MessagesTimelineProps { markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; timestampFormat: TimestampFormat; + transcriptView?: TranscriptView; workspaceRoot: string | undefined; skills?: ReadonlyArray; runs: ReadonlyArray; @@ -388,6 +403,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ markdownCwd, resolvedTheme, timestampFormat, + transcriptView = "normal", workspaceRoot, skills = EMPTY_TIMELINE_SKILLS, runs: runsProp, @@ -409,7 +425,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ // Find open at the opening is part of the snapshot: opening find later // announces its own navigation (threadFindStore's announceOpening). const [listState, setListState] = useState(() => ({ - ...timelineListStateFor(listIdentityKey), + ...timelineListStateFor(listIdentityKey, transcriptView), findOpenAtOpening: findOpen, })); const [expandedRunIds, setExpandedRunIds] = useState>( @@ -436,9 +452,22 @@ const ConversationTimeline = memo(function ConversationTimeline({ let paintedExpandedRunIds = expandedRunIds; let paintedExpandedWorkGroupIds = expandedWorkGroupIds; let paintedExpandedAttemptIds = expandedAttemptIds; + const startsOpen = transcriptFoldsStartOpen(transcriptView); + // Switching the view starts every disclosure from the new default. + const [disclosureView, setDisclosureView] = useState(transcriptView); + if (disclosureView !== transcriptView) { + setDisclosureView(transcriptView); + setListState((current) => ({ ...current, workGroupViewState: emptyWorkGroupViewState() })); + paintedExpandedRunIds = new Set(); + paintedExpandedWorkGroupIds = new Set(); + paintedExpandedAttemptIds = new Set(); + setExpandedRunIds(paintedExpandedRunIds); + setExpandedWorkGroupIds(paintedExpandedWorkGroupIds); + setExpandedAttemptIds(paintedExpandedAttemptIds); + } const identityChanged = listState.identity !== listIdentityKey; const { rememberedPosition, workGroupViewState } = identityChanged - ? timelineListStateFor(listIdentityKey) + ? timelineListStateFor(listIdentityKey, transcriptView) : listState; if (identityChanged) { setPlayingAudioRows(new Map()); @@ -547,7 +576,8 @@ const ConversationTimeline = memo(function ConversationTimeline({ }); }; - // An interrupted run opens its fold; a newer run closes the previous run's. + // An interrupted run opens its fold; a newer run closes the previous run's + // unless every fold starts open. const [observedLatestRun, setObservedLatestRun] = useState({ identity: listIdentityKey, runId: latestRun?.runId, @@ -571,9 +601,11 @@ const ConversationTimeline = memo(function ConversationTimeline({ ) { if (latestRun.runId === previousRunId) { if (observedLatestRun.status === "running" && latestRun.status === "interrupted") { - setExpandedRunIds((existing) => new Set(existing).add(latestRun.runId)); + setExpandedRunIds((existing) => + withDisclosureOpen(existing, latestRun.runId, startsOpen), + ); } - } else { + } else if (!startsOpen) { setExpandedRunIds((existing) => { if (!existing.has(previousRunId)) return existing; const next = new Set(existing); @@ -600,9 +632,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ if (expandedFindRunId !== activeFindRunId) { setExpandedFindRunId(activeFindRunId); if (activeFindRunId) { - setExpandedRunIds((previous) => - previous.has(activeFindRunId) ? previous : new Set(previous).add(activeFindRunId), - ); + setExpandedRunIds((previous) => withDisclosureOpen(previous, activeFindRunId, startsOpen)); } } // A cited response folded inside a turn opens its fold once per activation; @@ -637,21 +667,18 @@ const ConversationTimeline = memo(function ConversationTimeline({ ).get(citedEntry.id); setCitationExpandedFor(citationRequest.key); if (citedRunId) { - setExpandedRunIds((previous) => - previous.has(citedRunId) ? previous : new Set(previous).add(citedRunId), - ); + setExpandedRunIds((previous) => withDisclosureOpen(previous, citedRunId, startsOpen)); } const citedAttempt = citedEntry.attempt; if (citedAttempt?.status === "superseded") { setExpandedAttemptIds((previous) => - previous.has(citedAttempt.id) ? previous : new Set(previous).add(citedAttempt.id), + withDisclosureOpen(previous, citedAttempt.id, startsOpen), ); } } - const visibleExpandedRunIds = - !activeFindRunId || paintedExpandedRunIds.has(activeFindRunId) - ? paintedExpandedRunIds - : new Set(paintedExpandedRunIds).add(activeFindRunId); + const visibleExpandedRunIds = activeFindRunId + ? withDisclosureOpen(paintedExpandedRunIds, activeFindRunId, startsOpen) + : paintedExpandedRunIds; const activeFindAttemptId = activeFindMatch ? timelineEntries.find((entry) => entry.id === activeFindMatch.entryId)?.attempt?.id @@ -661,14 +688,13 @@ const ConversationTimeline = memo(function ConversationTimeline({ setExpandedFindAttemptId(activeFindAttemptId); if (activeFindAttemptId) { setExpandedAttemptIds((previous) => - previous.has(activeFindAttemptId) ? previous : new Set(previous).add(activeFindAttemptId), + withDisclosureOpen(previous, activeFindAttemptId, startsOpen), ); } } - const visibleExpandedAttemptIds = - activeFindAttemptId && !paintedExpandedAttemptIds.has(activeFindAttemptId) - ? new Set(paintedExpandedAttemptIds).add(activeFindAttemptId) - : paintedExpandedAttemptIds; + const visibleExpandedAttemptIds = activeFindAttemptId + ? withDisclosureOpen(paintedExpandedAttemptIds, activeFindAttemptId, startsOpen) + : paintedExpandedAttemptIds; const isVisiblePendingApproval = (approval: PendingApproval) => !heldApprovalRequestIds?.has(approval.requestId); @@ -682,7 +708,8 @@ const ConversationTimeline = memo(function ConversationTimeline({ runningRunId, expandedRunIds: visibleExpandedRunIds, expandedAttemptIds: visibleExpandedAttemptIds, - expandedWorkGroupIds, + expandedWorkGroupIds: paintedExpandedWorkGroupIds, + transcriptView, isWorking, runlessWorkActive, activeTurnStartedAt, @@ -924,6 +951,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ scrollOffset: element.scrollTop, atEnd: isAtEnd, disclosures: { + transcriptView, runs: paintedExpandedRunIds, workGroups: paintedExpandedWorkGroupIds, attempts: paintedExpandedAttemptIds, @@ -1027,6 +1055,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ citationRequest: readyCitationRequest, listRef, timestampFormat, + transcriptView, routeThreadKey, threadRef: citationThreadRef, markdownCwd, diff --git a/apps/web/src/components/chat/TimelineV2EventRow.tsx b/apps/web/src/components/chat/TimelineV2EventRow.tsx index e69ffdbaffdd4..2786bc431e010 100644 --- a/apps/web/src/components/chat/TimelineV2EventRow.tsx +++ b/apps/web/src/components/chat/TimelineV2EventRow.tsx @@ -1,5 +1,9 @@ import { WorkLogBlock } from "./WorkLog"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + disclosureOpen, + transcriptFoldsStartOpen, +} from "@t3tools/client-runtime/work-log/disclosure"; import { useAtomValue } from "@effect/atom-react"; import { environmentThreadDetails } from "../../state/threads"; import { @@ -20,7 +24,7 @@ export function V2EventTimelineRow({ row }: { row: Extract 1) { - return ; + return ; } if (item.type === "secret_request") { return ( @@ -58,8 +62,9 @@ const V2SubagentGroup = memo(function V2SubagentGroup({ }) { const ctx = use(TimelineRowCtx); const groupId = `subagent-group:${row.id}`; + const foldsStartOpen = transcriptFoldsStartOpen(ctx.transcriptView); const [expanded, setExpanded] = useState(() => - ctx.workGroupViewState.expandedEntries.has(groupId), + disclosureOpen(ctx.workGroupViewState.expandedEntries, groupId, foldsStartOpen), ); const members = (row.subagents ?? [row.projectedItem]).flatMap(({ item }) => item.type === "subagent" ? [item] : [], @@ -84,7 +89,7 @@ const V2SubagentGroup = memo(function V2SubagentGroup({ const statusSummary = summarizeSubagentStatuses(agents.map(({ status }) => status)); const toggleExpanded = (open: boolean) => { ctx.onToggleWorkEntry(row.id, expanded); - if (open) ctx.workGroupViewState.expandedEntries.add(groupId); + if (open !== foldsStartOpen) ctx.workGroupViewState.expandedEntries.add(groupId); else ctx.workGroupViewState.expandedEntries.delete(groupId); setExpanded(open); }; diff --git a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx index 76f2f604ba85f..50699813529f2 100644 --- a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx +++ b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx @@ -12,6 +12,10 @@ import { ThreadId, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + disclosureOpen, + transcriptRowsStartOpen, +} from "@t3tools/client-runtime/work-log/disclosure"; import { Link } from "@tanstack/react-router"; import { notificationChildThreadId } from "@t3tools/client-runtime/state/thread-execution"; import { @@ -186,18 +190,20 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { ? notificationChildThreadId(workEntry.projectedItem.item.source) : undefined; const groupView = use(WorkGroupViewCtx); - const [expanded, setExpanded] = useState( - () => groupView?.state.expandedEntries.has(workEntry.id) ?? false, + const rowsStartOpen = transcriptRowsStartOpen(ctx.transcriptView); + // A standalone row closed by hand in Verbose stays closed when it remounts. + const flipped = + groupView?.state.expandedEntries ?? + (rowsStartOpen ? ctx.workGroupViewState.expandedEntries : undefined); + const [expanded, setExpanded] = useState(() => + disclosureOpen(flipped, workEntry.id, rowsStartOpen), ); const toggleExpanded = () => { const next = !expanded; - if (groupView) { - groupView.onToggleEntry(!next); - if (next) groupView.state.expandedEntries.add(workEntry.id); - else groupView.state.expandedEntries.delete(workEntry.id); - } else { - props.onToggleEntry?.(!next); - } + if (groupView) groupView.onToggleEntry(!next); + else props.onToggleEntry?.(!next); + if (flipped && next !== rowsStartOpen) flipped.add(workEntry.id); + else flipped?.delete(workEntry.id); setExpanded(next); }; const failureItem = workEntry.projectedItem?.item; diff --git a/apps/web/src/components/chat/TimelineWorkGroupRows.tsx b/apps/web/src/components/chat/TimelineWorkGroupRows.tsx index a1fccbaf78678..f440f9c6126ab 100644 --- a/apps/web/src/components/chat/TimelineWorkGroupRows.tsx +++ b/apps/web/src/components/chat/TimelineWorkGroupRows.tsx @@ -4,6 +4,7 @@ import type { WorktreeSetupSnapshot } from "@t3tools/contracts"; import { type ToolActivityIcon } from "@t3tools/contracts"; import { liveThoughtLine } from "@t3tools/client-runtime/work-log/presentation"; import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; +import { transcriptRowsStartOpen } from "@t3tools/client-runtime/work-log/disclosure"; import { observeResize } from "~/lib/observeResize"; import { tickTextContent } from "~/lib/tickTextContent"; import { observeVisibleAnimation } from "../../lib/visibleAnimation"; @@ -68,7 +69,7 @@ export const WorkGroupSection = memo(function WorkGroupSection({ isExpandedToolGroup: boolean; displayLabel?: string | undefined; }) { - const { workspaceRoot, routeThreadKey, onToggleWorkEntry, pendingApprovals } = + const { workspaceRoot, routeThreadKey, onToggleWorkEntry, pendingApprovals, transcriptView } = use(TimelineRowCtx); const onToggleStandaloneEntry = (collapsed: boolean) => onToggleWorkEntry(disclosureAnchorKey, collapsed); @@ -81,13 +82,18 @@ export const WorkGroupSection = memo(function WorkGroupSection({ }); if (nonEmptyEntries.length === 0) return null; - if (isExpandedToolGroup && nonEmptyEntries.every((entry) => entry.itemType === "reasoning")) { + // Above Normal, thinking rows stay individually foldable instead of opening as one trace. + if ( + isExpandedToolGroup && + transcriptView === "normal" && + nonEmptyEntries.every((entry) => entry.itemType === "reasoning") + ) { return ; } if (isExpandedToolGroup) { return ( {nonEmptyEntries.map((workEntry) => ( resolveWorkGroupScrollIndex(entries, viewState.scrollPositions.get(anchorKey)), ); @@ -218,7 +224,9 @@ function ExpandedWorkGroupEntries({ const updateExpandedContentHeight = () => { const state = listRef.current?.getState(); let height = 0; - for (const entryId of viewState.expandedEntries) { + // Rows opened by hand grow the box; Verbose opens every row, so its box keeps the cap. + const openedByHand = transcriptRowsStartOpen(transcriptView) ? [] : viewState.expandedEntries; + for (const entryId of openedByHand) { if (state?.indexByKey(entryId) === undefined) continue; height += Math.max( 0, diff --git a/apps/web/src/components/chat/timelineRowContext.ts b/apps/web/src/components/chat/timelineRowContext.ts index 6334ef53bd050..1040b84b8da10 100644 --- a/apps/web/src/components/chat/timelineRowContext.ts +++ b/apps/web/src/components/chat/timelineRowContext.ts @@ -26,7 +26,7 @@ import { type ChatFileAttachment } from "../../types"; import { ExpandedImagePreview } from "./ExpandedImagePreview"; import { type AssistantCitationTarget } from "./AssistantCitationSource"; import { type MessagesTimelineRow, type WorkGroupScrollAnchor } from "./MessagesTimeline.logic"; -import { type TimestampFormat } from "@t3tools/contracts/settings"; +import { type TimestampFormat, type TranscriptView } from "@t3tools/contracts/settings"; import type { TurnItemDetailSource } from "../../state/queries"; import { type HandoffTimelineRun } from "./V2LifecycleRow"; @@ -52,6 +52,7 @@ export interface TimelineRowSharedState { citationRequest: AssistantCitationTarget | null; listRef: React.RefObject; timestampFormat: TimestampFormat; + transcriptView: TranscriptView; routeThreadKey: string; threadRef: ScopedThreadRef | null; markdownCwd: string | undefined; diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.ts b/apps/web/src/components/chat/timelineScrollAnchoring.ts index 70f4e639293b0..1052840e97ffb 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.ts +++ b/apps/web/src/components/chat/timelineScrollAnchoring.ts @@ -32,7 +32,7 @@ export function observeTimelineRun( } return { observation, anchorMessageId: input.messageId }; } -import type { RunAttemptId } from "@t3tools/contracts"; +import type { RunAttemptId, TranscriptView } from "@t3tools/contracts"; // Match the titlebar fade inset so draft promotion preserves the first row's position. export const CHAT_TIMELINE_ANCHOR_OFFSET = 24; @@ -97,6 +97,8 @@ export interface RememberedTimelinePosition { readonly scrollOffset: number; readonly atEnd: boolean; readonly disclosures?: { + /** The sets below hold ids flipped from this view's default. */ + readonly transcriptView: TranscriptView; readonly runs: ReadonlySet; readonly workGroups: ReadonlySet; readonly attempts: ReadonlySet; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 362210f96eee1..592d70c592b43 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -22,7 +22,9 @@ import { type ResponseStreamingMode, type QuitConfirmationMode, SidebarProjectSortOrder, + TranscriptView, } from "@t3tools/contracts/settings"; +import { TRANSCRIPT_VIEW_LABELS } from "@t3tools/client-runtime/work-log/disclosure"; import { desktopUpdateChannelOf } from "@t3tools/shared/cliRelease"; import { createModelSelection } from "@t3tools/shared/model"; import { resolvePastedPath } from "@t3tools/shared/path"; @@ -133,6 +135,14 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +const TRANSCRIPT_VIEW_DESCRIPTIONS: Record = { + normal: "Each finished turn folds its work behind “Worked for”.", + thinking: "Every turn, tool group, and superseded attempt starts open.", + verbose: + "Every turn and group starts open, and so does each row's thinking text and tool output.", +}; +const isTranscriptView = Schema.is(TranscriptView); + const DIFF_LAYOUT_LABELS: Record = { stacked: "Stacked", split: "Split", @@ -528,6 +538,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.followUpBehavior !== DEFAULT_UNIFIED_SETTINGS.followUpBehavior ? ["Follow-up behavior"] : []), + ...(settings.transcriptView !== DEFAULT_UNIFIED_SETTINGS.transcriptView + ? ["Transcript view"] + : []), ...(settings.contextWindowMeterEnabled !== DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled ? ["Context window indicator"] : []), @@ -642,6 +655,7 @@ export function useSettingsRestore(onRestored?: () => void) { composerRichTextEnabled: DEFAULT_UNIFIED_SETTINGS.composerRichTextEnabled, sendShortcut: DEFAULT_UNIFIED_SETTINGS.sendShortcut, followUpBehavior: DEFAULT_UNIFIED_SETTINGS.followUpBehavior, + transcriptView: DEFAULT_UNIFIED_SETTINGS.transcriptView, contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled, environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, @@ -1229,6 +1243,42 @@ export function ChatSettingsPanel() { } /> + + + + updateSettings({ transcriptView: DEFAULT_UNIFIED_SETTINGS.transcriptView }) + } + /> + ) : null + } + control={ + + } + /> + ); } diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index d28e467b0cf70..e34cc89758884 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -600,6 +600,14 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/chat", searchTerms: ["queue steer running turn send default behavior composer"], }, + { + id: "transcript-view", + title: "Transcript view", + to: "/settings/chat", + searchTerms: [ + "normal thinking verbose expand all turns worked for fold collapse tool calls activity log", + ], + }, { id: "provider-update-checks", title: "Provider update checks", diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index bd9dfdcdcc4b6..ea3cd1ffde759 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { buildThreadActionMenuItems, type ThreadActionMenuState } from "./threadActionMenu.logic"; +import { + buildThreadActionMenuItems, + transcriptViewFromMenuAction, + type ThreadActionMenuState, +} from "./threadActionMenu.logic"; const baseState: ThreadActionMenuState = { canOperate: true, @@ -9,6 +13,7 @@ const baseState: ThreadActionMenuState = { isPinned: false, isSettled: false, autoSettleEnabled: true, + transcriptView: "normal", isSnoozed: false, canSnoozeNow: true, isRegeneratingTitle: false, @@ -26,6 +31,26 @@ const baseState: ThreadActionMenuState = { }; describe("buildThreadActionMenuItems", () => { + it("offers every Transcript view level with the current one checked, even to read-only clients", () => { + const items = buildThreadActionMenuItems({ + ...baseState, + canOperate: false, + transcriptView: "thinking", + }); + const menu = items.find((item) => item.id === "transcript-view"); + expect(menu?.disabled).toBeUndefined(); + expect( + menu?.children?.map(({ id, label, checked, disabled }) => ({ id, label, checked, disabled })), + ).toEqual([ + { id: "transcript-view:normal", label: "Normal", checked: false, disabled: undefined }, + { id: "transcript-view:thinking", label: "Thinking", checked: true, disabled: undefined }, + { id: "transcript-view:verbose", label: "Verbose", checked: false, disabled: undefined }, + ]); + expect(transcriptViewFromMenuAction("transcript-view:verbose")).toBe("verbose"); + expect(transcriptViewFromMenuAction("transcript-view")).toBeNull(); + expect(transcriptViewFromMenuAction("snooze:custom")).toBeNull(); + }); + it.each([false, true])( "disables both lifecycle directions without permission (reversed: %s)", (reversed) => { diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index ac4ef649ed07c..7608bd999db7e 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -1,5 +1,7 @@ -import type { ContextMenuItem } from "@t3tools/contracts"; +import { TranscriptView, type ContextMenuItem } from "@t3tools/contracts"; import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; +import { TRANSCRIPT_VIEW_LABELS } from "@t3tools/client-runtime/work-log/disclosure"; +import * as Schema from "effect/Schema"; export type ThreadActionMenuId = | "find" @@ -14,6 +16,8 @@ export type ThreadActionMenuId = | "auto-settle" | "auto-settle:enabled" | "auto-settle:disabled" + | "transcript-view" + | `transcript-view:${TranscriptView}` | "snooze" | `snooze:${string}` | "unsnooze" @@ -78,6 +82,8 @@ export interface ThreadActionMenuState { readonly isPinned: boolean; readonly isSettled: boolean; readonly autoSettleEnabled: boolean; + /** The device's Transcript view setting; the menu changes it for every thread. */ + readonly transcriptView: TranscriptView; readonly isSnoozed: boolean; readonly canSnoozeNow: boolean; readonly isRegeneratingTitle: boolean; @@ -97,20 +103,32 @@ export interface ThreadActionMenuState { readonly canTurnIntoProject?: boolean; } -/** Local navigation, read markers, and copying remain available to read-only clients. */ +/** Local navigation, read markers, copying, and display choices remain available to read-only clients. */ export function threadActionRequiresOperate(action: ThreadActionMenuId): boolean { - return ![ - "find", - "new-thread-on-branch", - "project-settings", - "mark-unread", - "share-guest", - "copy", - "copy-path", - "copy-branch", - "copy-thread", - "copy-thread-id", - ].includes(action); + return ( + !action.startsWith("transcript-view") && + ![ + "find", + "new-thread-on-branch", + "project-settings", + "mark-unread", + "share-guest", + "copy", + "copy-path", + "copy-branch", + "copy-thread", + "copy-thread-id", + ].includes(action) + ); +} + +const isTranscriptView = Schema.is(TranscriptView); + +export function transcriptViewFromMenuAction(action: ThreadActionMenuId): TranscriptView | null { + const view = action.startsWith("transcript-view:") + ? action.slice("transcript-view:".length) + : null; + return isTranscriptView(view) ? view : null; } export function buildThreadActionMenuItems( @@ -223,6 +241,16 @@ export function buildThreadActionMenuItems( }, ] : []), + { + id: "transcript-view", + label: "Transcript view", + icon: "list-collapse", + children: TranscriptView.literals.map((view) => ({ + id: `transcript-view:${view}` as const, + label: TRANSCRIPT_VIEW_LABELS[view], + checked: state.transcriptView === view, + })), + }, { id: "copy", label: "Copy", diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index a3e760ddc58ab..87258021c129b 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -9,6 +9,13 @@ const ICON_PATHS: Record ({ confirmThreadDelete: true, confirmThreadArchive: true, timestampFormat: "12-hour", + transcriptView: "normal", }), + updateClientSettings: async (patch: { transcriptView?: string }) => + recordEffect(`transcript-view:${patch.transcriptView}`), })); vi.mock("./useThreadActions", () => ({ canTurnThreadIntoProject: () => false, @@ -165,6 +168,20 @@ beforeEach(() => { state.show.mockReset().mockResolvedValue(null); }); +describe("thread menu transcript view", () => { + it("changes the device's view from a connection that cannot change threads", async () => { + state.show.mockResolvedValue("transcript-view:verbose"); + createMenu().openMenu(position); + await state.completed.promise; + expect(state.effects).toEqual(["transcript-view:verbose"]); + const [items] = state.show.mock.calls[0]!; + const levels = items.find((item) => item.id === "transcript-view")?.children ?? []; + expect(levels.filter((level) => level.checked).map((level) => level.id)).toEqual([ + "transcript-view:normal", + ]); + }); +}); + describe("thread menu permissions", () => { it.each(["rename", "regenerate-title", "delete", "pin", "settle", "archive"] as const)( "%s rechecks after the native menu closes", diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index cddef6046cf8b..df292ec89afc9 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -19,6 +19,7 @@ import { pickSnoozePreset, resolveSnoozePresets } from "../components/Sidebar.sn import { buildThreadActionMenuItems, threadActionRequiresOperate, + transcriptViewFromMenuAction, type ThreadActionMenuId, } from "../components/threadActionMenu.logic"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; @@ -44,7 +45,7 @@ import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping"; import { threadRuntimeCanArchive } from "@t3tools/client-runtime/state/models"; import { useCopyToClipboard } from "./useCopyToClipboard"; import { useNewThreadHandler } from "./useHandleNewThread"; -import { useClientSettings } from "./useSettings"; +import { updateClientSettings, useClientSettings } from "./useSettings"; import { canTurnThreadIntoProject, useThreadActions } from "./useThreadActions"; function failureToast(title: string, error: unknown) { @@ -94,6 +95,7 @@ export function useThreadActionMenu(input: { const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const timestampFormat = useClientSettings((s) => s.timestampFormat); + const transcriptView = useClientSettings((s) => s.transcriptView); const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ onCopy: ({ path }) => { toastManager.add({ type: "success", title: "Path copied", description: path }); @@ -140,6 +142,7 @@ export function useThreadActionMenu(input: { isPinned: thread.pinnedAt != null, isSettled: supports.settlement && thread.settledOverride === "settled", autoSettleEnabled: thread.autoSettleDisabledAt == null, + transcriptView, isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }), canSnoozeNow: canSnooze(thread, { now: now.toISOString() }), isRegeneratingTitle, @@ -152,6 +155,11 @@ export function useThreadActionMenu(input: { const clicked = await settlePromise(() => api.contextMenu.show(items, position)); if (clicked._tag === "Failure" || clicked.value === null) return; const action: ThreadActionMenuId = clicked.value; + const chosenView = transcriptViewFromMenuAction(action); + if (chosenView) { + await updateClientSettings({ transcriptView: chosenView }); + return; + } if ( threadActionRequiresOperate(action) && !readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope) diff --git a/docs/user/activity-log.md b/docs/user/activity-log.md index 0aad505e0faed..1b4059db0ab36 100644 --- a/docs/user/activity-log.md +++ b/docs/user/activity-log.md @@ -5,6 +5,12 @@ commands, tool inputs, status, and exit codes. Expand a tool call to see its out command's output updates live, and long output shows only its most recent part. Use **Open diff** on a file change to review its contents. +A finished turn folds its work behind **Worked for**. **Settings → Chat → Transcript view** changes +that: **Thinking** opens every turn, tool group, and superseded attempt, and **Verbose** also opens +each row's thinking text and tool output. On web and desktop you can also switch it from a thread's +menu under **Transcript view**, and mobile has the same choice under **Settings → Chat**. Select any +header or row to fold it again. + T3 Orchestrator summaries describe the actions taken, such as **Ran 2 commands and sent messages to 3 threads**. Repeated messages to the same destinations show both counts: **Sent 5 messages to 2 threads**. Thread creation counts the threads returned by the tool, including batches. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index d1b48e1d76f4f..c06190ff5cc41 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -399,6 +399,10 @@ "types": "./src/work-log/toolPresentation.ts", "default": "./src/work-log/toolPresentation.ts" }, + "./work-log/disclosure": { + "types": "./src/work-log/disclosure.ts", + "default": "./src/work-log/disclosure.ts" + }, "./work-log/command-label": { "types": "./src/work-log/commandLabel.ts", "default": "./src/work-log/commandLabel.ts" diff --git a/packages/client-runtime/src/work-log/disclosure.test.ts b/packages/client-runtime/src/work-log/disclosure.test.ts new file mode 100644 index 0000000000000..ec7b98a96e2df --- /dev/null +++ b/packages/client-runtime/src/work-log/disclosure.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + disclosureOpen, + transcriptFoldsStartOpen, + transcriptRowsStartOpen, + withDisclosureOpen, +} from "./disclosure.js"; + +describe("transcript disclosures", () => { + it("opens folds from Thinking up and rows only in Verbose", () => { + const views = ["normal", "thinking", "verbose"] as const; + expect(views.map(transcriptFoldsStartOpen)).toEqual([false, true, true]); + expect(views.map(transcriptRowsStartOpen)).toEqual([false, false, true]); + }); + + it("reads a flipped id as the opposite of the default", () => { + const flipped = new Set(["a"]); + expect(disclosureOpen(flipped, "a", false)).toBe(true); + expect(disclosureOpen(flipped, "b", false)).toBe(false); + expect(disclosureOpen(flipped, "a", true)).toBe(false); + expect(disclosureOpen(flipped, "b", true)).toBe(true); + expect(disclosureOpen(undefined, "a", true)).toBe(true); + }); + + it("opens a disclosure against either default and keeps an open one unchanged", () => { + const untouched = new Set(); + expect([...withDisclosureOpen(untouched, "a", false)]).toEqual(["a"]); + expect(withDisclosureOpen(untouched, "a", true)).toBe(untouched); + const flipped = new Set(["a"]); + expect([...withDisclosureOpen(flipped, "a", true)]).toEqual([]); + expect(withDisclosureOpen(flipped, "a", false)).toBe(flipped); + }); +}); diff --git a/packages/client-runtime/src/work-log/disclosure.ts b/packages/client-runtime/src/work-log/disclosure.ts new file mode 100644 index 0000000000000..eb6d2266b9ad8 --- /dev/null +++ b/packages/client-runtime/src/work-log/disclosure.ts @@ -0,0 +1,38 @@ +import type { TranscriptView } from "@t3tools/contracts"; + +export const TRANSCRIPT_VIEW_LABELS: Record = { + normal: "Normal", + thinking: "Thinking", + verbose: "Verbose", +}; + +/** Turn folds, tool and subagent groups, and superseded attempts. */ +export function transcriptFoldsStartOpen(view: TranscriptView): boolean { + return view !== "normal"; +} + +/** A row's own details, such as thinking text and tool output. */ +export function transcriptRowsStartOpen(view: TranscriptView): boolean { + return view === "verbose"; +} + +/** Disclosure sets hold the ids flipped away from the transcript's default state. */ +export function disclosureOpen( + flipped: ReadonlySet | undefined, + id: Id, + startsOpen: boolean, +): boolean { + return (flipped?.has(id) ?? false) !== startsOpen; +} + +export function withDisclosureOpen( + flipped: ReadonlySet, + id: Id, + startsOpen: boolean, +): ReadonlySet { + if (disclosureOpen(flipped, id, startsOpen)) return flipped; + const next = new Set(flipped); + if (startsOpen) next.delete(id); + else next.add(id); + return next; +} diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index b1848090d9d00..b1d48bc87fc70 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -328,6 +328,10 @@ export const DiffColorScheme = Schema.Literals(["red-green", "blue-orange"]); export const ChatWidth = Schema.Literals(["comfortable", "wide", "full"]); export type ChatWidth = typeof ChatWidth.Type; +/** Normal folds finished turns, Thinking opens folds and groups, Verbose also opens each row. */ +export const TranscriptView = Schema.Literals(["normal", "thinking", "verbose"]); +export type TranscriptView = typeof TranscriptView.Type; + /** How the composer shows reasoning effort: the level's name, or rising bars. */ export const EffortDisplay = Schema.Literals(["label", "bars"]); export type EffortDisplay = typeof EffortDisplay.Type; @@ -473,6 +477,9 @@ export const ClientSettingsSchema = Schema.Struct({ followUpBehavior: Schema.Literals(["queue", "steer"]).pipe( Schema.withDecodingDefault(Effect.succeed("steer")), ), + transcriptView: TranscriptView.pipe( + Schema.withDecodingDefault(Effect.succeed("normal" as const)), + ), proactivePanelsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), boringSidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -1795,6 +1802,7 @@ export const ClientSettingsPatch = Schema.Struct({ composerRichTextEnabled: Schema.optionalKey(Schema.Boolean), sendShortcut: Schema.optionalKey(Schema.Literals(["enter", "mod-enter-multiline", "mod-enter"])), followUpBehavior: Schema.optionalKey(Schema.Literals(["queue", "steer"])), + transcriptView: Schema.optionalKey(TranscriptView), proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean), showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), boringSidebarEnabled: Schema.optionalKey(Schema.Boolean),