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
57 changes: 54 additions & 3 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3360,7 +3360,8 @@ it("accepts ready attachment-only answers while preserving selected options", ()
).toBeNull();
});

it("makes attachment-only question answers expandable in the mobile feed", () => {
it("keeps attachment-only question answers expandable outside mobile work groups and turn folds", () => {
const turnId = TurnId.make("turn-answer");
const answer = {
requestId: ApprovalRequestId.make("question-request"),
answers: { q: "" },
Expand All @@ -3381,22 +3382,72 @@ it("makes attachment-only question answers expandable in the mobile feed", () =>
id: ThreadId.make("thread-answer"),
projectId: ProjectId.make("project-answer"),
title: "Answer history",
latestTurn: {
turnId,
state: "completed",
requestedAt: "2026-09-08T00:00:00.000Z",
startedAt: "2026-09-08T00:00:00.000Z",
completedAt: "2026-09-08T00:00:04.000Z",
assistantMessageId: null,
},
activities: [
makeActivity({
id: EventId.make("tool-before-answer"),
createdAt: "2026-09-08T00:00:01.000Z",
kind: "tool.completed",
tone: "tool",
summary: "Read files",
turnId,
payload: { itemType: "command_execution", status: "completed" },
}),
makeActivity({
id: EventId.make("answer-submitted"),
createdAt: "2026-09-08T00:00:00.000Z",
createdAt: "2026-09-08T00:00:02.000Z",
kind: "user-input.answer-submitted",
summary: "Answered questions",
turnId,
payload: answer,
}),
makeActivity({
id: EventId.make("tool-after-answer"),
createdAt: "2026-09-08T00:00:03.000Z",
kind: "tool.completed",
tone: "tool",
summary: "Read files",
turnId,
payload: { itemType: "command_execution", status: "completed" },
}),
],
});
const [group] = buildThreadFeed(thread);
const feed = buildThreadFeed(thread);
expect(feed).toHaveLength(3);
const group = feed[1];
expect(group?.type).toBe("activity-group");
if (group?.type !== "activity-group") return;
expect(group.activities[0]).toMatchObject({
canExpand: true,
workEntry: { questionAnswer: answer },
});
expect(group.activities[0]?.getFullDetail()).toBeNull();
const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set());
expect(collapsed.map((entry) => entry.type)).toEqual(["turn-fold", "activity-group"]);
expect(collapsed[1]).toBe(group);
const expanded = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set([turnId]));
expect(expanded.map((entry) => entry.type)).toEqual([
"turn-fold",
"work-toggle",
"activity-group",
"work-toggle",
]);
expect(expanded[2]).toBe(group);
const running = deriveThreadFeedPresentation(
feed,
{ ...thread.latestTurn!, state: "running", completedAt: null },
new Set(),
new Set(),
"2026-09-08T00:00:00.000Z",
);
expect(running[0]?.type).toBe("work-toggle");
expect(running[1]).toBe(group);
expect(running[2]?.type).toBe("work-toggle");
});
18 changes: 13 additions & 5 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,10 @@ export function isContextCompactionActivityGroup(
);
}

function isUserInputActivityGroup(entry: ThreadFeedActivityGroup): boolean {
return entry.activities.some((activity) => activity.workEntry.questionAnswer !== undefined);
}

