Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 2 additions & 0 deletions apps/mobile/src/components/AppSymbol.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import IconArrowsMinimize from "@tabler/icons-react-native/IconArrowsMinimize";
import IconBellRinging from "@tabler/icons-react-native/IconBellRinging";
import IconBolt from "@tabler/icons-react-native/IconBolt";
import IconBox from "@tabler/icons-react-native/IconBox";
import IconBrain from "@tabler/icons-react-native/IconBrain";
import IconCamera from "@tabler/icons-react-native/IconCamera";
import IconChartBar from "@tabler/icons-react-native/IconChartBar";
import IconCheck from "@tabler/icons-react-native/IconCheck";
Expand Down Expand Up @@ -109,6 +110,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
"bell.badge": IconBellRinging,
"bolt.circle": IconBolt,
"bolt.horizontal.circle": IconBolt,
brain: IconBrain,
camera: IconCamera,
"chart.bar.xaxis": IconChartBar,
checkmark: IconCheck,
Expand Down
6 changes: 6 additions & 0 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ import {
collapsedWorkLogHeight,
ThreadDisclosureChevron,
ThreadWorkGroupToggle,
ThreadThinkingRow,
ThreadWorkLog,
THREAD_DISCLOSURE_TRANSITION_MS,
WORK_GROUP_TOGGLE_HEIGHT,
Expand Down Expand Up @@ -1364,6 +1365,10 @@ function renderFeedEntry(
);
}

if (entry.type === "thinking") {
return <ThreadThinkingRow rowSizing={props.workRowSizing} iconSubtleColor={iconSubtleColor} />;
}

