authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-12 17:33:27-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-12 17:33:27-03:00
log3486451525ecaf3a8379aa6d08c885c7cc5335e3
tree5884525533adf49328c76a4c96ab515b7362ab18
parentc542b781c6c4c766e66dcb764b2142d95600e890
signature Signed by PGP key B5690EEEBB952194

fix(chat): keep user input outside collapsed work (#11363)


5 files changed, 157 insertions(+), 11 deletions(-)

apps/mobile/src/lib/threadActivity.test.ts+54-3
...@@ -3360,7 +3360,8 @@ it("accepts ready attachment-only answers while preserving selected options", ()...@@ -3360,7 +3360,8 @@ it("accepts ready attachment-only answers while preserving selected options", ()
3360 ).toBeNull();3360 ).toBeNull();
3361});3361});
33623362
3363it("makes attachment-only question answers expandable in the mobile feed", () => {3363it("keeps attachment-only question answers expandable outside mobile work groups and turn folds", () => {
3364 const turnId = TurnId.make("turn-answer");
3364 const answer = {3365 const answer = {
3365 requestId: ApprovalRequestId.make("question-request"),3366 requestId: ApprovalRequestId.make("question-request"),
3366 answers: { q: "" },3367 answers: { q: "" },
...@@ -3381,17 +3382,46 @@ it("makes attachment-only question answers expandable in the mobile feed", () =>...@@ -3381,17 +3382,46 @@ it("makes attachment-only question answers expandable in the mobile feed", () =>
3381 id: ThreadId.make("thread-answer"),3382 id: ThreadId.make("thread-answer"),
3382 projectId: ProjectId.make("project-answer"),3383 projectId: ProjectId.make("project-answer"),
3383 title: "Answer history",3384 title: "Answer history",
3385 latestTurn: {
3386 turnId,
3387 state: "completed",
3388 requestedAt: "2026-09-08T00:00:00.000Z",
3389 startedAt: "2026-09-08T00:00:00.000Z",
3390 completedAt: "2026-09-08T00:00:04.000Z",
3391 assistantMessageId: null,
3392 },
3384 activities: [3393 activities: [
3394 makeActivity({
3395 id: EventId.make("tool-before-answer"),
3396 createdAt: "2026-09-08T00:00:01.000Z",
3397 kind: "tool.completed",
3398 tone: "tool",
3399 summary: "Read files",
3400 turnId,
3401 payload: { itemType: "command_execution", status: "completed" },
3402 }),
3385 makeActivity({3403 makeActivity({
3386 id: EventId.make("answer-submitted"),3404 id: EventId.make("answer-submitted"),
3387 createdAt: "2026-09-08T00:00:00.000Z",3405 createdAt: "2026-09-08T00:00:02.000Z",
3388 kind: "user-input.answer-submitted",3406 kind: "user-input.answer-submitted",
3389 summary: "Answered questions",3407 summary: "Answered questions",
3408 turnId,
3390 payload: answer,3409 payload: answer,
3391 }),3410 }),
3411 makeActivity({
3412 id: EventId.make("tool-after-answer"),
3413 createdAt: "2026-09-08T00:00:03.000Z",
3414 kind: "tool.completed",
3415 tone: "tool",
3416 summary: "Read files",
3417 turnId,
3418 payload: { itemType: "command_execution", status: "completed" },
3419 }),
3392 ],3420 ],
3393 });3421 });
3394 const [group] = buildThreadFeed(thread);3422 const feed = buildThreadFeed(thread);
3423 expect(feed).toHaveLength(3);
3424 const group = feed[1];
3395 expect(group?.type).toBe("activity-group");3425 expect(group?.type).toBe("activity-group");
3396 if (group?.type !== "activity-group") return;3426 if (group?.type !== "activity-group") return;
3397 expect(group.activities[0]).toMatchObject({3427 expect(group.activities[0]).toMatchObject({
...@@ -3399,4 +3429,25 @@ it("makes attachment-only question answers expandable in the mobile feed", () =>...@@ -3399,4 +3429,25 @@ it("makes attachment-only question answers expandable in the mobile feed", () =>
3399 workEntry: { questionAnswer: answer },3429 workEntry: { questionAnswer: answer },
3400 });3430 });
3401 expect(group.activities[0]?.getFullDetail()).toBeNull();3431 expect(group.activities[0]?.getFullDetail()).toBeNull();
3432 const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set());
3433 expect(collapsed.map((entry) => entry.type)).toEqual(["turn-fold", "activity-group"]);
3434 expect(collapsed[1]).toBe(group);
3435 const expanded = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set([turnId]));
3436 expect(expanded.map((entry) => entry.type)).toEqual([
3437 "turn-fold",
3438 "work-toggle",
3439 "activity-group",
3440 "work-toggle",
3441 ]);
3442 expect(expanded[2]).toBe(group);
3443 const running = deriveThreadFeedPresentation(
3444 feed,
3445 { ...thread.latestTurn!, state: "running", completedAt: null },
3446 new Set(),
3447 new Set(),
3448 "2026-09-08T00:00:00.000Z",
3449 );
3450 expect(running[0]?.type).toBe("work-toggle");
3451 expect(running[1]).toBe(group);
3452 expect(running[2]?.type).toBe("work-toggle");
3402});3453});
apps/mobile/src/lib/threadActivity.ts+13-5
...@@ -264,6 +264,10 @@ export function isContextCompactionActivityGroup(...@@ -264,6 +264,10 @@ export function isContextCompactionActivityGroup(
264 );264 );
265}265}
266266
267function isUserInputActivityGroup(entry: ThreadFeedActivityGroup): boolean {
268 return entry.activities.some((activity) => activity.workEntry.questionAnswer !== undefined);
269}
270
267function normalizeDraftAnswer(value: string | undefined): string | null {271function normalizeDraftAnswer(value: string | undefined): string | null {
268 if (typeof value !== "string") {272 if (typeof value !== "string") {
269 return null;273 return null;
...@@ -1556,13 +1560,15 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th...@@ -1556,13 +1560,15 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th
1556 continue;1560 continue;
1557 }1561 }
15581562
1559 const isCompaction = entry.activity.workEntry.sourceActivityKind === "context-compaction";1563 const isStandalone =
1560 if (isCompaction || firstActivityEntry?.turnId !== entry.turnId) {1564 entry.activity.workEntry.sourceActivityKind === "context-compaction" ||
1565 entry.activity.workEntry.questionAnswer !== undefined;
1566 if (isStandalone || firstActivityEntry?.turnId !== entry.turnId) {
1561 flushGroup();1567 flushGroup();
1562 }1568 }
1563 firstActivityEntry ??= entry;1569 firstActivityEntry ??= entry;
1564 openGroupActivities.push(entry.activity);1570 openGroupActivities.push(entry.activity);
1565 if (isCompaction) {1571 if (isStandalone) {
1566 flushGroup();1572 flushGroup();
1567 }1573 }
1568 }1574 }
...@@ -1668,7 +1674,9 @@ function deriveThreadFeedTurnFolds(...@@ -1668,7 +1674,9 @@ function deriveThreadFeedTurnFolds(
1668 entries1674 entries
1669 .filter(1675 .filter(
1670 (entry) =>1676 (entry) =>
1671 entry.id !== firstAssistantMessageId && entry.id !== terminalAssistantMessageId,1677 entry.id !== firstAssistantMessageId &&
1678 entry.id !== terminalAssistantMessageId &&
1679 !(entry.type === "activity-group" && isUserInputActivityGroup(entry)),
1672 )1680 )
1673 .map((entry) => entry.id),1681 .map((entry) => entry.id),
1674 );1682 );
...@@ -1848,7 +1856,7 @@ function appendPresentedFeedEntry(...@@ -1848,7 +1856,7 @@ function appendPresentedFeedEntry(
1848 result.push(entry);1856 result.push(entry);
1849 return;1857 return;
1850 }1858 }
1851 if (isContextCompactionActivityGroup(entry)) {1859 if (isContextCompactionActivityGroup(entry) || isUserInputActivityGroup(entry)) {
1852 result.push(entry);1860 result.push(entry);
1853 return;1861 return;
1854 }1862 }
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+79
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import {2import {
3 ApprovalRequestId,
3 CheckpointRef,4 CheckpointRef,
4 EnvironmentId,5 EnvironmentId,
5 EventId,6 EventId,
...@@ -2622,6 +2623,84 @@ describe("deriveMessagesTimelineRows", () => {...@@ -2622,6 +2623,84 @@ describe("deriveMessagesTimelineRows", () => {
2622 });2623 });
2623 });2624 });
26242625
2626 it("keeps user input in its own row through tool grouping and turn folding", () => {
2627 const turnId = TurnId.make("answer-turn");
2628 const time = (second: number) => new Date(Date.UTC(2026, 8, 8, 0, 0, second)).toISOString();
2629 const answer: WorkLogEntry = {
2630 id: "answer-submitted",
2631 createdAt: time(3),
2632 turnId,
2633 tone: "info",
2634 label: "User input submitted",
2635 sourceActivityKind: "user-input.answer-submitted",
2636 questionAnswer: {
2637 requestId: ApprovalRequestId.make("answer-request"),
2638 answers: { scope: "Use the private repository" },
2639 questionTextById: { scope: "Which repository?" },
2640 attachmentsByQuestionId: {},
2641 },
2642 };
2643 const tools: WorkLogEntry[] = [1, 2, 4, 5].map((second) => ({
2644 id: `tool-${second}`,
2645 createdAt: time(second),
2646 turnId,
2647 tone: "tool",
2648 label: "Ran command",
2649 command: "git status",
2650 toolCallId: `call-${second}`,
2651 toolLifecycleStatus: "completed",
2652 sourceActivityKind: "tool.completed",
2653 }));
2654 const input = {
2655 timelineEntries: deriveTimelineEntries([], [], [...tools, answer]),
2656 latestTurn: { turnId, state: "completed", startedAt: time(0), completedAt: time(6) },
2657 isWorking: false,
2658 activeTurnStartedAt: null,
2659 turnDiffSummaries: [],
2660 supportsConversationRollback: false,
2661 } satisfies Parameters<typeof deriveMessagesTimelineRows>[0];
2662 const collapsed = deriveMessagesTimelineRows(input);
2663 expect(collapsed.map((row) => row.kind)).toEqual(["turn-fold", "work"]);
2664 const expanded = deriveMessagesTimelineRows({ ...input, expandedTurnIds: new Set([turnId]) });
2665 expect(expanded.map((row) => row.kind)).toEqual([
2666 "turn-fold",
2667 "work-toggle",
2668 "work",
2669 "work-toggle",
2670 ]);
2671 const expandedGroups = deriveMessagesTimelineRows({
2672 ...input,
2673 expandedTurnIds: new Set([turnId]),
2674 expandedWorkGroupIds: new Set(
2675 expanded.flatMap((row) => (row.kind === "work-toggle" ? [row.groupId] : [])),
2676 ),
2677 });
2678 expect(
2679 expandedGroups.flatMap((row) =>
2680 row.kind === "work" && row.isExpandedToolGroup ? [row.groupedEntries] : [],
2681 ),
2682 ).toEqual([tools.slice(0, 2), tools.slice(2)]);
2683 const active = deriveMessagesTimelineRows({
2684 ...input,
2685 latestTurn: { ...input.latestTurn, state: "running", completedAt: null },
2686 runningTurnId: turnId,
2687 isWorking: true,
2688 activeTurnStartedAt: time(0),
2689 });
2690 for (const rows of [collapsed, expanded, expandedGroups, active]) {
2691 const answerRows = rows.filter(
2692 (row) =>
2693 (row.kind === "work" || row.kind === "work-live") && row.groupedEntries.includes(answer),
2694 );
2695 expect(answerRows).toMatchObject([
2696 { kind: "work", groupedEntries: [answer], isExpandedToolGroup: false },
2697 ]);
2698 }
2699 expect(active.find((row) => row.kind === "work-live")).toMatchObject({
2700 groupedEntries: tools.slice(2),
2701 });
2702 });
2703
2625 it("deduplicates integration sources and uses the first source icon for the group", () => {2704 it("deduplicates integration sources and uses the first source icon for the group", () => {
2626 const chromeSource = {2705 const chromeSource = {
2627 key: "browser-use:chrome",2706 key: "browser-use:chrome",
apps/web/src/components/chat/MessagesTimeline.logic.ts+11-1
...@@ -657,6 +657,10 @@ function deriveTurnFolds(input: {...@@ -657,6 +657,10 @@ function deriveTurnFolds(input: {
657 if (!isCompaction && index > terminalEntryIndex && !isSingleTrailingActivity) {657 if (!isCompaction && index > terminalEntryIndex && !isSingleTrailingActivity) {
658 continue;658 continue;
659 }659 }
660 // User input stays visible after the surrounding work settles.
661 if (entry.kind === "work" && entry.entry.questionAnswer !== undefined) {
662 continue;
663 }
660 // Agent-spawn CTA rows never fold: workflows outlive their launching664 // Agent-spawn CTA rows never fold: workflows outlive their launching
661 // turn (dynamic spawns, background execution), and folding the CTA665 // turn (dynamic spawns, background execution), and folding the CTA
662 // when the turn settles makes a still-running fleet invisible.666 // when the turn settles makes a still-running fleet invisible.
...@@ -919,6 +923,7 @@ export function deriveMessagesTimelineRows(input: {...@@ -919,6 +923,7 @@ export function deriveMessagesTimelineRows(input: {
919 !entryBelongsToActiveTurn(entry, index) ||923 !entryBelongsToActiveTurn(entry, index) ||
920 entry.kind !== "work" ||924 entry.kind !== "work" ||
921 entry.entry.agentSpawn !== undefined ||925 entry.entry.agentSpawn !== undefined ||
926 entry.entry.questionAnswer !== undefined ||
922 entry.entry.sourceActivityKind === "context-compaction" ||927 entry.entry.sourceActivityKind === "context-compaction" ||
923 entry.entry.tone === "error"928 entry.entry.tone === "error"
924 ) {929 ) {
...@@ -1043,7 +1048,11 @@ export function deriveMessagesTimelineRows(input: {...@@ -1043,7 +1048,11 @@ export function deriveMessagesTimelineRows(input: {
1043 }1048 }
10441049
1045 if (timelineEntry.kind === "work") {1050 if (timelineEntry.kind === "work") {
1046 if (timelineEntry.entry.agentSpawn !== undefined || timelineEntry.entry.tone === "error") {1051 if (
1052 timelineEntry.entry.agentSpawn !== undefined ||
1053 timelineEntry.entry.questionAnswer !== undefined ||
1054 timelineEntry.entry.tone === "error"
1055 ) {
1047 nextRows.push({1056 nextRows.push({
1048 kind: "work",1057 kind: "work",
1049 id: timelineEntry.id,1058 id: timelineEntry.id,
...@@ -1061,6 +1070,7 @@ export function deriveMessagesTimelineRows(input: {...@@ -1061,6 +1070,7 @@ export function deriveMessagesTimelineRows(input: {
1061 !nextEntry ||1070 !nextEntry ||
1062 nextEntry.kind !== "work" ||1071 nextEntry.kind !== "work" ||
1063 nextEntry.entry.agentSpawn !== undefined ||1072 nextEntry.entry.agentSpawn !== undefined ||
1073 nextEntry.entry.questionAnswer !== undefined ||
1064 nextEntry.entry.sourceActivityKind === "context-compaction" ||1074 nextEntry.entry.sourceActivityKind === "context-compaction" ||
1065 nextEntry.entry.tone === "error" ||1075 nextEntry.entry.tone === "error" ||
1066 activeWorkEntryIds.has(nextEntry.id) ||1076 activeWorkEntryIds.has(nextEntry.id) ||
apps/web/src/components/chat/MessagesTimeline.test.tsx-2
...@@ -348,8 +348,6 @@ describe("MessagesTimeline", () => {...@@ -348,8 +348,6 @@ describe("MessagesTimeline", () => {
348 />,348 />,
349 );349 );
350 });350 });
351 const toggle = renderer!.root.findByProps({ "aria-expanded": false });
352 await act(() => toggle.props.onClick());
353 const questionToggle = renderer!.root.find(351 const questionToggle = renderer!.root.find(
354 (node) =>352 (node) =>
355 node.props["aria-label"]?.startsWith("Question answer submitted:") &&353 node.props["aria-label"]?.startsWith("Question answer submitted:") &&