function normalizeDraftAnswer(value: string | undefined): string | null {
if (typeof value !== "string") {
return null;
Expand Down Expand Up @@ -1556,13 +1560,15 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th
continue;
}

const isCompaction = entry.activity.workEntry.sourceActivityKind === "context-compaction";
if (isCompaction || firstActivityEntry?.turnId !== entry.turnId) {
const isStandalone =
entry.activity.workEntry.sourceActivityKind === "context-compaction" ||
entry.activity.workEntry.questionAnswer !== undefined;
if (isStandalone || firstActivityEntry?.turnId !== entry.turnId) {
flushGroup();
}
firstActivityEntry ??= entry;
openGroupActivities.push(entry.activity);
if (isCompaction) {
if (isStandalone) {
flushGroup();
}
}
Expand Down Expand Up @@ -1668,7 +1674,9 @@ function deriveThreadFeedTurnFolds(
entries
.filter(
(entry) =>
entry.id !== firstAssistantMessageId && entry.id !== terminalAssistantMessageId,
entry.id !== firstAssistantMessageId &&
entry.id !== terminalAssistantMessageId &&
!(entry.type === "activity-group" && isUserInputActivityGroup(entry)),
)
.map((entry) => entry.id),
);
Expand Down Expand Up @@ -1848,7 +1856,7 @@ function appendPresentedFeedEntry(
result.push(entry);
return;
}
if (isContextCompactionActivityGroup(entry)) {
if (isContextCompactionActivityGroup(entry) || isUserInputActivityGroup(entry)) {
result.push(entry);
return;
}
Expand Down
79 changes: 79 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vite-plus/test";
import {
ApprovalRequestId,
CheckpointRef,
EnvironmentId,
EventId,
Expand Down Expand Up @@ -2622,6 +2623,84 @@ describe("deriveMessagesTimelineRows", () => {
});
});

it("keeps user input in its own row through tool grouping and turn folding", () => {
const turnId = TurnId.make("answer-turn");
const time = (second: number) => new Date(Date.UTC(2026, 8, 8, 0, 0, second)).toISOString();
const answer: WorkLogEntry = {
id: "answer-submitted",
createdAt: time(3),
turnId,
tone: "info",
label: "User input submitted",
sourceActivityKind: "user-input.answer-submitted",
questionAnswer: {
requestId: ApprovalRequestId.make("answer-request"),
answers: { scope: "Use the private repository" },
questionTextById: { scope: "Which repository?" },
attachmentsByQuestionId: {},
},
};
const tools: WorkLogEntry[] = [1, 2, 4, 5].map((second) => ({
id: `tool-${second}`,
createdAt: time(second),
turnId,
tone: "tool",
label: "Ran command",
command: "git status",
toolCallId: `call-${second}`,
toolLifecycleStatus: "completed",
sourceActivityKind: "tool.completed",
}));
const input = {
timelineEntries: deriveTimelineEntries([], [], [...tools, answer]),
latestTurn: { turnId, state: "completed", startedAt: time(0), completedAt: time(6) },
isWorking: false,
activeTurnStartedAt: null,
turnDiffSummaries: [],
supportsConversationRollback: false,
} satisfies Parameters<typeof deriveMessagesTimelineRows>[0];
const collapsed = deriveMessagesTimelineRows(input);
expect(collapsed.map((row) => row.kind)).toEqual(["turn-fold", "work"]);
const expanded = deriveMessagesTimelineRows({ ...input, expandedTurnIds: new Set([turnId]) });
expect(expanded.map((row) => row.kind)).toEqual([
"turn-fold",
"work-toggle",
"work",
"work-toggle",
]);
const expandedGroups = deriveMessagesTimelineRows({
...input,
expandedTurnIds: new Set([turnId]),
expandedWorkGroupIds: new Set(
expanded.flatMap((row) => (row.kind === "work-toggle" ? [row.groupId] : [])),
),
});
expect(
expandedGroups.flatMap((row) =>
row.kind === "work" && row.isExpandedToolGroup ? [row.groupedEntries] : [],
),
).toEqual([tools.slice(0, 2), tools.slice(2)]);
const active = deriveMessagesTimelineRows({
...input,
latestTurn: { ...input.latestTurn, state: "running", completedAt: null },
runningTurnId: turnId,
isWorking: true,
activeTurnStartedAt: time(0),
});
for (const rows of [collapsed, expanded, expandedGroups, active]) {
const answerRows = rows.filter(
(row) =>
(row.kind === "work" || row.kind === "work-live") && row.groupedEntries.includes(answer),
);
expect(answerRows).toMatchObject([
{ kind: "work", groupedEntries: [answer], isExpandedToolGroup: false },
]);
}
expect(active.find((row) => row.kind === "work-live")).toMatchObject({
groupedEntries: tools.slice(2),
});
});

it("deduplicates integration sources and uses the first source icon for the group", () => {
const chromeSource = {
key: "browser-use:chrome",
Expand Down
12 changes: 11 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,10 @@ function deriveTurnFolds(input: {
if (!isCompaction && index > terminalEntryIndex && !isSingleTrailingActivity) {
continue;
}
// User input stays visible after the surrounding work settles.
if (entry.kind === "work" && entry.entry.questionAnswer !== undefined) {
continue;
}
// Agent-spawn CTA rows never fold: workflows outlive their launching
// turn (dynamic spawns, background execution), and folding the CTA
// when the turn settles makes a still-running fleet invisible.
Expand Down Expand Up @@ -919,6 +923,7 @@ export function deriveMessagesTimelineRows(input: {
!entryBelongsToActiveTurn(entry, index) ||
entry.kind !== "work" ||
entry.entry.agentSpawn !== undefined ||
entry.entry.questionAnswer !== undefined ||
entry.entry.sourceActivityKind === "context-compaction" ||
entry.entry.tone === "error"
) {
Expand Down Expand Up @@ -1043,7 +1048,11 @@ export function deriveMessagesTimelineRows(input: {
}

if (timelineEntry.kind === "work") {
if (timelineEntry.entry.agentSpawn !== undefined || timelineEntry.entry.tone === "error") {
if (
timelineEntry.entry.agentSpawn !== undefined ||
timelineEntry.entry.questionAnswer !== undefined ||
timelineEntry.entry.tone === "error"
) {
nextRows.push({
kind: "work",
id: timelineEntry.id,
Expand All @@ -1061,6 +1070,7 @@ export function deriveMessagesTimelineRows(input: {
!nextEntry ||
nextEntry.kind !== "work" ||
nextEntry.entry.agentSpawn !== undefined ||
nextEntry.entry.questionAnswer !== undefined ||
nextEntry.entry.sourceActivityKind === "context-compaction" ||
nextEntry.entry.tone === "error" ||
activeWorkEntryIds.has(nextEntry.id) ||
Expand Down
2 changes: 0 additions & 2 deletions apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -346,8 +346,6 @@ describe("MessagesTimeline", () => {
/>,
);
});
const toggle = renderer!.root.findByProps({ "aria-expanded": false });
await act(() => toggle.props.onClick());
const questionToggle = renderer!.root.find(
(node) =>
node.props["aria-label"]?.startsWith("Question answer submitted:") &&
Expand Down
Loading