Skip to content
58 changes: 36 additions & 22 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -882,7 +882,7 @@ describe("buildThreadFeed", () => {
},
);

it("names the fold that hides a progress message, with or without a run", () => {
it("folds tool work around a mid-turn answer while keeping the answer visible", () => {
const middle = {
...assistantMessage("2026-06-20T00:00:02.500Z"),
id: TurnItemId.make("item-middle"),
Expand All @@ -896,23 +896,27 @@ describe("buildThreadFeed", () => {
projected(runless ? { ...item, runId: null } : item, position),
),
);
const ids = (expanded: ReadonlySet<RunId>) =>
deriveThreadFeedPresentation(feed, null, expanded).map((entry) => entry.id);
const foldRunId = messageFoldRunId(
const collapsed = deriveThreadFeedPresentation(feed, null, new Set());
const fold = collapsed.find((entry) => entry.type === "run-fold");
expect(fold?.type === "run-fold" ? fold.runId : null, String(runless)).toBe(
runless ? "runless:message-user" : runId,
);
expect(collapsed.map((entry) => entry.id)).toContain("message-middle");
expect(collapsed.some((entry) => entry.type === "activity-group")).toBe(false);
const expanded = deriveThreadFeedPresentation(
feed,
"message-middle",
deriveThreadFeedPresentation(feed, null, new Set()),
null,
new Set([fold?.type === "run-fold" ? fold.runId : runId]),
);
expect(foldRunId, String(runless)).toBe(runless ? "runless:message-user" : runId);
expect(ids(new Set())).not.toContain("message-middle");
expect(ids(new Set([foldRunId!]))).toContain("message-middle");
expect(expanded.some((entry) => entry.type === "activity-group")).toBe(true);
expect(expanded.map((entry) => entry.id)).toContain("message-middle");
}
expect(
messageFoldRunId(buildThreadFeed([projected(userMessage(), 0)]), "message-user", []),
).toBe(null);
});

it("folds progress messages while keeping the final assistant answer visible", () => {
it("keeps earlier assistant prose visible while folding tool work under Worked for", () => {
const opening = {
...assistantMessage("2026-06-20T00:00:01.500Z"),
id: TurnItemId.make("item-opening"),
Expand Down Expand Up @@ -942,20 +946,22 @@ describe("buildThreadFeed", () => {
const collapsed = deriveThreadFeedPresentation(feed, latestRun, new Set());
expect(collapsed.map((entry) => entry.id)).toEqual([
"message-user",
"message-opening",
"run-fold:run-1",
"message-middle",
"message-assistant",
]);
expect(collapsed[1]).toMatchObject({
expect(collapsed[2]).toMatchObject({
type: "run-fold",
createdAt: "2026-06-20T00:00:01.500Z",
createdAt: "2026-06-20T00:00:02.000Z",
label: "Worked for 2s",
});

const expanded = deriveThreadFeedPresentation(feed, latestRun, new Set([runId]));
expect(expanded.map((entry) => entry.type)).toEqual([
"message",
"run-fold",
"message",
"run-fold",
"activity-group",
"message",
"message",
Expand Down Expand Up @@ -990,8 +996,8 @@ describe("buildThreadFeed", () => {
]);
});

// regression: #21393 show plans and fold settled progress notes
it("folds progress even when a response has no tool calls", () => {
// Assistant prose stays visible; with no tool work there is nothing to fold.
it("keeps every assistant message visible when a response has no tool calls", () => {
const feed = buildThreadFeed([
projected(userMessage(), 0),
projected(
Expand All @@ -1009,7 +1015,7 @@ describe("buildThreadFeed", () => {
const presented = deriveThreadFeedPresentation(feed, null, new Set());
expect(presented.map((entry) => entry.id)).toEqual([
"message-user",
"run-fold:run-1",
"message-opening",
"message-assistant",
]);
});
Expand Down Expand Up @@ -1042,7 +1048,14 @@ describe("buildThreadFeed", () => {
deriveThreadFeedPresentation(feed, null, new Set()).map((entry) =>
entry.type === "message" ? entry.id : entry.type,
),
).toEqual(["message-user", "run-fold", "message-steer", "run-fold", "message-assistant"]);
).toEqual([
"message-user",
"run-fold",
"message-steer",
"run-fold",
"message-progress",
"message-assistant",
]);
const foldRunId = messageFoldRunId(
feed,
"message-progress",
Expand Down Expand Up @@ -1345,7 +1358,7 @@ describe("buildThreadFeed", () => {
id: TurnItemId.make("new-prompt"),
messageId: MessageId.make("new-prompt"),
}),
).toEqual(["user", "run-fold", "assistant"]);
).toEqual(["user", "assistant", "run-fold"]);
expect(
presented({
...base("wake", "2026-06-20T00:01:00.000Z", 4),
Expand All @@ -1354,7 +1367,7 @@ describe("buildThreadFeed", () => {
outcome: "completed",
summary: "Background task finished",
}),
).toEqual(["user", "run-fold", "assistant"]);
).toEqual(["user", "assistant", "run-fold"]);
});

it("keeps a provider-native subagent's runless tool call live while it works", () => {
Expand Down Expand Up @@ -1447,8 +1460,8 @@ describe("buildThreadFeed", () => {
);

const turn = [user, step, passed, failed, final];
expect(rows(turn, "hideSuccessful")).toEqual(["user", "fold", "step", "failed", "final"]);
expect(rows(turn, "hideAll")).toEqual(["user", "fold", "step", "final"]);
expect(rows(turn, "hideSuccessful")).toEqual(["user", "step", "fold", "failed", "final"]);
expect(rows(turn, "hideAll")).toEqual(["user", "step", "final"]);
expect(rows(turn, "zen")).toEqual(["user", "final"]);
const [steer, answer] = [say("steer", 2, true, true), say("answer", 3)];
expect(rows([user, steer, answer, final], "zen")).toEqual(["user", "steer", "answer", "final"]);
Expand All @@ -1463,7 +1476,7 @@ describe("buildThreadFeed", () => {
deriveThreadFeedPresentation(interrupted, null, new Set()).some(
(row) => row.type === "message" && row.message.id === answer.messageId,
),
).toBe(phase === "final_answer");
).toBe(true);
expect(rows([user, partial, steer, final], "zen").includes("answer")).toBe(
phase === "final_answer",
);
Expand Down Expand Up @@ -1749,6 +1762,7 @@ describe("subagent continued by a later turn", () => {
"answer-1",
"prompt-2",
`run-fold:${run2}`,
"answer-2",
"closing-2",
]);
});
Expand Down
5 changes: 2 additions & 3 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1129,7 +1129,6 @@ function deriveThreadFeedRunFolds(
feedFailedRunIds: ReadonlySet<RunId>,
): ReadonlyMap<string, ThreadFeedRunFold> {
const terminalAssistantMessageIdByRun = new Map<RunId, string>();
const retainedAnswerIds = answersBeforeSteers(feed);
const interruptedRunIds = new Set<RunId>();
const failedRunIds = new Set(feedFailedRunIds);
const promptIndexByRunId = new Map<RunId, number>();
Expand Down Expand Up @@ -1228,8 +1227,8 @@ function deriveThreadFeedRunFolds(
group.entries
.filter(
(entry) =>
entry.id !== terminalAssistantId &&
!(entry.type === "message" && retainedAnswerIds.has(entry.message.id)) &&
// Every reply stays visible: a turn can answer, run tools, then close briefly.
entry.type !== "message" &&
entry.type !== "html-render" &&
entry.type !== "mcp-app" &&
entry.id !== visibleCompactionId &&
Expand Down
25 changes: 21 additions & 4 deletions apps/web/src/components/chat/MessagesTimeline.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,25 @@ function work(id: string, entry: Record<string, unknown>): Entry {
} as Entry;
}

function fileReads(runId: RunId): Entry[] {
return Array.from({ length: 24 }, (_, index) => ({
...work(`work-${index}`, {
runId,
label: `Read file ${index}`,
tone: "tool",
itemType: "file_read",
}),
attempt: {
id: RunAttemptId.make(`attempt-${index}`),
runId,
attemptOrdinal: index + 1,
rootNodeId: NodeId.make(`root-${index}`),
status: "completed" as const,
completedAt: DateTime.makeUnsafe(CREATED_AT),
},
}));
}

function v2Item(type: string, fields: Record<string, unknown>) {
const id = (fields.id as string | undefined) ?? type;
return {
Expand Down Expand Up @@ -308,7 +327,7 @@ describe("MessagesTimeline work rows", () => {
...Array.from({ length: 12 }, (_, index) =>
message(`Earlier context ${index}`, {}, `context-${index}`),
),
message("Progress details\n\n".repeat(50), { role: "assistant", runId }, "progress"),
...fileReads(runId),
message("Done", { role: "assistant", runId }, "final"),
]}
/>,
Expand All @@ -327,9 +346,7 @@ describe("MessagesTimeline work rows", () => {
expect(viewport.scrollTop).toBeGreaterThan(0);
const top = heading.element().getBoundingClientRect().top;
const height = viewport.scrollHeight;
using _timers = fakeTimers();
await heading.click();
await act(() => vi.advanceTimersByTime(1000));
await vi.waitFor(() => {
expect(viewport.scrollHeight).toBeGreaterThan(height + 500);
expect(heading.element().getBoundingClientRect().top).toBeCloseTo(top, 0);
Expand All @@ -349,7 +366,7 @@ describe("MessagesTimeline work rows", () => {
...Array.from({ length: 12 }, (_, index) =>
message(`Earlier context ${index}`, {}, `context-${index}`),
),
message("Progress details\n\n".repeat(50), { role: "assistant", runId }, "progress"),
...fileReads(runId),
message("Done", { role: "assistant", runId }, "final"),
];
await using screen = await render(<MessagesTimeline {...props} timelineEntries={entries} />, {
Expand Down
Loading
Loading