if (entry.type === "work-toggle") {
return (
<ThreadWorkGroupToggle
Expand Down Expand Up @@ -2570,6 +2575,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
case "turn-fold":
return TURN_FOLD_HEIGHT;
case "work-toggle":
case "thinking":
return WORK_GROUP_TOGGLE_HEIGHT;
case "activity-group":
if (isContextCompactionActivityGroup(entry)) {
Expand Down
22 changes: 22 additions & 0 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -922,6 +922,28 @@ export function ThreadWorkGroupToggle(props: {
);
}

export function ThreadThinkingRow(props: {
readonly rowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
readonly iconSubtleColor: ColorValue;
}) {
return (
<View
accessible
accessibilityLabel="Thinking"
className="-mx-1 min-h-8 flex-row items-center px-1.5 py-0"
style={{ minHeight: props.rowSizing.estimatedRowHeight }}
>
<ShimmeringWorkContent
key={props.rowSizing.textSizeKey}
icon="brain"
iconSubtleColor={props.iconSubtleColor}
label="Thinking"
showIcon
/>
</View>
);
}

function ToolActivityIconView(props: {
readonly environmentId: EnvironmentId;
readonly icon?: ToolActivityIcon;
Expand Down
53 changes: 51 additions & 2 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1566,7 +1566,8 @@ describe("buildThreadFeed", () => {
summaryToolIcon: "browser",
hasFailure,
live: true,
shimmer: false,
// A successful trailing call keeps shining; a failure hands off to "Thinking".
shimmer: !hasFailure,
},
{
type: "activity-group",
Expand All @@ -1580,6 +1581,7 @@ describe("buildThreadFeed", () => {
},
],
},
...(hasFailure ? [{ type: "thinking", turnId }] : []),
]);
const terminalGroup = terminalRows[1];
if (terminalGroup?.type !== "activity-group") return;
Expand Down Expand Up @@ -2128,7 +2130,7 @@ describe("buildThreadFeed", () => {
(
[
{ lifecycleStatus: "inProgress", summary: "Running pnpm", shimmer: true },
{ lifecycleStatus: "completed", summary: "Running pnpm", shimmer: false },
{ lifecycleStatus: "completed", summary: "Running pnpm", shimmer: true },
{ lifecycleStatus: "failed", summary: "Failed pnpm", shimmer: false },
{ lifecycleStatus: "declined", summary: "Declined pnpm", shimmer: false },
{ lifecycleStatus: "stopped", summary: "Stopped pnpm", shimmer: false },
Expand Down Expand Up @@ -2228,8 +2230,12 @@ describe("buildThreadFeed", () => {
shimmer,
});
expect(rows[0]).toMatchObject({ live: false, shimmer: false });
// Exactly one live activity: the shimmering call, or "Thinking" once it fails.
expect(rows.filter((entry) => entry.type === "thinking")).toHaveLength(shimmer ? 0 : 1);
expect(rows.at(-1)?.type).toBe(shimmer ? "work-toggle" : "thinking");

const stoppedRows = deriveThreadFeedPresentation(feed, latestTurn, new Set());
expect(stoppedRows.some((entry) => entry.type === "thinking")).toBe(false);
expect(stoppedRows.filter((entry) => entry.type === "work-toggle")).toMatchObject([
{ live: false, shimmer: false },
{
Expand All @@ -2253,6 +2259,49 @@ describe("buildThreadFeed", () => {
},
);

it("shows one Thinking row while a turn works without live tool activity", () => {
const turnId = TurnId.make("turn-thinking");
const latestTurn = {
turnId,
state: "running" as const,
requestedAt: "2026-04-01T00:00:00.000Z",
startedAt: "2026-04-01T00:00:00.000Z",
completedAt: null,
assistantMessageId: null,
};
const feed = buildThreadFeed(
makeThread({
id: ThreadId.make("thread-thinking"),
projectId: ProjectId.make("project-1"),
title: "Thinking",
latestTurn,
messages: [
{
id: MessageId.make("user-1"),
role: "user",
text: "hello",
turnId,
streaming: false,
createdAt: "2026-04-01T00:00:00.000Z",
updatedAt: "2026-04-01T00:00:00.000Z",
},
],
}),
);

const rows = deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), "now");
expect(rows.map((entry) => entry.type)).toEqual(["message", "thinking"]);
expect(rows[1]).toMatchObject({ id: "thinking", createdAt: "now", turnId });
// The row identity is stable across re-derivations so the list can reuse it.
expect(deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), "now")[1]).toBe(
rows[1],
);
// Idle threads show no live activity.
expect(deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), null)).toEqual(
rows.slice(0, 1),
);
});

it("preserves serialized shell wrappers with non-matching boundary quotes", () => {
const turnId = TurnId.make("turn-serialized-shell-wrapper");
const command =
Expand Down
34 changes: 31 additions & 3 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,12 @@ export type ThreadFeedEntry =
readonly turnId: TurnId;
readonly label: string;
readonly expanded: boolean;
}
| {
readonly type: "thinking";
readonly id: string;
readonly createdAt: string;
readonly turnId: TurnId | null;
};

export type ThreadFeedLatestTurn = Pick<
Expand Down Expand Up @@ -1510,7 +1516,8 @@ export function deriveThreadFeedPresentation(
activeWorkStartedAt: string | null = null,
): ThreadFeedEntry[] {
const sourceFeed = feed.filter(
(entry) => entry.type !== "turn-fold" && entry.type !== "work-toggle",
(entry) =>
entry.type !== "turn-fold" && entry.type !== "work-toggle" && entry.type !== "thinking",
);
const activeTailGroup = sourceFeed.findLast(
(entry) => entry.type !== "message" || !isEmptyMessage(entry),
Expand Down Expand Up @@ -1570,12 +1577,30 @@ export function deriveThreadFeedPresentation(
);
}
}
// A working turn always shows one live activity. When no tool row is
// shimmering (no tools yet, or the latest failed), that row is "Thinking".
if (
activeWorkStartedAt !== null &&
!result.some((row) => row.type === "work-toggle" && row.shimmer)
) {
result.push(thinkingRow(activeWorkStartedAt, unsettledTurnId));
}
return result;
}

const THINKING_ROW_ID = "thinking";
let cachedThinkingRow: Extract<ThreadFeedEntry, { readonly type: "thinking" }> | null = null;

function thinkingRow(createdAt: string, turnId: TurnId | null) {
if (cachedThinkingRow?.createdAt !== createdAt || cachedThinkingRow.turnId !== turnId) {
cachedThinkingRow = { type: "thinking", id: THINKING_ROW_ID, createdAt, turnId };
}
return cachedThinkingRow;
}

function appendPresentedFeedEntry(
result: ThreadFeedEntry[],
entry: Exclude<ThreadFeedEntry, { readonly type: "turn-fold" | "work-toggle" }>,
entry: Exclude<ThreadFeedEntry, { readonly type: "turn-fold" | "work-toggle" | "thinking" }>,
expandedWorkGroupIds: ReadonlySet<string>,
unsettledTurnId: TurnId | null,
isWorking: boolean,
Expand Down Expand Up @@ -1696,6 +1721,9 @@ function appendToolGroupRows(
const active = latestActiveActivity !== undefined;
const live = activeTail || active;
const latestActivity = latestActiveActivity ?? activities.at(-1)!;
// Like web, the trailing run keeps shining after its latest call succeeds;
// only a failed, declined, or stopped call hands the live slot to "Thinking".
const shimmer = active || (activeTail && latestActivity.status === "success");
const singleActivity = activities.length === 1 ? latestActivity : null;
const summary = live
? liveToolActivitySummary(latestActivity, live)
Expand Down Expand Up @@ -1751,7 +1779,7 @@ function appendToolGroupRows(
...(summaryToolIcon ? { summaryToolIcon } : {}),
hasFailure: activities.findLast((activity) => activity.toolLike)?.status === "failure",
live,
shimmer: active,
shimmer,
});
if (!expanded) {
return;
Expand Down
Loading