diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index fb85ce1e1cc3..4e301288c94f 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -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: "" }, @@ -3381,17 +3382,46 @@ 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({ @@ -3399,4 +3429,25 @@ it("makes attachment-only question answers expandable in the mobile feed", () => 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"); }); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index b7d5018edd98..f1550042eae8 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -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; @@ -1556,13 +1560,15 @@ function groupAdjacentActivities(entries: ReadonlyArray): 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(); } } @@ -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), ); @@ -1848,7 +1856,7 @@ function appendPresentedFeedEntry( result.push(entry); return; } - if (isContextCompactionActivityGroup(entry)) { + if (isContextCompactionActivityGroup(entry) || isUserInputActivityGroup(entry)) { result.push(entry); return; } diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 0c5e53261b4f..c677e6fff65e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import { + ApprovalRequestId, CheckpointRef, EnvironmentId, EventId, @@ -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[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", diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 866cbd0201b5..fc3ffdb98a90 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -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. @@ -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" ) { @@ -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, @@ -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) || diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 07252d6ab7fb..41caf1e7acdf 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -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:") &&