diff --git a/apps/mobile/src/features/settings/SettingsFollowUpRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsFollowUpRouteScreen.tsx
index bdaab6c3ea4b..292414fd4804 100644
--- a/apps/mobile/src/features/settings/SettingsFollowUpRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsFollowUpRouteScreen.tsx
@@ -1,6 +1,8 @@
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 { View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppText as Text } from "../../components/ui/AppText";
@@ -27,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);
@@ -35,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 6c82f6c317d9..a6a3deb6b646 100644
--- a/apps/mobile/src/features/settings/settings-search.ts
+++ b/apps/mobile/src/features/settings/settings-search.ts
@@ -69,7 +69,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 27cce9c5795b..5efa904a1bd7 100644
--- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
@@ -484,6 +484,9 @@ function ThreadDetailBody(
});
const composerError = useAtomValue(threadComposerErrorsAtom)[selectedThreadKey]?.message ?? null;
const preferences = useAtomValue(mobilePreferencesAtom);
+ const transcriptView = AsyncResult.isSuccess(preferences)
+ ? (preferences.value.transcriptView ?? "normal")
+ : "normal";
const toolCallVisibility = AsyncResult.isSuccess(preferences)
? (preferences.value.toolCallVisibility ?? "showAll")
: "showAll";
@@ -1508,6 +1511,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 d6fdc9e955cd..cc28f20dd00a 100644
--- a/apps/mobile/src/features/threads/ThreadFeed.tsx
+++ b/apps/mobile/src/features/threads/ThreadFeed.tsx
@@ -58,6 +58,7 @@ import {
type RunId,
type ScheduledTaskId,
type ScopedThreadRef,
+ type TranscriptView,
} from "@t3tools/contracts";
import type { ToolCallVisibility } from "@t3tools/contracts/settings";
import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations";
@@ -88,6 +89,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,
@@ -355,6 +362,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;
@@ -1662,6 +1670,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;
@@ -2317,6 +2327,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}
@@ -2589,7 +2601,11 @@ function useThreadFeedPresentation(
props.activeWorkStartedAt,
props.runlessWorkActive ?? false,
feedCache,
- { toolCallVisibility: props.toolCallVisibility ?? "showAll", revealedMessageId },
+ {
+ toolCallVisibility: props.toolCallVisibility ?? "showAll",
+ revealedMessageId,
+ transcriptView: props.transcriptView,
+ },
),
props.feed,
props.queuedMessages,
@@ -2753,8 +2769,25 @@ export const ThreadFeed = memo(function ThreadFeed({
expandedTurnIds: new Set(),
revealedMessageId: null,
});
- const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds, revealedMessageId } =
- interactionState;
+ const { copiedRowId, revealedMessageId } = 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();
@@ -3326,10 +3359,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(
@@ -3453,7 +3486,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);
@@ -3461,7 +3495,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);
@@ -3471,7 +3505,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,
+ ),
}));
}
}
@@ -3628,7 +3666,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) => {
@@ -3636,7 +3675,7 @@ export const ThreadFeed = memo(function ThreadFeed({
return {
...current,
expandedWorkRows: { ...current.expandedWorkRows, [rowId]: expanded },
- ...(groupId === undefined
+ ...(groupId === undefined || foldsStartOpen
? {}
: { expandedWorkGroups: { ...current.expandedWorkGroups, [groupId]: expanded } }),
};
@@ -3693,7 +3732,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);
@@ -3762,6 +3803,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 4d3f89f21b9d..f06429e732d8 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 f3d3f3b5252b..a71454fe6a4a 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;
}) {
@@ -134,7 +135,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 000000000000..d12221882b3f
--- /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 4ed6cbef9481..ee2cae85d210 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,
@@ -97,8 +97,8 @@ import Animated, {
FadeOut,
LinearTransition,
ReduceMotion,
- useAnimatedScrollHandler,
useAnimatedStyle,
+ useDerivedValue,
useSharedValue,
withTiming,
} from "react-native-reanimated";
@@ -438,7 +438,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;
@@ -475,6 +474,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;
@@ -495,7 +498,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}
@@ -527,17 +531,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 ?? "")}
+ ))}
+
+
);
}
@@ -549,6 +562,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}
@@ -561,59 +575,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;
@@ -653,16 +619,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();
@@ -736,7 +693,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 }) => (
@@ -794,47 +752,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;
}
@@ -915,8 +841,9 @@ const ThreadWorkLogRow = function ThreadWorkLogRow(
| "continuesWorkLog"
| "activities"
| "copiedRowId"
- | "edgeFadeColor"
| "expandedRows"
+ | "foldsStartOpen"
+ | "rowsStartOpen"
| "rowSizing"
| "scrollPositions"
> & {
@@ -925,7 +852,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) &&
@@ -1010,11 +944,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";
@@ -1246,12 +1175,13 @@ const ThreadWorkLogRow = function ThreadWorkLogRow(
))}
-
{reasoning ? (
props.renderReasoning(reasoning.text)
@@ -1335,7 +1265,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 5dee41b54c16..48ef6fd03a25 100644
--- a/apps/mobile/src/lib/filePreview.test.ts
+++ b/apps/mobile/src/lib/filePreview.test.ts
@@ -452,6 +452,7 @@ describe("ThreadInlineFilePreview playback", () => {
dispatchingMessageId: null,
onEditPendingMessage: null,
contentPresentation: { kind: "ready" },
+ transcriptView: "normal",
latestRun: null,
activeWorkStartedAt: null,
listRef: { current: null },
@@ -1391,6 +1392,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 f5b4d0232a90..492e46552ed4 100644
--- a/apps/mobile/src/lib/threadActivity.test.ts
+++ b/apps/mobile/src/lib/threadActivity.test.ts
@@ -16,6 +16,7 @@ import {
type OrchestrationV2RunAttempt,
type OrchestrationV2ProjectedTurnItem,
type OrchestrationV2TurnItem,
+ type TranscriptView,
} from "@t3tools/contracts";
import { deriveThreadCheckpointSummaries } from "@t3tools/client-runtime/state/thread-checkpoints";
import { summarizeToolGroup } from "@t3tools/client-runtime/work-log/presentation";
@@ -907,6 +908,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: TranscriptView = "normal",
+ ) =>
+ deriveThreadFeedPresentation(
+ feed,
+ latestRun,
+ expandedRunIds,
+ expandedWorkGroupIds,
+ null,
+ false,
+ undefined,
+ { toolCallVisibility: "showAll", revealedMessageId: null, 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([
@@ -1292,7 +1346,7 @@ describe("buildThreadFeed", () => {
latestRun ? at(1) : null,
false,
undefined,
- { toolCallVisibility, revealedMessageId: null },
+ { toolCallVisibility, revealedMessageId: null, transcriptView: "normal" },
).map((row) =>
row.type === "activity-group"
? row.activities.map((activity) => activity.projectedItem.item.id).join("+")
diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts
index 3c336b029238..bcaac25fafd4 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,
@@ -52,6 +56,7 @@ import type {
OrchestrationV2UserMessageInputIntent,
RunAttemptId,
ScheduledTaskId,
+ TranscriptView,
TurnItemId,
} from "@t3tools/contracts";
import { idleCompactionOf, isCompactionCommandMessage, RunId, ThreadId } from "@t3tools/contracts";
@@ -1336,6 +1341,7 @@ function presentationRestart(
previous.runlessWorkActive !== runlessWorkActive ||
previous.display.toolCallVisibility !== display.toolCallVisibility ||
previous.display.revealedMessageId !== display.revealedMessageId ||
+ previous.display.transcriptView !== display.transcriptView ||
!setsEqual(previous.expandedRunIds, expandedRunIds) ||
!setsEqual(previous.expandedWorkGroupIds, expandedWorkGroupIds)
) {
@@ -1484,11 +1490,14 @@ function liveAnswerPreview(feed: ReadonlyArray): string | null
export interface ThreadFeedDisplay {
readonly toolCallVisibility: ToolCallVisibility;
readonly revealedMessageId: MessageId | null;
+ /** Above "normal" every fold starts open, and the expanded-id sets name the ones closed. */
+ readonly transcriptView: TranscriptView;
}
const DEFAULT_THREAD_FEED_DISPLAY: ThreadFeedDisplay = {
toolCallVisibility: "showAll",
revealedMessageId: null,
+ transcriptView: "normal",
};
export function deriveThreadFeedPresentation(
@@ -1501,6 +1510,7 @@ export function deriveThreadFeedPresentation(
cache?: ThreadFeedCache,
display: ThreadFeedDisplay = DEFAULT_THREAD_FEED_DISPLAY,
): ThreadFeedEntry[] {
+ const startsOpen = transcriptFoldsStartOpen(display.transcriptView);
const previous = cache?.presentation ?? null;
const restart = presentationRestart(
previous,
@@ -1592,7 +1602,7 @@ export function deriveThreadFeedPresentation(
: deriveThreadFeedRunFolds(sourceFeed, latestRun, isWorking && runlessWorkActive, failedRunIds);
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);
}
}
@@ -1611,7 +1621,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 ||
@@ -1673,6 +1683,7 @@ export function deriveThreadFeedPresentation(
result,
entry,
expandedWorkGroupIds,
+ startsOpen,
activeRunId,
isWorking,
isActiveTailGroup,
@@ -1820,6 +1831,7 @@ function appendPresentedFeedEntry(
result: ThreadFeedEntry[],
entry: Exclude,
expandedWorkGroupIds: ReadonlySet,
+ startsOpen: boolean,
activeRunId: RunId | null,
isWorking: boolean,
activeTail: boolean,
@@ -1845,11 +1857,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);
}
@@ -1862,6 +1884,7 @@ function appendActivityGroupRows(
result: ThreadFeedEntry[],
entry: ThreadFeedActivityGroup,
expandedWorkGroupIds: ReadonlySet,
+ startsOpen: boolean,
activeRunId: RunId | null,
isWorking: boolean,
activeTail: boolean,
@@ -1895,6 +1918,7 @@ function appendActivityGroupRows(
groupableRun,
`work-group:${groupAnchorIdByActivityId.get(groupableRun[0]!.id) ?? groupableRun[0]!.id}`,
expandedWorkGroupIds,
+ startsOpen,
activeRunId,
isWorking,
activeTail && isTrailingRun,
@@ -1926,11 +1950,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 436f3b172cda..a7beeb0b7667 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 { ToolCallVisibility } from "@t3tools/contracts/settings";
import type { AndroidNavigationStyle } from "../lib/androidNavigationStyle";
@@ -113,6 +114,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;
@@ -198,6 +200,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
dismissedDatabaseStorageNotices?: readonly string[];
composerEnterBehavior?: ComposerEnterBehavior;
followUpBehavior?: FollowUpBehavior;
+ transcriptView?: TranscriptView;
microphones?: ReadonlyArray;
projectGroupingEnabled?: boolean;
projectGroupingMode?: SidebarProjectGroupingMode;
@@ -307,6 +310,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 76ad543630fb..944ebac3ebfc 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -3502,6 +3502,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 d45ba6a3a368..6b3e19bc6170 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -133,7 +133,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";
@@ -176,6 +180,7 @@ import {
buildDraftActionMenuItems,
buildThreadActionMenuItems,
threadActionRequiresOperate,
+ transcriptViewFromMenuAction,
} from "./threadActionMenu.logic";
import {
applySidebarThreadDrop,
@@ -914,6 +919,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,
@@ -2632,6 +2638,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
isPinned,
isSettled,
autoSettleEnabled: thread.autoSettleDisabledAt == null,
+ transcriptView,
isSnoozed,
canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }),
isRegeneratingTitle,
@@ -2653,6 +2660,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 46ce247ba6af..8bf32011e6b5 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,
@@ -1555,3 +1556,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 ba0f508ebeec..b28accdda95b 100644
--- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts
+++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts
@@ -2760,6 +2760,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("hides tool calls or everything but the final answer", () => {
const runId = RunId.make("turn-1");
@@ -3052,6 +3070,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 4536e9a25038..6c5cea3c5a06 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,
@@ -61,6 +65,7 @@ import {
type RuntimeRequestId,
RunId,
type ThreadId,
+ type TranscriptView,
type TurnItemId,
} from "@t3tools/contracts";
import type { ThreadRunSummary } from "@t3tools/client-runtime/state/shell";
@@ -1507,6 +1512,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;
@@ -1520,6 +1527,7 @@ export function deriveMessagesTimelineRows(input: {
revealedMessageId?: MessageId | null;
}): MessagesTimelineRow[] {
const timelineEntries = timelineRowEntries(input);
+ const startsOpen = transcriptFoldsStartOpen(input.transcriptView ?? "normal");
const isPendingApproval = (entry: WorkLogEntry) => {
const item = entry.projectedItem?.item;
return (
@@ -1580,7 +1588,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);
}
@@ -1591,7 +1599,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);
}
@@ -1684,7 +1692,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 } : {}),
};
@@ -1844,7 +1852,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),
});
}
@@ -1862,7 +1870,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),
});
}
@@ -1975,7 +1983,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",
@@ -2011,7 +2019,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,
@@ -2235,7 +2243,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 28d3299a7bd6..b75613389def 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";
@@ -95,7 +99,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";
@@ -138,22 +142,32 @@ import { ThreadActivityAnnouncer } from "./ThreadActivityAnnouncer";
import { useStableHandoffRuns, useIncrementalTimelineRows, useStableRows } from "./useTimelineRows";
import { useClientSettings } from "../../hooks/useSettings";
-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 = (
@@ -291,6 +305,7 @@ interface MessagesTimelineProps {
markdownCwd: string | undefined;
resolvedTheme: "light" | "dark";
timestampFormat: TimestampFormat;
+ transcriptView?: TranscriptView;
workspaceRoot: string | undefined;
skills?: ReadonlyArray;
runs: ReadonlyArray;
@@ -390,6 +405,7 @@ const ConversationTimeline = memo(function ConversationTimeline({
markdownCwd,
resolvedTheme,
timestampFormat,
+ transcriptView = "normal",
workspaceRoot,
skills = EMPTY_TIMELINE_SKILLS,
runs: runsProp,
@@ -411,7 +427,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>(
@@ -438,9 +454,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());
@@ -537,7 +566,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,
@@ -561,9 +591,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);
@@ -596,9 +628,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;
@@ -635,21 +665,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
@@ -659,14 +686,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);
@@ -680,7 +706,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,
@@ -1026,6 +1054,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 e69ffdbaffdd..2786bc431e01 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 95661d16b930..6fc5e1ab4341 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 5a62cde69c37..9cf4b868a5bf 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 1a0c3aa014ad..13ea2e272f70 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 70f4e639293b..1052840e97ff 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 d408d6c8d5d9..81133c7f3c63 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"]
: []),
@@ -645,6 +658,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,
@@ -1233,6 +1247,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 4c890c05aa4e..6773cca4bef0 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -707,6 +707,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 bd9dfdcdcc4b..ea3cd1ffde75 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 ac4ef649ed07..7608bd999db7 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 a3e760ddc58a..87258021c129 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 d22b1d9cf6c4..4811c4fdb4d2 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";
@@ -45,7 +46,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) {
@@ -95,6 +96,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 });
@@ -141,6 +143,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,
@@ -153,6 +156,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 221e58e006c2..978adc4360c6 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 f52115577f10..8569bf0e73ee 100644
--- a/packages/client-runtime/package.json
+++ b/packages/client-runtime/package.json
@@ -407,6 +407,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 000000000000..ec7b98a96e2d
--- /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 000000000000..eb6d2266b9ad
--- /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 8a4b25589121..0bd18714692e 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -338,6 +338,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;
@@ -496,6 +500,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))),
@@ -1879,6 +1886,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),