Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
42 changes: 19 additions & 23 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1476,33 +1476,31 @@ function renderFeedEntry(
if (entry.type === "message") {
const { message } = entry;
if (message.role === "reasoning") {
// Only the live turn may claim to still be thinking, and only while the
// thread is actually working: a block left open by a crashed provider
// must not shimmer on a turn that settled long ago. Same test as web.
const liveReasoning =
Boolean(message.streaming) &&
props.isWorking &&
message.turnId !== null &&
message.turnId === props.unsettledTurnId;
const messages = entry.reasoningMessages ?? [message];
return (
<ThreadReasoningRow
rowSizing={props.workRowSizing}
iconSubtleColor={iconSubtleColor}
expanded={props.expandedReasoningMessageIds.has(message.id)}
label={liveReasoning ? "Thinking" : "Thought"}
streaming={liveReasoning}
onToggle={() => props.onToggleReasoning(message.id)}
expanded={props.expandedReasoningMessageIds.has(entry.id)}
label={`Thought${messages.length > 1 ? ` (×${messages.length})` : ""}`}
streaming={false}
onToggle={() => props.onToggleReasoning(entry.id)}
>
<MarkdownImageAvailableWidthContext
value={props.markdownContentWidth - REASONING_CONTENT_INSET}
>
<AssistantMarkdownContent
markdown={message.text}
markdownStyles={markdownStyles.assistant}
linkHandlers={props.markdownLinkHandlers}
renderImage={props.renderMarkdownImage}
skills={props.skills}
/>
<View className="gap-3">
{messages.map((reasoningMessage) => (
<AssistantMarkdownContent
key={reasoningMessage.id}
markdown={reasoningMessage.text}
markdownStyles={markdownStyles.assistant}
linkHandlers={props.markdownLinkHandlers}
renderImage={props.renderMarkdownImage}
skills={props.skills}
/>
))}
</View>
</MarkdownImageAvailableWidthContext>
</ThreadReasoningRow>
);
Expand Down Expand Up @@ -2656,8 +2654,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {

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.
// Reasoning details use their own row within the expanded activity history.
suspendEndScrollMaintenanceForDisclosure(messageId);
setInteractionState((current) => {
const next = new Set(current.expandedReasoningMessageIds);
Expand Down Expand Up @@ -2700,8 +2697,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
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)
return entry.message.role === "reasoning" && !expandedReasoningMessageIds.has(entry.id)
? WORK_GROUP_TOGGLE_HEIGHT
: undefined;
case "turn-fold":
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/features/threads/thread-work-log.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -920,7 +920,7 @@ export function ThreadWorkGroupToggle(props: {
readonly iconSubtleColor: import("react-native").ColorValue;
readonly summary: string;
readonly summaryKind: ToolGroupSummaryKind;
readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request";
readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request" | "brain";
readonly themeAppearance: "light" | "dark";
readonly toolSurface?: import("@t3tools/contracts").ToolActivitySurface;
readonly toolIcon?: ToolActivityIcon;
Expand Down
275 changes: 275 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2295,6 +2295,281 @@ describe("buildThreadFeed", () => {
},
);

it("groups ordered reasoning blocks, keeps the live slot, and restores the group after unfolding", () => {
const turnId = TurnId.make("reasoning-group");
const messages: OrchestrationThread["messages"] = [1, 2, 3, 4].map((second) => ({
id: MessageId.make(`reasoning-${second}`),
role: "reasoning",
text: `**Step ${second}**\n\nCheck ${second}.`,
turnId,
streaming: second === 4,
createdAt: `2026-04-01T00:00:0${second}.000Z`,
updatedAt: `2026-04-01T00:00:0${second}.000Z`,
}));
const thread = makeThread({
id: ThreadId.make("reasoning-group"),
projectId: ProjectId.make("project-1"),
title: "Reasoning",
messages,
latestTurn: {
turnId,
state: "running",
requestedAt: "2026-04-01T00:00:00.000Z",
startedAt: "2026-04-01T00:00:00.000Z",
completedAt: null,
assistantMessageId: null,
},
});
const feed = buildThreadFeed(thread);
const rows = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set(), new Set(), "now");
expect(rows).toMatchObject([
{ type: "work-toggle", id: "live-activity-row", summary: "Thinking", hiddenCount: 4 },
]);
expect(rows).toHaveLength(1);
expect(
deriveThreadFeedPresentation(feed, thread.latestTurn, new Set(), new Set(), "now")[0],
).toBe(rows[0]);

const settledTurn = {
...thread.latestTurn!,
state: "completed" as const,
completedAt: "2026-04-01T00:00:06.000Z",
};
// Reasoning alone stays visible, including a streaming flag left behind on settlement.
expect(deriveThreadFeedPresentation(feed, settledTurn, new Set())).toMatchObject([
{ type: "work-toggle", summary: "Thought (×4)", hiddenCount: 4 },
]);
const completedMessages = messages.map((message) => ({ ...message, streaming: false }));
const waiting = deriveThreadFeedPresentation(
buildThreadFeed({ ...thread, messages: completedMessages }),
thread.latestTurn,
new Set(),
new Set(),
"now",
);
expect(waiting).toMatchObject([
{ type: "work-toggle", id: "live-activity-row", summary: "Thinking", hiddenCount: 4 },
]);
const feedWithWork = buildThreadFeed({
...thread,
activities: [
makeActivity({
id: EventId.make("reasoning-tool"),
kind: "tool.completed",
tone: "tool",
summary: "Read files",
createdAt: "2026-04-01T00:00:05.000Z",
turnId,
payload: { itemType: "file_read", status: "completed" },
}),
],
});
const toolRunning = deriveThreadFeedPresentation(
feedWithWork,
thread.latestTurn,
new Set(),
new Set(),
"now",
);
expect(toolRunning).toMatchObject([
{ type: "work-toggle", id: "live-activity-row", shimmer: true, hiddenCount: 5 },
]);
expect(toolRunning[0]).not.toMatchObject({ summary: "Thinking" });
const nextThought = {
...messages[3]!,
id: MessageId.make("reasoning-after-tool"),
createdAt: "2026-04-01T00:00:06.000Z",
updatedAt: "2026-04-01T00:00:06.000Z",
};
const reasoningAgainFeed: ThreadFeedEntry[] = [
...feedWithWork,
{
type: "message",
id: nextThought.id,
createdAt: nextThought.createdAt,
message: nextThought,
},
];
const reasoningAgain = deriveThreadFeedPresentation(
reasoningAgainFeed,
thread.latestTurn,
new Set(),
new Set(),
"now",
);
expect(reasoningAgain.filter((row) => row.id === "live-activity-row")).toMatchObject([
{ type: "work-toggle", summary: "Thinking", hiddenCount: 6 },
]);
expect(reasoningAgain).toHaveLength(1);
const expandedLive = deriveThreadFeedPresentation(
reasoningAgainFeed,
thread.latestTurn,
new Set(),
new Set([`activity-run:${messages[0]!.id}`]),
"now",
);
expect(expandedLive.map((entry) => entry.type)).toEqual([
"work-toggle",
"message",
"activity-group",
"message",
]);
expect(expandedLive[1]).toMatchObject({ reasoningMessages: messages });
expect(expandedLive[3]).toMatchObject({ message: nextThought });
expect(deriveThreadFeedPresentation(feedWithWork, settledTurn, new Set())).toMatchObject([
{ type: "turn-fold", expanded: false },
]);
const reopened = deriveThreadFeedPresentation(
feedWithWork,
settledTurn,
new Set([turnId]),
new Set([`activity-run:${messages[0]!.id}`]),
);
expect(reopened.map((entry) => entry.type)).toEqual([
"turn-fold",
"work-toggle",
"message",
"activity-group",
]);
expect(reopened[2]).toMatchObject({ id: messages[0]!.id, reasoningMessages: messages });
const toolFirstFeed = feedWithWork.filter((entry) => entry.type === "activity-group");
const toolFirst = deriveThreadFeedPresentation(
toolFirstFeed,
thread.latestTurn,
new Set(),
new Set(),
"now",
)[0];
expect(toolFirst?.type).toBe("work-toggle");
if (toolFirst?.type !== "work-toggle") return;
const preservedExpansion = deriveThreadFeedPresentation(
[...toolFirstFeed, reasoningAgainFeed.at(-1)!],
thread.latestTurn,
new Set(),
new Set([toolFirst.groupId]),
"now",
);
expect(preservedExpansion.map((entry) => entry.type)).toEqual([
"work-toggle",
"activity-group",
"message",
]);
expect(preservedExpansion[0]).toMatchObject({
id: "live-activity-row",
groupId: toolFirst.groupId,
expanded: true,
summary: "Thinking",
});
const strandedToolFeed = buildThreadFeed({
...thread,
messages: [],
activities: [
makeActivity({
id: EventId.make("stranded-tool"),
kind: "tool.updated",
tone: "tool",
summary: "Running command",
createdAt: "2026-04-01T00:00:00.000Z",
turnId,
payload: {
toolCallId: "stranded-tool",
itemType: "command_execution",
command: "sleep 60",
status: "inProgress",
},
}),
],
});
const afterStrandedTool = deriveThreadFeedPresentation(
[...strandedToolFeed, ...feedWithWork],
thread.latestTurn,
new Set(),
new Set(),
"now",
);
expect(afterStrandedTool).toMatchObject([
{ type: "work-toggle", id: "live-activity-row", summary: toolFirst.summary, hiddenCount: 6 },
]);
});

it.each(["tool", "failed-tool", "assistant", "turn", "unknown-turn"] as const)(
"preserves a %s boundary in expanded activity history",
(boundary) => {
const turnId = TurnId.make("reasoning-boundary");
const messages: OrchestrationThread["messages"] = [1, 3].map((second) => ({
id: MessageId.make(`reasoning-${second}`),
role: "reasoning",
text: `Step ${second}`,
turnId:
boundary === "unknown-turn"
? null
: boundary === "turn" && second === 3
? TurnId.make("other-turn")
: turnId,
streaming: false,
createdAt: `2026-04-01T00:00:0${second}.000Z`,
updatedAt: `2026-04-01T00:00:0${second}.000Z`,
}));
const thread = makeThread({
id: ThreadId.make("reasoning-boundary"),
projectId: ProjectId.make("project-1"),
title: "Reasoning",
messages:
boundary === "assistant"
? [
messages[0]!,
{
...messages[0]!,
id: MessageId.make("assistant-between"),
role: "assistant",
text: "Checking the next file.",
createdAt: "2026-04-01T00:00:02.000Z",
},
messages[1]!,
]
: messages,
activities:
boundary === "tool" || boundary === "failed-tool"
? [
makeActivity({
id: EventId.make("tool-between"),
kind: "tool.completed",
tone: "tool",
summary: "Read files",
createdAt: "2026-04-01T00:00:02.000Z",
turnId,
payload: {
itemType: "file_read",
status: boundary === "failed-tool" ? "failed" : "completed",
},
}),
]
: [],
});
const rows = deriveThreadFeedPresentation(
buildThreadFeed(thread),
null,
new Set([turnId]),
new Set(messages.map((message) => `activity-run:${message.id}`)),
);
const reasoningRows = rows.filter(
(entry) => entry.type === "message" && entry.message.role === "reasoning",
);
if (boundary === "failed-tool") {
expect(rows.filter((entry) => entry.type === "work-toggle")).toHaveLength(3);
expect(rows.some((entry) => entry.type === "work-toggle" && entry.hasFailure)).toBe(true);
}
expect(reasoningRows).toEqual(
messages.map((message) => ({
type: "message",
id: message.id,
createdAt: message.createdAt,
message,
})),
);
},
);

it("shows one Thinking row while a turn works without live tool activity", () => {
const turnId = TurnId.make("turn-thinking");
const latestTurn = {
Expand Down
Loading
Loading