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", ()
33603360 ).toBeNull();
33613361});
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");
33643365 const answer = {
33653366 requestId: ApprovalRequestId.make("question-request"),
33663367 answers: { q: "" },
......@@ -3381,17 +3382,46 @@ it("makes attachment-only question answers expandable in the mobile feed", () =>
33813382 id: ThreadId.make("thread-answer"),
33823383 projectId: ProjectId.make("project-answer"),
33833384 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 },
33843393 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 }),
33853403 makeActivity({
33863404 id: EventId.make("answer-submitted"),
3387 createdAt: "2026-09-08T00:00:00.000Z",
3405 createdAt: "2026-09-08T00:00:02.000Z",
33883406 kind: "user-input.answer-submitted",
33893407 summary: "Answered questions",
3408 turnId,
33903409 payload: answer,
33913410 }),
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 }),
33923420 ],
33933421 });
3394 const [group] = buildThreadFeed(thread);
3422 const feed = buildThreadFeed(thread);
3423 expect(feed).toHaveLength(3);
3424 const group = feed[1];
33953425 expect(group?.type).toBe("activity-group");
33963426 if (group?.type !== "activity-group") return;
33973427 expect(group.activities[0]).toMatchObject({
......@@ -3399,4 +3429,25 @@ it("makes attachment-only question answers expandable in the mobile feed", () =>
33993429 workEntry: { questionAnswer: answer },
34003430 });
34013431 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");
34023453});
apps/mobile/src/lib/threadActivity.ts+13-5
......@@ -264,6 +264,10 @@ export function isContextCompactionActivityGroup(
264264 );
265265}
266266
267function isUserInputActivityGroup(entry: ThreadFeedActivityGroup): boolean {
268 return entry.activities.some((activity) => activity.workEntry.questionAnswer !== undefined);
269}
270
267271function normalizeDraftAnswer(value: string | undefined): string | null {
268272 if (typeof value !== "string") {
269273 return null;
......@@ -1556,13 +1560,15 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th
15561560 continue;
15571561 }
15581562
1559 const isCompaction = entry.activity.workEntry.sourceActivityKind === "context-compaction";
1560 if (isCompaction || firstActivityEntry?.turnId !== entry.turnId) {
1563 const isStandalone =
1564 entry.activity.workEntry.sourceActivityKind === "context-compaction" ||
1565 entry.activity.workEntry.questionAnswer !== undefined;
1566 if (isStandalone || firstActivityEntry?.turnId !== entry.turnId) {
15611567 flushGroup();
15621568 }
15631569 firstActivityEntry ??= entry;
15641570 openGroupActivities.push(entry.activity);
1565 if (isCompaction) {
1571 if (isStandalone) {
15661572 flushGroup();
15671573 }
15681574 }
......@@ -1668,7 +1674,9 @@ function deriveThreadFeedTurnFolds(
16681674 entries
16691675 .filter(
16701676 (entry) =>
1671 entry.id !== firstAssistantMessageId && entry.id !== terminalAssistantMessageId,
1677 entry.id !== firstAssistantMessageId &&
1678 entry.id !== terminalAssistantMessageId &&
1679 !(entry.type === "activity-group" && isUserInputActivityGroup(entry)),
16721680 )
16731681 .map((entry) => entry.id),
16741682 );
......@@ -1848,7 +1856,7 @@ function appendPresentedFeedEntry(
18481856 result.push(entry);
18491857 return;
18501858 }
1851 if (isContextCompactionActivityGroup(entry)) {
1859 if (isContextCompactionActivityGroup(entry) || isUserInputActivityGroup(entry)) {
18521860 result.push(entry);
18531861 return;
18541862 }
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+79
......@@ -1,5 +1,6 @@
11import { describe, expect, it } from "vite-plus/test";
22import {
3 ApprovalRequestId,
34 CheckpointRef,
45 EnvironmentId,
56 EventId,
......@@ -2622,6 +2623,84 @@ describe("deriveMessagesTimelineRows", () => {
26222623 });
26232624 });
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
26252704 it("deduplicates integration sources and uses the first source icon for the group", () => {
26262705 const chromeSource = {
26272706 key: "browser-use:chrome",
apps/web/src/components/chat/MessagesTimeline.logic.ts+11-1
......@@ -657,6 +657,10 @@ function deriveTurnFolds(input: {
657657 if (!isCompaction && index > terminalEntryIndex && !isSingleTrailingActivity) {
658658 continue;
659659 }
660 // User input stays visible after the surrounding work settles.
661 if (entry.kind === "work" && entry.entry.questionAnswer !== undefined) {
662 continue;
663 }
660664 // Agent-spawn CTA rows never fold: workflows outlive their launching
661665 // turn (dynamic spawns, background execution), and folding the CTA
662666 // when the turn settles makes a still-running fleet invisible.
......@@ -919,6 +923,7 @@ export function deriveMessagesTimelineRows(input: {
919923 !entryBelongsToActiveTurn(entry, index) ||
920924 entry.kind !== "work" ||
921925 entry.entry.agentSpawn !== undefined ||
926 entry.entry.questionAnswer !== undefined ||
922927 entry.entry.sourceActivityKind === "context-compaction" ||
923928 entry.entry.tone === "error"
924929 ) {
......@@ -1043,7 +1048,11 @@ export function deriveMessagesTimelineRows(input: {
10431048 }
10441049
10451050 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 ) {
10471056 nextRows.push({
10481057 kind: "work",
10491058 id: timelineEntry.id,
......@@ -1061,6 +1070,7 @@ export function deriveMessagesTimelineRows(input: {
10611070 !nextEntry ||
10621071 nextEntry.kind !== "work" ||
10631072 nextEntry.entry.agentSpawn !== undefined ||
1073 nextEntry.entry.questionAnswer !== undefined ||
10641074 nextEntry.entry.sourceActivityKind === "context-compaction" ||
10651075 nextEntry.entry.tone === "error" ||
10661076 activeWorkEntryIds.has(nextEntry.id) ||
apps/web/src/components/chat/MessagesTimeline.test.tsx-2
......@@ -348,8 +348,6 @@ describe("MessagesTimeline", () => {
348348 />,
349349 );
350350 });
351 const toggle = renderer!.root.findByProps({ "aria-expanded": false });
352 await act(() => toggle.props.onClick());
353351 const questionToggle = renderer!.root.find(
354352 (node) =>
355353 node.props["aria-label"]?.startsWith("Question answer submitted:") &&