Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
cba2e73
feat(chat): show provider thinking traces
Sep 14, 2026
14e9c64
fix(chat): harden thinking traces against review findings
Sep 14, 2026
5bb6933
fix(chat): stop a stranded thinking block from shimmering forever
Sep 14, 2026
a9a75e9
test(server): wait for the state each reasoning test asserts
Sep 15, 2026
6e1ca46
fix(chat): fold thinking blocks with the work that follows them
Sep 15, 2026
c0c307e
fix(chat): keep a thought-only turn out of the work fold
Sep 15, 2026
f72ae8a
fix(chat): stop a stranded thinking block from holding a fold open
Sep 15, 2026
fce22d1
fix(mobile): keep a live thinking row that has no text yet
Sep 15, 2026
fb5e4dc
fix(chat): align the two clients' fold and liveness rules
Sep 15, 2026
0a59aec
perf(chat): stop the fold rules re-scanning and re-rendering
Sep 15, 2026
8d92839
refactor(mobile): share one definition of an unsettled turn
Sep 15, 2026
47f21f9
Merge remote-tracking branch 'origin/main' into t3code/implement-thin…
Sep 15, 2026
a21ad0d
Merge remote-tracking branch 'origin/main' into t3code/implement-thin…
maria-rcks Sep 15, 2026
0495c68
feat(claude): request and persist thinking summaries
maria-rcks Sep 15, 2026
0d91723
fix(provider): preserve claude thinking snapshot order
maria-rcks Sep 16, 2026
185fd6b
chore: merge main into thinking traces
maria-rcks Sep 16, 2026
eb3d580
fix: negotiate thinking messages with older clients
maria-rcks Sep 16, 2026
f3b5c70
fix: invalidate cached thinking fallback messages
maria-rcks Sep 16, 2026
fdf819f
fix(server): settle thinking on completed answers
maria-rcks Sep 16, 2026
7c93b42
fix(server): ignore empty completed thinking snapshots
maria-rcks Sep 16, 2026
3cfa1d2
fix(chat): remove thinking duration labels
maria-rcks Sep 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 89 additions & 3 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ import {
import {
deriveThreadFeedPresentation,
isContextCompactionActivityGroup,
reasoningRowLabel,
type ThreadFeedEntry,
type ThreadFeedLatestTurn,
} from "../../lib/threadActivity";
Expand All @@ -158,6 +159,7 @@ import {
collapsedWorkLogHeight,
ThreadAgentSpawnCard,
ThreadDisclosureChevron,
ThreadReasoningRow,
ThreadWorkGroupToggle,
ThreadThinkingRow,
ThreadWorkLog,
Expand Down Expand Up @@ -193,6 +195,9 @@ import {
ThreadMarkdownImageView,
} from "./ThreadMarkdownImage";

/** `ml-7` gutter plus the `px-3` padding of the expanded reasoning container. */
const REASONING_CONTENT_INSET = 52;

const WIDE_MARKDOWN_BLOCK_OPTIONS = {
// Native iOS blockquotes and adjacent selectable text are separate layout
// chunks. Giving their shrink-to-fit bubble a definite width keeps both
Expand Down Expand Up @@ -1350,6 +1355,7 @@ function renderFeedEntry(
> & {
readonly copiedRowId: string | null;
readonly expandedWorkRows: Record<string, boolean>;
readonly expandedReasoningMessageIds: ReadonlySet<string>;
readonly workRowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
readonly workGroupScrollPositions: Map<string, ThreadWorkGroupScrollPosition>;
readonly terminalAssistantMessageIds: ReadonlySet<string>;
Expand All @@ -1358,6 +1364,7 @@ function renderFeedEntry(
readonly onToggleWorkGroup: (groupId: string, anchorKey: string) => void;
readonly onToggleWorkRow: (rowId: string, anchorKey: string) => void;
readonly onToggleTurnFold: (turnId: TurnId) => void;
readonly onToggleReasoning: (messageId: string) => void;
readonly onPressPreview: (source: FilePreviewSource) => void;
readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;
readonly markdownLinkHandlers: MarkdownLinkHandlers;
Expand Down Expand Up @@ -1469,6 +1476,41 @@ function renderFeedEntry(

if (entry.type === "message") {
const { message } = entry;
if (message.role === "reasoning") {
// An empty trace would render as a bare label; the live "Thinking" row
// already covers that window.
if (message.text.trim().length === 0) {
return null;
}
// Only the live turn may claim to still be thinking: a block left open by
// a crashed provider must not shimmer on a turn that settled long ago.
const liveReasoning =
Boolean(message.streaming) &&
message.turnId !== null &&
message.turnId === props.unsettledTurnId;
return (
<ThreadReasoningRow
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
rowSizing={props.workRowSizing}
iconSubtleColor={iconSubtleColor}
expanded={props.expandedReasoningMessageIds.has(message.id)}
label={reasoningRowLabel(message, liveReasoning)}
streaming={liveReasoning}
onToggle={() => props.onToggleReasoning(message.id)}
>
<MarkdownImageAvailableWidthContext
value={props.markdownContentWidth - REASONING_CONTENT_INSET}
>
<AssistantMarkdownContent
markdown={message.text}
markdownStyles={markdownStyles.assistant}
linkHandlers={props.markdownLinkHandlers}
renderImage={props.renderMarkdownImage}
skills={props.skills}
/>
</MarkdownImageAvailableWidthContext>
</ThreadReasoningRow>
);
}
const isUser = message.role === "user";
const renderedText = renderAssistantCitationsAsText(message.text);
const styles = isUser ? markdownStyles.user : markdownStyles.assistant;
Expand Down Expand Up @@ -1962,13 +2004,21 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
readonly expandedWorkGroups: Record<string, boolean>;
readonly expandedWorkRows: Record<string, boolean>;
readonly expandedTurnIds: ReadonlySet<TurnId>;
readonly expandedReasoningMessageIds: ReadonlySet<string>;
}>({
copiedRowId: null,
expandedWorkGroups: {},
expandedWorkRows: {},
expandedTurnIds: new Set(),
expandedReasoningMessageIds: new Set(),
});
const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState;
const {
copiedRowId,
expandedWorkGroups,
expandedWorkRows,
expandedTurnIds,
expandedReasoningMessageIds,
} = interactionState;
const [expandedFile, setExpandedFile] = useState<FilePreviewSource | null>(null);
const [expandedVideo, setExpandedVideo] = useState<VideoPreviewSource | null>(null);
const fileShareSourceIdentifier = useId();
Expand Down Expand Up @@ -2234,6 +2284,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
unsettledTurnId,
copiedRowId,
expandedWorkRows,
expandedReasoningMessageIds,
workRowSizing,
iconSubtleColor,
markdownStyles,
Expand All @@ -2247,6 +2298,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
unsettledTurnId,
copiedRowId,
expandedWorkRows,
expandedReasoningMessageIds,
workRowSizing,
iconSubtleColor,
markdownStyles,
Expand Down Expand Up @@ -2519,7 +2571,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
if (disclosureAnchorKeyRef.current !== null) {
settleDisclosureAfterLayout();
}
}, [expandedTurnIds, expandedWorkGroups, expandedWorkRows, settleDisclosureAfterLayout]);
}, [
expandedTurnIds,
expandedWorkGroups,
expandedWorkRows,
expandedReasoningMessageIds,
settleDisclosureAfterLayout,
]);

const handleItemSizeChanged = useCallback(() => {
if (disclosureAnchorKeyRef.current !== null) {
Expand Down Expand Up @@ -2602,6 +2660,24 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
[suspendEndScrollMaintenanceForDisclosure],
);

const onToggleReasoning = useCallback(
(messageId: string) => {
// The anchor must be the feed row id, which for a message row is the
// message id, or position restoration is skipped for every row.
suspendEndScrollMaintenanceForDisclosure(messageId);
setInteractionState((current) => {
const next = new Set(current.expandedReasoningMessageIds);
if (next.has(messageId)) {
next.delete(messageId);
} else {
next.add(messageId);
}
return { ...current, expandedReasoningMessageIds: next };
});
},
[suspendEndScrollMaintenanceForDisclosure],
);

const onPressPreview = useCallback((source: FilePreviewSource) => {
setExpandedFile((current) => current ?? source);
}, []);
Expand All @@ -2628,6 +2704,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
return undefined;
}
switch (entry.type) {
case "message":
// A collapsed reasoning row is the same chrome as a work toggle.
return entry.message.role === "reasoning" &&
!expandedReasoningMessageIds.has(entry.message.id)
? WORK_GROUP_TOGGLE_HEIGHT
: undefined;
case "turn-fold":
return TURN_FOLD_HEIGHT;
case "work-toggle":
Expand All @@ -2646,7 +2728,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
return undefined;
}
},
[expandedWorkRows, workRowSizing.fixedRowHeight],
[expandedReasoningMessageIds, expandedWorkRows, workRowSizing.fixedRowHeight],
);

// Disclosures can mount existing offscreen rows as well as new work rows.
Expand All @@ -2664,6 +2746,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
onEditPendingMessage: props.onEditPendingMessage,
copiedRowId,
expandedWorkRows,
expandedReasoningMessageIds,
workRowSizing,
workGroupScrollPositions,
terminalAssistantMessageIds,
Expand All @@ -2672,6 +2755,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
onToggleWorkGroup,
onToggleWorkRow,
onToggleTurnFold,
onToggleReasoning,
onPressPreview,
onPressVideo,
markdownLinkHandlers,
Expand All @@ -2698,6 +2782,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
copiedRowId,
disclosureToggleSettling,
expandedWorkRows,
expandedReasoningMessageIds,
workRowSizing,
workGroupScrollPositions,
terminalAssistantMessageIds,
Expand All @@ -2715,6 +2800,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
markdownLinkHandlers,
onPressPreview,
onPressVideo,
onToggleReasoning,
onToggleTurnFold,
onToggleWorkGroup,
onToggleWorkRow,
Expand Down
79 changes: 79 additions & 0 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1150,6 +1150,85 @@ export function ThreadThinkingRow(props: {
);
}

/**
* A provider's thinking trace. Collapsed by default: reasoning is context for
* the answer, not the answer. `expanded` lives on the feed so it survives row
* recycling; `children` is the trace body and only mounts while open.
*/
export function ThreadReasoningRow(props: {
readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
readonly iconSubtleColor: ColorValue;
readonly expanded: boolean;
readonly label: string;
readonly streaming: boolean;
readonly onToggle: () => void;
readonly children: ReactNode;
}) {
return (
<View className={cn("-mx-1 px-1 py-0", props.expanded && "pb-1.5")}>
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: props.expanded }}
accessibilityLabel={props.label}
accessibilityHint={`Double tap to ${props.expanded ? "hide" : "show"} the thinking trace.`}
hitSlop={4}
onPress={() => {
void Haptics.selectionAsync();
props.onToggle();
}}
className="min-h-8 flex-row items-center gap-1.5 rounded-md px-0.5 py-0 active:bg-subtle"
style={{ minHeight: props.rowSizing.estimatedRowHeight }}
>
{props.streaming ? (
<ShimmeringWorkContent
key={props.rowSizing.textSizeKey}
icon="brain"
iconSubtleColor={props.iconSubtleColor}
label={props.label}
showIcon
/>
) : (
<>
<View className="h-6 w-6 shrink-0 items-center justify-center">
<WorkLogIcon icon="brain" color={props.iconSubtleColor} />
</View>
<Text
key={props.rowSizing.textSizeKey}
className="min-w-0 flex-1 text-sm text-foreground-muted"
numberOfLines={1}
>
{props.label}
</Text>
</>
)}
<ThreadDisclosureChevron
expanded={props.expanded}
collapsedDirection="right"
size={11}
tintColor={props.iconSubtleColor}
/>
</Pressable>
{props.expanded ? (
<Animated.View
entering={WORK_LOG_DETAIL_ENTER_TRANSITION}
exiting={WORK_LOG_DETAIL_EXIT_TRANSITION}
layout={WORK_LOG_LAYOUT_TRANSITION}
className="ml-7 mt-1 rounded-xl bg-subtle px-3 py-2"
>
<ScrollView
nestedScrollEnabled
directionalLockEnabled
showsVerticalScrollIndicator
className="max-h-80"
>
{props.children}
</ScrollView>
</Animated.View>
) : null}
</View>
);
}

