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),