function ToolActivityIconView(props: {
readonly environmentId: EnvironmentId;
readonly icon?: ToolActivityIcon;
Expand Down
29 changes: 29 additions & 0 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1576,6 +1576,23 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th
return grouped;
}

/**
* Row label for a provider thinking trace: live while the trace streams, then
* how long it took, in the same duration format as the "Worked for" fold.
*/
export function reasoningRowLabel(
message: OrchestrationThread["messages"][number],
live: boolean,
): string {
if (live) {
return "Thinking";
}
const elapsedMs = computeElapsedMs(message.createdAt, message.updatedAt);
return elapsedMs === null || elapsedMs <= 0
? "Thought"
: `Thought for ${formatDuration(elapsedMs)}`;
}

function computeElapsedMs(startIso: string, endIso: string): number | null {
const start = Date.parse(startIso);
const end = Date.parse(endIso);
Expand Down Expand Up @@ -1636,6 +1653,9 @@ function deriveThreadFeedTurnFolds(
pendingUserBoundary = entry.message.createdAt;
continue;
}
// Reasoning is deliberately not grouped here: a thinking row stays a
// visible one-click disclosure after its turn folds, instead of hiding
// behind "Worked for ...".
const turnId =
entry.type === "message" && entry.message.role === "assistant"
? entry.message.turnId
Expand Down Expand Up @@ -1818,6 +1838,15 @@ export function deriveThreadFeedPresentation(
!result.some(
(row) =>
(row.type === "work-toggle" && row.shimmer) ||
// A live thinking block is the real version of this row, so it takes
// the slot instead of sitting under a second "Thinking". Scoped to the
// live turn: a block stranded by a killed server must not silence this
// row for every turn that follows.
(row.type === "message" &&
row.message.role === "reasoning" &&
row.message.streaming &&
row.message.turnId !== null &&
row.message.turnId === unsettledTurnId) ||
// A working spawn card is the live activity: its status line shows
// what the agents are doing, so a Thinking row under it would lie.
(row.type === "agent-spawn" &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
EventId,
type ModelSelection,
type OrchestrationEvent,
type OrchestrationMessageRole,
ProviderDriverKind,
type ProjectId,
type OrchestrationSession,
Expand Down Expand Up @@ -118,13 +119,15 @@ const THREAD_TITLE_CONTEXT_TRUNCATION_MARKER = "[Earlier content truncated]\n\n"
const FIRST_USER_CONTEXT_TRUNCATION_MARKER = "\n[First user message truncated]";

type ThreadTitleMessage = {
readonly role: "user" | "assistant" | "system";
readonly role: OrchestrationMessageRole;
readonly text: string;
readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;
};

function formatThreadTitleSection(message: ThreadTitleMessage): string | undefined {
if (message.role === "system") {
// A thinking trace is working notes, not what the thread is about, and it
// dwarfs the answer it precedes. Titling on it would be worse and costlier.
if (message.role === "system" || message.role === "reasoning") {
return undefined;
}
const text = assistantCitationsToPlainText(message.text).trim();
Expand Down
Loading
Loading